package project_test
import (
"context"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
"sourcecraft.dev/bigbes/lethe/internal/config"
"sourcecraft.dev/bigbes/lethe/internal/domain/project"
"sourcecraft.dev/bigbes/lethe/internal/domain/session"
"sourcecraft.dev/bigbes/lethe/internal/platform/database"
)
// newTestDatabase builds a Database steward against :memory: (one DB per
// test, isolated). Cleanup runs Destroy.
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
}
// newRepo wires a project.Repository against a fresh in-memory database.
func newRepo(t *testing.T) (*project.Repository, *sqlx.DB) {
t.Helper()
d := newTestDatabase(t)
repo := &project.Repository{Database: d}
if err := repo.Init(context.Background()); err != nil {
t.Fatalf("repo.Init: %v", err)
}
return repo, d.DB
}
// seedSession inserts a session row. working_dir may be empty string (treated
// as non-NULL) or use seedSessionNullCwd for a NULL working_dir row.
func seedSessionWithCwd(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("seed session %s/%s/%s/%s (cwd=%s): %v", owner, tool, host, sid, cwd, err)
}
}
// seedSessionNullCwd inserts a session row with a NULL working_dir.
func seedSessionNullCwd(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("seed session null cwd %s/%s/%s/%s: %v", owner, tool, host, sid, err)
}
}
// seedTurn inserts a turn row with the specified tool value.
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("seed turn %s/%s: %v", sid, tid, err)
}
}
// seedTurnFull inserts a turn row with tokens_in, tokens_out, and a model set.
func seedTurnFull(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts, tokensIn, tokensOut int64, model string) {
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', ?, ?, ?, NULL, NULL, NULL)`,
owner, tool, host, sid, tid, seq, ts, model, tokensIn, tokensOut,
)
if err != nil {
t.Fatalf("seed turn full %s/%s: %v", sid, tid, err)
}
}
func TestProjectList_EmptyDB(t *testing.T) {
repo, _ := newRepo(t)
got, err := repo.List(context.Background(), project.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if got == nil {
t.Fatal("expected non-nil empty slice; got nil")
}
if len(got) != 0 {
t.Fatalf("expected 0 projects; got %d (%#v)", len(got), got)
}
}
func TestProjectList_OneCwdTwoSessionsThreeTurns(t *testing.T) {
repo, db := newRepo(t)
// Two sessions share /code/x.
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s2", 1700000020, 1700000050, "/code/x")
// Three turns across both sessions.
seedTurn(t, db, "alice", "cc", "phoebe", "s1", "t1", 1, 1700000005)
seedTurn(t, db, "alice", "cc", "phoebe", "s2", "t2", 1, 1700000025)
seedTurn(t, db, "alice", "cc", "phoebe", "s2", "t3", 2, 1700000035)
got, err := repo.List(context.Background(), project.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 project row; got %d (%#v)", len(got), got)
}
p := got[0]
if p.Cwd != "/code/x" {
t.Errorf("Cwd: got %q; want /code/x", p.Cwd)
}
if p.Sessions != 2 {
t.Errorf("Sessions: got %d; want 2", p.Sessions)
}
if p.TurnCount != 3 {
t.Errorf("TurnCount: got %d; want 3", p.TurnCount)
}
// LastActive = MAX(ended_at) of the two sessions = 1700000050.
if p.LastActive != 1700000050 {
t.Errorf("LastActive: got %d; want 1700000050", p.LastActive)
}
}
func TestProjectList_NullCwdExcluded(t *testing.T) {
repo, db := newRepo(t)
// One row with a real cwd, one with NULL.
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
seedSessionNullCwd(t, db, "alice", "cc", "phoebe", "s2", 1700000020, 1700000030)
got, err := repo.List(context.Background(), project.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 project (null cwd excluded); got %d (%#v)", len(got), got)
}
if got[0].Cwd != "/code/x" {
t.Errorf("Cwd: got %q; want /code/x", got[0].Cwd)
}
}
func TestProjectList_TopToolTiesBrokenByAsc(t *testing.T) {
repo, db := newRepo(t)
// Two tools each with 1 turn for the same cwd — alphabetically "cc" < "gemini".
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
seedSessionWithCwd(t, db, "alice", "gemini", "phoebe", "s2", 1700000020, 1700000030, "/code/x")
seedTurn(t, db, "alice", "cc", "phoebe", "s1", "t1", 1, 1700000005)
seedTurn(t, db, "alice", "gemini", "phoebe", "s2", "t2", 1, 1700000025)
got, err := repo.List(context.Background(), project.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 project; got %d", len(got))
}
if got[0].TopTool != "cc" {
t.Errorf("TopTool tie: got %q; want cc (alphabetically first)", got[0].TopTool)
}
}
func TestProjectList_HostsAndToolsDeduped(t *testing.T) {
repo, db := newRepo(t)
// Same tool + host across two sessions → should appear once each.
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s2", 1700000020, 1700000030, "/code/x")
got, err := repo.List(context.Background(), project.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 project; got %d", len(got))
}
p := got[0]
if len(p.Tools) != 1 || p.Tools[0] != "cc" {
t.Errorf("Tools: got %v; want [cc]", p.Tools)
}
if len(p.Hosts) != 1 || p.Hosts[0] != "phoebe" {
t.Errorf("Hosts: got %v; want [phoebe]", p.Hosts)
}
}
func TestProjectList_HostsAndToolsSorted(t *testing.T) {
repo, db := newRepo(t)
// Two different tools + two different hosts across sessions.
seedSessionWithCwd(t, db, "alice", "gemini", "rhea", "s1", 1700000000, 1700000010, "/code/x")
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s2", 1700000020, 1700000030, "/code/x")
got, err := repo.List(context.Background(), project.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 project; got %d", len(got))
}
p := got[0]
// Tools sorted: cc < gemini.
if len(p.Tools) != 2 || p.Tools[0] != "cc" || p.Tools[1] != "gemini" {
t.Errorf("Tools sorted: got %v; want [cc gemini]", p.Tools)
}
// Hosts sorted: phoebe < rhea.
if len(p.Hosts) != 2 || p.Hosts[0] != "phoebe" || p.Hosts[1] != "rhea" {
t.Errorf("Hosts sorted: got %v; want [phoebe rhea]", p.Hosts)
}
}
func TestProjectList_OwnerScopes(t *testing.T) {
t.Run("AllOwners returns rows from both owners", func(t *testing.T) {
repo, db := newRepo(t)
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010, "/code/x")
seedSessionWithCwd(t, db, "bob", "cc", "phoebe", "sB", 1700000020, 1700000030, "/code/y")
got, err := repo.List(context.Background(), project.ListFilter{
Owner: session.OwnerScope{User: "admin", AllOwners: true},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 2 {
t.Fatalf("AllOwners: expected 2 rows; got %d (%#v)", len(got), got)
}
})
t.Run("SpecificOwner pins to one owner", func(t *testing.T) {
repo, db := newRepo(t)
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010, "/code/x")
seedSessionWithCwd(t, db, "bob", "cc", "phoebe", "sB", 1700000020, 1700000030, "/code/y")
bob := "bob"
got, err := repo.List(context.Background(), project.ListFilter{
Owner: session.OwnerScope{User: "admin", SpecificOwner: &bob},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 || got[0].Cwd != "/code/y" {
t.Fatalf("SpecificOwner bob: expected /code/y; got %#v", got)
}
})
t.Run("default scope returns only caller rows", func(t *testing.T) {
repo, db := newRepo(t)
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010, "/code/x")
seedSessionWithCwd(t, db, "bob", "cc", "phoebe", "sB", 1700000020, 1700000030, "/code/y")
got, err := repo.List(context.Background(), project.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 || got[0].Cwd != "/code/x" {
t.Fatalf("default scope alice: expected /code/x; got %#v", got)
}
})
}
func TestProjectList_TokenSums(t *testing.T) {
repo, db := newRepo(t)
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000100, "/code/x")
seedTurnFull(t, db, "alice", "cc", "phoebe", "s1", "t1", 1, 1700000010, 100, 200, "gpt-4")
seedTurnFull(t, db, "alice", "cc", "phoebe", "s1", "t2", 2, 1700000020, 50, 75, "gpt-4")
got, err := repo.List(context.Background(), project.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1; got %d", len(got))
}
p := got[0]
if p.TokensInTotal != 150 {
t.Errorf("TokensInTotal: got %d; want 150", p.TokensInTotal)
}
if p.TokensOutTotal != 275 {
t.Errorf("TokensOutTotal: got %d; want 275", p.TokensOutTotal)
}
}