test(crud): cobertura completa de los handlers y generadores CRUD

Anade tests unitarios e integracion sobre SQLite in-memory:
- CRUDDefineResource: casos felices y rechazo de inputs invalidos
  (nombre vacio, tipos no soportados, nombres reservados, duplicados).
- CRUDGenerateTableSQL: columnas base, NOT NULL/UNIQUE/DEFAULT, deleted_at
  con soft_delete y verificacion de que el DDL es ejecutable en sqlite.
- Create + Get: creacion feliz, validaciones required/min_length/max_length/
  enum/min/max, 409 en UNIQUE, GET 200/404.
- List: paginacion, filtros, orden ascendente, campos desconocidos ignorados.
- Update: partial update, 404 y validacion de campos enviados.
- Delete: hard delete, soft delete, 404, ocultar soft-deleted en list.
- Integracion end-to-end con httptest.NewServer cubriendo CRUD completo y
  multiples recursos registrados en el mismo mux.
This commit is contained in:
2026-04-18 17:17:42 +02:00
parent 28599436e5
commit 6265133ac9
+653
View File
@@ -0,0 +1,653 @@
package infra
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
_ "github.com/mattn/go-sqlite3"
)
// --- helpers ---
func openCRUDTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("cannot open test DB: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func sampleProjectFields() []CRUDField {
return []CRUDField{
{Name: "name", Type: "TEXT", Required: true, Unique: true,
Validations: map[string]string{"min_length": "1", "max_length": "50"}},
{Name: "description", Type: "TEXT"},
{Name: "status", Type: "TEXT", Default: "'active'",
Validations: map[string]string{"enum": "active,archived"}},
{Name: "priority", Type: "INTEGER", Default: "0",
Validations: map[string]string{"min": "0", "max": "10"}},
}
}
func buildProjectResource(t *testing.T, softDelete bool) (CRUDResource, *sql.DB) {
t.Helper()
res, err := CRUDDefineResource("project", "projects", sampleProjectFields(), softDelete)
if err != nil {
t.Fatalf("define resource: %v", err)
}
db := openCRUDTestDB(t)
if _, err := db.Exec(CRUDGenerateTableSQL(res)); err != nil {
t.Fatalf("create table: %v", err)
}
return res, db
}
func doJSONRequest(t *testing.T, mux http.Handler, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
reqBody = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, reqBody)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
var got map[string]any
if rec.Body.Len() > 0 && strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") {
_ = json.Unmarshal(rec.Body.Bytes(), &got)
}
return rec, got
}
// --- CRUDDefineResource ---
func TestCRUDDefineResource(t *testing.T) {
t.Run("construye un recurso valido", func(t *testing.T) {
res, err := CRUDDefineResource("project", "projects", sampleProjectFields(), false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Name != "project" || res.Table != "projects" || len(res.Fields) != 4 {
t.Errorf("unexpected resource: %+v", res)
}
})
t.Run("rechaza nombre vacio", func(t *testing.T) {
_, err := CRUDDefineResource("", "projects", sampleProjectFields(), false)
if err == nil {
t.Error("expected error for empty name")
}
})
t.Run("rechaza tabla vacia", func(t *testing.T) {
_, err := CRUDDefineResource("project", "", sampleProjectFields(), false)
if err == nil {
t.Error("expected error for empty table")
}
})
t.Run("rechaza lista de campos vacia", func(t *testing.T) {
_, err := CRUDDefineResource("project", "projects", nil, false)
if err == nil {
t.Error("expected error for empty fields")
}
})
t.Run("rechaza tipos invalidos", func(t *testing.T) {
_, err := CRUDDefineResource("project", "projects", []CRUDField{{Name: "x", Type: "FOO"}}, false)
if err == nil {
t.Error("expected error for invalid type")
}
})
t.Run("rechaza nombres reservados", func(t *testing.T) {
for _, reserved := range []string{"id", "created_at", "updated_at", "deleted_at"} {
_, err := CRUDDefineResource("project", "projects", []CRUDField{{Name: reserved, Type: "TEXT"}}, false)
if err == nil {
t.Errorf("expected error for reserved field %q", reserved)
}
}
})
t.Run("rechaza duplicados", func(t *testing.T) {
_, err := CRUDDefineResource("project", "projects", []CRUDField{
{Name: "name", Type: "TEXT"},
{Name: "name", Type: "TEXT"},
}, false)
if err == nil {
t.Error("expected error for duplicate field")
}
})
}
// --- CRUDGenerateTableSQL ---
func TestCRUDGenerateTableSQL(t *testing.T) {
t.Run("genera tabla basica con timestamps", func(t *testing.T) {
res, _ := CRUDDefineResource("project", "projects",
[]CRUDField{{Name: "name", Type: "TEXT"}}, false)
ddl := CRUDGenerateTableSQL(res)
for _, want := range []string{
"CREATE TABLE IF NOT EXISTS projects",
"id TEXT PRIMARY KEY",
"name TEXT",
"created_at TEXT NOT NULL",
"updated_at TEXT NOT NULL",
} {
if !strings.Contains(ddl, want) {
t.Errorf("DDL missing %q:\n%s", want, ddl)
}
}
if strings.Contains(ddl, "deleted_at") {
t.Errorf("DDL should not contain deleted_at:\n%s", ddl)
}
})
t.Run("aplica NOT NULL y UNIQUE", func(t *testing.T) {
res, _ := CRUDDefineResource("project", "projects",
[]CRUDField{{Name: "name", Type: "TEXT", Required: true, Unique: true}}, false)
ddl := CRUDGenerateTableSQL(res)
if !strings.Contains(ddl, "name TEXT NOT NULL UNIQUE") {
t.Errorf("expected NOT NULL UNIQUE:\n%s", ddl)
}
})
t.Run("aplica DEFAULT", func(t *testing.T) {
res, _ := CRUDDefineResource("project", "projects",
[]CRUDField{{Name: "priority", Type: "INTEGER", Default: "0"}}, false)
ddl := CRUDGenerateTableSQL(res)
if !strings.Contains(ddl, "priority INTEGER DEFAULT 0") {
t.Errorf("expected DEFAULT clause:\n%s", ddl)
}
})
t.Run("anade deleted_at si soft_delete", func(t *testing.T) {
res, _ := CRUDDefineResource("project", "projects",
[]CRUDField{{Name: "name", Type: "TEXT"}}, true)
ddl := CRUDGenerateTableSQL(res)
if !strings.Contains(ddl, "deleted_at TEXT") {
t.Errorf("expected deleted_at for soft_delete:\n%s", ddl)
}
})
t.Run("el DDL generado es valido en sqlite", func(t *testing.T) {
res, _ := CRUDDefineResource("project", "projects", sampleProjectFields(), true)
db := openCRUDTestDB(t)
if _, err := db.Exec(CRUDGenerateTableSQL(res)); err != nil {
t.Fatalf("DDL not valid: %v", err)
}
})
}
// --- CRUDCreateHandler + CRUDGetHandler ---
func TestCRUDCreateAndGet(t *testing.T) {
res, db := buildProjectResource(t, false)
mux := http.NewServeMux()
CRUDRegisterRoutes(mux, "/api/projects", res, db)
t.Run("crea un registro valido y retorna 201", func(t *testing.T) {
rec, body := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
"name": "demo", "description": "hola", "status": "active", "priority": 5,
})
if rec.Code != http.StatusCreated {
t.Fatalf("got status %d, want 201: body=%s", rec.Code, rec.Body.String())
}
if body["name"] != "demo" {
t.Errorf("got name=%v, want demo", body["name"])
}
if body["id"] == nil || body["id"] == "" {
t.Errorf("expected generated id, got %v", body["id"])
}
if body["created_at"] == nil {
t.Errorf("expected created_at, got nil")
}
})
t.Run("retorna 400 si faltan required", func(t *testing.T) {
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", rec.Code)
}
})
t.Run("valida min_length", func(t *testing.T) {
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": ""})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400 for empty name", rec.Code)
}
})
t.Run("valida max_length", func(t *testing.T) {
longName := strings.Repeat("a", 60)
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": longName})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400 for too-long name", rec.Code)
}
})
t.Run("valida enum", func(t *testing.T) {
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
"name": "x", "status": "pirate",
})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400 for invalid enum", rec.Code)
}
})
t.Run("valida min y max numericos", func(t *testing.T) {
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
"name": "numeric-min", "priority": -1,
})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400 for priority<0", rec.Code)
}
rec, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
"name": "numeric-max", "priority": 999,
})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400 for priority>10", rec.Code)
}
})
t.Run("retorna 409 si se viola UNIQUE", func(t *testing.T) {
_, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "unique-1"})
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "unique-1"})
if rec.Code != http.StatusConflict {
t.Errorf("got %d, want 409", rec.Code)
}
})
t.Run("GET recupera el registro creado", func(t *testing.T) {
rec, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "get-me"})
if rec.Code != http.StatusCreated {
t.Fatalf("create failed: %d", rec.Code)
}
id := created["id"].(string)
rec, body := doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
if rec.Code != http.StatusOK {
t.Errorf("got %d, want 200", rec.Code)
}
if body["name"] != "get-me" {
t.Errorf("got name=%v, want get-me", body["name"])
}
})
t.Run("GET no existente retorna 404", func(t *testing.T) {
rec, _ := doJSONRequest(t, mux, "GET", "/api/projects/nonexistent", nil)
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404", rec.Code)
}
})
}
// --- CRUDListHandler ---
func TestCRUDListHandler(t *testing.T) {
res, db := buildProjectResource(t, false)
mux := http.NewServeMux()
CRUDRegisterRoutes(mux, "/api/projects", res, db)
// Sembrar datos
for i, name := range []string{"a", "b", "c", "d", "e"} {
status := "active"
if i%2 == 1 {
status = "archived"
}
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
"name": name, "status": status, "priority": i,
})
if rec.Code != http.StatusCreated {
t.Fatalf("seed %s failed: %d", name, rec.Code)
}
}
t.Run("lista todo con paginacion default", func(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
var got CRUDListResult
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("parse body: %v", err)
}
if got.Total != 5 {
t.Errorf("got total=%d, want 5", got.Total)
}
if len(got.Items) != 5 {
t.Errorf("got %d items, want 5", len(got.Items))
}
})
t.Run("respeta page y per_page", func(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?page=1&per_page=2", nil))
var got CRUDListResult
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if len(got.Items) != 2 {
t.Errorf("got %d items, want 2", len(got.Items))
}
if got.TotalPages != 3 {
t.Errorf("got total_pages=%d, want 3", got.TotalPages)
}
})
t.Run("filtra por campo con filter_<field>", func(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?filter_status=archived", nil))
var got CRUDListResult
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got.Total != 2 {
t.Errorf("got total=%d, want 2 archived", got.Total)
}
})
t.Run("ordena con sort_by y sort_dir", func(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?sort_by=name&sort_dir=asc", nil))
var got CRUDListResult
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if len(got.Items) < 2 {
t.Fatalf("not enough items: %d", len(got.Items))
}
if got.Items[0]["name"] != "a" || got.Items[1]["name"] != "b" {
t.Errorf("sort asc failed: first=%v second=%v", got.Items[0]["name"], got.Items[1]["name"])
}
})
t.Run("ignora filtros con campos desconocidos", func(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?filter_unknown=xxx", nil))
var got CRUDListResult
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got.Total != 5 {
t.Errorf("got total=%d, want 5 (filter should be ignored)", got.Total)
}
})
}
// --- CRUDUpdateHandler ---
func TestCRUDUpdateHandler(t *testing.T) {
res, db := buildProjectResource(t, false)
mux := http.NewServeMux()
CRUDRegisterRoutes(mux, "/api/projects", res, db)
createRec, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
"name": "original", "description": "first", "priority": 2,
})
if createRec.Code != http.StatusCreated {
t.Fatalf("create failed: %d", createRec.Code)
}
id := created["id"].(string)
originalUpdatedAt := fmt.Sprintf("%v", created["updated_at"])
t.Run("actualiza solo los campos enviados", func(t *testing.T) {
rec, body := doJSONRequest(t, mux, "PUT", "/api/projects/"+id, map[string]any{
"description": "updated",
})
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String())
}
if body["description"] != "updated" {
t.Errorf("description not updated: %v", body["description"])
}
if body["name"] != "original" {
t.Errorf("name should not change: %v", body["name"])
}
if fmt.Sprintf("%v", body["updated_at"]) == originalUpdatedAt {
t.Errorf("updated_at should change")
}
})
t.Run("retorna 404 si no existe", func(t *testing.T) {
rec, _ := doJSONRequest(t, mux, "PUT", "/api/projects/nonexistent", map[string]any{"description": "x"})
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404", rec.Code)
}
})
t.Run("valida los campos enviados", func(t *testing.T) {
rec, _ := doJSONRequest(t, mux, "PUT", "/api/projects/"+id, map[string]any{"priority": 999})
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", rec.Code)
}
})
}
// --- CRUDDeleteHandler ---
func TestCRUDDeleteHandler(t *testing.T) {
t.Run("hard delete borra fisicamente", func(t *testing.T) {
res, db := buildProjectResource(t, false)
mux := http.NewServeMux()
CRUDRegisterRoutes(mux, "/api/projects", res, db)
_, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "hard"})
id := created["id"].(string)
rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/"+id, nil)
if rec.Code != http.StatusNoContent {
t.Errorf("got %d, want 204", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("expected empty body, got %s", rec.Body.String())
}
// Verificar que ya no existe
rec, _ = doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
if rec.Code != http.StatusNotFound {
t.Errorf("after delete, GET got %d, want 404", rec.Code)
}
// Verificar que la fila ya no esta en la tabla
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM projects WHERE id = ?", id).Scan(&count); err != nil {
t.Fatalf("count: %v", err)
}
if count != 0 {
t.Errorf("hard delete should remove row, got count=%d", count)
}
})
t.Run("soft delete actualiza deleted_at", func(t *testing.T) {
res, db := buildProjectResource(t, true)
mux := http.NewServeMux()
CRUDRegisterRoutes(mux, "/api/projects", res, db)
_, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "soft"})
id := created["id"].(string)
rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/"+id, nil)
if rec.Code != http.StatusNoContent {
t.Errorf("got %d, want 204", rec.Code)
}
// GET debe dar 404 (la fila esta oculta por soft delete)
rec, _ = doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
if rec.Code != http.StatusNotFound {
t.Errorf("after soft delete, GET got %d, want 404", rec.Code)
}
// Pero la fila sigue fisica en la tabla con deleted_at no nulo
var deletedAt sql.NullString
if err := db.QueryRow("SELECT deleted_at FROM projects WHERE id = ?", id).Scan(&deletedAt); err != nil {
t.Fatalf("select: %v", err)
}
if !deletedAt.Valid || deletedAt.String == "" {
t.Errorf("expected deleted_at set, got %+v", deletedAt)
}
})
t.Run("retorna 404 si no existe", func(t *testing.T) {
res, db := buildProjectResource(t, false)
mux := http.NewServeMux()
CRUDRegisterRoutes(mux, "/api/projects", res, db)
rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/nonexistent", nil)
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404", rec.Code)
}
})
t.Run("soft delete no lista registros ocultos", func(t *testing.T) {
res, db := buildProjectResource(t, true)
mux := http.NewServeMux()
CRUDRegisterRoutes(mux, "/api/projects", res, db)
_, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "keep"})
_, del := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "remove"})
delID := del["id"].(string)
_, _ = doJSONRequest(t, mux, "DELETE", "/api/projects/"+delID, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects", nil))
var got CRUDListResult
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got.Total != 1 {
t.Errorf("got total=%d, want 1 (soft deleted should be hidden)", got.Total)
}
})
}
// --- CRUDGenerateHandlers + CRUDRegisterRoutes integration ---
func TestCRUDGenerateHandlers(t *testing.T) {
t.Run("retorna las 5 keys esperadas", func(t *testing.T) {
res, db := buildProjectResource(t, false)
handlers := CRUDGenerateHandlers(res, db)
for _, key := range []string{"list", "get", "create", "update", "delete"} {
if handlers[key] == nil {
t.Errorf("handler %q is nil", key)
}
}
if len(handlers) != 5 {
t.Errorf("expected 5 handlers, got %d", len(handlers))
}
})
}
func TestCRUDRegisterRoutesIntegration(t *testing.T) {
t.Run("CRUD completo end-to-end via servidor HTTP", func(t *testing.T) {
res, db := buildProjectResource(t, false)
mux := http.NewServeMux()
CRUDRegisterRoutes(mux, "/api/projects", res, db)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
// Create
createBody, _ := json.Marshal(map[string]any{"name": "e2e", "description": "integration", "priority": 3})
resp, err := http.Post(srv.URL+"/api/projects", "application/json", bytes.NewReader(createBody))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: got %d, want 201", resp.StatusCode)
}
var created map[string]any
_ = json.NewDecoder(resp.Body).Decode(&created)
id := created["id"].(string)
// Get
resp, err = http.Get(srv.URL + "/api/projects/" + id)
if err != nil {
t.Fatalf("GET: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("get: got %d, want 200", resp.StatusCode)
}
// Update
updateBody, _ := json.Marshal(map[string]any{"description": "modified"})
req, _ := http.NewRequest("PUT", srv.URL+"/api/projects/"+id, bytes.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("PUT: %v", err)
}
var updated map[string]any
_ = json.NewDecoder(resp.Body).Decode(&updated)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("update: got %d, want 200", resp.StatusCode)
}
if updated["description"] != "modified" {
t.Errorf("update did not persist: %v", updated["description"])
}
// List
resp, err = http.Get(srv.URL + "/api/projects")
if err != nil {
t.Fatalf("LIST: %v", err)
}
var list CRUDListResult
_ = json.NewDecoder(resp.Body).Decode(&list)
resp.Body.Close()
if list.Total != 1 {
t.Errorf("list total: got %d, want 1", list.Total)
}
// Delete
req, _ = http.NewRequest("DELETE", srv.URL+"/api/projects/"+id, nil)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("DELETE: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("delete: got %d, want 204", resp.StatusCode)
}
// Get after delete: 404
resp, _ = http.Get(srv.URL + "/api/projects/" + id)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("after delete: got %d, want 404", resp.StatusCode)
}
})
t.Run("multiples recursos en el mismo mux", func(t *testing.T) {
projectRes, db := buildProjectResource(t, false)
// Segundo recurso sobre la misma DB
userFields := []CRUDField{
{Name: "email", Type: "TEXT", Required: true, Unique: true},
}
userRes, err := CRUDDefineResource("user", "users", userFields, false)
if err != nil {
t.Fatalf("define user: %v", err)
}
if _, err := db.Exec(CRUDGenerateTableSQL(userRes)); err != nil {
t.Fatalf("create users table: %v", err)
}
mux := http.NewServeMux()
CRUDRegisterRoutes(mux, "/api/projects", projectRes, db)
CRUDRegisterRoutes(mux, "/api/users", userRes, db)
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "p1"})
if rec.Code != http.StatusCreated {
t.Errorf("projects: got %d", rec.Code)
}
rec, _ = doJSONRequest(t, mux, "POST", "/api/users", map[string]any{"email": "a@b.c"})
if rec.Code != http.StatusCreated {
t.Errorf("users: got %d", rec.Code)
}
})
}