296 lines
8.3 KiB
Go
296 lines
8.3 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/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)
|
|
}
|
|
}
|