package api_test import ( "context" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "sort" "strings" "sync" "testing" "time" "git.k6n.net/mats/fullstack-template/internal/api" "git.k6n.net/mats/fullstack-template/internal/browse" "git.k6n.net/mats/fullstack-template/internal/items" ) // These tests run the full HTTP stack (mux patterns, middleware, JSON // conventions) against the real items service over an in-memory repository. // fakeRepo is the same in-memory fake as internal/items/service_test.go, // duplicated on purpose: test fakes stay package-local. type fakeRepo struct { mu sync.Mutex nextID int64 byID map[int64]items.Item } func newFakeRepo() *fakeRepo { return &fakeRepo{nextID: 1, byID: map[int64]items.Item{}} } func (f *fakeRepo) List(_ context.Context, fl items.Filter) ([]items.Item, error) { f.mu.Lock() defer f.mu.Unlock() ids := make([]int64, 0, len(f.byID)) for id := range f.byID { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) out := []items.Item{} for _, id := range ids { item := f.byID[id] if fl.Status != "" && item.Status != fl.Status { continue } out = append(out, item) } if fl.Offset < len(out) { out = out[fl.Offset:] } else { out = []items.Item{} } if fl.Limit < len(out) { out = out[:fl.Limit] } return out, nil } func (f *fakeRepo) Get(_ context.Context, id int64) (items.Item, error) { f.mu.Lock() defer f.mu.Unlock() item, ok := f.byID[id] if !ok { return items.Item{}, items.ErrNotFound } return item, nil } func (f *fakeRepo) Create(_ context.Context, item *items.Item) error { f.mu.Lock() defer f.mu.Unlock() item.ID = f.nextID f.nextID++ now := time.Now() item.CreatedAt, item.UpdatedAt = now, now f.byID[item.ID] = *item return nil } func (f *fakeRepo) Update(_ context.Context, item *items.Item) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.byID[item.ID]; !ok { return items.ErrNotFound } item.UpdatedAt = time.Now() f.byID[item.ID] = *item return nil } func (f *fakeRepo) Delete(_ context.Context, id int64) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.byID[id]; !ok { return items.ErrNotFound } delete(f.byID, id) return nil } // stubPinger fakes the database for /readyz. type stubPinger struct{ err error } func (p stubPinger) PingContext(context.Context) error { return p.err } func newTestServer(t *testing.T) http.Handler { t.Helper() return api.New(api.Deps{ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Items: items.NewService(newFakeRepo()), DB: stubPinger{}, AllowedOrigins: []string{"http://localhost:5173"}, }) } func do(t *testing.T, h http.Handler, method, target, body string) *httptest.ResponseRecorder { t.Helper() var rdr io.Reader if body != "" { rdr = strings.NewReader(body) } req := httptest.NewRequest(method, target, rdr) if body != "" { req.Header.Set("Content-Type", "application/json") } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, dst any) { t.Helper() if err := json.NewDecoder(rec.Body).Decode(dst); err != nil { t.Fatalf("decode response body %q: %v", rec.Body.String(), err) } } func TestHealthAndReadiness(t *testing.T) { h := newTestServer(t) if rec := do(t, h, http.MethodGet, "/healthz", ""); rec.Code != http.StatusOK { t.Errorf("healthz = %d, want 200", rec.Code) } if rec := do(t, h, http.MethodGet, "/readyz", ""); rec.Code != http.StatusOK { t.Errorf("readyz = %d, want 200", rec.Code) } sick := api.New(api.Deps{ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Items: items.NewService(newFakeRepo()), DB: stubPinger{err: errors.New("down")}, }) if rec := do(t, sick, http.MethodGet, "/readyz", ""); rec.Code != http.StatusServiceUnavailable { t.Errorf("readyz with dead db = %d, want 503", rec.Code) } } func TestItemCRUDFlow(t *testing.T) { h := newTestServer(t) // Create. rec := do(t, h, http.MethodPost, "/api/v1/items", `{"title":"First","description":"hello"}`) if rec.Code != http.StatusCreated { t.Fatalf("create = %d: %s", rec.Code, rec.Body.String()) } var created items.Item decodeBody(t, rec, &created) if created.ID != 1 || created.Title != "First" || created.Status != items.StatusActive { t.Errorf("created = %+v", created) } if loc := rec.Header().Get("Location"); loc != "/api/v1/items/1" { t.Errorf("Location = %q, want /api/v1/items/1", loc) } // Get by id (PathValue routing). rec = do(t, h, http.MethodGet, "/api/v1/items/1", "") if rec.Code != http.StatusOK { t.Fatalf("get = %d: %s", rec.Code, rec.Body.String()) } var got items.Item decodeBody(t, rec, &got) if got.Title != "First" { t.Errorf("get title = %q", got.Title) } // Patch one field. rec = do(t, h, http.MethodPatch, "/api/v1/items/1", `{"status":"archived"}`) if rec.Code != http.StatusOK { t.Fatalf("patch = %d: %s", rec.Code, rec.Body.String()) } var patched items.Item decodeBody(t, rec, &patched) if patched.Status != items.StatusArchived || patched.Title != "First" { t.Errorf("patched = %+v", patched) } // List. rec = do(t, h, http.MethodGet, "/api/v1/items", "") if rec.Code != http.StatusOK { t.Fatalf("list = %d: %s", rec.Code, rec.Body.String()) } var listed []items.Item decodeBody(t, rec, &listed) if len(listed) != 1 { t.Fatalf("list len = %d, want 1", len(listed)) } // Delete, then confirm it's gone. if rec := do(t, h, http.MethodDelete, "/api/v1/items/1", ""); rec.Code != http.StatusNoContent { t.Fatalf("delete = %d, want 204", rec.Code) } if rec := do(t, h, http.MethodGet, "/api/v1/items/1", ""); rec.Code != http.StatusNotFound { t.Errorf("get after delete = %d, want 404", rec.Code) } } func TestErrorResponses(t *testing.T) { h := newTestServer(t) do(t, h, http.MethodPost, "/api/v1/items", `{"title":"seed"}`) tests := []struct { name string method string target string body string wantStatus int wantCode string }{ {"invalid path id", "GET", "/api/v1/items/abc", "", 400, "invalid_id"}, {"unknown item", "GET", "/api/v1/items/999", "", 404, "not_found"}, {"broken json", "POST", "/api/v1/items", `{"title":`, 400, "invalid_json"}, {"unknown field", "POST", "/api/v1/items", `{"title":"x","wat":1}`, 400, "invalid_json"}, {"validation", "POST", "/api/v1/items", `{"title":""}`, 400, "validation_failed"}, {"unknown route", "GET", "/api/v1/nope", "", 404, "not_found"}, {"empty patch", "PATCH", "/api/v1/items/1", `{}`, 400, "validation_failed"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { rec := do(t, h, tt.method, tt.target, tt.body) if rec.Code != tt.wantStatus { t.Fatalf("status = %d, want %d (body %s)", rec.Code, tt.wantStatus, rec.Body.String()) } var res struct { Error struct { Code string `json:"code"` } `json:"error"` } decodeBody(t, rec, &res) if res.Error.Code != tt.wantCode { t.Errorf("code = %q, want %q", res.Error.Code, tt.wantCode) } }) } } func TestMethodNotAllowed(t *testing.T) { h := newTestServer(t) rec := do(t, h, http.MethodPut, "/api/v1/items/1", `{}`) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("put = %d, want 405 (ServeMux method patterns)", rec.Code) } if allow := rec.Header().Get("Allow"); !strings.Contains(allow, "PATCH") { t.Errorf("Allow = %q, want it to mention PATCH", allow) } } func TestCORSPreflight(t *testing.T) { h := newTestServer(t) req := httptest.NewRequest(http.MethodOptions, "/api/v1/items", nil) req.Header.Set("Origin", "http://localhost:5173") req.Header.Set("Access-Control-Request-Method", http.MethodPost) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("preflight = %d, want 204", rec.Code) } if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" { t.Errorf("Allow-Origin = %q", got) } // Unknown origins get no CORS headers and fall through to the mux. req = httptest.NewRequest(http.MethodOptions, "/api/v1/items", nil) req.Header.Set("Origin", "http://evil.example") req.Header.Set("Access-Control-Request-Method", http.MethodPost) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("Allow-Origin for unknown origin = %q, want none", got) } } // fakeFetcher is the same in-memory browse.Fetcher as // internal/browse/service_test.go, duplicated on purpose: test fakes stay // package-local. A nil response simulates a connection failure. type fakeFetcher struct { pages map[string]*browse.Response } func (f fakeFetcher) Fetch(_ context.Context, url string) (*browse.Response, error) { r, ok := f.pages[url] if !ok { return &browse.Response{Status: 404, FinalURL: url, ContentType: "text/plain", Body: []byte("not found")}, nil } if r == nil { return nil, &browse.FetchError{URL: url, Err: errors.New("dial tcp: connection refused")} } return r, nil } func newBrowseTestServer(t *testing.T) (http.Handler, fakeFetcher) { t.Helper() pages := map[string]*browse.Response{} add := func(url, ct, body string) { pages[url] = &browse.Response{Status: 200, FinalURL: url, ContentType: ct, Body: []byte(body)} } add("https://site.test/", "text/html; charset=utf-8", `Site`+ `

