package search_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/go-chi/chi/v5" "sourcecraft.dev/bigbes/lethe/internal/domain/search" "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 Repository against a fresh in-memory database and // returns the Handler. func newHandler(t *testing.T) (*search.Handler, *search.Repository) { t.Helper() repo, _ := newRepo(t) h := &search.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 fake auth and the search handler. func mountWithIdentity(h *search.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 } // problemBody captures RFC 7807 fields tests assert on. type problemBody struct { Status int `json:"status"` Code string `json:"code"` } // doSearch performs GET /api/v1/search with the given query string. func doSearch(t *testing.T, router http.Handler, query string) (*httptest.ResponseRecorder, search.Result) { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/v1/search", nil) if query != "" { req.URL.RawQuery = query[1:] // strip leading '?' } rec := httptest.NewRecorder() router.ServeHTTP(rec, req) var body search.Result if rec.Code == http.StatusOK { if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("unmarshal search body: %v (body=%s)", err, rec.Body.String()) } } return rec, body } func TestHandler_Mount_RegistersSearchRoute(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/search" { found = true } return nil }) if !found { t.Fatal("expected GET /api/v1/search registered") } } func TestHandler_MissingQueryReturns400(t *testing.T) { h, _ := newHandler(t) router := mountWithIdentity(h, auth.Identity{User: "alice"}) rec, _ := doSearch(t, router, "") 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_EmptyQueryReturns400(t *testing.T) { h, _ := newHandler(t) router := mountWithIdentity(h, auth.Identity{User: "alice"}) rec, _ := doSearch(t, router, "?q= ") 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_BadSinceReturns400(t *testing.T) { h, _ := newHandler(t) router := mountWithIdentity(h, auth.Identity{User: "alice"}) rec, _ := doSearch(t, router, "?q=hello&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_NonAdminOwnerParamReturns403(t *testing.T) { h, _ := newHandler(t) router := mountWithIdentity(h, auth.Identity{User: "alice", IsAdmin: false}) for _, q := range []string{"?q=hello&owner=alice", "?q=hello&owner=bob", "?q=hello&owner=*"} { rec, _ := doSearch(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_AdminOwnerStarReturnsAllOwners(t *testing.T) { h, repo := newHandler(t) db := repo.Database.DB seedSession(t, db, "alice", "cc", "phoebe", "sa", 100, 110) seedTurn(t, db, "alice", "cc", "phoebe", "sa", "t1", 1, 101, "user", "needle alpha", nil) seedSession(t, db, "bob", "cc", "phoebe", "sb", 100, 110) seedTurn(t, db, "bob", "cc", "phoebe", "sb", "t1", 1, 101, "user", "needle beta", nil) router := mountWithIdentity(h, auth.Identity{User: "admin", IsAdmin: true}) rec, body := doSearch(t, router, "?q=needle&owner=*") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if len(body.Results) != 2 { t.Fatalf("expected 2 rows; got %d (%#v)", len(body.Results), body.Results) } } func TestHandler_BadCursorReturns400(t *testing.T) { h, _ := newHandler(t) router := mountWithIdentity(h, auth.Identity{User: "alice"}) rec, _ := doSearch(t, router, "?q=hello&cursor=not-valid") 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_SuccessfulResponseEnvelope(t *testing.T) { h, repo := newHandler(t) db := repo.Database.DB seedSession(t, db, "alice", "cc", "phoebe", "s1", 100, 110) seedTurn(t, db, "alice", "cc", "phoebe", "s1", "t1", 1, 101, "user", "needle in haystack", nil) router := mountWithIdentity(h, auth.Identity{User: "alice"}) rec, body := doSearch(t, router, "?q=needle&limit=10") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if body.Limit != 10 { t.Fatalf("limit=%d; want 10", body.Limit) } if len(body.Results) != 1 { t.Fatalf("expected 1 result; got %d (%#v)", len(body.Results), body.Results) } if body.Results[0].SessionID != "s1" { t.Fatalf("expected session s1; got %#v", body.Results[0]) } // NextCursor should be empty when results fit in one page. if body.NextCursor != "" { t.Fatalf("expected empty next_cursor for single page; got %q", body.NextCursor) } } func TestHandler_LimitClamping(t *testing.T) { h, _ := newHandler(t) router := mountWithIdentity(h, auth.Identity{User: "alice"}) // Missing limit → default 50 rec, body := doSearch(t, router, "?q=hello") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if body.Limit != 50 { t.Fatalf("default limit=%d; want 50", body.Limit) } // Over max → capped at 200 rec, body = doSearch(t, router, "?q=hello&limit=999") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if body.Limit != 200 { t.Fatalf("capped limit=%d; want 200", body.Limit) } } func TestHandler_IncludeToolOutputsParam(t *testing.T) { h, repo := newHandler(t) db := repo.Database.DB seedSession(t, db, "alice", "cc", "phoebe", "s1", 100, 110) seedTurn(t, db, "alice", "cc", "phoebe", "s1", "prose", 1, 101, "user", "needle in prose", nil) seedTurn(t, db, "alice", "cc", "phoebe", "s1", "tool", 2, 102, "assistant", "plain text", strptr(`{"output":"needle in shell"}`)) router := mountWithIdentity(h, auth.Identity{User: "alice"}) // Default: prose only rec, body := doSearch(t, router, "?q=needle") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if len(body.Results) != 1 || body.Results[0].MatchSource != search.SourceTurn { t.Fatalf("expected 1 prose result; got %#v", body.Results) } // Explicit include rec, body = doSearch(t, router, "?q=needle&include_tool_outputs=1") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if len(body.Results) != 2 { t.Fatalf("expected 2 results; got %#v", body.Results) } } func TestHandler_PerUserIsolation(t *testing.T) { h, repo := newHandler(t) db := repo.Database.DB seedSession(t, db, "alice", "cc", "phoebe", "sa", 100, 110) seedTurn(t, db, "alice", "cc", "phoebe", "sa", "t1", 1, 101, "user", "alice needle", nil) seedSession(t, db, "bob", "cc", "phoebe", "sb", 100, 110) seedTurn(t, db, "bob", "cc", "phoebe", "sb", "t1", 1, 101, "user", "bob needle", nil) router := mountWithIdentity(h, auth.Identity{User: "alice"}) rec, body := doSearch(t, router, "?q=needle") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if len(body.Results) != 1 || body.Results[0].Owner != "alice" { t.Fatalf("alice should see only her row; got %#v", body.Results) } }