M internal/domain/session/handler.go => internal/domain/session/handler.go +197 -11
@@ 1,22 1,208 @@
-// Phase-5 stub. Replaced by real implementation in Phase 8.
-//
-// Mount is a no-op so the /api/v1 group registers no session routes yet;
-// Server.Init still wires it in so the full router topology compiles.
package session
import (
"context"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "strings"
"github.com/go-chi/chi/v5"
+ "go.bigb.es/auxilia/culpa"
+ "go.bigb.es/auxilia/scribe"
+
+ "sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
+ "sourcecraft.dev/bigbes/lethe/internal/pkg/httputil"
+ "sourcecraft.dev/bigbes/lethe/internal/server/auth"
+)
+
+// Pagination knobs locked by the spec. The Handler clamps client-supplied
+// values into [0, defaultLimit] / [0, maxLimit] before reaching Repository.
+const (
+ defaultLimit = 50
+ maxLimit = 200
)
-// Handler is a no-op steward service. Phase 8 supplies the real session
-// read API.
-type Handler struct{}
+// allOwnersSentinel is the literal value of `?owner=` that an admin uses to
+// scope a list across every owner. Any other non-empty value is interpreted
+// as a SpecificOwner.
+const allOwnersSentinel = "*"
-// Init satisfies the steward Service contract.
+// Handler is the steward-managed HTTP boundary for the sessions read API.
+// Repo is the injected SQL steward; the Handler holds no other state.
+type Handler struct {
+ Repo *Repository `inject:""`
+}
+
+// Init satisfies the steward Initer contract. The Handler is stateless
+// beyond its injected dependencies.
func (h *Handler) Init(_ context.Context) error { return nil }
-// Mount registers no routes. Phase 8 replaces this with the sessions list /
-// detail endpoints rooted at the /api/v1 group passed in by Server.Init.
-func (h *Handler) Mount(_ chi.Router) {}
+// Mount registers the two read routes under r. Server.Init mounts this
+// inside the /api/v1 group, so the effective paths are
+// `/api/v1/sessions` and `/api/v1/sessions/{tool}/{host}/{session_id}`.
+func (h *Handler) Mount(r chi.Router) {
+ r.Get("/sessions", h.List)
+ r.Get("/sessions/{tool}/{host}/{session_id}", h.Get)
+}
+
+// listResponse is the JSON body returned by List. The Limit/Offset echo back
+// the (possibly clamped) effective values so clients can detect that their
+// supplied limit was capped.
+type listResponse struct {
+ Sessions []Session `json:"sessions"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+}
+
+// List handles GET /sessions. It resolves the owner scope (admin gating on
+// `?owner=`), parses optional filters, clamps pagination, and writes a
+// listResponse. Errors surface through apierror.Render as RFC 7807.
+func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
+ scope, err := h.resolveScope(r)
+ if err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+
+ q := r.URL.Query()
+ filter := ListFilter{Owner: scope}
+
+ if v := q.Get("tool"); v != "" {
+ filter.Tool = &v
+ }
+ if v := q.Get("host"); v != "" {
+ filter.Host = &v
+ }
+ if v := q.Get("since"); v != "" {
+ n, perr := strconv.ParseInt(v, 10, 64)
+ if perr != nil {
+ apierror.Render(w, r, culpa.WithCode(
+ culpa.WithPublic(culpa.Wrap(perr, "parse since"), "since must be an integer (unix epoch seconds)"),
+ "INVALID",
+ ))
+ return
+ }
+ filter.Since = &n
+ }
+ if v := q.Get("until"); v != "" {
+ n, perr := strconv.ParseInt(v, 10, 64)
+ if perr != nil {
+ apierror.Render(w, r, culpa.WithCode(
+ culpa.WithPublic(culpa.Wrap(perr, "parse until"), "until must be an integer (unix epoch seconds)"),
+ "INVALID",
+ ))
+ return
+ }
+ filter.Until = &n
+ }
+ if filter.Since != nil && filter.Until != nil && *filter.Since > *filter.Until {
+ apierror.Render(w, r, culpa.WithCode(
+ culpa.WithPublic(culpa.New("since > until"), "since must be <= until"),
+ "INVALID",
+ ))
+ return
+ }
+
+ filter.Limit = clampLimit(q.Get("limit"))
+ filter.Offset = clampOffset(q.Get("offset"))
+
+ rows, err := h.Repo.List(r.Context(), filter)
+ if err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+
+ if writeErr := httputil.WriteJSON(w, http.StatusOK, listResponse{
+ Sessions: rows,
+ Limit: filter.Limit,
+ Offset: filter.Offset,
+ }); writeErr != nil {
+ slog.Default().ErrorContext(r.Context(), "write sessions response", scribe.Err(writeErr))
+ }
+}
+
+// Get handles GET /sessions/{tool}/{host}/{session_id}. The chi router
+// guarantees non-empty captures, but we defend in depth (a misconfigured
+// mount would otherwise produce a SQL query with empty keys).
+func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
+ scope, err := h.resolveScope(r)
+ if err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+
+ tool := chi.URLParam(r, "tool")
+ host := chi.URLParam(r, "host")
+ sessionID := chi.URLParam(r, "session_id")
+ if tool == "" || host == "" || sessionID == "" {
+ apierror.Render(w, r, culpa.WithCode(
+ culpa.WithPublic(culpa.New("tool, host, and session_id are required"), "tool, host, and session_id are required"),
+ "INVALID",
+ ))
+ return
+ }
+
+ out, err := h.Repo.Get(r.Context(), scope, tool, host, sessionID)
+ if err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+ if writeErr := httputil.WriteJSON(w, http.StatusOK, out); writeErr != nil {
+ slog.Default().ErrorContext(r.Context(), "write session response", scribe.Err(writeErr))
+ }
+}
+
+// resolveScope reads the authenticated identity off the context and the
+// optional `?owner=` query parameter, then returns the appropriate
+// OwnerScope. Non-admin requests with `?owner=` set (any value, including
+// the requester's own user) are 403 — the parameter is admin-only and must
+// not be ignored silently for non-admins.
+func (h *Handler) resolveScope(r *http.Request) (OwnerScope, error) {
+ id := auth.MustIdentity(r.Context())
+ param := r.URL.Query().Get("owner")
+ if param == "" {
+ return OwnerScope{User: id.User}, nil
+ }
+ if !id.IsAdmin {
+ return OwnerScope{}, culpa.WithCode(
+ culpa.WithPublic(culpa.New("?owner= is admin-only"), "?owner= is admin-only"),
+ "FORBIDDEN",
+ )
+ }
+ if param == allOwnersSentinel {
+ return OwnerScope{User: id.User, AllOwners: true}, nil
+ }
+ owner := strings.ToLower(param)
+ return OwnerScope{User: id.User, SpecificOwner: &owner}, nil
+}
+
+// clampLimit returns the effective limit: defaultLimit when missing,
+// non-numeric, or negative; capped at maxLimit when the parsed value
+// exceeds it.
+func clampLimit(raw string) int {
+ if raw == "" {
+ return defaultLimit
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil || n < 0 {
+ return defaultLimit
+ }
+ if n > maxLimit {
+ return maxLimit
+ }
+ return n
+}
+
+// clampOffset returns the effective offset: 0 when missing, non-numeric,
+// or negative.
+func clampOffset(raw string) int {
+ if raw == "" {
+ return 0
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil || n < 0 {
+ return 0
+ }
+ return n
+}
A internal/domain/session/handler_test.go => internal/domain/session/handler_test.go +349 -0
@@ 0,0 1,349 @@
+package session_test
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "sourcecraft.dev/bigbes/lethe/internal/domain/session"
+ "sourcecraft.dev/bigbes/lethe/internal/server/auth"
+)
+
+// fakeAuthMiddleware injects a fixed Identity onto the request context so
+// the handler can call auth.MustIdentity without a real Authenticator.
+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 Repository against a fresh in-memory database and
+// returns the Handler. The DB is exposed so each test can seed rows.
+func newHandler(t *testing.T) (*session.Handler, *session.Repository) {
+ t.Helper()
+ repo, _ := newRepo(t)
+ h := &session.Handler{Repo: repo}
+ if err := h.Init(context.Background()); err != nil {
+ t.Fatalf("handler.Init: %v", err)
+ }
+ return h, repo
+}
+
+// mountWithIdentity builds a chi router with the fake auth middleware
+// (injecting id) and the session handler mounted under /api/v1.
+func mountWithIdentity(h *session.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
+}
+
+// listBody is the decoded JSON of GET /sessions; matches the handler's
+// listResponse but lives here so tests don't reach into unexported names.
+type listBody struct {
+ Sessions []session.Session `json:"sessions"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+}
+
+// problemBody captures only the fields tests assert on for RFC 7807 docs.
+type problemBody struct {
+ Status int `json:"status"`
+ Code string `json:"code"`
+}
+
+func doList(t *testing.T, router http.Handler, query string) (*httptest.ResponseRecorder, listBody) {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions"+query, nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ var body listBody
+ 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 doGet(t *testing.T, router http.Handler, tool, host, sid string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions/"+tool+"/"+host+"/"+sid, nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestHandler_List_PaginationDefaults(t *testing.T) {
+ h, repo := newHandler(t)
+ seedSession(t, repo.Database.DB, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
+ 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 body.Limit != 50 || body.Offset != 0 {
+ t.Fatalf("defaults: limit=%d offset=%d; want 50/0", body.Limit, body.Offset)
+ }
+}
+
+func TestHandler_List_PaginationCaps(t *testing.T) {
+ h, _ := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec, body := doList(t, router, "?limit=999")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if body.Limit != 200 {
+ t.Fatalf("expected limit capped to 200; got %d", body.Limit)
+ }
+}
+
+func TestHandler_List_NegativeClamped(t *testing.T) {
+ h, _ := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec, body := doList(t, router, "?limit=-3&offset=-7")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if body.Limit != 50 {
+ t.Fatalf("expected negative limit clamped to default 50; got %d", body.Limit)
+ }
+ if body.Offset != 0 {
+ t.Fatalf("expected negative offset clamped to 0; got %d", body.Offset)
+ }
+}
+
+func TestHandler_List_SinceGreaterThanUntilReturns400(t *testing.T) {
+ h, _ := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec, _ := doList(t, router, "?since=200&until=100")
+ 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 code INVALID; got %q (body=%s)", p.Code, rec.Body.String())
+ }
+}
+
+func TestHandler_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 TestHandler_List_PerUserIsolation(t *testing.T) {
+ h, repo := newHandler(t)
+ db := repo.Database.DB
+ seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
+ seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)
+ seedSession(t, db, "bob", "cc", "phoebe", "sB2", 1700000200, 1700000210)
+
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+ // No filter — should still only see alice's row.
+ rec, body := doList(t, router, "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if len(body.Sessions) != 1 || body.Sessions[0].Owner != "alice" {
+ t.Fatalf("alice should see only her row; got %#v", body.Sessions)
+ }
+
+ // Filter by tool — bob's matching rows still excluded.
+ rec, body = doList(t, router, "?tool=cc")
+ if rec.Code != http.StatusOK || len(body.Sessions) != 1 || body.Sessions[0].Owner != "alice" {
+ t.Fatalf("alice + tool filter should yield 1 alice row; got code=%d %#v", rec.Code, body.Sessions)
+ }
+}
+
+func TestHandler_List_NonAdminOwnerParamReturns403(t *testing.T) {
+ h, repo := newHandler(t)
+ seedSession(t, repo.Database.DB, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
+ 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 TestHandler_List_AdminOwnerParamHonored(t *testing.T) {
+ h, repo := newHandler(t)
+ db := repo.Database.DB
+ seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
+ seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)
+ router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true})
+
+ rec, body := doList(t, router, "?owner=bob")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if len(body.Sessions) != 1 || body.Sessions[0].Owner != "bob" {
+ t.Fatalf("expected bob's row only; got %#v", body.Sessions)
+ }
+}
+
+func TestHandler_List_AdminOwnerStarReturnsAllOwners(t *testing.T) {
+ h, repo := newHandler(t)
+ db := repo.Database.DB
+ seedSession(t, db, "alice", "cc", "phoebe", "sA", 1700000000, 1700000010)
+ seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)
+ 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.Sessions) != 2 {
+ t.Fatalf("expected 2 rows; got %d (%#v)", len(body.Sessions), body.Sessions)
+ }
+}
+
+func TestHandler_List_AdminWithoutOwnerParam_ShowsOnlyOwnRows(t *testing.T) {
+ h, repo := newHandler(t)
+ db := repo.Database.DB
+ seedSession(t, db, "admin", "cc", "phoebe", "sAdm", 1700000000, 1700000010)
+ seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000100, 1700000110)
+ router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true})
+
+ rec, body := doList(t, router, "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if len(body.Sessions) != 1 || body.Sessions[0].Owner != "admin" {
+ t.Fatalf("admin without ?owner= should see only own rows; got %#v", body.Sessions)
+ }
+}
+
+func TestHandler_Get_OtherOwnersSession_ReturnsNotFound404(t *testing.T) {
+ h, repo := newHandler(t)
+ seedSession(t, repo.Database.DB, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec := doGet(t, router, "cc", "phoebe", "sB")
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status=%d; want 404; body=%s", rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "NOT_FOUND" {
+ t.Fatalf("expected NOT_FOUND; got %q", p.Code)
+ }
+}
+
+func TestHandler_Get_OwnSession_Returns200(t *testing.T) {
+ h, repo := newHandler(t)
+ db := repo.Database.DB
+ seedSession(t, db, "alice", "cc", "phoebe", "s1", 1700000000, 1700000010)
+ seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tA", 1, 1700000005, "user", "hi")
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec := doGet(t, router, "cc", "phoebe", "s1")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d; want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ var got session.SessionWithTurns
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if got.SessionID != "s1" || got.Owner != "alice" {
+ t.Fatalf("unexpected session: %#v", got.Session)
+ }
+ if len(got.Turns) != 1 || got.Turns[0].TurnID != "tA" {
+ t.Fatalf("unexpected turns: %#v", got.Turns)
+ }
+}
+
+func TestHandler_Get_AdminCanGetAnyOwner(t *testing.T) {
+ h, repo := newHandler(t)
+ db := repo.Database.DB
+ seedSession(t, db, "bob", "cc", "phoebe", "sB", 1700000000, 1700000010)
+ router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true})
+
+ // With ?owner=bob admins fetch any user's session.
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions/cc/phoebe/sB?owner=bob", nil)
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d; body=%s", rec.Code, rec.Body.String())
+ }
+ var got session.SessionWithTurns
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if got.Owner != "bob" {
+ t.Fatalf("expected bob's session; got %#v", got.Session)
+ }
+
+ // And with ?owner=* the admin can also reach across owners.
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest(http.MethodGet, "/api/v1/sessions/cc/phoebe/sB?owner=*", nil)
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("?owner=*: status=%d; body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestHandler_Mount_RegistersExpectedRoutes(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)
+ })
+
+ wantPatterns := map[string]bool{
+ "/api/v1/sessions": false,
+ "/api/v1/sessions/{tool}/{host}/{session_id}": false,
+ }
+ walkErr := chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
+ if method != http.MethodGet {
+ return nil
+ }
+ for k := range wantPatterns {
+ if route == k {
+ wantPatterns[k] = true
+ }
+ }
+ return nil
+ })
+ if walkErr != nil {
+ t.Fatalf("walk: %v", walkErr)
+ }
+ for pat, found := range wantPatterns {
+ if !found {
+ t.Errorf("expected GET %s registered; not found", pat)
+ }
+ }
+}
A internal/domain/session/repository.go => internal/domain/session/repository.go +289 -0
@@ 0,0 1,289 @@
+// Package session implements the read-only sessions API: list with filters
+// and detail-with-turns. The package layers as Repository (raw SQL) and
+// Handler (HTTP boundary). Both are steward services; the Repository owns
+// the SQL composition and the per-owner isolation invariant, while the
+// Handler resolves the owner scope from the authenticated identity and
+// translates errors into RFC 7807 problems.
+//
+// The owner-scope resolution rules (locked):
+// - Default scope is `WHERE owner = <current user>`. Non-admins always read
+// their own data only.
+// - Admins may pass `?owner=<user>` to scope to that user, or `?owner=*` to
+// read across all owners (no WHERE clause on owner).
+// - Non-admin requests that include `?owner=` (any value, including their
+// own user) are 403 — the parameter is admin-only.
+// - A Get for another owner's session returns 404 (not 403). Returning a
+// distinct status would leak existence across tenants.
+package session
+
+import (
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "go.bigb.es/auxilia/culpa"
+
+ "sourcecraft.dev/bigbes/lethe/internal/platform/database"
+)
+
+// JSONText is a thin wrapper around the raw JSON bytes stored in TEXT columns
+// (sessions.metadata, turns.tool_calls, turns.metadata). The SQLite driver
+// does not know how to scan NULL into json.RawMessage directly, and the
+// stored values may legitimately be NULL — Scan handles that by leaving the
+// underlying slice nil. JSON marshal omits the field entirely (`,omitempty`)
+// when the slice is empty, so a NULL column becomes an absent JSON key.
+type JSONText []byte
+
+// Scan implements sql.Scanner so NULL TEXT columns become a nil []byte.
+// Non-NULL values are copied (the driver's buffer is not safe to retain).
+func (j *JSONText) Scan(src any) error {
+ if src == nil {
+ *j = nil
+ return nil
+ }
+ switch v := src.(type) {
+ case []byte:
+ buf := make([]byte, len(v))
+ copy(buf, v)
+ *j = buf
+ return nil
+ case string:
+ *j = []byte(v)
+ return nil
+ default:
+ return fmt.Errorf("session: cannot scan %T into JSONText", src)
+ }
+}
+
+// Value implements driver.Valuer; included for completeness so the type can
+// flow back through any future writes (Phase 8 itself is read-only).
+func (j JSONText) Value() (driver.Value, error) {
+ if len(j) == 0 {
+ return nil, nil
+ }
+ return string(j), nil
+}
+
+// MarshalJSON returns the stored bytes verbatim (or `null` if empty) so the
+// JSON output preserves whatever the collector originally produced.
+func (j JSONText) MarshalJSON() ([]byte, error) {
+ if len(j) == 0 {
+ return []byte("null"), nil
+ }
+ // Validate that the stored bytes are syntactically valid JSON before
+ // emitting them — guards against a corrupt row turning a 200 into an
+ // HTTP-level encoding panic.
+ if !json.Valid(j) {
+ return nil, fmt.Errorf("session: stored JSON is invalid")
+ }
+ return []byte(j), nil
+}
+
+// UnmarshalJSON stores the raw bytes verbatim, mirroring json.RawMessage.
+func (j *JSONText) UnmarshalJSON(b []byte) error {
+ if j == nil {
+ return errors.New("session.JSONText: UnmarshalJSON on nil pointer")
+ }
+ *j = append((*j)[0:0], b...)
+ return nil
+}
+
+// Session is the row shape returned by List and embedded in SessionWithTurns.
+// JSON tags mirror the wire vocabulary used by the collector and clients.
+type Session struct {
+ Owner string `db:"owner" json:"owner"`
+ Tool string `db:"tool" json:"tool"`
+ Host string `db:"host" json:"host"`
+ SessionID string `db:"session_id" json:"session_id"`
+ StartedAt int64 `db:"started_at" json:"started_at"`
+ EndedAt int64 `db:"ended_at" json:"ended_at"`
+ WorkingDir *string `db:"working_dir" json:"working_dir,omitempty"`
+ SourceFile string `db:"source_file" json:"source_file"`
+ Metadata JSONText `db:"metadata" json:"metadata,omitempty"`
+}
+
+// Turn is the row shape returned inside SessionWithTurns. Optional columns
+// (model, tokens_in/out, cost_usd, tool_calls, metadata) are nullable in the
+// schema and are exposed as pointers / RawMessage so callers can distinguish
+// "absent" from "zero".
+type Turn struct {
+ Owner string `db:"owner" json:"owner"`
+ Tool string `db:"tool" json:"tool"`
+ Host string `db:"host" json:"host"`
+ SessionID string `db:"session_id" json:"session_id"`
+ TurnID string `db:"turn_id" json:"turn_id"`
+ Seq int64 `db:"seq" json:"seq"`
+ Role string `db:"role" json:"role"`
+ Timestamp int64 `db:"timestamp" json:"timestamp"`
+ Content string `db:"content" json:"content"`
+ Model *string `db:"model" json:"model,omitempty"`
+ TokensIn *int64 `db:"tokens_in" json:"tokens_in,omitempty"`
+ TokensOut *int64 `db:"tokens_out" json:"tokens_out,omitempty"`
+ CostUSD *float64 `db:"cost_usd" json:"cost_usd,omitempty"`
+ ToolCalls JSONText `db:"tool_calls" json:"tool_calls,omitempty"`
+ Metadata JSONText `db:"metadata" json:"metadata,omitempty"`
+}
+
+// SessionWithTurns is the response shape for Get. Session is embedded so the
+// JSON output flattens the session columns at the top level alongside the
+// "turns" array.
+type SessionWithTurns struct {
+ Session
+ Turns []Turn `json:"turns"`
+}
+
+// OwnerScope is resolved by the Handler from the authenticated identity and
+// the optional `?owner=` query parameter. It is the only knob the Repository
+// has for tightening or widening the owner WHERE clause.
+//
+// Exactly one of the three states is meaningful per call:
+// - AllOwners=true → no WHERE clause on owner (admin + ?owner=*)
+// - SpecificOwner != nil → WHERE owner = *SpecificOwner (admin + ?owner=u)
+// - otherwise → WHERE owner = User (default; non-admins always)
+type OwnerScope struct {
+ User string
+ AllOwners bool
+ SpecificOwner *string
+}
+
+// ListFilter aggregates every option List supports. The Handler clamps Limit
+// and Offset before constructing this struct; the Repository assumes both are
+// already in the safe range.
+type ListFilter struct {
+ Owner OwnerScope
+ Tool *string
+ Host *string
+ Since *int64
+ Until *int64
+ Limit int
+ Offset int
+}
+
+// Repository is the SQL steward for the sessions read API. It is stateless
+// beyond its injected dependencies; Init is empty.
+type Repository struct {
+ Database *database.Database `inject:""`
+}
+
+// Init satisfies the steward Initer contract. Nothing to set up — the
+// underlying *sqlx.DB is owned by the Database steward.
+func (r *Repository) Init(_ context.Context) error { return nil }
+
+// sessionSelectColumns is the canonical column list for SELECTs against
+// `sessions`. Centralized so the List and Get queries stay in lock-step with
+// the Session struct's `db` tags.
+const sessionSelectColumns = `owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata`
+
+// turnSelectColumns mirrors sessionSelectColumns for the `turns` table.
+const turnSelectColumns = `owner, tool, host, session_id, turn_id, seq, role, timestamp, content, model, tokens_in, tokens_out, cost_usd, tool_calls, metadata`
+
+// List runs the dynamic-WHERE list query. The owner clause is built first
+// (per OwnerScope), then optional filters are appended in fixed order; only
+// values reach the driver via "?" placeholders — column names and the
+// AND-skeleton are constructed from string literals never derived from input.
+//
+// Ordering is `started_at DESC, session_id DESC`: the secondary key keeps
+// pagination deterministic when two sessions share a started_at.
+//
+// An empty result set returns a non-nil zero-length slice. Callers that
+// JSON-encode the slice get `[]` rather than `null`.
+func (r *Repository) List(ctx context.Context, f ListFilter) ([]Session, error) {
+ var (
+ sb strings.Builder
+ args []any
+ )
+ sb.WriteString("SELECT ")
+ sb.WriteString(sessionSelectColumns)
+ sb.WriteString(" FROM sessions")
+
+ clauses := make([]string, 0, 5)
+ switch {
+ case f.Owner.AllOwners:
+ // no owner clause
+ case f.Owner.SpecificOwner != nil:
+ clauses = append(clauses, "owner = ?")
+ args = append(args, *f.Owner.SpecificOwner)
+ default:
+ clauses = append(clauses, "owner = ?")
+ args = append(args, f.Owner.User)
+ }
+ if f.Tool != nil {
+ clauses = append(clauses, "tool = ?")
+ args = append(args, *f.Tool)
+ }
+ if f.Host != nil {
+ clauses = append(clauses, "host = ?")
+ args = append(args, *f.Host)
+ }
+ if f.Since != nil {
+ clauses = append(clauses, "started_at >= ?")
+ args = append(args, *f.Since)
+ }
+ if f.Until != nil {
+ clauses = append(clauses, "started_at <= ?")
+ args = append(args, *f.Until)
+ }
+ if len(clauses) > 0 {
+ sb.WriteString(" WHERE ")
+ sb.WriteString(strings.Join(clauses, " AND "))
+ }
+ sb.WriteString(" ORDER BY started_at DESC, session_id DESC LIMIT ? OFFSET ?")
+ args = append(args, f.Limit, f.Offset)
+
+ out := make([]Session, 0)
+ if err := r.Database.DB.SelectContext(ctx, &out, sb.String(), args...); err != nil {
+ return nil, culpa.WithCode(culpa.Wrap(err, "list sessions"), "DB_QUERY")
+ }
+ return out, nil
+}
+
+// Get returns the named session and its turns in seq order. The owner clause
+// is built from scope identically to List: AllOwners means no clause,
+// SpecificOwner pins the row, otherwise the current user is the only allowed
+// owner. A miss for any of those reasons returns NOT_FOUND — never 403 —
+// because differentiating "wrong owner" from "no such session" would leak
+// existence across tenants.
+//
+// The turns query uses the resolved session's owner (read off the loaded
+// row), keeping the result set internally consistent even under AllOwners.
+func (r *Repository) Get(ctx context.Context, scope OwnerScope, tool, host, sessionID string) (*SessionWithTurns, error) {
+ var (
+ sb strings.Builder
+ args []any
+ )
+ sb.WriteString("SELECT ")
+ sb.WriteString(sessionSelectColumns)
+ sb.WriteString(" FROM sessions WHERE ")
+
+ switch {
+ case scope.AllOwners:
+ // no owner clause
+ case scope.SpecificOwner != nil:
+ sb.WriteString("owner = ? AND ")
+ args = append(args, *scope.SpecificOwner)
+ default:
+ sb.WriteString("owner = ? AND ")
+ args = append(args, scope.User)
+ }
+ sb.WriteString("tool = ? AND host = ? AND session_id = ?")
+ args = append(args, tool, host, sessionID)
+
+ var s Session
+ if err := r.Database.DB.GetContext(ctx, &s, sb.String(), args...); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, culpa.WithCode(culpa.New("session not found"), "NOT_FOUND")
+ }
+ return nil, culpa.WithCode(culpa.Wrap(err, "get session"), "DB_QUERY")
+ }
+
+ turns := make([]Turn, 0)
+ const turnsQuery = "SELECT " + turnSelectColumns + " FROM turns WHERE owner = ? AND tool = ? AND host = ? AND session_id = ? ORDER BY seq ASC"
+ if err := r.Database.DB.SelectContext(ctx, &turns, turnsQuery, s.Owner, s.Tool, s.Host, s.SessionID); err != nil {
+ return nil, culpa.WithCode(culpa.Wrap(err, "list session turns"), "DB_QUERY")
+ }
+ return &SessionWithTurns{Session: s, Turns: turns}, nil
+}
A internal/domain/session/repository_test.go => internal/domain/session/repository_test.go +344 -0
@@ 0,0 1,344 @@
+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
+}