From 4b6373861cf892a1c213ac4ababcae33ef025497 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 4 May 2026 09:01:31 -0700 Subject: [PATCH] Memory v2 fixup C2: backfill -verify mode (parity check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review missed deliverable from PR-7's task spec. Operators had no way to confirm a -apply produced equivalent search results to the legacy agent_memories direct queries; this PR ships that. Usage: memory-backfill -verify # 50-workspace random sample memory-backfill -verify -verify-sample=200 # bigger sample memory-backfill -verify -workspace= # one specific workspace Algorithm: 1. Pick N random workspaces (or use -workspace if specified) 2. For each: query agent_memories direct, query plugin search via the workspace's readable namespace list 3. Multiset-compare contents: every legacy row must have a matching plugin row. Plugin having MORE rows is OK (team-shared content may be visible from sibling workspaces). 4. Print mismatches with content excerpt; non-zero mismatches/errors yields a non-zero exit so CI can gate cutover. Sql: - Sampling uses ORDER BY random() LIMIT N (TABLESAMPLE has surprising distribution at small populations). - Filters out status='removed' workspaces. Test coverage: * pickWorkspaceSample: single-ws short-circuit, random sampling, query error, scan error * queryLegacyMemories: happy path, error path * verifyParity: - all match → 1 match, 0 mismatch - missing-from-plugin → 1 mismatch with content excerpt - plugin-extra rows → 1 match (legacy is subset of plugin) - legacy query error → 1 error counter - resolver error → 1 error counter - plugin search error → 1 error counter - no readable namespaces + empty legacy → match - no readable namespaces + non-empty legacy → mismatch - pickSample error → propagated up * CLI: -verify+-apply rejected as mutually exclusive; -verify alone is a valid mode Note: namespaceResolverAdapter bridges *namespace.Resolver to the verify package's verifyResolver interface so verify.go has zero dependency on the namespace package — keeps test stubs minimal. --- workspace-server/cmd/memory-backfill/main.go | 56 ++- .../cmd/memory-backfill/verify.go | 200 +++++++++ .../cmd/memory-backfill/verify_test.go | 390 ++++++++++++++++++ 3 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 workspace-server/cmd/memory-backfill/verify.go create mode 100644 workspace-server/cmd/memory-backfill/verify_test.go diff --git a/workspace-server/cmd/memory-backfill/main.go b/workspace-server/cmd/memory-backfill/main.go index 96ef7d21..de37a8d9 100644 --- a/workspace-server/cmd/memory-backfill/main.go +++ b/workspace-server/cmd/memory-backfill/main.go @@ -48,13 +48,25 @@ func run(argv []string, stdout, stderr *os.File) error { fs.SetOutput(stderr) dryRun := fs.Bool("dry-run", false, "count + diff only, no writes") apply := fs.Bool("apply", false, "actually copy rows to the plugin") + verify := fs.Bool("verify", false, "post-apply parity check: random-sample N workspaces, diff agent_memories vs plugin search") + verifySample := fs.Int("verify-sample", 50, "number of workspaces to sample in -verify mode") workspace := fs.String("workspace", "", "limit to a single workspace UUID (empty = all)") limit := fs.Int("limit", defaultLimit, "max rows to process this run") if err := fs.Parse(argv); err != nil { return err } - if *dryRun == *apply { - return errors.New("specify exactly one of -dry-run or -apply") + modesPicked := 0 + if *dryRun { + modesPicked++ + } + if *apply { + modesPicked++ + } + if *verify { + modesPicked++ + } + if modesPicked != 1 { + return errors.New("specify exactly one of -dry-run, -apply, or -verify") } dbURL := os.Getenv("DATABASE_URL") @@ -80,6 +92,26 @@ func run(argv []string, stdout, stderr *os.File) error { plugin := mclient.New(mclient.Config{BaseURL: pluginURL}) resolver := namespace.New(db) + if *verify { + vcfg := verifyConfig{ + DB: db, + Plugin: plugin, + Resolver: namespaceResolverAdapter{resolver}, + SampleSize: *verifySample, + WorkspaceID: *workspace, + } + report, err := verifyParity(context.Background(), vcfg, stdout) + if err != nil { + return err + } + fmt.Fprintf(stdout, "\nVerify complete: workspaces_sampled=%d matches=%d mismatches=%d errors=%d\n", + report.WorkspacesSampled, report.Matches, report.Mismatches, report.Errors) + if report.Mismatches > 0 || report.Errors > 0 { + return fmt.Errorf("verify found %d mismatches and %d errors", report.Mismatches, report.Errors) + } + return nil + } + cfg := backfillConfig{ DB: db, Plugin: plugin, @@ -245,3 +277,23 @@ func namespaceKindFromString(scope string) contract.NamespaceKind { return contract.NamespaceKindWorkspace } } + +// namespaceResolverAdapter bridges *namespace.Resolver (which returns +// []namespace.Namespace) to verify.go's verifyResolver interface +// (which wants []ResolvedNamespace). Keeps verify.go independent of +// the namespace-package dependency so its tests can stub easily. +type namespaceResolverAdapter struct { + r *namespace.Resolver +} + +func (a namespaceResolverAdapter) ReadableNamespaces(ctx context.Context, workspaceID string) ([]ResolvedNamespace, error) { + src, err := a.r.ReadableNamespaces(ctx, workspaceID) + if err != nil { + return nil, err + } + out := make([]ResolvedNamespace, len(src)) + for i, ns := range src { + out[i] = ResolvedNamespace{Name: ns.Name} + } + return out, nil +} diff --git a/workspace-server/cmd/memory-backfill/verify.go b/workspace-server/cmd/memory-backfill/verify.go new file mode 100644 index 00000000..e522e740 --- /dev/null +++ b/workspace-server/cmd/memory-backfill/verify.go @@ -0,0 +1,200 @@ +package main + +// verify.go — post-apply parity check. +// +// After a backfill -apply, run with -verify to confirm the migration +// actually produced equivalent data. Picks `SampleSize` random +// workspaces, queries agent_memories direct + plugin search via the +// caller's namespaces, and diffs the result sets by content. +// +// The diff is best-effort: pg's recent-first ordering and the plugin's +// internal ordering may differ, so we compare as sets, not lists. +// We do require strict 1:1 multiset equality (every legacy row maps +// to exactly one plugin row, ignoring id since the backfill preserves +// it via the C1 idempotency key). + +import ( + "context" + "database/sql" + "fmt" + "math/rand" + "os" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract" +) + +// verifyConfig is the typed dependency bundle for verifyParity. +type verifyConfig struct { + DB *sql.DB + Plugin verifyPlugin + Resolver verifyResolver + SampleSize int + WorkspaceID string // optional: limit to one workspace + Rand *rand.Rand +} + +// verifyPlugin is the slice of memory-plugin client we call. +type verifyPlugin interface { + Search(ctx context.Context, body contract.SearchRequest) (*contract.SearchResponse, error) +} + +// verifyResolver mirrors namespace.Resolver. Same shape as +// backfillResolver but kept distinct so verify isn't tied to +// backfill's interface. +type verifyResolver interface { + ReadableNamespaces(ctx context.Context, workspaceID string) ([]ResolvedNamespace, error) +} + +// ResolvedNamespace is the minimum we need from the resolver — kept +// separate so the verify code doesn't depend on the namespace package +// (the live tests inject stubs, the binary uses an adapter). +type ResolvedNamespace struct { + Name string +} + +// verifyReport accumulates the per-workspace results. +type verifyReport struct { + WorkspacesSampled int + Matches int + Mismatches int + Errors int +} + +// verifyParity is the workhorse. Returns a report; the CLI converts +// any non-zero mismatches/errors into a non-zero exit so CI can gate +// the cutover. +func verifyParity(ctx context.Context, cfg verifyConfig, stdout *os.File) (*verifyReport, error) { + report := &verifyReport{} + rng := cfg.Rand + if rng == nil { + rng = rand.New(rand.NewSource(42)) //nolint:gosec // determinism > unpredictability for ops + } + + wsIDs, err := pickWorkspaceSample(ctx, cfg.DB, cfg.WorkspaceID, cfg.SampleSize, rng) + if err != nil { + return report, fmt.Errorf("pick sample: %w", err) + } + + for _, wsID := range wsIDs { + report.WorkspacesSampled++ + legacy, err := queryLegacyMemories(ctx, cfg.DB, wsID) + if err != nil { + fmt.Fprintf(stdout, "[err] workspace=%s legacy query: %v\n", wsID, err) + report.Errors++ + continue + } + readable, err := cfg.Resolver.ReadableNamespaces(ctx, wsID) + if err != nil { + fmt.Fprintf(stdout, "[err] workspace=%s resolve: %v\n", wsID, err) + report.Errors++ + continue + } + nsList := make([]string, len(readable)) + for i, ns := range readable { + nsList[i] = ns.Name + } + if len(nsList) == 0 { + // No readable namespaces — empty plugin result expected. + if len(legacy) == 0 { + report.Matches++ + } else { + fmt.Fprintf(stdout, "[mismatch] workspace=%s legacy=%d plugin=0 (no readable namespaces)\n", wsID, len(legacy)) + report.Mismatches++ + } + continue + } + resp, err := cfg.Plugin.Search(ctx, contract.SearchRequest{Namespaces: nsList, Limit: 100}) + if err != nil { + fmt.Fprintf(stdout, "[err] workspace=%s plugin search: %v\n", wsID, err) + report.Errors++ + continue + } + pluginContents := make(map[string]int, len(resp.Memories)) + for _, m := range resp.Memories { + pluginContents[m.Content]++ + } + // Compare as multisets: each legacy content appears at least + // once in plugin output. We deliberately tolerate plugin + // having MORE rows (the namespace might include team-shared + // memories from sibling workspaces that aren't in this + // workspace's agent_memories rows). + matched := true + for _, c := range legacy { + if pluginContents[c] == 0 { + fmt.Fprintf(stdout, "[mismatch] workspace=%s missing-from-plugin content=%q\n", wsID, truncate(c, 80)) + matched = false + break + } + pluginContents[c]-- + } + if matched { + report.Matches++ + } else { + report.Mismatches++ + } + } + return report, nil +} + +// pickWorkspaceSample returns up to N workspace UUIDs. If +// WorkspaceID is set, returns only that one. Otherwise selects N +// random workspaces from the workspaces table (TABLESAMPLE would be +// nicer but SYSTEM/BERNOULLI sampling has surprising distribution +// properties for small populations; we just ORDER BY random() LIMIT). +func pickWorkspaceSample(ctx context.Context, db *sql.DB, workspaceID string, n int, _ *rand.Rand) ([]string, error) { + if workspaceID != "" { + return []string{workspaceID}, nil + } + rows, err := db.QueryContext(ctx, ` + SELECT id::text + FROM workspaces + WHERE status != 'removed' + ORDER BY random() + LIMIT $1 + `, n) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]string, 0, n) + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// queryLegacyMemories pulls all agent_memories rows for a workspace +// (LOCAL + TEAM scopes — what the plugin search would return through +// the resolver's readable list, mapped via PR-6 shim semantics). +func queryLegacyMemories(ctx context.Context, db *sql.DB, workspaceID string) ([]string, error) { + rows, err := db.QueryContext(ctx, ` + SELECT content + FROM agent_memories + WHERE workspace_id = $1 + ORDER BY created_at DESC + `, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []string{} + for rows.Next() { + var c string + if err := rows.Scan(&c); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/workspace-server/cmd/memory-backfill/verify_test.go b/workspace-server/cmd/memory-backfill/verify_test.go new file mode 100644 index 00000000..8ffe806a --- /dev/null +++ b/workspace-server/cmd/memory-backfill/verify_test.go @@ -0,0 +1,390 @@ +package main + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract" +) + +// stubVerifyPlugin records search calls and returns canned results. +type stubVerifyPlugin struct { + searchFn func(ctx context.Context, body contract.SearchRequest) (*contract.SearchResponse, error) +} + +func (s *stubVerifyPlugin) Search(ctx context.Context, body contract.SearchRequest) (*contract.SearchResponse, error) { + if s.searchFn != nil { + return s.searchFn(ctx, body) + } + return &contract.SearchResponse{}, nil +} + +// stubVerifyResolver returns a canned readable namespace list. +type stubVerifyResolver struct { + namespaces []ResolvedNamespace + err error +} + +func (s *stubVerifyResolver) ReadableNamespaces(_ context.Context, _ string) ([]ResolvedNamespace, error) { + return s.namespaces, s.err +} + +// --- pickWorkspaceSample --- + +func TestPickWorkspaceSample_SingleWorkspaceShortCircuit(t *testing.T) { + db, _, _ := sqlmock.New() + defer db.Close() + got, err := pickWorkspaceSample(context.Background(), db, "specific-ws", 50, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 1 || got[0] != "specific-ws" { + t.Errorf("got %v, want [specific-ws]", got) + } +} + +func TestPickWorkspaceSample_RandomSample(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WithArgs(50). + WillReturnRows(sqlmock.NewRows([]string{"id"}). + AddRow("ws-1"). + AddRow("ws-2"). + AddRow("ws-3")) + got, err := pickWorkspaceSample(context.Background(), db, "", 50, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 3 { + t.Errorf("got len %d, want 3", len(got)) + } +} + +func TestPickWorkspaceSample_QueryError(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WillReturnError(errors.New("dead")) + _, err := pickWorkspaceSample(context.Background(), db, "", 50, nil) + if err == nil { + t.Error("expected error") + } +} + +func TestPickWorkspaceSample_ScanError(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WillReturnRows(sqlmock.NewRows([]string{"id", "extra"}). // wrong shape + AddRow("ws-1", "extra")) + _, err := pickWorkspaceSample(context.Background(), db, "", 50, nil) + if err == nil { + t.Error("expected scan error") + } +} + +// --- queryLegacyMemories --- + +func TestQueryLegacyMemories_HappyPath(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT content FROM agent_memories"). + WithArgs("ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"content"}). + AddRow("fact 1"). + AddRow("fact 2")) + got, err := queryLegacyMemories(context.Background(), db, "ws-1") + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 2 || got[0] != "fact 1" { + t.Errorf("got %v", got) + } +} + +func TestQueryLegacyMemories_QueryError(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT content FROM agent_memories"). + WillReturnError(errors.New("dead")) + _, err := queryLegacyMemories(context.Background(), db, "ws-1") + if err == nil { + t.Error("expected error") + } +} + +// --- verifyParity (the workhorse) --- + +func TestVerifyParity_AllMatch(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) + mock.ExpectQuery("SELECT content FROM agent_memories"). + WithArgs("ws-1"). + WillReturnRows(sqlmock.NewRows([]string{"content"}). + AddRow("fact A"). + AddRow("fact B")) + + plugin := &stubVerifyPlugin{ + searchFn: func(_ context.Context, _ contract.SearchRequest) (*contract.SearchResponse, error) { + return &contract.SearchResponse{Memories: []contract.Memory{ + {ID: "id-A", Content: "fact A"}, + {ID: "id-B", Content: "fact B"}, + }}, nil + }, + } + resolver := &stubVerifyResolver{ + namespaces: []ResolvedNamespace{{Name: "workspace:ws-1"}}, + } + cfg := verifyConfig{DB: db, Plugin: plugin, Resolver: resolver, SampleSize: 50} + devnull, _ := os.Open(os.DevNull) + defer devnull.Close() + report, err := verifyParity(context.Background(), cfg, devnull) + if err != nil { + t.Fatalf("err: %v", err) + } + if report.Matches != 1 || report.Mismatches != 0 || report.Errors != 0 { + t.Errorf("report = %+v, want 1 match", report) + } +} + +func TestVerifyParity_MismatchDetectsMissingFromPlugin(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) + mock.ExpectQuery("SELECT content FROM agent_memories"). + WillReturnRows(sqlmock.NewRows([]string{"content"}). + AddRow("fact A"). + AddRow("fact-missing-from-plugin")) + + plugin := &stubVerifyPlugin{ + searchFn: func(_ context.Context, _ contract.SearchRequest) (*contract.SearchResponse, error) { + return &contract.SearchResponse{Memories: []contract.Memory{ + {ID: "id-A", Content: "fact A"}, + }}, nil + }, + } + resolver := &stubVerifyResolver{ + namespaces: []ResolvedNamespace{{Name: "workspace:ws-1"}}, + } + cfg := verifyConfig{DB: db, Plugin: plugin, Resolver: resolver, SampleSize: 50} + devnull, _ := os.Open(os.DevNull) + defer devnull.Close() + report, err := verifyParity(context.Background(), cfg, devnull) + if err != nil { + t.Fatalf("err: %v", err) + } + if report.Mismatches != 1 { + t.Errorf("report = %+v, want 1 mismatch", report) + } +} + +func TestVerifyParity_PluginExtraRowsTolerated(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) + mock.ExpectQuery("SELECT content FROM agent_memories"). + WillReturnRows(sqlmock.NewRows([]string{"content"}). + AddRow("fact A")) + + // Plugin returns more rows (e.g., team-shared from a sibling). + // Verify treats this as a match — legacy is a subset of plugin. + plugin := &stubVerifyPlugin{ + searchFn: func(_ context.Context, _ contract.SearchRequest) (*contract.SearchResponse, error) { + return &contract.SearchResponse{Memories: []contract.Memory{ + {ID: "id-A", Content: "fact A"}, + {ID: "id-team-1", Content: "team-shared content from sibling"}, + }}, nil + }, + } + resolver := &stubVerifyResolver{ + namespaces: []ResolvedNamespace{{Name: "workspace:ws-1"}, {Name: "team:root"}}, + } + cfg := verifyConfig{DB: db, Plugin: plugin, Resolver: resolver, SampleSize: 50} + devnull, _ := os.Open(os.DevNull) + defer devnull.Close() + report, err := verifyParity(context.Background(), cfg, devnull) + if err != nil { + t.Fatalf("err: %v", err) + } + if report.Matches != 1 || report.Mismatches != 0 { + t.Errorf("report = %+v, want 1 match (plugin-extra is OK)", report) + } +} + +func TestVerifyParity_LegacyQueryError(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) + mock.ExpectQuery("SELECT content FROM agent_memories"). + WillReturnError(errors.New("dead")) + + cfg := verifyConfig{ + DB: db, + Plugin: &stubVerifyPlugin{}, + Resolver: &stubVerifyResolver{namespaces: []ResolvedNamespace{{Name: "workspace:ws-1"}}}, + } + devnull, _ := os.Open(os.DevNull) + defer devnull.Close() + report, err := verifyParity(context.Background(), cfg, devnull) + if err != nil { + t.Fatalf("err: %v", err) + } + if report.Errors != 1 { + t.Errorf("report = %+v, want 1 error", report) + } +} + +func TestVerifyParity_ResolverError(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) + mock.ExpectQuery("SELECT content FROM agent_memories"). + WillReturnRows(sqlmock.NewRows([]string{"content"}).AddRow("x")) + + cfg := verifyConfig{ + DB: db, + Plugin: &stubVerifyPlugin{}, + Resolver: &stubVerifyResolver{err: errors.New("dead")}, + } + devnull, _ := os.Open(os.DevNull) + defer devnull.Close() + report, _ := verifyParity(context.Background(), cfg, devnull) + if report.Errors != 1 { + t.Errorf("report = %+v, want 1 error", report) + } +} + +func TestVerifyParity_PluginSearchError(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) + mock.ExpectQuery("SELECT content FROM agent_memories"). + WillReturnRows(sqlmock.NewRows([]string{"content"}).AddRow("x")) + + cfg := verifyConfig{ + DB: db, + Plugin: &stubVerifyPlugin{ + searchFn: func(_ context.Context, _ contract.SearchRequest) (*contract.SearchResponse, error) { + return nil, errors.New("plugin dead") + }, + }, + Resolver: &stubVerifyResolver{namespaces: []ResolvedNamespace{{Name: "workspace:ws-1"}}}, + } + devnull, _ := os.Open(os.DevNull) + defer devnull.Close() + report, _ := verifyParity(context.Background(), cfg, devnull) + if report.Errors != 1 { + t.Errorf("report = %+v, want 1 error", report) + } +} + +func TestVerifyParity_NoReadableNamespacesEmptyLegacy(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) + mock.ExpectQuery("SELECT content FROM agent_memories"). + WillReturnRows(sqlmock.NewRows([]string{"content"})) // empty + + cfg := verifyConfig{ + DB: db, + Plugin: &stubVerifyPlugin{}, + Resolver: &stubVerifyResolver{namespaces: []ResolvedNamespace{}}, // empty + } + devnull, _ := os.Open(os.DevNull) + defer devnull.Close() + report, _ := verifyParity(context.Background(), cfg, devnull) + // Empty legacy + empty namespaces → match. + if report.Matches != 1 { + t.Errorf("report = %+v, want 1 match (both empty)", report) + } +} + +func TestVerifyParity_NoReadableNamespacesNonEmptyLegacy(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) + mock.ExpectQuery("SELECT content FROM agent_memories"). + WillReturnRows(sqlmock.NewRows([]string{"content"}).AddRow("orphan-fact")) + + cfg := verifyConfig{ + DB: db, + Plugin: &stubVerifyPlugin{}, + Resolver: &stubVerifyResolver{namespaces: []ResolvedNamespace{}}, + } + devnull, _ := os.Open(os.DevNull) + defer devnull.Close() + report, _ := verifyParity(context.Background(), cfg, devnull) + // Legacy has rows but plugin can't see any → mismatch. + if report.Mismatches != 1 { + t.Errorf("report = %+v, want 1 mismatch", report) + } +} + +func TestVerifyParity_PickSampleError(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + mock.ExpectQuery("SELECT id::text FROM workspaces"). + WillReturnError(errors.New("dead")) + cfg := verifyConfig{DB: db, Plugin: &stubVerifyPlugin{}, Resolver: &stubVerifyResolver{}} + devnull, _ := os.Open(os.DevNull) + defer devnull.Close() + _, err := verifyParity(context.Background(), cfg, devnull) + if err == nil || !strings.Contains(err.Error(), "pick sample") { + t.Errorf("err = %v", err) + } +} + +// --- Truncate --- + +func TestVerifyTruncate(t *testing.T) { + if got := truncate("short", 10); got != "short" { + t.Errorf("got %q", got) + } + if got := truncate(strings.Repeat("a", 200), 10); !strings.HasSuffix(got, "…") { + t.Errorf("expected ellipsis: %q", got) + } +} + +// --- CLI: -verify mode --- + +func TestRun_VerifyVsApplyMutuallyExclusive(t *testing.T) { + stderr, _ := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + defer stderr.Close() + stdout, _ := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + defer stdout.Close() + err := run([]string{"-verify", "-apply"}, stdout, stderr) + if err == nil || !strings.Contains(err.Error(), "exactly one") { + t.Errorf("err = %v", err) + } +} + +func TestRun_VerifyAloneIsValid(t *testing.T) { + t.Setenv("DATABASE_URL", "") + t.Setenv("MEMORY_PLUGIN_URL", "http://x") + stderr, _ := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + defer stderr.Close() + stdout, _ := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + defer stdout.Close() + err := run([]string{"-verify"}, stdout, stderr) + // Will fail later on missing DATABASE_URL, NOT on the + // mutually-exclusive-modes check. Asserts that -verify is + // recognized as a valid mode. + if err == nil || !strings.Contains(err.Error(), "DATABASE_URL") { + t.Errorf("err = %v, want DATABASE_URL error (-verify alone is a valid mode)", err) + } +}