fix(syb): #340 phase 4 — fingerprint includes rule evidence + MySQL IT

1. recomputeFingerprint now hashes a JSON-serialized (not naive string-
   concatenated, to avoid delimiter-collision) sorted list of
   {id, direction, ruleId, ruleKind, ruleKeyword} per planned change — the
   rule kind/keyword are exactly what gets written into
   excluded_rule_kind/excluded_rule_keyword, so a plan that affects the
   same ids/directions via a since-edited rule must now be rejected as
   stale, not silently accepted. New tests:
   TestRecomputeFingerprintChangesWhenRuleEvidenceChanges (edits the rule
   row directly between preview and execute, since the API has no edit
   endpoint, and asserts RECOMPUTE_PREVIEW_STALE with nothing written and
   no log row) and TestRecomputeFingerprintStableAcrossUnchangedPreviews
   (two previews of the same data yield the same fingerprint and execute
   succeeds).

2. New server/app/goauto/sybproductfilter/recompute_mysql_integration_test.go,
   gated on GOAUTO_IT_MYSQL_DSN (t.Skip when unset, so `go test` is
   unaffected normally). It creates a uniquely named throwaway database
   (zz_goauto_it_340_<ts>), migrates it, and drops it in t.Cleanup — never
   touches an existing database. Two real-MySQL, two-connection scenarios
   reproduce the exact race the phase-3 fix closes: connection A takes its
   REPEATABLE-READ snapshot via the planning step, connection B takes the
   row's FOR UPDATE lock and holds it (confirmed via a channel) while A's
   write phase is proven to actually block on that same lock (asserted via
   a wait window), B then inserts a purchase_task / active return_match
   and commits, and A is asserted to unblock, see it, and skip the row.
   Verified locally against the dev MySQL server (this session never
   printed the password: read via a shell one-liner into an env var,
   exported only for the go test invocation): both tests PASS with the
   phase-3 fix in place. Temporarily reverted purchaseTaskLockedQuery to a
   plain (non-locking) read (not committed) and reran —
   TestRecomputeConcurrentPurchaseTaskUnderRealMySQL correctly FAILED
   ("expected A to skip the row ... got {PDDToExcluded:1 SkippedHasTask:0}"),
   proving the test is meaningful; restored and diffed byte-identical
   against a backup before rerunning to confirm both tests pass again.
   Confirmed via `SHOW DATABASES LIKE 'zz_goauto_it_%'` (empty) that every
   throwaway database, across all these runs, was actually dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-29 16:05:06 +08:00
