package phasea import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" ) func TestValidationRejectsInvalidInputsBeforePersistence(t *testing.T) { store := &Store{} valid := Account{ ID: "account-a", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned", CredentialReference: CredentialReference{ID: "credential-a", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-a", } for name, mutate := range map[string]func(*Account){ "id": func(account *Account) { account.ID = "INVALID" }, "platform": func(account *Account) { account.Platform = "bad platform" }, "platform account key": func(account *Account) { account.PlatformAccountKey = "secret value" }, "authorization kind": func(account *Account) { account.AuthorizationKind = "borrowed" }, "credential provider": func(account *Account) { account.CredentialReference.Provider = "plain_text" }, } { t.Run(name, func(t *testing.T) { account := valid mutate(&account) if err := store.CreateAccount(context.Background(), account); !errors.Is(err, ErrInvalid) { t.Fatalf("expected invalid account, got %v", err) } }) } if err := store.CreateAccount(context.Background(), Account{ ID: "account-a", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned", CredentialReference: CredentialReference{ID: "credential-a", Provider: "os_keyring"}, CredentialKey: "raw-value", }); !errors.Is(err, ErrInvalid) { t.Fatalf("expected non-reference credential value to be rejected, got %v", err) } if _, _, err := store.Enqueue(context.Background(), Task{ID: "task-a"}); !errors.Is(err, ErrInvalid) { t.Fatalf("expected invalid task, got %v", err) } if _, err := store.ExecuteMock(context.Background(), "worker-a", "retry"); !errors.Is(err, ErrInvalid) { t.Fatalf("expected unsupported outcome to be rejected, got %v", err) } } func TestPhaseAOfflineWorkflow(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") } ctx := context.Background() store, err := Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = store.Close() }) applyHubMigrationsForPhaseATest(t, store) if _, err := store.db.ExecContext(ctx, ` TRUNCATE audit_event, execution_attempt, operation_task, confirmation, content_draft, runtime_instance, environment_binding, network_exit, social_account, credential_reference, browser_env, browser_image, gateway RESTART IDENTITY CASCADE`); err != nil { t.Fatal(err) } accounts := []Account{ {ID: "account-a", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned", CredentialReference: CredentialReference{ID: "credential-a", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-a"}, {ID: "account-b", Platform: "mock", PlatformAccountKey: "platform-b", AuthorizationKind: "authorized", CredentialReference: CredentialReference{ID: "credential-b", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-b"}, } for _, account := range accounts { if err := store.CreateAccount(ctx, account); err != nil { t.Fatal(err) } } createdAccount, err := store.GetAccount(ctx, "account-a") if err != nil || createdAccount.RuntimeStatus != "paused" || createdAccount.Version != 1 { t.Fatalf("new account must start paused: %#v %v", createdAccount, err) } if err := store.ResumeAccount(ctx, "account-a"); !errors.Is(err, ErrConflict) { t.Fatalf("account without a healthy binding must not resume: %v", err) } if err := store.CreateAccount(ctx, Account{ ID: "account-c", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned", CredentialReference: CredentialReference{ID: "credential-c", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-c", }); !errors.Is(err, ErrConflict) { t.Fatalf("expected platform accounts to be unique, got %v", err) } if _, err := store.db.ExecContext(ctx, ` INSERT INTO gateway (name, endpoint, token) VALUES ('test-gateway', 'http://127.0.0.1:8081', 'phase-a-test-gateway-token')`); err != nil { t.Fatal(err) } if _, err := store.db.ExecContext(ctx, ` INSERT INTO browser_image (version, image_ref) VALUES ('1', 'example/browser:1')`); err != nil { t.Fatal(err) } if _, err := store.db.ExecContext(ctx, ` INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint) VALUES ('account-a', 'Account A', 'test-gateway', '1', '{"seed":1}'), ('account-b', 'Account B', 'test-gateway', '1', '{"seed":2}'); INSERT INTO network_exit (id, protocol, host, port, health_status) VALUES ('exit-shared', 'socks5', '127.0.0.1', 1080, 'healthy'); INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id) VALUES ('binding-a', 'account-a', 'account-a', 'exit-shared'), ('binding-b', 'account-b', 'account-b', 'exit-shared')`); err != nil { t.Fatal(err) } for _, id := range []string{"account-a", "account-b"} { if err := store.ResumeAccount(ctx, id); err != nil { t.Fatalf("resume %s: %v", id, err) } } accountA, err := store.GetAccount(ctx, "account-a") if err != nil || accountA.RuntimeStatus != "active" || accountA.Version != 2 { t.Fatalf("unexpected resumed account: %#v %v", accountA, err) } exportedAccount, _ := json.Marshal(accountA) if strings.Contains(string(exportedAccount), "creatorhub/account-a") || strings.Contains(string(exportedAccount), "reference_key") { t.Fatalf("account response leaked credential key: %s", exportedAccount) } listedAccounts, err := store.ListAccounts(ctx) if err != nil || len(listedAccounts) != 2 { t.Fatalf("unexpected account list: %#v %v", listedAccounts, err) } accountB, err := store.GetAccount(ctx, "account-b") if err != nil { t.Fatal(err) } if _, err := store.db.ExecContext(ctx, ` INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until) VALUES ('runtime-instance-a', 'account-a', 'binding-a', 1, 'runtime-a', now() + interval '1 minute'), ('runtime-instance-b', 'account-b', 'binding-b', 1, 'runtime-b', now() + interval '1 minute')`); err != nil { t.Fatal(err) } createApprovedDraft(t, store, "account-a", accountA.Version, "draft-a", "confirmation-a") createApprovedDraft(t, store, "account-b", accountB.Version, "draft-b", "confirmation-b") for index := range 20 { accountID, accountVersion, draftID, confirmationID := "account-a", accountA.Version, "draft-a", "confirmation-a" if index%2 == 1 { accountID, accountVersion, draftID, confirmationID = "account-b", accountB.Version, "draft-b", "confirmation-b" } task := approvedTask(index, accountID, accountVersion, draftID, confirmationID) if _, inserted, err := store.Enqueue(ctx, task); err != nil || !inserted { t.Fatalf("enqueue task %d: inserted=%v err=%v", index, inserted, err) } task.ID = fmt.Sprintf("duplicate-%02d", index) if existing, inserted, err := store.Enqueue(ctx, task); err != nil || inserted || existing.ID == task.ID { t.Fatalf("redeliver task %d: existing=%q inserted=%v err=%v", index, existing.ID, inserted, err) } } var executed atomic.Int64 var workers sync.WaitGroup errorsFromWorkers := make(chan error, 4) for worker := range 4 { workers.Add(1) go func() { defer workers.Done() for { execution, err := store.ExecuteMock(ctx, fmt.Sprintf("worker-%d", worker), "succeeded") if err != nil { errorsFromWorkers <- err return } if !execution.WasClaimed { return } executed.Add(1) } }() } workers.Wait() close(errorsFromWorkers) for err := range errorsFromWorkers { t.Fatal(err) } if executed.Load() != 20 { t.Fatalf("expected exactly 20 executions, got %d", executed.Load()) } assertCount(t, store, `SELECT count(*) FROM operation_task WHERE state = 'succeeded'`, 20) assertCount(t, store, `SELECT count(*) FROM execution_attempt`, 20) for _, test := range []struct { name, accountStatus, authorizationStatus, exitStatus, runtimeLease, wantState, wantReason string binding, versionChanged, confirmationChanged bool }{ {name: "binding-missing", accountStatus: "active", authorizationStatus: "authorized", wantState: "policy_hold", wantReason: "binding_missing"}, {name: "exit-unhealthy", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "unhealthy", wantState: "policy_hold", wantReason: "exit_unhealthy"}, {name: "runtime-missing", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", wantState: "policy_hold", wantReason: "runtime_missing"}, {name: "runtime-expired", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "expired", wantState: "policy_hold", wantReason: "runtime_lease_expired"}, {name: "binding-version", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "active", versionChanged: true, wantState: "needs_confirmation", wantReason: "binding_version_changed"}, {name: "confirmation-version", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "active", confirmationChanged: true, wantState: "needs_confirmation", wantReason: "confirmation_version_changed"}, {name: "paused", accountStatus: "paused", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "active", wantState: "policy_hold", wantReason: "account_paused"}, {name: "revoked", accountStatus: "paused", authorizationStatus: "revoked", binding: true, exitStatus: "healthy", runtimeLease: "active", wantState: "policy_hold", wantReason: "account_revoked"}, } { t.Run("gate "+test.name, func(t *testing.T) { taskID := seedGateTask(t, store, test.name, test.accountStatus, test.authorizationStatus, test.exitStatus, test.runtimeLease, test.binding, test.versionChanged, test.confirmationChanged) if execution, err := store.ExecuteMock(ctx, "worker-gate-"+test.name, "succeeded"); err != nil || execution.WasClaimed { t.Fatalf("invalid task was claimed: execution=%+v err=%v", execution, err) } assertTaskGate(t, store, taskID, test.wantState, test.wantReason) }) } // A held task is never revived in place: only a newly confirmed task with a new idempotency key may run. if _, err := store.db.ExecContext(ctx, ` INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until) VALUES ('runtime-recovered', 'gate-runtime-missing', 'binding-runtime-missing', 1, 'container-recovered', now() + interval '1 minute')`); err != nil { t.Fatal(err) } if execution, err := store.ExecuteMock(ctx, "worker-old-held", "succeeded"); err != nil || execution.WasClaimed { t.Fatalf("held task was revived after health recovery: execution=%+v err=%v", execution, err) } createApprovedDraft(t, store, "gate-runtime-missing", 1, "draft-recovered", "confirmation-recovered") recovered := Task{ ID: "task-recovered", IdempotencyKey: "idempotency-recovered", AccountID: "gate-runtime-missing", AccountVersion: 1, DraftID: "draft-recovered", DraftVersion: 1, ConfirmationID: "confirmation-recovered", ConfirmationVersion: 1, } if _, inserted, err := store.Enqueue(ctx, recovered); err != nil || !inserted { t.Fatalf("enqueue recovered task: inserted=%v err=%v", inserted, err) } if execution, err := store.ExecuteMock(ctx, "worker-recovered", "succeeded"); err != nil || !execution.WasClaimed || execution.State != "succeeded" { t.Fatalf("newly confirmed task did not run after recovery: execution=%+v err=%v", execution, err) } assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-gate-runtime-missing' AND state = 'policy_hold'`, 1) unconfirmed := approvedTask(30, "account-b", accountB.Version, "draft-b", "") unconfirmed.ConfirmationVersion = 0 if _, _, err := store.Enqueue(ctx, unconfirmed); err != nil { t.Fatal(err) } mismatch := approvedTask(31, "account-b", accountB.Version, "draft-b", "confirmation-b") mismatch.AccountVersion++ if _, _, err := store.Enqueue(ctx, mismatch); err != nil { t.Fatal(err) } if execution, err := store.ExecuteMock(ctx, "worker-validation", "succeeded"); err != nil || execution.WasClaimed { t.Fatalf("invalid tasks must not execute: execution=%+v err=%v", execution, err) } assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id IN ('task-30', 'task-31') AND state = 'needs_confirmation'`, 2) assertCount(t, store, `SELECT count(*) FROM execution_attempt WHERE task_id IN ('task-30', 'task-31')`, 0) uncertain := approvedTask(32, "account-b", accountB.Version, "draft-b", "confirmation-b") if _, _, err := store.Enqueue(ctx, uncertain); err != nil { t.Fatal(err) } if execution, err := store.ExecuteMock(ctx, "worker-uncertain", "uncertain"); err != nil || execution.State != "needs_confirmation" { t.Fatalf("uncertain outcome was not held: execution=%+v err=%v", execution, err) } if execution, err := store.ExecuteMock(ctx, "worker-uncertain", "succeeded"); err != nil || execution.WasClaimed { t.Fatalf("uncertain task was retried: execution=%+v err=%v", execution, err) } assertCount(t, store, `SELECT count(*) FROM execution_attempt WHERE task_id = 'task-32'`, 1) policy := approvedTask(33, "account-b", accountB.Version, "draft-b", "confirmation-b") if _, _, err := store.Enqueue(ctx, policy); err != nil { t.Fatal(err) } if execution, err := store.ExecuteMock(ctx, "worker-policy", "policy_hold"); err != nil || execution.State != "policy_hold" { t.Fatalf("policy outcome was not held: execution=%+v err=%v", execution, err) } if execution, err := store.ExecuteMock(ctx, "worker-policy", "succeeded"); err != nil || execution.WasClaimed { t.Fatalf("policy task was retried: execution=%+v err=%v", execution, err) } paused := approvedTask(34, "account-b", accountB.Version, "draft-b", "confirmation-b") if _, _, err := store.Enqueue(ctx, paused); err != nil { t.Fatal(err) } pauseStarted := time.Now() if err := store.PauseAccount(ctx, "account-b"); err != nil { t.Fatal(err) } if err := store.PauseAccount(ctx, "account-b"); err != nil { t.Fatalf("pause should be idempotent: %v", err) } if execution, err := store.ExecuteMock(ctx, "worker-paused", "succeeded"); err != nil || execution.WasClaimed { t.Fatalf("paused account task was claimed: execution=%+v err=%v", execution, err) } assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-34' AND state = 'policy_hold'`, 1) pausedAccount, err := store.GetAccount(ctx, "account-b") if err != nil || pausedAccount.Version != accountB.Version+1 { t.Fatalf("idempotent pause must increment once: %#v %v", pausedAccount, err) } var pausedAt time.Time if err := store.db.QueryRowContext(ctx, `SELECT paused_at FROM social_account WHERE id = 'account-b'`).Scan(&pausedAt); err != nil || pausedAt.Sub(pauseStarted) >= time.Minute { t.Fatalf("account was not paused within one minute: paused_at=%v err=%v", pausedAt, err) } if err := store.RevokeAccount(ctx, "account-b"); err != nil { t.Fatal(err) } if err := store.RevokeAccount(ctx, "account-b"); err != nil { t.Fatalf("revoke should be idempotent: %v", err) } revokedAccount, err := store.GetAccount(ctx, "account-b") if err != nil || revokedAccount.AuthorizationStatus != "revoked" || revokedAccount.Version != accountB.Version+2 { t.Fatalf("unexpected revoked account: %#v %v", revokedAccount, err) } if err := store.ResumeAccount(ctx, "account-b"); !errors.Is(err, ErrConflict) { t.Fatalf("revoked account must not resume: %v", err) } cancelled := approvedTask(35, "account-a", accountA.Version, "draft-a", "confirmation-a") if _, _, err := store.Enqueue(ctx, cancelled); err != nil { t.Fatal(err) } if err := store.CancelTask(ctx, cancelled.ID); err != nil { t.Fatal(err) } assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-35' AND state = 'cancelled'`, 1) expired := approvedTask(36, "account-a", accountA.Version, "draft-a", "confirmation-a") if _, _, err := store.Enqueue(ctx, expired); err != nil { t.Fatal(err) } claimed, err := store.claim(ctx, "worker-crashed") if err != nil || !claimed.WasClaimed { t.Fatalf("claim task for lease test: execution=%+v err=%v", claimed, err) } if _, err := store.db.ExecContext(ctx, `UPDATE operation_task SET lease_until = now() - interval '1 second' WHERE id = $1`, claimed.TaskID); err != nil { t.Fatal(err) } if execution, err := store.ExecuteMock(ctx, "worker-after-crash", "succeeded"); err != nil || execution.WasClaimed { t.Fatalf("expired unknown result was retried: execution=%+v err=%v", execution, err) } assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-36' AND state = 'needs_confirmation'`, 1) assertCount(t, store, `SELECT count(*) FROM execution_attempt WHERE task_id = 'task-36' AND outcome = 'uncertain'`, 1) events, err := store.Audit(ctx) if err != nil { t.Fatal(err) } traceable := false for _, event := range events { if event.EventType == "task_finished" && event.ConfirmationID != "" && event.ConfirmationVersion == 1 && event.AttemptID != "" && event.TaskID != "" { traceable = true break } } if !traceable { t.Fatal("audit does not trace confirmation version, task, and attempt") } exported, _ := json.Marshal(events) for _, forbidden := range []string{"password", "cookie", "token", "credential-a", "creatorhub/account-a"} { if strings.Contains(strings.ToLower(string(exported)), forbidden) { t.Fatalf("audit export contains sensitive field or credential reference %q", forbidden) } } if _, err := store.db.ExecContext(ctx, `UPDATE audit_event SET event_type = 'rewritten' WHERE id = 1`); err == nil { t.Fatal("audit events must be append-only") } } func createApprovedDraft(t *testing.T, store *Store, accountID string, accountVersion int64, draftID, confirmationID string) { t.Helper() ctx := context.Background() if err := store.CreateDraft(ctx, Draft{ID: draftID, AccountID: accountID, Version: 1, Content: "offline mock content"}); err != nil { t.Fatal(err) } if err := store.Confirm(ctx, Confirmation{ ID: confirmationID, AccountID: accountID, AccountVersion: accountVersion, DraftID: draftID, DraftVersion: 1, Version: 1, }); err != nil { t.Fatal(err) } } func approvedTask(index int, accountID string, accountVersion int64, draftID, confirmationID string) Task { return Task{ ID: fmt.Sprintf("task-%02d", index), IdempotencyKey: fmt.Sprintf("idempotency-%02d", index), AccountID: accountID, AccountVersion: accountVersion, DraftID: draftID, DraftVersion: 1, ConfirmationID: confirmationID, ConfirmationVersion: 1, } } func seedGateTask(t *testing.T, store *Store, suffix, accountStatus, authorizationStatus, exitStatus, runtimeLease string, binding, versionChanged, confirmationChanged bool) string { t.Helper() ctx := context.Background() accountID, taskID := "gate-"+suffix, "task-gate-"+suffix tx, err := store.db.BeginTx(ctx, nil) if err != nil { t.Fatal(err) } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `INSERT INTO credential_reference (id, provider, reference_key) VALUES ($1, 'os_keyring', $2)`, "credential-"+suffix, "creatorhub/gate-"+suffix); err != nil { t.Fatal(err) } if _, err := tx.ExecContext(ctx, ` INSERT INTO social_account (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status, status, version) VALUES ($1, $2, 'mock', $1, 'owned', $3, $4, 1)`, accountID, "credential-"+suffix, authorizationStatus, accountStatus); err != nil { t.Fatal(err) } if _, err := tx.ExecContext(ctx, `INSERT INTO content_draft (id, account_id, version, content) VALUES ($1, $2, 1, 'gate test')`, "draft-gate-"+suffix, accountID); err != nil { t.Fatal(err) } if _, err := tx.ExecContext(ctx, ` INSERT INTO confirmation (id, account_id, account_version, draft_id, draft_version, version) VALUES ($1, $2, 1, $3, 1, 1)`, "confirmation-gate-"+suffix, accountID, "draft-gate-"+suffix); err != nil { t.Fatal(err) } if binding { exitID := "exit-gate-" + suffix if _, err := tx.ExecContext(ctx, `INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint) VALUES ($1, $1, 'test-gateway', '1', '{"seed":3}')`, accountID); err != nil { t.Fatal(err) } if _, err := tx.ExecContext(ctx, `INSERT INTO network_exit (id, protocol, host, port, health_status) VALUES ($1, 'socks5', '127.0.0.1', 1080, $2)`, exitID, exitStatus); err != nil { t.Fatal(err) } if _, err := tx.ExecContext(ctx, ` INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id, version) VALUES ($1, $2, $2, $3, 1)`, "binding-"+suffix, accountID, exitID); err != nil { t.Fatal(err) } if runtimeLease != "" { interval := "1 minute" if runtimeLease == "expired" { interval = "-1 second" } if _, err := tx.ExecContext(ctx, ` INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until) VALUES ($1, $2, $3, 1, $4, now() + $5::interval)`, "runtime-gate-"+suffix, accountID, "binding-"+suffix, "container-gate-"+suffix, interval); err != nil { t.Fatal(err) } } } if versionChanged { if _, err := tx.ExecContext(ctx, `UPDATE environment_binding SET version = 2 WHERE account_id = $1`, accountID); err != nil { t.Fatal(err) } } if err := tx.Commit(); err != nil { t.Fatal(err) } confirmationVersion := int64(1) if confirmationChanged { confirmationVersion = 2 } if _, _, err := store.Enqueue(ctx, Task{ ID: taskID, IdempotencyKey: "idempotency-gate-" + suffix, AccountID: accountID, AccountVersion: 1, DraftID: "draft-gate-" + suffix, DraftVersion: 1, ConfirmationID: "confirmation-gate-" + suffix, ConfirmationVersion: confirmationVersion, }); err != nil { t.Fatal(err) } return taskID } func assertTaskGate(t *testing.T, store *Store, taskID, wantState, wantReason string) { t.Helper() var state string if err := store.db.QueryRow(`SELECT state FROM operation_task WHERE id = $1`, taskID).Scan(&state); err != nil || state != wantState { t.Fatalf("task gate state: got=%q want=%q err=%v", state, wantState, err) } var attempts int if err := store.db.QueryRow(`SELECT count(*) FROM execution_attempt WHERE task_id = $1`, taskID).Scan(&attempts); err != nil || attempts != 0 { t.Fatalf("gated task executed: attempts=%d err=%v", attempts, err) } var reason string if err := store.db.QueryRow(`SELECT reason_code FROM audit_event WHERE task_id = $1 AND event_type = 'task_quarantined' ORDER BY id DESC LIMIT 1`, taskID).Scan(&reason); err != nil || reason != wantReason { t.Fatalf("task gate reason: got=%q want=%q err=%v", reason, wantReason, err) } } func applyHubMigrationsForPhaseATest(t *testing.T, store *Store) { t.Helper() for _, migrationFile := range []struct { version int name string }{{2, "002_hub.sql"}, {3, "003_unified_accounts.sql"}, {4, "004_environment_actions.sql"}, {5, "005_sanitize_legacy_proxy.sql"}, {6, "006_runtime_cleanup.sql"}, {7, "007_runtime_binding_version.sql"}} { var applied bool if err := store.db.QueryRow(`SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migrationFile.version).Scan(&applied); err != nil { t.Fatal(err) } if applied { continue } migration, err := os.ReadFile(filepath.Join("..", "hub", "migrations", migrationFile.name)) if err != nil { t.Fatal(err) } if _, err := store.db.Exec(string(migration)); err != nil { t.Fatalf("apply test migration %d: %v", migrationFile.version, err) } if _, err := store.db.Exec(`INSERT INTO schema_migration (version) VALUES ($1)`, migrationFile.version); err != nil { t.Fatal(err) } } } func assertCount(t *testing.T, store *Store, query string, expected int) { t.Helper() var actual int if err := store.db.QueryRowContext(context.Background(), query).Scan(&actual); err != nil || actual != expected { t.Fatalf("count mismatch: expected=%d actual=%d err=%v query=%s", expected, actual, err, query) } }