Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

366 lines
11 KiB
Go

package persistence
import (
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
)
func testSnapshot() *Snapshot {
return &Snapshot{
Version: "0.1.0-test",
RepoPath: "/tmp/test-repo",
CommitHash: "abc123def456",
Branch: "main",
IndexedAt: time.Now().Truncate(time.Second),
Nodes: []*graph.Node{
{
ID: "main.go::Foo", Kind: graph.KindFunction, Name: "Foo",
FilePath: "main.go", StartLine: 1, EndLine: 5, Language: "go",
Meta: map[string]any{"signature": "func Foo(x int) error"},
},
{
ID: "main.go::Bar", Kind: graph.KindMethod, Name: "Bar",
FilePath: "main.go", StartLine: 7, EndLine: 12, Language: "go",
Meta: map[string]any{"receiver": "Server", "signature": "func (s *Server) Bar()"},
},
},
Edges: []*graph.Edge{
{
From: "main.go::Foo", To: "main.go::Bar", Kind: graph.EdgeCalls,
FilePath: "main.go", Line: 3, Confidence: 0.95,
Meta: map[string]any{"receiver_type": "Server"},
},
},
FileMtimes: map[string]int64{
"main.go": 1700000000000000000,
"util.go": 1700000001000000000,
},
}
}
func TestFileStore_RoundTrip(t *testing.T) {
dir := t.TempDir()
fs, err := NewFileStore(dir, "0.1.0-test")
require.NoError(t, err)
snap := testSnapshot()
require.NoError(t, fs.Save(snap))
assert.True(t, fs.Check(snap.RepoPath, snap.Branch, snap.CommitHash))
assert.True(t, fs.Validate(snap.RepoPath, snap.Branch, snap.CommitHash))
loaded, err := fs.Load(snap.RepoPath, snap.Branch, snap.CommitHash)
require.NoError(t, err)
assert.Equal(t, snap.Version, loaded.Version)
assert.Equal(t, snap.RepoPath, loaded.RepoPath)
assert.Equal(t, snap.CommitHash, loaded.CommitHash)
assert.Equal(t, snap.Branch, loaded.Branch)
assert.Equal(t, snap.IndexedAt, loaded.IndexedAt)
require.Len(t, loaded.Nodes, 2)
assert.Equal(t, "main.go::Foo", loaded.Nodes[0].ID)
assert.Equal(t, "Foo", loaded.Nodes[0].Name)
assert.Equal(t, "func Foo(x int) error", loaded.Nodes[0].Meta["signature"])
assert.Equal(t, "Server", loaded.Nodes[1].Meta["receiver"])
require.Len(t, loaded.Edges, 1)
assert.Equal(t, "main.go::Foo", loaded.Edges[0].From)
assert.Equal(t, "main.go::Bar", loaded.Edges[0].To)
assert.Equal(t, 0.95, loaded.Edges[0].Confidence)
assert.Equal(t, "Server", loaded.Edges[0].Meta["receiver_type"])
assert.Equal(t, snap.FileMtimes, loaded.FileMtimes)
}
func TestFileStore_Validate_VersionMismatch(t *testing.T) {
dir := t.TempDir()
fsV1, err := NewFileStore(dir, "0.1.0")
require.NoError(t, err)
snap := testSnapshot()
snap.Version = "0.1.0"
require.NoError(t, fsV1.Save(snap))
// Same version validates.
assert.True(t, fsV1.Validate(snap.RepoPath, snap.Branch, snap.CommitHash))
// A different BINARY version now REUSES the slot — the extraction version
// (not the binary string) gates reuse, so a no-op release skips the rebuild.
fsV2, err := NewFileStore(dir, "0.2.0")
require.NoError(t, err)
assert.True(t, fsV2.Validate(snap.RepoPath, snap.Branch, snap.CommitHash))
// A LEGACY slot with no extraction-version marker falls back to the exact
// binary-version match — so it correctly invalidates across the bump.
entry := fsV2.entryDir(snap.RepoPath, snap.Branch, snap.CommitHash)
require.NoError(t, os.Remove(filepath.Join(entry, extractionVersionFile)))
assert.False(t, fsV2.Validate(snap.RepoPath, snap.Branch, snap.CommitHash))
}
func TestFileStore_Evict(t *testing.T) {
dir := t.TempDir()
fs, err := NewFileStore(dir, "0.1.0")
require.NoError(t, err)
snap := testSnapshot()
require.NoError(t, fs.Save(snap))
assert.True(t, fs.Check(snap.RepoPath, snap.Branch, snap.CommitHash))
require.NoError(t, fs.Evict(snap.RepoPath, snap.Branch, snap.CommitHash))
assert.False(t, fs.Check(snap.RepoPath, snap.Branch, snap.CommitHash))
}
func TestFileStore_Load_NotFound(t *testing.T) {
dir := t.TempDir()
fs, err := NewFileStore(dir, "0.1.0")
require.NoError(t, err)
_, err = fs.Load("/nonexistent", "main", "abc123")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestFileStore_MetaWithSliceTypes(t *testing.T) {
dir := t.TempDir()
fs, err := NewFileStore(dir, "0.1.0")
require.NoError(t, err)
snap := &Snapshot{
Version: "0.1.0",
RepoPath: "/tmp/test",
CommitHash: "def789",
Branch: "main",
IndexedAt: time.Now().Truncate(time.Second),
Nodes: []*graph.Node{
{
ID: "iface.go::Reader", Kind: graph.KindInterface, Name: "Reader",
FilePath: "iface.go", Language: "go",
Meta: map[string]any{"methods": []string{"Read", "Close"}},
},
},
FileMtimes: map[string]int64{"iface.go": 1700000000},
}
require.NoError(t, fs.Save(snap))
loaded, err := fs.Load(snap.RepoPath, snap.Branch, snap.CommitHash)
require.NoError(t, err)
methods, ok := loaded.Nodes[0].Meta["methods"].([]string)
require.True(t, ok, "methods should deserialize as []string")
assert.Equal(t, []string{"Read", "Close"}, methods)
}
// TestFileStore_BranchKeyedSlots proves snapshots are keyed by
// (repo, branch): two branches of the same repo, even at the same
// commit, occupy distinct slots, so switching branches never clobbers
// the other branch's cached index.
func TestFileStore_BranchKeyedSlots(t *testing.T) {
dir := t.TempDir()
fs, err := NewFileStore(dir, "0.1.0-test")
require.NoError(t, err)
main := testSnapshot()
main.Branch = "main"
feature := testSnapshot()
feature.Branch = "feature/login"
feature.Nodes[0].Name = "FeatureFoo"
require.NoError(t, fs.Save(main))
require.NoError(t, fs.Save(feature))
gotMain, err := fs.Load(main.RepoPath, "main", main.CommitHash)
require.NoError(t, err)
assert.Equal(t, "Foo", gotMain.Nodes[0].Name)
gotFeature, err := fs.Load(feature.RepoPath, "feature/login", feature.CommitHash)
require.NoError(t, err)
assert.Equal(t, "FeatureFoo", gotFeature.Nodes[0].Name)
}
// TestFileStore_DetachedHeadKeyedByCommit checks the detached-HEAD
// fallback: with no branch the slot keys on the commit hash, so two
// checked-out commits keep separate snapshots.
func TestFileStore_DetachedHeadKeyedByCommit(t *testing.T) {
dir := t.TempDir()
fs, err := NewFileStore(dir, "0.1.0-test")
require.NoError(t, err)
a := testSnapshot()
a.Branch = ""
a.CommitHash = "aaaaaaaaaaaa"
b := testSnapshot()
b.Branch = ""
b.CommitHash = "bbbbbbbbbbbb"
require.NoError(t, fs.Save(a))
require.NoError(t, fs.Save(b))
assert.True(t, fs.Check(a.RepoPath, "", "aaaaaaaaaaaa"))
assert.True(t, fs.Check(b.RepoPath, "", "bbbbbbbbbbbb"))
}
func TestNopStore(t *testing.T) {
var s NopStore
assert.False(t, s.Check("x", "main", "y"))
_, err := s.Load("x", "main", "y")
assert.ErrorIs(t, err, ErrNotFound)
assert.NoError(t, s.Save(testSnapshot()))
assert.False(t, s.Validate("x", "main", "y"))
assert.NoError(t, s.Evict("x", "main", "y"))
assert.NoError(t, s.Close())
}
// TestFileStore_ConcurrentSave exercises the cross-process advisory lock:
// every writer targets the same cache key, so without serialization
// one writer's os.RemoveAll would race another's MkdirAll/write sequence
// and leave a torn entry. flock(2) contends across file descriptors even
// within one process, so concurrent goroutines reproduce the cross-process
// hazard. After all writers complete, the entry must load as exactly one
// writer's complete payload.
func TestFileStore_ConcurrentSave(t *testing.T) {
dir := t.TempDir()
fs, err := NewFileStore(dir, "0.1.0-test")
require.NoError(t, err)
const writers = 12
markers := make(map[string]bool, writers)
var wg sync.WaitGroup
errs := make(chan error, writers)
for i := range writers {
marker := fmt.Sprintf("writer-%d", i)
markers[marker] = true
wg.Add(1)
go func(m string) {
defer wg.Done()
snap := testSnapshot()
snap.Nodes[0].Meta["writer"] = m
errs <- fs.Save(snap)
}(marker)
}
wg.Wait()
close(errs)
for e := range errs {
require.NoError(t, e)
}
loaded, err := fs.Load("/tmp/test-repo", "main", "abc123def456")
require.NoError(t, err)
require.Len(t, loaded.Nodes, 2)
got, _ := loaded.Nodes[0].Meta["writer"].(string)
assert.True(t, markers[got], "loaded snapshot must be one writer's complete payload, got %q", got)
}
// TestFileStore_ConcurrentReadWrite runs readers against a writer churning
// the same entry. The shared read lock must hand every reader either a
// fully decodable snapshot or a clean ErrNotFound — never a gob/gzip
// decode error from a half-written file.
func TestFileStore_ConcurrentReadWrite(t *testing.T) {
dir := t.TempDir()
fs, err := NewFileStore(dir, "0.1.0-test")
require.NoError(t, err)
snap := testSnapshot()
require.NoError(t, fs.Save(snap))
var wg sync.WaitGroup
errs := make(chan error, 64)
stop := make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
var e error
if i%2 == 0 {
e = fs.Save(snap)
} else {
e = fs.Evict(snap.RepoPath, snap.Branch, snap.CommitHash)
}
// Honour stop while sending: errs is buffered, and the
// writer outruns the buffer in microseconds. Without the
// stop arm here the writer blocks on a full errs channel,
// never re-checks stop, and wg.Wait() deadlocks (the buffer
// only drains after wg.Wait()).
select {
case errs <- e:
case <-stop:
return
}
}
}()
for r := 0; r < 6; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 40; i++ {
_, err := fs.Load(snap.RepoPath, snap.Branch, snap.CommitHash)
if err != nil && err != ErrNotFound {
errs <- err
}
}
}()
}
time.Sleep(50 * time.Millisecond)
close(stop)
wg.Wait()
close(errs)
for e := range errs {
require.NoError(t, e, "no reader may observe a torn snapshot")
}
}
// TestSnapshotReuseAcrossBinaryBump proves the warm snapshot is reused across a
// binary version bump that did NOT change extraction output (the extraction
// version is the gate, not the binary-version string) — avoiding the needless
// full cold rebuild a binary-string gate would force on a no-op release — while
// a genuine extraction-version change still invalidates the slot.
func TestSnapshotReuseAcrossBinaryBump(t *testing.T) {
dir := t.TempDir()
s1, err := NewFileStore(dir, "0.48.0")
if err != nil {
t.Fatal(err)
}
snap := testSnapshot()
snap.Version = "0.48.0"
if err := s1.Save(snap); err != nil {
t.Fatal(err)
}
// A newer binary with the SAME extraction version reuses the slot.
s2, err := NewFileStore(dir, "0.48.1")
if err != nil {
t.Fatal(err)
}
if !s2.Validate(snap.RepoPath, snap.Branch, snap.CommitHash) {
t.Error("snapshot should be reused across a binary bump with an unchanged extraction version")
}
// Corrupt the extraction-version marker to simulate an extraction-output
// change: the slot must now be rejected (cold rebuild).
entry := s2.entryDir(snap.RepoPath, snap.Branch, snap.CommitHash)
if err := os.WriteFile(filepath.Join(entry, extractionVersionFile), []byte("999"), 0o644); err != nil {
t.Fatal(err)
}
if s2.Validate(snap.RepoPath, snap.Branch, snap.CommitHash) {
t.Error("a changed extraction version must invalidate the snapshot")
}
}