package session_test
import (
"context"
"testing"
"time"
"github.com/jmoiron/sqlx"
"go.bigb.es/auxilia/culpa"
_ "modernc.org/sqlite"
"sourcecraft.dev/bigbes/lethe/internal/config"
"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 Repository against a fresh in-memory database.
func newRepo(t *testing.T) (*session.Repository, *sqlx.DB) {
t.Helper()
d := newTestDatabase(t)
repo := &session.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 directly via SQL. The tests deliberately
// do not depend on internal/domain/ingest/ — the read-side package must be
// testable in isolation.
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("seed session %s/%s/%s/%s: %v", owner, tool, host, sid, err)
}
}
// seedSessionWithCwd inserts a session row with a non-NULL working_dir so
// tests of the cwd filter can distinguish rows by their project path.
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)
}
}
// seedTurn inserts a turn row directly via SQL. Optional columns are NULL.
func seedTurn(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts int64, role, content 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL)`,
owner, tool, host, sid, tid, seq, role, ts, content,
)
if err != nil {
t.Fatalf("seed turn %s/%s: %v", sid, tid, err)
}
}
func ptrString(v string) *string { return &v }
func TestList_FilterByTool(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
seedSession(t, db, "alice", "gemini", "phoebe", "s2", 1700000020, 1700000030)
tool := "cc"
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Tool: &tool,
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 || got[0].SessionID != "s1" {
t.Fatalf("expected exactly s1; got %#v", got)
}
}
func TestList_FilterByHost(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
seedSession(t, db, "alice", "cc", "rhea", "s2", 1700000020, 1700000030)
host := "rhea"
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Host: &host,
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 || got[0].SessionID != "s2" {
t.Fatalf("expected exactly s2; got %#v", got)
}
}
func TestList_FilterByTimeRange(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
seedSession(t, db, "alice", "cc", "phoebe", "s2", 1700000100, 1700000110)
seedSession(t, db, "alice", "cc", "phoebe", "s3", 1700000200, 1700000210)
since := int64(1700000050)
until := int64(1700000150)
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Since: &since,
Until: &until,
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 || got[0].SessionID != "s2" {
t.Fatalf("expected exactly s2 in range; got %#v", got)
}
}
func TestList_FilterByCwd(t *testing.T) {
repo, db := newRepo(t)
// Two sessions share /code/x; one has /code/y; one has NULL working_dir.
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s2", 1700000020, 1700000030, "/code/y")
seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s3", 1700000040, 1700000050, "/code/x")
seedSession(t, db, "alice", "cc", "phoebe", "s4", 1700000060, 1700000070) // NULL cwd
cwd := "/code/x"
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Cwd: &cwd,
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 rows for cwd=/code/x; got %d (%#v)", len(got), got)
}
ids := map[string]bool{got[0].SessionID: true, got[1].SessionID: true}
if !ids["s1"] || !ids["s3"] {
t.Fatalf("expected {s1,s3}; got %v", ids)
}
}
func TestList_FilterCombined(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
seedSession(t, db, "alice", "cc", "rhea", "s2", 1700000050, 1700000060)
seedSession(t, db, "alice", "gemini", "phoebe", "s3", 1700000070, 1700000080)
tool := "cc"
host := "phoebe"
since := int64(1699999999)
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Tool: &tool,
Host: &host,
Since: &since,
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 || got[0].SessionID != "s1" {
t.Fatalf("expected only s1; got %#v", got)
}
}
func TestList_OrderingByStartedAtDesc(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
seedSession(t, db, "alice", "cc", "phoebe", "s2", 1700000200, 1700000210)
seedSession(t, db, "alice", "cc", "phoebe", "s3", 1700000100, 1700000110)
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 3 {
t.Fatalf("want 3 rows, got %d", len(got))
}
if got[0].SessionID != "s2" || got[1].SessionID != "s3" || got[2].SessionID != "s1" {
t.Fatalf("ordering wrong: %s, %s, %s", got[0].SessionID, got[1].SessionID, got[2].SessionID)
}
}
func TestList_PaginationLimitOffset(t *testing.T) {
repo, db := newRepo(t)
for i := 0; i < 5; i++ {
sid := "s" + string(rune('0'+i))
seedSession(t, db, "alice", "cc", "phoebe", sid, int64(1700000000+i*100), int64(1700000010+i*100))
}
page1, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 2,
Offset: 0,
})
if err != nil {
t.Fatalf("page1: %v", err)
}
if len(page1) != 2 {
t.Fatalf("page1 len=%d; want 2", len(page1))
}
// Newest first: s4, s3
if page1[0].SessionID != "s4" || page1[1].SessionID != "s3" {
t.Fatalf("page1 unexpected: %s, %s", page1[0].SessionID, page1[1].SessionID)
}
page2, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 2,
Offset: 2,
})
if err != nil {
t.Fatalf("page2: %v", err)
}
if len(page2) != 2 {
t.Fatalf("page2 len=%d; want 2", len(page2))
}
if page2[0].SessionID != "s2" || page2[1].SessionID != "s1" {
t.Fatalf("page2 unexpected: %s, %s", page2[0].SessionID, page2[1].SessionID)
}
}
func TestList_OwnerAllOwners(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "admin", AllOwners: true},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 2 {
t.Fatalf("want 2 rows across owners, got %d", len(got))
}
owners := map[string]bool{got[0].Owner: true, got[1].Owner: true}
if !owners["alice"] || !owners["bob"] {
t.Fatalf("expected alice + bob; got %v", owners)
}
}
func TestList_OwnerSpecific(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "admin", SpecificOwner: ptrString("bob")},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 || got[0].SessionID != "sB" || got[0].Owner != "bob" {
t.Fatalf("expected only bob's sB; got %#v", got)
}
}
func TestList_OwnerUserOnly(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 || got[0].Owner != "alice" {
t.Fatalf("expected only alice's row; got %#v", got)
}
}
func TestGet_OwnRow_Returns200WithTurnsInSeqOrder(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000200)
seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tC", 3, 1700000150, "user", "third")
seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tA", 1, 1700000010, "user", "first")
seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tB", 2, 1700000080, "assistant", "second")
got, err := repo.Get(context.Background(), session.OwnerScope{User: "alice"}, "cc", "phoebe", "s1")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got == nil || got.SessionID != "s1" {
t.Fatalf("missing session; got %#v", got)
}
if len(got.Turns) != 3 {
t.Fatalf("want 3 turns, got %d", len(got.Turns))
}
if got.Turns[0].TurnID != "tA" || got.Turns[1].TurnID != "tB" || got.Turns[2].TurnID != "tC" {
t.Fatalf("turns out of order: %s, %s, %s",
got.Turns[0].TurnID, got.Turns[1].TurnID, got.Turns[2].TurnID)
}
}
func TestGet_OtherOwnersRow_ReturnsNotFound(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010)
_, err := repo.Get(context.Background(), session.OwnerScope{User: "alice"}, "cc", "phoebe", "sB")
if err == nil {
t.Fatalf("expected NOT_FOUND, got nil")
}
if code := codeOf(err); code != "NOT_FOUND" {
t.Fatalf("expected code NOT_FOUND, got %q", code)
}
}
func TestGet_AdminAllOwners_FetchesAnyOwner(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010)
seedTurn(t, db, "bob", "cc", "phoebe", "sB", "tA", 1, 1700000005, "user", "hi")
got, err := repo.Get(context.Background(),
session.OwnerScope{User: "admin", AllOwners: true},
"cc", "phoebe", "sB",
)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got == nil || got.Owner != "bob" {
t.Fatalf("expected bob's session; got %#v", got)
}
if len(got.Turns) != 1 || got.Turns[0].Owner != "bob" {
t.Fatalf("expected 1 turn owned by bob; got %#v", got.Turns)
}
}
// seedTurnFull inserts a turn row with model, tokens_in, and tokens_out set.
func seedTurnFull(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts int64, role, content, model string, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)`,
owner, tool, host, sid, tid, seq, role, ts, content, model, tokensIn, tokensOut,
)
if err != nil {
t.Fatalf("seed turn full %s/%s: %v", sid, tid, err)
}
}
func TestList_Aggregates(t *testing.T) {
t.Run("zero turns yields empty aggregates", func(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 session; got %d", len(got))
}
s := got[0]
if s.TurnCount != 0 {
t.Errorf("TurnCount: got %d; want 0", s.TurnCount)
}
if s.TokensInTotal != 0 {
t.Errorf("TokensInTotal: got %d; want 0", s.TokensInTotal)
}
if s.TokensOutTotal != 0 {
t.Errorf("TokensOutTotal: got %d; want 0", s.TokensOutTotal)
}
if s.Summary != "" {
t.Errorf("Summary: got %q; want empty string", s.Summary)
}
if s.Model != nil {
t.Errorf("Model: got %v; want nil", s.Model)
}
})
t.Run("one user turn long content truncates summary to 200 chars", func(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
longContent := "x" + string(make([]byte, 249)) // 250 chars total ('x' + 249 zero bytes replaced below)
// Build a deterministic 250-char string.
longContent = ""
for i := 0; i < 250; i++ {
longContent += string(rune('a' + i%26))
}
seedTurn(t, db, "alice", "cc", "phoebe", "s1", "t1", 1, 1700000005, "user", longContent)
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 session; got %d", len(got))
}
s := got[0]
if len(s.Summary) != 200 {
t.Errorf("Summary length: got %d; want 200", len(s.Summary))
}
if s.Summary != longContent[:200] {
t.Errorf("Summary content mismatch: got %q; want %q", s.Summary, longContent[:200])
}
})
t.Run("multiple turns with mixed roles model is newest turn model regardless of role", func(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
// seq 1: user turn (no model)
seedTurn(t, db, "alice", "cc", "phoebe", "s1", "t1", 1, 1700000001, "user", "hello")
// seq 2: assistant turn with model "gpt-4"
seedTurnFull(t, db, "alice", "cc", "phoebe", "s1", "t2", 2, 1700000002, "assistant", "hi there", "gpt-4", 10, 20)
// seq 3: user turn again (no model — seeded via seedTurn, model stays NULL)
seedTurn(t, db, "alice", "cc", "phoebe", "s1", "t3", 3, 1700000003, "user", "follow up")
// seq 4: assistant with newer model "gpt-4o"
seedTurnFull(t, db, "alice", "cc", "phoebe", "s1", "t4", 4, 1700000004, "assistant", "response", "gpt-4o", 15, 30)
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 session; got %d", len(got))
}
s := got[0]
if s.TurnCount != 4 {
t.Errorf("TurnCount: got %d; want 4", s.TurnCount)
}
if s.Model == nil {
t.Fatal("Model: got nil; want non-nil")
}
if *s.Model != "gpt-4o" {
t.Errorf("Model: got %q; want %q", *s.Model, "gpt-4o")
}
})
t.Run("NULL tokens_in and tokens_out on some turns treated as zero in sums", func(t *testing.T) {
repo, db := newRepo(t)
seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
// t1: NULL tokens
seedTurn(t, db, "alice", "cc", "phoebe", "s1", "t1", 1, 1700000001, "user", "msg1")
// t2: non-NULL tokens
seedTurnFull(t, db, "alice", "cc", "phoebe", "s1", "t2", 2, 1700000002, "assistant", "resp1", "gpt-4", 100, 200)
// t3: NULL tokens
seedTurn(t, db, "alice", "cc", "phoebe", "s1", "t3", 3, 1700000003, "user", "msg2")
// t4: non-NULL tokens
seedTurnFull(t, db, "alice", "cc", "phoebe", "s1", "t4", 4, 1700000004, "assistant", "resp2", "gpt-4", 50, 75)
got, err := repo.List(context.Background(), session.ListFilter{
Owner: session.OwnerScope{User: "alice"},
Limit: 50,
})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 session; got %d", len(got))
}
s := got[0]
if s.TokensInTotal != 150 {
t.Errorf("TokensInTotal: got %d; want 150", s.TokensInTotal)
}
if s.TokensOutTotal != 275 {
t.Errorf("TokensOutTotal: got %d; want 275", s.TokensOutTotal)
}
})
}
// codeOf walks the culpa chain for a CodeDetail and returns the string code,
// or "" if there isn't one. Local helper so tests don't reach into apierror's
// unexported lookup.
func codeOf(err error) string {
var cd culpa.CodeDetail
if !culpa.FindDetail(err, &cd) {
return ""
}
s, ok := cd.Code.(string)
if !ok {
return ""
}
return s
}