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)
		}
	})
}
