Initial commit

This commit is contained in:
mats committed 2026-10-19 16:04:17 +02:00
commit afed023152
8152 files changed
+2086461

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
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/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)
}
}