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))
}
}