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