Hello

docs

`) add("https://site.test/llms.txt", "text/markdown", "# Site\n\n> summary\n\n## Docs\n\n- [Docs](https://site.test/docs): the docs\n") add("https://site.test/robots.txt", "text/plain", "User-agent: *\nAllow: /\n") return api.New(api.Deps{ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Items: items.NewService(newFakeRepo()), Browse: browse.NewService(fakeFetcher{pages: pages}), DB: stubPinger{}, AllowedOrigins: []string{"http://localhost:5173"}, }), fakeFetcher{pages: pages} } func TestBrowseEndpoint(t *testing.T) { h, _ := newBrowseTestServer(t) rec := do(t, h, http.MethodGet, `/api/v1/browse?url=https%3A%2F%2Fsite.test%2F`, "") if rec.Code != http.StatusOK { t.Fatalf("browse = %d: %s", rec.Code, rec.Body.String()) } var snap browse.Snapshot decodeBody(t, rec, &snap) if snap.FinalURL != "https://site.test/" || snap.Status != 200 { t.Errorf("snapshot head = %d %s", snap.Status, snap.FinalURL) } if snap.Page == nil || snap.Page.Title != "Site" { t.Errorf("page = %+v", snap.Page) } if !strings.Contains(snap.Markdown, "# Hello") || !strings.Contains(snap.Markdown, "[docs](https://site.test/docs)") { t.Errorf("markdown = %q", snap.Markdown) } if snap.Files.LlmsTxt == nil || !strings.Contains(snap.Files.LlmsTxt.Content, "# Site") { t.Errorf("llms.txt = %+v", snap.Files.LlmsTxt) } if len(snap.Checks) == 0 { t.Error("checks empty") } // Bare-domain input normalizes to https on the wire. rec = do(t, h, http.MethodGet, `/api/v1/browse?url=site.test`, "") if rec.Code != http.StatusOK { t.Fatalf("bare domain = %d: %s", rec.Code, rec.Body.String()) } } func TestBrowseEndpointErrors(t *testing.T) { h, fetcher := newBrowseTestServer(t) // Missing and malformed input map to validation_failed. for _, target := range []string{"/api/v1/browse", "/api/v1/browse?url=", "/api/v1/browse?url=ftp://x.test"} { rec := do(t, h, http.MethodGet, target, "") if rec.Code != http.StatusBadRequest { t.Errorf("GET %s = %d, want 400: %s", target, rec.Code, rec.Body.String()) } } // A dead site maps to 502 unavailable. fetcher.pages["https://dead.test/"] = nil rec := do(t, h, http.MethodGet, `/api/v1/browse?url=https%3A%2F%2Fdead.test%2F`, "") if rec.Code != http.StatusBadGateway { t.Errorf("dead site = %d, want 502: %s", rec.Code, rec.Body.String()) } var errBody struct { Error struct { Code string `json:"code"` } `json:"error"` } decodeBody(t, rec, &errBody) if errBody.Error.Code != "unavailable" { t.Errorf("error code = %q, want unavailable", errBody.Error.Code) } }