diff options
Diffstat (limited to 'internal/stats/stats_test.go')
| -rw-r--r-- | internal/stats/stats_test.go | 250 |
1 files changed, 250 insertions, 0 deletions
diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go index a81e215..75c1c5b 100644 --- a/internal/stats/stats_test.go +++ b/internal/stats/stats_test.go @@ -2,6 +2,8 @@ package stats import ( "context" + "encoding/json" + "os" "path/filepath" "sync" "testing" @@ -83,3 +85,251 @@ func TestCacheDir_XDG(t *testing.T) { t.Fatalf("got %q want %q", got, want) } } + +// TestCacheDir_FallbackHome covers the branch where XDG_CACHE_HOME is unset, +// so CacheDir falls back to $HOME/.local/hexai/cache. +func TestCacheDir_FallbackHome(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", "") + got, err := CacheDir() + if err != nil { + t.Fatal(err) + } + home, _ := os.UserHomeDir() + want := filepath.Join(home, ".local", "hexai", "cache") + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +// TestCacheDir_WhitespaceXDG covers the branch where XDG_CACHE_HOME contains +// only whitespace, which stringsTrim reduces to "" so the fallback is used. +func TestCacheDir_WhitespaceXDG(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", " \t\n ") + got, err := CacheDir() + if err != nil { + t.Fatal(err) + } + home, _ := os.UserHomeDir() + want := filepath.Join(home, ".local", "hexai", "cache") + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +// TestSetWindow_ClampLow covers the branch where d < 1s is clamped to 1s. +func TestSetWindow_ClampLow(t *testing.T) { + SetWindow(100 * time.Millisecond) + got := Window() + if got != time.Second { + t.Fatalf("expected 1s, got %v", got) + } +} + +// TestSetWindow_ClampHigh covers the branch where d > 24h is clamped to 24h. +func TestSetWindow_ClampHigh(t *testing.T) { + SetWindow(48 * time.Hour) + got := Window() + if got != 24*time.Hour { + t.Fatalf("expected 24h, got %v", got) + } + // Restore a reasonable default for other tests. + SetWindow(time.Hour) +} + +// TestStringsTrim_NoTrimNeeded covers the early-return branch where the input +// has no leading or trailing whitespace, so the original string is returned. +func TestStringsTrim_NoTrimNeeded(t *testing.T) { + in := "hello" + got := stringsTrim(in) + if got != "hello" { + t.Fatalf("expected %q, got %q", "hello", got) + } +} + +// TestStringsTrim_AllWhitespace covers trimming a string that is entirely whitespace. +func TestStringsTrim_AllWhitespace(t *testing.T) { + got := stringsTrim(" \t\r\n ") + if got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +// TestStringsTrim_LeadingAndTrailing covers trimming from both ends. +func TestStringsTrim_LeadingAndTrailing(t *testing.T) { + got := stringsTrim(" abc ") + if got != "abc" { + t.Fatalf("expected %q, got %q", "abc", got) + } +} + +// TestStringsTrim_Empty covers the empty string edge case. +func TestStringsTrim_Empty(t *testing.T) { + got := stringsTrim("") + if got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +// TestUpdate_CorruptFile covers the branch where the existing stats file has +// invalid JSON or a wrong version, forcing a reset. +func TestUpdate_CorruptFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + SetWindow(1 * time.Minute) + + // Write a corrupt stats file. + statsDir := filepath.Join(dir, "hexai") + if err := os.MkdirAll(statsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(statsDir, fileName), []byte("{invalid json"), 0o644); err != nil { + t.Fatal(err) + } + + // Update should still succeed: the corrupt file is discarded. + if err := Update(context.Background(), "p", "m", 5, 5); err != nil { + t.Fatalf("update after corrupt file: %v", err) + } + snap, err := TakeSnapshot() + if err != nil { + t.Fatal(err) + } + if snap.Global.Reqs != 1 { + t.Fatalf("expected 1 req, got %d", snap.Global.Reqs) + } +} + +// TestUpdate_WrongVersion covers the branch where the file version does not +// match fileVersion, causing a reset of the file structure. +func TestUpdate_WrongVersion(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + SetWindow(1 * time.Minute) + + statsDir := filepath.Join(dir, "hexai") + if err := os.MkdirAll(statsDir, 0o755); err != nil { + t.Fatal(err) + } + // Write a valid JSON file but with version=99 (wrong). + wrongVer := File{Version: 99, Events: []Event{{TS: time.Now(), Provider: "old", Model: "old", Sent: 100, Recv: 100}}} + b, _ := json.Marshal(wrongVer) + if err := os.WriteFile(filepath.Join(statsDir, fileName), b, 0o644); err != nil { + t.Fatal(err) + } + + if err := Update(context.Background(), "p", "m", 1, 1); err != nil { + t.Fatalf("update: %v", err) + } + snap, err := TakeSnapshot() + if err != nil { + t.Fatal(err) + } + // The old event from version 99 should be discarded. + if snap.Global.Reqs != 1 { + t.Fatalf("expected 1 req after version reset, got %d", snap.Global.Reqs) + } +} + +// TestTakeSnapshot_NoFile covers the ErrNotExist branch in TakeSnapshot. +func TestTakeSnapshot_NoFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + SetWindow(5 * time.Minute) + + snap, err := TakeSnapshot() + if err != nil { + t.Fatal(err) + } + if snap.Global.Reqs != 0 { + t.Fatalf("expected 0 reqs, got %d", snap.Global.Reqs) + } + if snap.Providers == nil { + t.Fatal("expected non-nil Providers map") + } +} + +// TestTakeSnapshot_BadJSON covers the json.Unmarshal error branch in TakeSnapshot. +func TestTakeSnapshot_BadJSON(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + + statsDir := filepath.Join(dir, "hexai") + if err := os.MkdirAll(statsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(statsDir, fileName), []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := TakeSnapshot() + if err == nil { + t.Fatal("expected error for bad JSON, got nil") + } +} + +// TestTakeSnapshot_ZeroWindowSeconds covers the branch where the file has +// WindowSeconds <= 0, causing TakeSnapshot to use the process-level Window(). +func TestTakeSnapshot_ZeroWindowSeconds(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + SetWindow(5 * time.Minute) + + statsDir := filepath.Join(dir, "hexai") + if err := os.MkdirAll(statsDir, 0o755); err != nil { + t.Fatal(err) + } + sf := File{ + Version: fileVersion, + WindowSeconds: 0, // triggers the win <= 0 branch + Events: []Event{{TS: time.Now(), Provider: "p", Model: "m", Sent: 1, Recv: 1}}, + } + b, _ := json.Marshal(sf) + if err := os.WriteFile(filepath.Join(statsDir, fileName), b, 0o644); err != nil { + t.Fatal(err) + } + + snap, err := TakeSnapshot() + if err != nil { + t.Fatal(err) + } + if snap.Window != 5*time.Minute { + t.Fatalf("expected 5m window fallback, got %v", snap.Window) + } + if snap.Global.Reqs != 1 { + t.Fatalf("expected 1 req, got %d", snap.Global.Reqs) + } +} + +// TestUpdate_CancelledContext covers the context cancellation branch in +// acquireFileLock when the lock is already held. +func TestUpdate_CancelledContext(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + SetWindow(1 * time.Minute) + + statsDir := filepath.Join(dir, "hexai") + if err := os.MkdirAll(statsDir, 0o755); err != nil { + t.Fatal(err) + } + + // Hold the lock file to force acquireFileLock to spin. + lockPath := filepath.Join(statsDir, lockFileName) + lf, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + defer func() { _ = lf.Close() }() + unlock, err := acquireFileLock(context.Background(), lf) + if err != nil { + t.Fatal(err) + } + defer func() { _ = unlock() }() + + // Now try to Update with an already-cancelled context. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = Update(ctx, "p", "m", 1, 1) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } +} |
