Files
multica/server/internal/handler/project_validation_test.go
Bohan Jiang 7dc05d28bc fix(projects): validate project status/priority — return 400 instead of 500 (#3925) (#3939)
* fix(projects): return 400 (not 500) for invalid project status/priority

CreateProject/UpdateProject passed an unvalidated status/priority straight to
the INSERT, so an unknown value (e.g. --status active) tripped the table's
CHECK constraint and surfaced as a blanket 500 'failed to create project'
with no server-side log to diagnose it (#3925).

Pre-validate both enums against the column CHECK lists and return a 400 with
the allowed values. Back it with isCheckViolation -> 400 for any other
constrained column, and log the underlying error on genuine 500s so transient
DB failures are diagnosable.

MUL-3153

Co-authored-by: multica-agent <github@multica.ai>

* fix(cli): validate project --status in create/update

project create and project update forwarded --status to the server without
checking it, while project status already validated. Share a single
validateProjectStatus helper across all three so a typo fails fast with the
valid list instead of a server round-trip.

MUL-3153

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 13:54:53 +08:00

94 lines
3.2 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// An unknown project status must fail fast with a 400 and the valid list, not
// surface the DB CHECK violation as a 500 (#3925: `--status active`).
func TestCreateProjectInvalidStatusReturns400(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "invalid status project",
"status": "active",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid status, got %d: %s", w.Code, w.Body.String())
}
if body := w.Body.String(); !strings.Contains(body, "planned") {
t.Errorf("expected error to list valid statuses, got: %s", body)
}
}
func TestCreateProjectInvalidPriorityReturns400(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "invalid priority project",
"priority": "critical",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid priority, got %d: %s", w.Code, w.Body.String())
}
}
// A valid status still creates the project (the validation does not over-reject).
func TestCreateProjectValidStatusReturns201(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "valid status project",
"status": "in_progress",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 for valid status, got %d: %s", w.Code, w.Body.String())
}
var project ProjectResponse
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
t.Fatalf("decode CreateProject: %v", err)
}
t.Cleanup(func() {
req := newRequest("DELETE", "/api/projects/"+project.ID, nil)
req = withURLParam(req, "id", project.ID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
})
if project.Status != "in_progress" {
t.Errorf("expected status in_progress, got %q", project.Status)
}
}
// Updating to an unknown status is a 400, not a 500.
func TestUpdateProjectInvalidStatusReturns400(t *testing.T) {
// Seed a project to update.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "update validation project",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("seed CreateProject: %d %s", w.Code, w.Body.String())
}
var project ProjectResponse
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
t.Fatalf("decode CreateProject: %v", err)
}
t.Cleanup(func() {
req := newRequest("DELETE", "/api/projects/"+project.ID, nil)
req = withURLParam(req, "id", project.ID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
})
w = httptest.NewRecorder()
req = newRequest("PUT", "/api/projects/"+project.ID, map[string]any{"status": "active"})
req = withURLParam(req, "id", project.ID)
testHandler.UpdateProject(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid update status, got %d: %s", w.Code, w.Body.String())
}
}