Initial commit
This commit is contained in:
commit
89053153cc
8152 files changed
+2086461
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user