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