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
592 lines
19 KiB
Go
592 lines
19 KiB
Go
package savings
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testLedgerPath returns a fresh sidecar DB path. Each test gets its own
|
|
// file so the process-shared sidecar handle cache can't leak state
|
|
// between tests.
|
|
func testLedgerPath(t *testing.T) string {
|
|
t.Helper()
|
|
return filepath.Join(t.TempDir(), "sidecar.sqlite")
|
|
}
|
|
|
|
// mustSnapshot unwraps Snapshot for tests that expect a healthy ledger.
|
|
func mustSnapshot(t *testing.T, s *Store) File {
|
|
t.Helper()
|
|
snap, err := s.Snapshot()
|
|
if err != nil {
|
|
t.Fatalf("Snapshot: %v", err)
|
|
}
|
|
return snap
|
|
}
|
|
|
|
// closeOnCleanup releases the sidecar handle when the test ends, so the
|
|
// process-wide handle cache doesn't accumulate open DBs (and TempDir
|
|
// cleanup works on platforms that refuse to delete open files).
|
|
func closeOnCleanup(t *testing.T, s *Store) *Store {
|
|
t.Helper()
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
return s
|
|
}
|
|
|
|
func TestAddObservation_PerLanguageBucket(t *testing.T) {
|
|
path := testLedgerPath(t)
|
|
|
|
s, err := Open(path)
|
|
if err == nil {
|
|
closeOnCleanup(t, s)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s.AddObservation(Observation{Repo: "/repo-a", Language: "go", Tool: "get_symbol_source", Returned: 100, Saved: 200})
|
|
s.AddObservation(Observation{Repo: "/repo-a", Language: "go", Tool: "get_symbol_source", Returned: 50, Saved: 80})
|
|
s.AddObservation(Observation{Repo: "/repo-b", Language: "typescript", Tool: "batch_symbols", Returned: 30, Saved: 70})
|
|
// Empty language is allowed (e.g. record() called with a nil node);
|
|
// it should accumulate in the totals but not in any per-language bucket.
|
|
s.AddObservation(Observation{Repo: "/repo-c", Tool: "smart_context", Returned: 10, Saved: 20})
|
|
|
|
reopened, err := Open(path)
|
|
if err == nil {
|
|
closeOnCleanup(t, reopened)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snap := mustSnapshot(t, reopened)
|
|
|
|
if got, want := snap.Totals.CallsCounted, int64(4); got != want {
|
|
t.Errorf("CallsCounted = %d, want %d", got, want)
|
|
}
|
|
if len(snap.PerLanguage) != 2 {
|
|
t.Errorf("PerLanguage size = %d, want 2 (empty-language observation must not create a bucket)", len(snap.PerLanguage))
|
|
}
|
|
if g := snap.PerLanguage["go"]; g == nil || g.CallsCounted != 2 || g.TokensSaved != 280 {
|
|
t.Errorf("go bucket = %+v, want calls=2 saved=280 (200+80)", g)
|
|
}
|
|
if ts := snap.PerLanguage["typescript"]; ts == nil || ts.CallsCounted != 1 || ts.TokensSaved != 70 {
|
|
t.Errorf("typescript bucket = %+v, want calls=1 saved=70", ts)
|
|
}
|
|
if len(snap.PerRepo) != 3 {
|
|
t.Errorf("PerRepo size = %d, want 3", len(snap.PerRepo))
|
|
}
|
|
}
|
|
|
|
// The headline property of the sidecar-backed ledger: an observation is
|
|
// durable the moment it is recorded. No flush, no ticker, no graceful
|
|
// shutdown required — the failure mode that left the flat-file ledger
|
|
// permanently empty under SIGKILLing MCP clients.
|
|
func TestAddObservation_DurableImmediately(t *testing.T) {
|
|
path := testLedgerPath(t)
|
|
|
|
s, err := Open(path)
|
|
if err == nil {
|
|
closeOnCleanup(t, s)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.AddObservation(Observation{Repo: "/r", Language: "go", Tool: "read_file", Returned: 10, Saved: 90})
|
|
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("ledger DB must exist immediately after the first observation: %v", err)
|
|
}
|
|
snap := mustSnapshot(t, s)
|
|
if snap.Totals.CallsCounted != 1 || snap.Totals.TokensSaved != 90 {
|
|
t.Errorf("snapshot = %+v, want calls=1 saved=90 with no flush", snap.Totals)
|
|
}
|
|
evs, err := s.EventsSince(time.Time{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(evs) != 1 || evs[0].Tool != "read_file" {
|
|
t.Errorf("events = %+v, want one read_file event", evs)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentWriters_SameLedger(t *testing.T) {
|
|
path := testLedgerPath(t)
|
|
|
|
const perStore = 200
|
|
stores := make([]*Store, 4)
|
|
for i := range stores {
|
|
s, err := Open(path)
|
|
if err == nil {
|
|
closeOnCleanup(t, s)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stores[i] = s
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
for i, s := range stores {
|
|
wg.Add(1)
|
|
go func(s *Store, repo string) {
|
|
defer wg.Done()
|
|
for j := 0; j < perStore; j++ {
|
|
s.AddObservation(Observation{Repo: repo, Tool: "test", Returned: 1, Saved: 10})
|
|
}
|
|
}(s, "/repo-"+string(rune('a'+i)))
|
|
}
|
|
wg.Wait()
|
|
|
|
snap := mustSnapshot(t, stores[0])
|
|
wantCalls := int64(len(stores) * perStore)
|
|
if got := snap.Totals.CallsCounted; got != wantCalls {
|
|
t.Errorf("CallsCounted = %d, want %d (observation lost across writers)", got, wantCalls)
|
|
}
|
|
if got, want := snap.Totals.TokensSaved, wantCalls*10; got != want {
|
|
t.Errorf("TokensSaved = %d, want %d", got, want)
|
|
}
|
|
evs, err := stores[0].EventsSince(time.Time{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := int64(len(evs)); got != wantCalls {
|
|
t.Errorf("events = %d, want %d", got, wantCalls)
|
|
}
|
|
}
|
|
|
|
// A fresh ledger reports nothing — including a zero FirstSeen. The
|
|
// flat-file store seeded FirstSeen=now at Open, which made the dashboard
|
|
// print "tracking since <the moment you ran the CLI>" on a machine that
|
|
// had never recorded anything.
|
|
func TestOpen_FreshLedger_EmptySnapshot(t *testing.T) {
|
|
s, err := Open(testLedgerPath(t))
|
|
if err == nil {
|
|
closeOnCleanup(t, s)
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
snap := mustSnapshot(t, s)
|
|
if snap.Totals.CallsCounted != 0 {
|
|
t.Errorf("new ledger has CallsCounted=%d, want 0", snap.Totals.CallsCounted)
|
|
}
|
|
if !snap.FirstSeen.IsZero() {
|
|
t.Errorf("new ledger FirstSeen = %v, want zero time (nothing recorded yet)", snap.FirstSeen)
|
|
}
|
|
if snap.Version != schemaVersion {
|
|
t.Errorf("new ledger version=%d, want %d", snap.Version, schemaVersion)
|
|
}
|
|
}
|
|
|
|
func TestObservation_StampsFirstAndLastSeen(t *testing.T) {
|
|
s, err := Open(testLedgerPath(t))
|
|
if err == nil {
|
|
closeOnCleanup(t, s)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := time.Now().UTC().Add(-time.Second)
|
|
s.AddObservation(Observation{Tool: "test", Returned: 1, Saved: 1})
|
|
after := time.Now().UTC().Add(time.Second)
|
|
|
|
snap := mustSnapshot(t, s)
|
|
if snap.FirstSeen.Before(before) || snap.FirstSeen.After(after) {
|
|
t.Errorf("FirstSeen = %v, want within [%v, %v]", snap.FirstSeen, before, after)
|
|
}
|
|
if snap.LastUpdated.Before(before) || snap.LastUpdated.After(after) {
|
|
t.Errorf("LastUpdated = %v, want within [%v, %v]", snap.LastUpdated, before, after)
|
|
}
|
|
}
|
|
|
|
func TestAddObservation_ConcurrentSafe(t *testing.T) {
|
|
s, err := Open("")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const workers = 8
|
|
const per = 250
|
|
var wg sync.WaitGroup
|
|
var expectedSaved atomic.Int64
|
|
for range workers {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for range per {
|
|
s.AddObservation(Observation{Tool: "test", Returned: 10, Saved: 100})
|
|
expectedSaved.Add(100)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
snap := mustSnapshot(t, s)
|
|
if got, want := snap.Totals.CallsCounted, int64(workers*per); got != want {
|
|
t.Errorf("CallsCounted = %d, want %d", got, want)
|
|
}
|
|
if got, want := snap.Totals.TokensSaved, expectedSaved.Load(); got != want {
|
|
t.Errorf("TokensSaved = %d, want %d", got, want)
|
|
}
|
|
}
|
|
|
|
func TestOpen_EmptyPath_InMemoryOnly(t *testing.T) {
|
|
s, err := Open("")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.AddObservation(Observation{Repo: "r", Tool: "test", Returned: 10, Saved: 100})
|
|
if err := s.Flush(); err != nil {
|
|
t.Errorf("Flush on in-memory store should no-op, got: %v", err)
|
|
}
|
|
snap := mustSnapshot(t, s)
|
|
if snap.Totals.CallsCounted != 1 {
|
|
t.Errorf("in-memory store should track, got CallsCounted=%d", snap.Totals.CallsCounted)
|
|
}
|
|
evs, err := s.EventsSince(time.Time{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(evs) != 1 {
|
|
t.Errorf("in-memory store should keep events, got %d", len(evs))
|
|
}
|
|
}
|
|
|
|
func TestReset_ClearsLedger(t *testing.T) {
|
|
path := testLedgerPath(t)
|
|
|
|
s, _ := Open(path)
|
|
closeOnCleanup(t, s)
|
|
s.AddObservation(Observation{Repo: "/r", Tool: "test", Returned: 50, Saved: 500})
|
|
|
|
if err := s.Reset(); err != nil {
|
|
t.Fatalf("Reset: %v", err)
|
|
}
|
|
snap := mustSnapshot(t, s)
|
|
if snap.Totals.CallsCounted != 0 {
|
|
t.Errorf("totals should be cleared after reset, got CallsCounted=%d", snap.Totals.CallsCounted)
|
|
}
|
|
if !snap.FirstSeen.IsZero() {
|
|
t.Errorf("FirstSeen should be cleared after reset, got %v", snap.FirstSeen)
|
|
}
|
|
evs, err := s.EventsSince(time.Time{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(evs) != 0 {
|
|
t.Errorf("events should be cleared after reset, got %d", len(evs))
|
|
}
|
|
}
|
|
|
|
// Snapshot keeps the JSON shape graph_stats and `gortex savings --json`
|
|
// expose — the surface contract of cumulative_savings.
|
|
func TestSnapshot_JSONShape(t *testing.T) {
|
|
s, _ := Open(testLedgerPath(t))
|
|
closeOnCleanup(t, s)
|
|
s.AddObservation(Observation{Repo: "/repo-a", Language: "go", Tool: "test", Returned: 10, Saved: 100})
|
|
|
|
data, err := json.Marshal(mustSnapshot(t, s))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, key := range []string{"version", "first_seen", "last_updated", "totals", "per_repo", "per_language"} {
|
|
if _, ok := parsed[key]; !ok {
|
|
t.Errorf("missing key %q in snapshot JSON", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEventsSince_Filters(t *testing.T) {
|
|
s, _ := Open(testLedgerPath(t))
|
|
closeOnCleanup(t, s)
|
|
s.AddObservation(Observation{Tool: "a", Returned: 1, Saved: 1})
|
|
time.Sleep(5 * time.Millisecond)
|
|
cutoff := time.Now().UTC()
|
|
time.Sleep(5 * time.Millisecond)
|
|
s.AddObservation(Observation{Tool: "b", Returned: 1, Saved: 1})
|
|
|
|
evs, err := s.EventsSince(cutoff)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(evs) != 1 || evs[0].Tool != "b" {
|
|
t.Errorf("EventsSince(cutoff) = %+v, want only [b]", evs)
|
|
}
|
|
all, err := s.EventsSince(time.Time{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(all) != 2 {
|
|
t.Errorf("EventsSince(zero) = %d, want 2", len(all))
|
|
}
|
|
}
|
|
|
|
func TestImportLegacy_FullFlatFiles(t *testing.T) {
|
|
legacyDir := t.TempDir()
|
|
jsonPath := filepath.Join(legacyDir, "savings.json")
|
|
jsonlPath := filepath.Join(legacyDir, "savings.jsonl")
|
|
|
|
firstSeen := time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC)
|
|
lastUpdated := time.Date(2026, 5, 20, 9, 0, 0, 0, time.UTC)
|
|
legacy := File{
|
|
Version: schemaVersion,
|
|
FirstSeen: firstSeen,
|
|
LastUpdated: lastUpdated,
|
|
Totals: Totals{TokensSaved: 1000, TokensReturned: 100, CallsCounted: 10},
|
|
PerRepo: map[string]*Totals{"repo-a": {TokensSaved: 1000, TokensReturned: 100, CallsCounted: 10}},
|
|
PerLanguage: map[string]*Totals{"go": {TokensSaved: 1000, TokensReturned: 100, CallsCounted: 10}},
|
|
}
|
|
data, _ := json.Marshal(legacy)
|
|
if err := os.WriteFile(jsonPath, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
line, _ := json.Marshal(Event{TS: lastUpdated, Repo: "repo-a", Language: "go", Tool: "get_symbol_source", Returned: 23, Saved: 77})
|
|
if err := os.WriteFile(jsonlPath, append(line, '\n'), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s, err := Open(testLedgerPath(t))
|
|
if err == nil {
|
|
closeOnCleanup(t, s)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ImportLegacy(jsonPath); err != nil {
|
|
t.Fatalf("ImportLegacy: %v", err)
|
|
}
|
|
|
|
snap := mustSnapshot(t, s)
|
|
if snap.Totals.CallsCounted != 10 || snap.Totals.TokensSaved != 1000 {
|
|
t.Errorf("imported totals = %+v, want calls=10 saved=1000", snap.Totals)
|
|
}
|
|
if r := snap.PerRepo["repo-a"]; r == nil || r.CallsCounted != 10 {
|
|
t.Errorf("imported repo bucket = %+v", r)
|
|
}
|
|
if !snap.FirstSeen.Equal(firstSeen) {
|
|
t.Errorf("FirstSeen = %v, want %v (carried from legacy file)", snap.FirstSeen, firstSeen)
|
|
}
|
|
evs, _ := s.EventsSince(time.Time{})
|
|
if len(evs) != 1 || evs[0].Tool != "get_symbol_source" || evs[0].Saved != 77 {
|
|
t.Errorf("imported events = %+v", evs)
|
|
}
|
|
|
|
// Legacy files renamed aside, originals gone.
|
|
if _, err := os.Stat(jsonPath); !os.IsNotExist(err) {
|
|
t.Errorf("legacy savings.json should be renamed after import, stat err=%v", err)
|
|
}
|
|
if _, err := os.Stat(jsonPath + ".bak"); err != nil {
|
|
t.Errorf("expected savings.json.bak, stat err=%v", err)
|
|
}
|
|
if _, err := os.Stat(jsonlPath + ".bak"); err != nil {
|
|
t.Errorf("expected savings.jsonl.bak, stat err=%v", err)
|
|
}
|
|
|
|
// Idempotent: a second import (e.g. another entry point racing the
|
|
// first) must not double-count.
|
|
if err := s.ImportLegacy(jsonPath); err != nil {
|
|
t.Fatalf("second ImportLegacy: %v", err)
|
|
}
|
|
if got := mustSnapshot(t, s).Totals.CallsCounted; got != 10 {
|
|
t.Errorf("totals after second import = %d, want 10 (no double count)", got)
|
|
}
|
|
}
|
|
|
|
// A jsonl without its cumulative file (the flat-file flush never ran
|
|
// before the process died — the common SIGKILL case) still imports:
|
|
// totals are rebuilt from the events.
|
|
func TestImportLegacy_EventsOnlyRebuildsTotals(t *testing.T) {
|
|
legacyDir := t.TempDir()
|
|
jsonPath := filepath.Join(legacyDir, "savings.json")
|
|
jsonlPath := filepath.Join(legacyDir, "savings.jsonl")
|
|
|
|
ts := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
|
var lines []byte
|
|
for i := 0; i < 3; i++ {
|
|
line, _ := json.Marshal(Event{TS: ts.Add(time.Duration(i) * time.Minute), Repo: "r", Language: "go", Tool: "smart_context", Returned: 10, Saved: 30})
|
|
lines = append(lines, line...)
|
|
lines = append(lines, '\n')
|
|
}
|
|
if err := os.WriteFile(jsonlPath, lines, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s, _ := Open(testLedgerPath(t))
|
|
closeOnCleanup(t, s)
|
|
if err := s.ImportLegacy(jsonPath); err != nil {
|
|
t.Fatalf("ImportLegacy: %v", err)
|
|
}
|
|
snap := mustSnapshot(t, s)
|
|
if snap.Totals.CallsCounted != 3 || snap.Totals.TokensSaved != 90 {
|
|
t.Errorf("rebuilt totals = %+v, want calls=3 saved=90", snap.Totals)
|
|
}
|
|
if r := snap.PerRepo["r"]; r == nil || r.CallsCounted != 3 {
|
|
t.Errorf("rebuilt repo bucket = %+v", r)
|
|
}
|
|
if !snap.FirstSeen.Equal(ts) {
|
|
t.Errorf("FirstSeen = %v, want first event ts %v", snap.FirstSeen, ts)
|
|
}
|
|
}
|
|
|
|
// With nothing to import the mark is still set, so legacy files that
|
|
// appear later (e.g. restored from a backup) are not silently merged
|
|
// into a ledger that has moved on.
|
|
func TestImportLegacy_NothingToImportMarksDone(t *testing.T) {
|
|
legacyDir := t.TempDir()
|
|
jsonPath := filepath.Join(legacyDir, "savings.json")
|
|
|
|
s, _ := Open(testLedgerPath(t))
|
|
closeOnCleanup(t, s)
|
|
if err := s.ImportLegacy(jsonPath); err != nil {
|
|
t.Fatalf("ImportLegacy on missing files: %v", err)
|
|
}
|
|
|
|
// A legacy file materializing afterwards is ignored.
|
|
legacy := File{Version: schemaVersion, Totals: Totals{TokensSaved: 5000, CallsCounted: 50}}
|
|
data, _ := json.Marshal(legacy)
|
|
if err := os.WriteFile(jsonPath, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ImportLegacy(jsonPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := mustSnapshot(t, s).Totals.CallsCounted; got != 0 {
|
|
t.Errorf("late-appearing legacy file must not import, got calls=%d", got)
|
|
}
|
|
}
|
|
|
|
// TestDefaultPath_HonorsXDGCacheHome verifies the legacy flat-file path is
|
|
// routed through the XDG resolver: an absolute $XDG_CACHE_HOME relocates
|
|
// it to <XDG_CACHE_HOME>/gortex/savings.json, so the legacy import looks
|
|
// where the flat-file era actually wrote.
|
|
func TestDefaultPath_HonorsXDGCacheHome(t *testing.T) {
|
|
xdg := t.TempDir()
|
|
t.Setenv("XDG_CACHE_HOME", xdg)
|
|
|
|
want := filepath.Join(xdg, "gortex", "savings.json")
|
|
if got := DefaultPath(); got != want {
|
|
t.Fatalf("DefaultPath() with XDG_CACHE_HOME = %s, want %s", got, want)
|
|
}
|
|
|
|
// The sibling event-log path follows the same root.
|
|
wantEvents := filepath.Join(xdg, "gortex", "savings.jsonl")
|
|
if got := DefaultEventsPath(); got != wantEvents {
|
|
t.Fatalf("DefaultEventsPath() with XDG_CACHE_HOME = %s, want %s", got, wantEvents)
|
|
}
|
|
}
|
|
|
|
// TestDefaultDBPath_HonorsXDGDataHome verifies the ledger DB follows the
|
|
// data-dir resolver — the same sidecar.sqlite the notes/memories
|
|
// managers share.
|
|
func TestDefaultDBPath_HonorsXDGDataHome(t *testing.T) {
|
|
xdg := t.TempDir()
|
|
t.Setenv("XDG_DATA_HOME", xdg)
|
|
|
|
want := filepath.Join(xdg, "gortex", "sidecar.sqlite")
|
|
if got := DefaultDBPath(); got != want {
|
|
t.Fatalf("DefaultDBPath() with XDG_DATA_HOME = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
// A legacy file with JSON null bucket values must import cleanly — a
|
|
// nil *Totals dereference here would crash-loop every server start.
|
|
func TestImportLegacy_NullBucketValues(t *testing.T) {
|
|
legacyDir := t.TempDir()
|
|
jsonPath := filepath.Join(legacyDir, "savings.json")
|
|
body := `{"version":1,"totals":{"tokens_saved":10,"tokens_returned":1,"calls_counted":1},"per_repo":{"x":null},"per_language":{"y":null}}`
|
|
if err := os.WriteFile(jsonPath, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s, _ := Open(testLedgerPath(t))
|
|
closeOnCleanup(t, s)
|
|
if err := s.ImportLegacy(jsonPath); err != nil {
|
|
t.Fatalf("ImportLegacy with null buckets: %v", err)
|
|
}
|
|
snap := mustSnapshot(t, s)
|
|
if snap.Totals.CallsCounted != 1 {
|
|
t.Errorf("totals = %+v, want calls=1", snap.Totals)
|
|
}
|
|
if len(snap.PerRepo) != 0 || len(snap.PerLanguage) != 0 {
|
|
t.Errorf("null buckets must be dropped, got repo=%v lang=%v", snap.PerRepo, snap.PerLanguage)
|
|
}
|
|
if _, err := os.Stat(jsonPath + ".bak"); err != nil {
|
|
t.Errorf("legacy file should be renamed after import: %v", err)
|
|
}
|
|
}
|
|
|
|
// The flat-file cumulative was flush-batched while the event log
|
|
// appended eagerly; the import floors totals at what the events
|
|
// reconstruct so "Last 7 days" can never exceed "All time".
|
|
func TestImportLegacy_FlushLaggedTotalsFlooredByEvents(t *testing.T) {
|
|
legacyDir := t.TempDir()
|
|
jsonPath := filepath.Join(legacyDir, "savings.json")
|
|
jsonlPath := filepath.Join(legacyDir, "savings.jsonl")
|
|
|
|
lagged := File{
|
|
Version: schemaVersion,
|
|
Totals: Totals{TokensSaved: 10, TokensReturned: 1, CallsCounted: 1},
|
|
}
|
|
data, _ := json.Marshal(lagged)
|
|
if err := os.WriteFile(jsonPath, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ts := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
|
var lines []byte
|
|
for i := 0; i < 3; i++ {
|
|
line, _ := json.Marshal(Event{TS: ts.Add(time.Duration(i) * time.Minute), Tool: "t", Returned: 1, Saved: 10})
|
|
lines = append(append(lines, line...), '\n')
|
|
}
|
|
if err := os.WriteFile(jsonlPath, lines, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s, _ := Open(testLedgerPath(t))
|
|
closeOnCleanup(t, s)
|
|
if err := s.ImportLegacy(jsonPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snap := mustSnapshot(t, s)
|
|
if snap.Totals.CallsCounted != 3 || snap.Totals.TokensSaved != 30 {
|
|
t.Errorf("totals = %+v, want floored at the events' calls=3 saved=30", snap.Totals)
|
|
}
|
|
}
|
|
|
|
// A hard event-log read error aborts the import without marking or
|
|
// renaming, so the next open retries instead of permanently losing the
|
|
// unread tail.
|
|
func TestImportLegacy_UnreadableEventsAborts(t *testing.T) {
|
|
if os.Getuid() == 0 {
|
|
t.Skip("permission bits don't bind as root")
|
|
}
|
|
legacyDir := t.TempDir()
|
|
jsonPath := filepath.Join(legacyDir, "savings.json")
|
|
jsonlPath := filepath.Join(legacyDir, "savings.jsonl")
|
|
if err := os.WriteFile(jsonlPath, []byte("{}\n"), 0o000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s, _ := Open(testLedgerPath(t))
|
|
closeOnCleanup(t, s)
|
|
if err := s.ImportLegacy(jsonPath); err == nil {
|
|
t.Fatal("unreadable event log must abort the import")
|
|
}
|
|
if _, err := os.Stat(jsonlPath); err != nil {
|
|
t.Errorf("aborted import must not rename the event log: %v", err)
|
|
}
|
|
// A later open (permissions fixed) imports successfully.
|
|
if err := os.Chmod(jsonlPath, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ImportLegacy(jsonPath); err != nil {
|
|
t.Fatalf("retry after fixing permissions: %v", err)
|
|
}
|
|
}
|