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
}