394 lines
12 KiB
Go
394 lines
12 KiB
Go
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",
|
|
`<!doctype html><html><head><title>Site</title><meta name="description" content="d"></head>`+
|
|
`<body><h1>Hello</h1><p><a href="/docs">docs</a></p></body></html>`)
|
|
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)
|
|
}
|
|
}
|