package main import ( "io" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/graph" ) func fakeRepoEntry(path string) config.RepoEntry { return config.RepoEntry{Path: path} } // TestSnapshot_RestartStability is the B1 regression guard at the // daemon-snapshot layer: N save/load cycles on top of the existing // graph must not grow node or edge counts. Before Phase 2, re-running // a snapshot load on top of a warm graph doubled edges every cycle // because AddNode/AddEdge appended blindly. func TestSnapshot_RestartStability(t *testing.T) { dir := t.TempDir() t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) orig := graph.New() orig.AddNode(&graph.Node{ID: "a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "a.go"}) orig.AddNode(&graph.Node{ID: "a.go::Bar", Name: "Bar", Kind: graph.KindFunction, FilePath: "a.go"}) orig.AddEdge(&graph.Edge{ From: "a.go::Foo", To: "a.go::Bar", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 5, }) want := orig.Stats() // Save once; then repeatedly load into the same graph. Each // load re-applies AddNode / AddEdge for every record. With // idempotent writes, stats must not drift. saveSnapshot(orig, nil, nil, snapshotVector{}, version, zap.NewNop()) for cycle := 0; cycle < 5; cycle++ { result, err := loadSnapshot(orig, zap.NewNop()) require.NoError(t, err, "cycle %d", cycle) require.True(t, result.Loaded, "cycle %d", cycle) got := orig.Stats() assert.Equal(t, want.TotalNodes, got.TotalNodes, "cycle %d: nodes drifted", cycle) assert.Equal(t, want.TotalEdges, got.TotalEdges, "cycle %d: edges drifted (B1 regression)", cycle) } } // TestSnapshot_RoundTripsRepos verifies the new per-repo FileMtimes // section survives encode → decode. Without this, IncrementalReindex on // warmup treats every file as stale (empty mtime map → IsStale=true for // all), producing the same duplicate-writes problem we fixed. func TestSnapshot_RoundTripsRepos(t *testing.T) { dir := t.TempDir() t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) g := graph.New() g.AddNode(&graph.Node{ID: "repo/a.go::X", Name: "X", Kind: graph.KindFunction, FilePath: "repo/a.go", RepoPrefix: "repo"}) repos := []snapshotRepo{{ RepoPrefix: "repo", RootPath: "/abs/path/to/repo", FileMtimes: map[string]int64{ "a.go": 1000, "b/c.go": 2000, "x/y/z.go": 3000, }, }} saveSnapshot(g, repos, nil, snapshotVector{}, version, zap.NewNop()) restored := graph.New() result, err := loadSnapshot(restored, zap.NewNop()) require.NoError(t, err) require.True(t, result.Loaded) require.NotNil(t, result.Repos, "repo section must round-trip") r, ok := result.Repos["repo"] require.True(t, ok, "repo prefix must be keyed correctly") assert.Equal(t, "/abs/path/to/repo", r.RootPath) assert.Equal(t, int64(1000), r.FileMtimes["a.go"]) assert.Equal(t, int64(2000), r.FileMtimes["b/c.go"]) assert.Equal(t, int64(3000), r.FileMtimes["x/y/z.go"]) } // TestSnapshot_BackwardCompat_OldSnapshotLoads_NoRepos verifies that a // snapshot written without repo metadata (the v2 layout, pre-Phase 1) // loads cleanly on the new daemon — the RepoCount field decodes as // zero, the repo section is empty, and Repos is nil. The new daemon // falls back to TrackRepoCtx, which does a full re-index. func TestSnapshot_BackwardCompat_OldSnapshotLoads_NoRepos(t *testing.T) { dir := t.TempDir() t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) g := graph.New() g.AddNode(&graph.Node{ID: "a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "a.go"}) // No repos passed — simulates a snapshot from before the // RepoCount field existed. (The on-disk bytes are the same // either way: saveSnapshot writes zero repo records when the // slice is empty, matching what older daemons produced.) saveSnapshot(g, nil, nil, snapshotVector{}, version, zap.NewNop()) restored := graph.New() result, err := loadSnapshot(restored, zap.NewNop()) require.NoError(t, err) assert.True(t, result.Loaded) assert.False(t, result.Partial) assert.Empty(t, result.Repos, "empty repos must surface as nil/empty map, not an error") assert.Equal(t, 1, restored.NodeCount()) } // TestSnapshot_DanglingEdgesDropped verifies the structural-validation // pass: edges whose endpoints aren't in the loaded node set are // dropped, not stored. A corrupt or truncated snapshot that landed // with dangling refs used to surface later as "edge pointing at nil // node" panics in traversal code; the loader's contract is to drop // them at load time. func TestSnapshot_DanglingEdgesDropped(t *testing.T) { dir := t.TempDir() t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) g := graph.New() g.AddNode(&graph.Node{ID: "a.go::A", Name: "A", Kind: graph.KindFunction, FilePath: "a.go"}) // B is deliberately NOT added — this edge will be dangling on load. // AddEdge still stores it (the graph doesn't validate at insert // time), but loadSnapshot should reject it during the structural // validation pass after nodes are in place. g.AddEdge(&graph.Edge{ From: "a.go::A", To: "b.go::B", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1, }) saveSnapshot(g, nil, nil, snapshotVector{}, version, zap.NewNop()) restored := graph.New() result, err := loadSnapshot(restored, zap.NewNop()) require.NoError(t, err) require.True(t, result.Loaded) assert.Equal(t, 1, restored.NodeCount(), "only the valid node should land") assert.Equal(t, 0, restored.EdgeCount(), "dangling edge must be dropped during load validation") } // TestCanMigrate covers the migration-chain guard that gates between // "attempt to migrate" and "discard cache on schema mismatch" paths. // The registry ships empty (Phase 4 is scaffolding) so every answer // today is `false`, but the shape of the lookup matters — a future // PR that lands a real migration needs this to return `true` only // when the chain is complete. func TestCanMigrate(t *testing.T) { // from >= to → no migration needed. assert.False(t, canMigrate(3, 3)) assert.False(t, canMigrate(5, 2)) // Empty registry — no migrations are available. assert.False(t, canMigrate(1, 2)) // Install a fake v1→v2 migration. A complete chain reports true; // a missing link (v2→v3 never registered) reports false. orig := snapshotMigrations t.Cleanup(func() { snapshotMigrations = orig }) snapshotMigrations = map[int]snapshotMigration{ 1: func(in io.Reader, out io.Writer) error { return nil }, } assert.True(t, canMigrate(1, 2)) assert.False(t, canMigrate(1, 3), "missing v2→v3 link breaks the chain") } // TestPriorMtimesForEntry_MatchesByAbsRootPath is the core of the // warmup decision: given a snapshot's per-repo metadata and a config // entry, priorMtimesForEntry must resolve the right FileMtimes for // incremental reconciliation. A miss here causes warmup to fall back // to full re-indexing — not a correctness bug, but the whole point of // Phase 1 is to avoid the full pass on routine restart. func TestPriorMtimesForEntry_MatchesByAbsRootPath(t *testing.T) { absPath := filepath.Join(t.TempDir(), "repo") repos := map[string]*snapshotRepo{ "repo": { RepoPrefix: "repo", RootPath: absPath, FileMtimes: map[string]int64{"a.go": 42}, }, } // Exact absolute-path match. entry := fakeRepoEntry(absPath) got := priorMtimesForEntry(repos, entry) assert.Equal(t, int64(42), got["a.go"]) // No match → nil. miss := fakeRepoEntry(filepath.Join(t.TempDir(), "nope")) assert.Nil(t, priorMtimesForEntry(repos, miss)) // Empty repos map is safe — no panic, returns nil. assert.Nil(t, priorMtimesForEntry(nil, entry)) }