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