package stats_test import ( "context" "testing" "time" "github.com/jmoiron/sqlx" _ "modernc.org/sqlite" "sourcecraft.dev/bigbes/lethe/internal/config" "sourcecraft.dev/bigbes/lethe/internal/domain/session" "sourcecraft.dev/bigbes/lethe/internal/domain/stats" "sourcecraft.dev/bigbes/lethe/internal/platform/database" ) // ──────────────────────────────────────────────────────────── // DB helpers (local to stats_test package) // ──────────────────────────────────────────────────────────── func newTestDatabase(t *testing.T) *database.Database { t.Helper() d := &database.Database{ Cfg: config.DatabaseConfig{ Path: ":memory:", BusyTimeout: 5 * time.Second, }, } if err := d.Init(context.Background()); err != nil { t.Fatalf("database.Init: %v", err) } t.Cleanup(func() { _ = d.Destroy(context.Background()) }) return d } func newStatsRepo(t *testing.T) (*stats.Repository, *sqlx.DB) { t.Helper() d := newTestDatabase(t) repo := &stats.Repository{Database: d} if err := repo.Init(context.Background()); err != nil { t.Fatalf("stats.Repository.Init: %v", err) } return repo, d.DB } // seedSession inserts a minimal session row (NULL working_dir). func seedSession(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64) { t.Helper() _, err := db.Exec(` INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, NULL)`, owner, tool, host, sid, startedAt, endedAt, "/tmp/x.jsonl", ) if err != nil { t.Fatalf("seedSession %s/%s/%s/%s: %v", owner, tool, host, sid, err) } } // seedSessionCwd inserts a session row with a non-NULL working_dir. func seedSessionCwd(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64, cwd string) { t.Helper() _, err := db.Exec(` INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, owner, tool, host, sid, startedAt, endedAt, cwd, "/tmp/x.jsonl", ) if err != nil { t.Fatalf("seedSessionCwd %s/%s/%s/%s cwd=%s: %v", owner, tool, host, sid, cwd, err) } } // seedTurn inserts a turn with NULL optional columns. func seedTurn(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts int64) { t.Helper() _, err := db.Exec(` INSERT INTO turns (owner, tool, host, session_id, turn_id, seq, role, timestamp, content, model, tokens_in, tokens_out, cost_usd, tool_calls, metadata) VALUES (?, ?, ?, ?, ?, ?, 'user', ?, 'hello', NULL, NULL, NULL, NULL, NULL, NULL)`, owner, tool, host, sid, tid, seq, ts, ) if err != nil { t.Fatalf("seedTurn %s/%s: %v", sid, tid, err) } } // seedTurnFull inserts a turn with tokens and model set. func seedTurnFull(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts, tokensIn, tokensOut int64) { t.Helper() _, err := db.Exec(` INSERT INTO turns (owner, tool, host, session_id, turn_id, seq, role, timestamp, content, model, tokens_in, tokens_out, cost_usd, tool_calls, metadata) VALUES (?, ?, ?, ?, ?, ?, 'assistant', ?, 'response', 'gpt-4', ?, ?, NULL, NULL, NULL)`, owner, tool, host, sid, tid, seq, ts, tokensIn, tokensOut, ) if err != nil { t.Fatalf("seedTurnFull %s/%s: %v", sid, tid, err) } } // ──────────────────────────────────────────────────────────── // Tests // ──────────────────────────────────────────────────────────── func TestStats_EmptyDB(t *testing.T) { repo, _ := newStatsRepo(t) now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() days := int64(7) since := now - days*86400 result, err := repo.Stats(context.Background(), stats.Filter{ Owner: session.OwnerScope{User: "alice"}, RangeSince: &since, Now: now, }) if err != nil { t.Fatalf("Stats: %v", err) } if result == nil { t.Fatal("expected non-nil result") } // All slices must be non-nil (not nil — JSON must encode as []). if result.PerTool == nil { t.Error("PerTool is nil; want non-nil empty slice") } if result.Daily == nil { t.Error("Daily is nil; want non-nil empty slice") } if result.Heatmap == nil { t.Error("Heatmap is nil; want non-nil empty slice") } if result.TopCwd == nil { t.Error("TopCwd is nil; want non-nil empty slice") } if result.HourOfDay == nil { t.Error("HourOfDay is nil; want non-nil slice") } if result.HostSplit == nil { t.Error("HostSplit is nil; want non-nil empty slice") } } func TestStats_OneTool_ThreeTurns_TwoDays(t *testing.T) { repo, db := newStatsRepo(t) // Two sessions on two different days. // Day 1 (2025-06-13 UTC midnight = 1749772800): session s1 with 1 turn. // Day 2 (2025-06-14 UTC midnight = 1749859200): session s2 with 2 turns. day1 := time.Date(2025, 6, 13, 12, 0, 0, 0, time.UTC).Unix() day2 := time.Date(2025, 6, 14, 12, 0, 0, 0, time.UTC).Unix() now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() seedSession(t, db, "alice", "cc", "host1", "s1", day1, day1+60) seedSession(t, db, "alice", "cc", "host1", "s2", day2, day2+60) seedTurnFull(t, db, "alice", "cc", "host1", "s1", "t1", 1, day1+10, 10, 20) seedTurnFull(t, db, "alice", "cc", "host1", "s2", "t2", 1, day2+10, 30, 40) seedTurnFull(t, db, "alice", "cc", "host1", "s2", "t3", 2, day2+20, 50, 60) since := now - 7*86400 result, err := repo.Stats(context.Background(), stats.Filter{ Owner: session.OwnerScope{User: "alice"}, RangeSince: &since, Now: now, }) if err != nil { t.Fatalf("Stats: %v", err) } // PerTool: exactly one tool "cc" with 3 turns. if len(result.PerTool) != 1 { t.Fatalf("PerTool len=%d; want 1", len(result.PerTool)) } tr := result.PerTool[0] if tr.Tool != "cc" { t.Errorf("PerTool[0].Tool=%q; want cc", tr.Tool) } if tr.Turns != 3 { t.Errorf("PerTool[0].Turns=%d; want 3", tr.Turns) } if tr.TokensIn != 90 { t.Errorf("PerTool[0].TokensIn=%d; want 90", tr.TokensIn) } if tr.TokensOut != 120 { t.Errorf("PerTool[0].TokensOut=%d; want 120", tr.TokensOut) } // Daily: window is 8 entries (days=7 → days+1). The two days with turns // must be populated; others have empty PerTool maps. if len(result.Daily) != 8 { t.Fatalf("Daily len=%d; want 8", len(result.Daily)) } popCount := 0 for _, b := range result.Daily { if len(b.PerTool) > 0 { popCount++ } } if popCount != 2 { t.Errorf("Daily populated slots=%d; want 2", popCount) } } func TestStats_HeatmapAlways84Cells(t *testing.T) { repo, db := newStatsRepo(t) now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() // Seed some data 100 days ago (outside any 7d/30d/90d range, inside 84-week window). seedSession(t, db, "alice", "cc", "host1", "s1", now-100*86400, now-100*86400+60) seedTurn(t, db, "alice", "cc", "host1", "s1", "t1", 1, now-100*86400+10) since := now - 7*86400 result, err := repo.Stats(context.Background(), stats.Filter{ Owner: session.OwnerScope{User: "alice"}, RangeSince: &since, Now: now, }) if err != nil { t.Fatalf("Stats: %v", err) } if len(result.Heatmap) != 84 { t.Errorf("Heatmap len=%d; want 84", len(result.Heatmap)) } } func TestStats_TopCwdCappedAt20(t *testing.T) { repo, db := newStatsRepo(t) now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() since := now - 90*86400 // Insert 25 distinct cwds. for i := 0; i < 25; i++ { sid := "s" + string(rune('A'+i)) cwd := "/code/dir" + string(rune('A'+i)) seedSessionCwd(t, db, "alice", "cc", "host1", sid, now-int64(i)*86400, now-int64(i)*86400+60, cwd) seedTurn(t, db, "alice", "cc", "host1", sid, "t"+sid, 1, now-int64(i)*86400+10) } result, err := repo.Stats(context.Background(), stats.Filter{ Owner: session.OwnerScope{User: "alice"}, RangeSince: &since, Now: now, }) if err != nil { t.Fatalf("Stats: %v", err) } if len(result.TopCwd) > 20 { t.Errorf("TopCwd len=%d; want <= 20", len(result.TopCwd)) } } func TestStats_OwnerScope_AllOwners(t *testing.T) { repo, db := newStatsRepo(t) now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() since := now - 30*86400 seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now) seedSession(t, db, "bob", "gemini", "host2", "sB", now-86400, now) seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20) seedTurnFull(t, db, "bob", "gemini", "host2", "sB", "tB", 1, now-3600, 30, 40) result, err := repo.Stats(context.Background(), stats.Filter{ Owner: session.OwnerScope{User: "admin", AllOwners: true}, RangeSince: &since, Now: now, }) if err != nil { t.Fatalf("Stats AllOwners: %v", err) } if len(result.PerTool) != 2 { t.Errorf("AllOwners PerTool len=%d; want 2 (cc + gemini)", len(result.PerTool)) } } func TestStats_OwnerScope_SpecificOwner(t *testing.T) { repo, db := newStatsRepo(t) now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() since := now - 30*86400 seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now) seedSession(t, db, "bob", "gemini", "host2", "sB", now-86400, now) seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20) seedTurnFull(t, db, "bob", "gemini", "host2", "sB", "tB", 1, now-3600, 30, 40) bob := "bob" result, err := repo.Stats(context.Background(), stats.Filter{ Owner: session.OwnerScope{User: "admin", SpecificOwner: &bob}, RangeSince: &since, Now: now, }) if err != nil { t.Fatalf("Stats SpecificOwner: %v", err) } if len(result.PerTool) != 1 || result.PerTool[0].Tool != "gemini" { t.Errorf("SpecificOwner(bob) PerTool=%v; want [gemini]", result.PerTool) } } func TestStats_OwnerScope_DefaultScopeIsolation(t *testing.T) { repo, db := newStatsRepo(t) now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() since := now - 30*86400 seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now) seedSession(t, db, "bob", "gemini", "host2", "sB", now-86400, now) seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20) seedTurnFull(t, db, "bob", "gemini", "host2", "sB", "tB", 1, now-3600, 30, 40) result, err := repo.Stats(context.Background(), stats.Filter{ Owner: session.OwnerScope{User: "alice"}, RangeSince: &since, Now: now, }) if err != nil { t.Fatalf("Stats default scope: %v", err) } if len(result.PerTool) != 1 || result.PerTool[0].Tool != "cc" { t.Errorf("default scope(alice) PerTool=%v; want [cc]", result.PerTool) } } func TestStats_AllScopeNil_ReturnsAll(t *testing.T) { repo, db := newStatsRepo(t) now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now) seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20) // RangeSince = nil means all time. result, err := repo.Stats(context.Background(), stats.Filter{ Owner: session.OwnerScope{User: "alice"}, Now: now, }) if err != nil { t.Fatalf("Stats(nil range): %v", err) } if len(result.PerTool) != 1 { t.Errorf("nil range PerTool len=%d; want 1", len(result.PerTool)) } }