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_", 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) } }) }