co-authored by Claude Opus 5.5
parent cd4bd6acb1
commit a56df9d908
3 changed files with 512 additions and 15 deletions
+55 -15
View File
@@ -5,7 +5,6 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
@@ -147,25 +146,66 @@ func recomputeVariationSku(rawJSON string) string {
return value
}
// recomputeFingerprint binds a preview to the exact plan it showed: it is a
// sha256 over the sorted list of "id:direction:ruleId" for every planned
// change, so a genuinely identical plan always produces the same fingerprint
// regardless of query row order (#340 phase 3 review item 2).
// recomputeFingerprintEntry is one change's canonical, unambiguous
// representation for hashing (#340 phase 4 review item 1). It is JSON, not
// naive string concatenation: a naive "id:direction:ruleId" (or any other
// delimiter-joined string) can collide between two different plans whenever
// a field's own text can contain the delimiter or vary in length — e.g. a
// rule keyword containing ":" or newlines could make two distinct plans hash
// identically. encoding/json's field ordering for a fixed struct is stable,
// so this is both deterministic and injective for our purposes.
type recomputeFingerprintEntry struct {
ID uint64 `json:"id"`
Direction string `json:"direction"`
RuleID uint64 `json:"ruleId"`
RuleKind string `json:"ruleKind"`
RuleKeyword string `json:"ruleKeyword"`
}
// recomputeFingerprint binds a preview to the exact plan it showed, INCLUDING
// the rule evidence that will be written to excluded_rule_kind/
// excluded_rule_keyword (#340 phase 4 review item 1): two plans that flip the
// exact same id+direction but via a different (or since-edited) rule must
// hash differently, because RecomputeExecute is about to persist exactly
// this rule kind/keyword as this row's excluded_rule_* snapshot — a
// fingerprint that ignored them could let a stale plan through unnoticed
// whenever a rule's keyword/kind changed between preview and execute but the
// set of affected ids/directions happened to stay the same. For the
// excluded_to_pdd direction there is no rule (the row is losing its mark),
// so RuleID/RuleKind/RuleKeyword are left at their zero values, matching what
// gets written (nil/""/"").
//
// It is a sha256 over the JSON-encoded, sorted (by id, then direction) list
// of recomputeFingerprintEntry — sorting the decoded entries themselves
// (not pre-serialized strings) keeps the ordering rule obviously correct
// regardless of how any field is later escaped.
func recomputeFingerprint(changes []recomputeChange) string {
entries := make([]string, 0, len(changes))
entries := make([]recomputeFingerprintEntry, 0, len(changes))
for _, change := range changes {
var ruleID uint64
if change.ruleID != nil {
ruleID = *change.ruleID
}
direction := "0"
entry := recomputeFingerprintEntry{ID: change.id}
if change.toExcluded {
direction = "1"
entry.Direction = DirectionPDDToExcluded
if change.ruleID != nil {
entry.RuleID = *change.ruleID
}
entry.RuleKind = change.ruleKind
entry.RuleKeyword = change.ruleKeyword
} else {
entry.Direction = DirectionExcludedToPDD
}
entries = append(entries, fmt.Sprintf("%d:%s:%d", change.id, direction, ruleID))
entries = append(entries, entry)
}
sort.Strings(entries)
sum := sha256.Sum256([]byte(strings.Join(entries, "\n")))
sort.Slice(entries, func(i, j int) bool {
if entries[i].ID != entries[j].ID {
return entries[i].ID < entries[j].ID
}
return entries[i].Direction < entries[j].Direction
})
// Marshal errors are impossible here (every field is a plain string/uint64
// with no cycles), so it is safe to ignore the error and hash whatever
// was produced rather than plumb an error return through every caller.
payload, _ := json.Marshal(entries)
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,351 @@
package sybproductfilter
import (
"context"
"database/sql"
"fmt"
"os"
"regexp"
"testing"
"time"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
_ "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
// dsnPathReplacer swaps the database name in a Go MySQL DSN of the form
// user:pass@tcp(host:port)/dbname?params — used to connect first to the
// server (no specific throwaway database yet) and then to the freshly
// created throwaway database.
var dsnPathReplacer = regexp.MustCompile(`^(.*/)([^/?]*)(\?.*)?$`)
func dsnWithDatabase(dsn, dbName string) string {
if dsnPathReplacer.MatchString(dsn) {
return dsnPathReplacer.ReplaceAllString(dsn, "${1}"+dbName+"${3}")
}
return dsn
}
// setupMySQLIntegrationDB is #340 phase 4 review item 2's throwaway-database
// harness: it never touches an existing database. GOAUTO_IT_MYSQL_DSN must
// point at a MySQL SERVER (any connectable path, e.g. the system "mysql"
// database) with permission to CREATE/DROP DATABASE; a uniquely named
// zz_goauto_it_340_<random> database is created, migrated, and guaranteed
// dropped via t.Cleanup even if the test fails or panics.
func setupMySQLIntegrationDB(t *testing.T) (dsn string, dbName string) {
t.Helper()
baseDSN := os.Getenv("GOAUTO_IT_MYSQL_DSN")
if baseDSN == "" {
t.Skip("GOAUTO_IT_MYSQL_DSN not set; skipping MySQL concurrency integration test")
}
admin, err := sql.Open("mysql", baseDSN)
if err != nil {
t.Fatalf("open admin connection: %v", err)
}
if err := admin.Ping(); err != nil {
admin.Close()
t.Fatalf("ping MySQL server: %v", err)
}
dbName = fmt.Sprintf("zz_goauto_it_340_%d", time.Now().UnixNano())
if _, err := admin.Exec("CREATE DATABASE `" + dbName + "`"); err != nil {
admin.Close()
t.Fatalf("create throwaway database %s: %v", dbName, err)
}
t.Cleanup(func() {
defer admin.Close()
if _, err := admin.Exec("DROP DATABASE IF EXISTS `" + dbName + "`"); err != nil {
t.Errorf("failed to drop throwaway database %s (manual cleanup required): %v", dbName, err)
}
})
dsn = dsnWithDatabase(baseDSN, dbName)
gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open throwaway database: %v", err)
}
if err := migrations.Migrate(gdb); err != nil {
t.Fatalf("migrate throwaway database: %v", err)
}
return dsn, dbName
}
func newMySQLIntegrationConn(t *testing.T, dsn string) *gorm.DB {
t.Helper()
conn, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open MySQL connection: %v", err)
}
return conn
}
// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL is #340 phase 4 review
// item 2: it reproduces, against a real MySQL server under REPEATABLE-READ,
// the exact race writeRecomputeChanges' locking rechecks exist to close.
//
// Timeline:
// 1. Connection A begins a transaction and runs the planning step
// (recomputeChanges) — this is A's FIRST read, so it fixes A's
// REPEATABLE-READ snapshot with zero purchase_task rows.
// 2. Connection B, concurrently, takes the SAME row's FOR UPDATE lock,
// confirmed via a channel before A is allowed to proceed.
// 3. A's write phase (writeRecomputeChanges) is started in a goroutine; it
// must BLOCK trying to take the same FOR UPDATE lock B already holds —
// the test asserts A has NOT finished after a wait window, proving a
// real block happened (not just a fast, uncontended lock grant).
// 4. B inserts a purchase_task for the row and commits, releasing the lock.
// 5. A's write phase unblocks, re-checks purchase_task under lock, and must
// see B's now-committed row and skip — this only holds because the
// recheck is a locking (FOR SHARE) read; a plain COUNT(*) would still be
// bound to A's step-1 snapshot (zero rows) and would wrongly write.
func TestRecomputeConcurrentPurchaseTaskUnderRealMySQL(t *testing.T) {
dsn, _ := setupMySQLIntegrationDB(t)
seedConn := newMySQLIntegrationConn(t, dsn)
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
if err := seedConn.Create(&rule).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{
OrderCode: "ORD-IT-TASK", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
}
if err := seedConn.Create(&row).Error; err != nil {
t.Fatal(err)
}
// purchase_task.pdd_product_id has a real FK (unlike this package's
// SQLite-backed tests, which don't enable foreign key enforcement) —
// MySQL requires an actual pdd_product row to reference.
pdd := models.PDDProduct{GoodsID: "IT-PDD-1", URL: "https://example.invalid/it", SpecsJSON: "[]"}
if err := seedConn.Create(&pdd).Error; err != nil {
t.Fatal(err)
}
connA := newMySQLIntegrationConn(t, dsn)
connB := newMySQLIntegrationConn(t, dsn)
ctx := context.Background()
txA := connA.Begin()
// Guard against ANY early return (t.Fatalf, panic) leaving txA open: an
// abandoned open transaction holds a connection into this throwaway
// database and blocks the DROP DATABASE cleanup indefinitely. Rollback
// on an already-committed transaction is a harmless no-op error, which
// is why the plain Commit() path below intentionally does not disable
// this cleanup.
t.Cleanup(func() { txA.Rollback() })
_, planned, err := recomputeChanges(ctx, txA)
if err != nil {
t.Fatalf("plan: %v", err)
}
if len(planned) != 1 || planned[0].id != row.ID {
t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned)
}
txB := connB.Begin()
t.Cleanup(func() { txB.Rollback() })
bHoldingLock := make(chan struct{})
bCanCommit := make(chan struct{})
bDone := make(chan error, 1)
go func() {
var locked models.SYBProduct
if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
First(&locked, row.ID).Error; err != nil {
bDone <- fmt.Errorf("B lock row: %w", err)
return
}
close(bHoldingLock)
<-bCanCommit
task := models.PurchaseTask{
SYBProductID: &row.ID, PDDProductID: pdd.ID, Quantity: 1, CreateRequestID: "it-race-task",
Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive,
TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}",
}
if err := txB.Create(&task).Error; err != nil {
bDone <- fmt.Errorf("B insert task: %w", err)
return
}
bDone <- txB.Commit().Error
}()
select {
case <-bHoldingLock:
case err := <-bDone:
t.Fatalf("B failed before taking the row lock: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for B to take the row lock")
}
aDone := make(chan struct{})
var aActual RecomputeCounts
var aErr error
go func() {
aActual, aErr = writeRecomputeChanges(ctx, txA, planned)
close(aDone)
}()
// A must still be blocked on B's row lock at this point — this is the
// test's proof that a real MySQL row lock, not just program logic, is
// what's being exercised.
select {
case <-aDone:
t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock")
case <-time.After(300 * time.Millisecond):
}
close(bCanCommit)
if err := <-bDone; err != nil {
t.Fatalf("B failed: %v", err)
}
select {
case <-aDone:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for A's write phase to unblock after B committed")
}
if aErr != nil {
t.Fatalf("A's write phase failed: %v", aErr)
}
if err := txA.Commit().Error; err != nil {
t.Fatalf("commit A: %v", err)
}
if aActual.SkippedHasTask != 1 || aActual.PDDToExcluded != 0 {
t.Fatalf("expected A to skip the row for the concurrently-created task, got %+v", aActual)
}
var reloaded models.SYBProduct
if err := seedConn.First(&reloaded, row.ID).Error; err != nil {
t.Fatal(err)
}
if reloaded.PDDExcluded {
t.Fatalf("row must not have been marked excluded — the concurrent task should have blocked it: %+v", reloaded)
}
}
// TestRecomputeConcurrentReturnMatchUnderRealMySQL is the same scenario as
// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL, with an active
// return_match row instead of a purchase_task as B's concurrent write.
func TestRecomputeConcurrentReturnMatchUnderRealMySQL(t *testing.T) {
dsn, _ := setupMySQLIntegrationDB(t)
seedConn := newMySQLIntegrationConn(t, dsn)
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
if err := seedConn.Create(&rule).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{
OrderCode: "ORD-IT-MATCH", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
}
if err := seedConn.Create(&row).Error; err != nil {
t.Fatal(err)
}
yeekeItem := models.YeekeReturnItem{PackageID: 0, ExternalKey: "it-race-return", ItemID: "1", VariationName: "档口-1", LastSyncedAt: time.Now()}
// A package row is required by the return_match/yeeke schema's foreign
// key; seed a minimal one.
pkg := models.YeekeReturnPackage{ExternalID: "it-race-pkg", OrderSN: "IT-ORD", TrackingNo: "IT-TRK", LastSyncedAt: time.Now()}
if err := seedConn.Create(&pkg).Error; err != nil {
t.Fatal(err)
}
yeekeItem.PackageID = pkg.ID
if err := seedConn.Create(&yeekeItem).Error; err != nil {
t.Fatal(err)
}
connA := newMySQLIntegrationConn(t, dsn)
connB := newMySQLIntegrationConn(t, dsn)
ctx := context.Background()
txA := connA.Begin()
// See TestRecomputeConcurrentPurchaseTaskUnderRealMySQL for why this
// unconditional cleanup is necessary regardless of the Commit() below.
t.Cleanup(func() { txA.Rollback() })
_, planned, err := recomputeChanges(ctx, txA)
if err != nil {
t.Fatalf("plan: %v", err)
}
if len(planned) != 1 || planned[0].id != row.ID {
t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned)
}
txB := connB.Begin()
t.Cleanup(func() { txB.Rollback() })
bHoldingLock := make(chan struct{})
bCanCommit := make(chan struct{})
bDone := make(chan error, 1)
go func() {
var locked models.SYBProduct
if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
First(&locked, row.ID).Error; err != nil {
bDone <- fmt.Errorf("B lock row: %w", err)
return
}
close(bHoldingLock)
<-bCanCommit
match := models.ReturnMatch{
SYBProductID: row.ID, YeekeReturnItemID: yeekeItem.ID,
ActiveSYBProductID: &row.ID, Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now(),
}
if err := txB.Create(&match).Error; err != nil {
bDone <- fmt.Errorf("B insert match: %w", err)
return
}
bDone <- txB.Commit().Error
}()
select {
case <-bHoldingLock:
case err := <-bDone:
t.Fatalf("B failed before taking the row lock: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for B to take the row lock")
}
aDone := make(chan struct{})
var aActual RecomputeCounts
var aErr error
go func() {
aActual, aErr = writeRecomputeChanges(ctx, txA, planned)
close(aDone)
}()
select {
case <-aDone:
t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock")
case <-time.After(300 * time.Millisecond):
}
close(bCanCommit)
if err := <-bDone; err != nil {
t.Fatalf("B failed: %v", err)
}
select {
case <-aDone:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for A's write phase to unblock after B committed")
}
if aErr != nil {
t.Fatalf("A's write phase failed: %v", aErr)
}
if err := txA.Commit().Error; err != nil {
t.Fatalf("commit A: %v", err)
}
if aActual.SkippedReturnMatch != 1 || aActual.PDDToExcluded != 0 {
t.Fatalf("expected A to skip the row for the concurrently-created return match, got %+v", aActual)
}
var reloaded models.SYBProduct
if err := seedConn.First(&reloaded, row.ID).Error; err != nil {
t.Fatal(err)
}
if reloaded.PDDExcluded {
t.Fatalf("row must not have been marked excluded — the concurrent return match should have blocked it: %+v", reloaded)
}
}
@@ -388,3 +388,109 @@ func TestMarkedCountsByRule(t *testing.T) {
t.Fatalf("expected rule B marked count 1, got %d", byID[ruleBID])
}
}
// TestRecomputeFingerprintChangesWhenRuleEvidenceChanges is #340 phase 4
// review item 1: the fingerprint must depend on the rule's kind/keyword, not
// just its id — because those are exactly what RecomputeExecute is about to
// write into excluded_rule_kind/excluded_rule_keyword. Same rule id, same
// affected product, same direction, but the rule's own keyword changed
// between preview and execute (simulated by editing the row directly since
// the API has no edit endpoint) must be rejected as stale.
func TestRecomputeFingerprintChangesWhenRuleEvidenceChanges(t *testing.T) {
db := testDB(t)
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{OrderCode: "ORD-RULE-EDIT", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
preview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if preview.PDDToExcluded != 1 || len(preview.Samples) != 1 || preview.Samples[0].RuleKeyword != "档口" {
t.Fatalf("unexpected preview: %+v", preview)
}
// The rule's own keyword and normalized_keyword change (same id, same
// kind, still matches the same variationSku prefix) — the plan's set of
// affected ids/directions is unchanged, but the evidence that would be
// written is not.
if err := db.Model(&models.SYBProductFilter{}).Where("id = ?", rule.ID).
Updates(map[string]any{"keyword": "档口新", "normalized_keyword": "档口"}).Error; err != nil {
t.Fatal(err)
}
_, err = s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
if err == nil {
t.Fatalf("expected the changed rule evidence to be rejected as stale")
}
se, ok := err.(*ServiceError)
if !ok || se.Code != CodeRecomputeStale {
t.Fatalf("expected CodeRecomputeStale, got %v", err)
}
var reloaded models.SYBProduct
db.First(&reloaded, row.ID)
if reloaded.PDDExcluded {
t.Fatalf("nothing should have been written: %+v", reloaded)
}
var logCount int64
db.Model(&models.SYBProductFilterRecomputeLog{}).Count(&logCount)
if logCount != 0 {
t.Fatalf("no audit log row should have been written, got %d", logCount)
}
// A fresh preview reflects the new keyword and executes normally.
freshPreview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if freshPreview.Samples[0].RuleKeyword != "档口新" {
t.Fatalf("expected fresh preview to see the new keyword, got %+v", freshPreview.Samples)
}
if _, err := s.RecomputeExecute(context.Background(), "admin1", freshPreview.Fingerprint); err != nil {
t.Fatalf("fresh fingerprint should be accepted: %v", err)
}
db.First(&reloaded, row.ID)
if !reloaded.PDDExcluded || reloaded.ExcludedRuleKeyword != "档口新" {
t.Fatalf("expected the row to be excluded with the new keyword snapshot: %+v", reloaded)
}
}
// TestRecomputeFingerprintStableAcrossUnchangedPreviews is the companion
// regression: an unchanged dataset must give the SAME fingerprint on two
// consecutive previews (map/slice iteration order must never leak into the
// hash), and that fingerprint must still execute successfully.
func TestRecomputeFingerprintStableAcrossUnchangedPreviews(t *testing.T) {
db := testDB(t)
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
row := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-STABLE-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), ShopeeItemID: fmt.Sprintf("s%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-x"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
s := NewService(db)
first, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
second, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if first.Fingerprint == "" || first.Fingerprint != second.Fingerprint {
t.Fatalf("expected a stable, non-empty fingerprint across two previews of the same data: %q vs %q", first.Fingerprint, second.Fingerprint)
}
if _, err := s.RecomputeExecute(context.Background(), "admin1", second.Fingerprint); err != nil {
t.Fatalf("unchanged-data fingerprint should execute successfully: %v", err)
}
}