diff --git a/functions/infra/crud_test.go b/functions/infra/crud_test.go new file mode 100644 index 00000000..7fd6bfcd --- /dev/null +++ b/functions/infra/crud_test.go @@ -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_", 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) + } + }) +}