@@ 0,0 1,161 @@
+package project_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "sourcecraft.dev/bigbes/lethe/internal/domain/project"
+ "sourcecraft.dev/bigbes/lethe/internal/server/auth"
+)
+
+// fakeAuthMiddleware injects a fixed Identity onto the request context.
+func fakeAuthMiddleware(id auth.Identity) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := auth.WithIdentity(r.Context(), id)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+// newHandler wires a Handler against a fresh in-memory database.
+func newHandler(t *testing.T) (*project.Handler, *project.Repository) {
+ t.Helper()
+ repo, _ := newRepo(t)
+ h := &project.Handler{Repo: repo}
+ if err := h.Init(t.Context()); err != nil {
+ t.Fatalf("handler.Init: %v", err)
+ }
+ return h, repo
+}
+
+// mountWithIdentity builds a chi router with the fake auth middleware and
+// the project handler mounted under /api/v1.
+func mountWithIdentity(h *project.Handler, id auth.Identity) http.Handler {
+ r := chi.NewRouter()
+ r.Route("/api/v1", func(r chi.Router) {
+ r.Use(fakeAuthMiddleware(id))
+ h.Mount(r)
+ })
+ return r
+}
+
+// projectListBody is the decoded response of GET /api/v1/projects.
+type projectListBody struct {
+ Projects []project.Project `json:"projects"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+}
+
+// problemBody captures the RFC 7807 fields tests assert on.
+type problemBody struct {
+ Status int `json:"status"`
+ Code string `json:"code"`
+}
+
+func doList(t *testing.T, router http.Handler, query string) (*httptest.ResponseRecorder, projectListBody) {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/projects"+query, nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ var body projectListBody
+ if rec.Code == http.StatusOK {
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal list body: %v (body=%s)", err, rec.Body.String())
+ }
+ }
+ return rec, body
+}
+
+func TestProjectHandler_List_OneProject(t *testing.T) {
+ h, repo := newHandler(t)
+ db := repo.Database.DB
+ seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010, "/code/x")
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec, body := doList(t, router, "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if len(body.Projects) != 1 {
+ t.Fatalf("expected 1 project; got %d (%#v)", len(body.Projects), body.Projects)
+ }
+ if body.Projects[0].Cwd != "/code/x" {
+ t.Errorf("Cwd: got %q; want /code/x", body.Projects[0].Cwd)
+ }
+ if body.Limit != 50 || body.Offset != 0 {
+ t.Errorf("defaults: limit=%d offset=%d; want 50/0", body.Limit, body.Offset)
+ }
+}
+
+func TestProjectHandler_List_NonAdminOwnerParamReturns403(t *testing.T) {
+ h, _ := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice", IsAdmin: false})
+
+ for _, q := range []string{"?owner=alice", "?owner=bob", "?owner=*"} {
+ rec, _ := doList(t, router, q)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("query %q: status=%d; want 403; body=%s", q, rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "FORBIDDEN" {
+ t.Fatalf("query %q: code=%q; want FORBIDDEN", q, p.Code)
+ }
+ }
+}
+
+func TestProjectHandler_List_BadSinceReturns400(t *testing.T) {
+ h, _ := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec, _ := doList(t, router, "?since=not-a-number")
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status=%d; want 400; body=%s", rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "INVALID" {
+ t.Fatalf("expected INVALID; got %q", p.Code)
+ }
+}
+
+func TestProjectHandler_List_AdminOwnerStarReturnsAllOwners(t *testing.T) {
+ h, repo := newHandler(t)
+ db := repo.Database.DB
+ seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010, "/code/x")
+ seedSessionWithCwd(t, db, "bob", "cc", "phoebe", "sB", 1700000020, 1700000030, "/code/y")
+ router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true})
+
+ rec, body := doList(t, router, "?owner=*")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if len(body.Projects) != 2 {
+ t.Fatalf("expected 2 projects; got %d (%#v)", len(body.Projects), body.Projects)
+ }
+}
+
+func TestProjectHandler_Mount_RegistersRoute(t *testing.T) {
+ h, _ := newHandler(t)
+ router := chi.NewRouter()
+ router.Route("/api/v1", func(r chi.Router) {
+ r.Use(fakeAuthMiddleware(auth.Identity{User: "alice"}))
+ h.Mount(r)
+ })
+
+ found := false
+ _ = chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
+ if method == http.MethodGet && route == "/api/v1/projects" {
+ found = true
+ }
+ return nil
+ })
+ if !found {
+ t.Error("expected GET /api/v1/projects registered; not found")
+ }
+}
@@ 0,0 1,326 @@
+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)
+ }
+}
@@ 57,6 57,20 @@ func seedSession(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, start
}
}
+// 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()
@@ 133,6 147,32 @@ func TestList_FilterByTimeRange(t *testing.T) {
}
}
+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)