package savedsearch_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"sourcecraft.dev/bigbes/lethe/internal/domain/savedsearch"
"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 Handler against a fresh in-memory database.
func newHandler(t *testing.T) *savedsearch.Handler {
t.Helper()
repo := newRepo(t)
h := &savedsearch.Handler{Repo: repo}
if err := h.Init(context.Background()); err != nil {
t.Fatalf("handler.Init: %v", err)
}
return h
}
// mountWithIdentity builds a chi router with the fake auth middleware and
// the savedsearch handler mounted under /api/v1.
func mountWithIdentity(h *savedsearch.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
}
// savedSearchListBody is the decoded JSON body from GET /saved-searches.
type savedSearchListBody struct {
SavedSearches []json.RawMessage `json:"saved_searches"`
}
// problemBody captures RFC 7807 fields tests assert on.
type problemBody struct {
Status int `json:"status"`
Code string `json:"code"`
}
func doGET(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func doPOST(t *testing.T, router http.Handler, path string, body any) *httptest.ResponseRecorder {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal POST body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func doPUT(t *testing.T, router http.Handler, path string, body any) *httptest.ResponseRecorder {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal PUT body: %v", err)
}
req := httptest.NewRequest(http.MethodPut, path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func doDELETE(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodDelete, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
// TestHandler_List_Authenticated verifies GET /saved-searches returns 200
// with { saved_searches: [] } when no rows exist.
func TestHandler_List_Authenticated(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
rec := doGET(t, router, "/api/v1/saved-searches")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d; want 200; body=%s", rec.Code, rec.Body.String())
}
var body savedSearchListBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v (body=%s)", err, rec.Body.String())
}
if body.SavedSearches == nil {
t.Fatal("saved_searches key missing or null")
}
if len(body.SavedSearches) != 0 {
t.Fatalf("expected empty list; got %d items", len(body.SavedSearches))
}
}
// TestHandler_List_OwnerParamIgnored verifies that ?owner= is silently ignored
// (IV2) and the response is identical to a call without the param.
func TestHandler_List_OwnerParamIgnored(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
rec1 := doGET(t, router, "/api/v1/saved-searches")
rec2 := doGET(t, router, "/api/v1/saved-searches?owner=alice")
if rec1.Code != http.StatusOK || rec2.Code != http.StatusOK {
t.Fatalf("status1=%d status2=%d; both want 200", rec1.Code, rec2.Code)
}
if rec1.Body.String() != rec2.Body.String() {
t.Fatalf("bodies differ:\n no-param: %s\n with-param: %s",
rec1.Body.String(), rec2.Body.String())
}
}
// TestHandler_List_OwnerFieldAbsentInJSON verifies that Owner field is not
// present in the JSON output (json:"-").
func TestHandler_List_OwnerFieldAbsentInJSON(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
// Create a row first.
doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "test", "query": "q"})
rec := doGET(t, router, "/api/v1/saved-searches")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d; want 200; body=%s", rec.Code, rec.Body.String())
}
bodyStr := rec.Body.String()
if strings.Contains(bodyStr, `"owner"`) {
t.Fatalf("owner field should be absent in JSON; body=%s", bodyStr)
}
}
// TestHandler_Create_EmptyNameReturns400 verifies validation of empty name.
func TestHandler_Create_EmptyNameReturns400(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "", "query": "x"})
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 != "VALIDATION" {
t.Fatalf("expected VALIDATION; got %q (body=%s)", p.Code, rec.Body.String())
}
}
// TestHandler_Create_SlashInNameReturns400 verifies that a name with "/" is
// rejected (IV7).
func TestHandler_Create_SlashInNameReturns400(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "a/b", "query": "x"})
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 != "VALIDATION" {
t.Fatalf("expected VALIDATION; got %q (body=%s)", p.Code, rec.Body.String())
}
}
// TestHandler_Create_NameTooLongReturns400 verifies the 64-char cap (UK1).
func TestHandler_Create_NameTooLongReturns400(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
longName := strings.Repeat("a", 65)
rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": longName, "query": "x"})
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 != "VALIDATION" {
t.Fatalf("expected VALIDATION; got %q (body=%s)", p.Code, rec.Body.String())
}
}
// TestHandler_Create_EmptyQueryReturns400 verifies that an empty query is
// rejected.
func TestHandler_Create_EmptyQueryReturns400(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "ok", "query": ""})
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 != "VALIDATION" {
t.Fatalf("expected VALIDATION; got %q (body=%s)", p.Code, rec.Body.String())
}
}
// TestHandler_Create_Valid verifies that a valid POST returns 201 with the new row.
func TestHandler_Create_Valid(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "mysearch", "query": "model:gpt-4"})
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d; want 201; body=%s", rec.Code, rec.Body.String())
}
var got savedsearch.SavedSearch
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v (body=%s)", err, rec.Body.String())
}
if got.Name != "mysearch" {
t.Errorf("Name: got %q; want %q", got.Name, "mysearch")
}
if got.Query != "model:gpt-4" {
t.Errorf("Query: got %q; want %q", got.Query, "model:gpt-4")
}
}
// TestHandler_Create_DuplicateReturns409 verifies that a duplicate name for the
// same owner returns 409 CONFLICT with problem+json.
func TestHandler_Create_DuplicateReturns409(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "x", "query": "q1"})
rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "x", "query": "q2"})
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d; want 409; body=%s", rec.Code, rec.Body.String())
}
var p problemBody
_ = json.Unmarshal(rec.Body.Bytes(), &p)
if p.Code != "CONFLICT" {
t.Fatalf("expected CONFLICT; got %q (body=%s)", p.Code, rec.Body.String())
}
// Verify content-type is problem+json.
ct := rec.Header().Get("Content-Type")
if !strings.Contains(ct, "application/problem+json") {
t.Fatalf("expected application/problem+json; got %q", ct)
}
}
// TestHandler_Update_NotFound verifies that PUT /saved-searches/missing returns 404.
func TestHandler_Update_NotFound(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
rec := doPUT(t, router, "/api/v1/saved-searches/missing", map[string]*string{"query": ptrStr("new")})
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 (body=%s)", p.Code, rec.Body.String())
}
}
// TestHandler_Update_RenameConflict verifies that renaming onto an existing name
// for the same owner returns 409 CONFLICT.
func TestHandler_Update_RenameConflict(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "x", "query": "q1"})
doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "y", "query": "q2"})
rec := doPUT(t, router, "/api/v1/saved-searches/x", map[string]*string{"name": ptrStr("y")})
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d; want 409; body=%s", rec.Code, rec.Body.String())
}
var p problemBody
_ = json.Unmarshal(rec.Body.Bytes(), &p)
if p.Code != "CONFLICT" {
t.Fatalf("expected CONFLICT; got %q (body=%s)", p.Code, rec.Body.String())
}
}
// TestHandler_Delete_Sequence verifies 204 on first delete and 404 on second.
func TestHandler_Delete_Sequence(t *testing.T) {
h := newHandler(t)
router := mountWithIdentity(h, auth.Identity{User: "alice"})
// Create a row first.
doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "todel", "query": "q"})
rec1 := doDELETE(t, router, "/api/v1/saved-searches/todel")
if rec1.Code != http.StatusNoContent {
t.Fatalf("first delete: status=%d; want 204; body=%s", rec1.Code, rec1.Body.String())
}
rec2 := doDELETE(t, router, "/api/v1/saved-searches/todel")
if rec2.Code != http.StatusNotFound {
t.Fatalf("second delete: status=%d; want 404; body=%s", rec2.Code, rec2.Body.String())
}
var p problemBody
_ = json.Unmarshal(rec2.Body.Bytes(), &p)
if p.Code != "NOT_FOUND" {
t.Fatalf("expected NOT_FOUND; got %q", p.Code)
}
}