Files
multica/server/internal/handler/project_dates_test.go
Bohan Jiang bf288349f6 feat(project): add start_date and due_date fields (MUL-4388) (#5313)
Projects become schedulable planning objects alongside their issues: add
optional start_date / due_date, mirroring issue.start_date / issue.due_date.
This is only the first slice of #5227 — labels, metadata, and the editable
metadata UI are still out of scope.

- migration 166: two nullable DATE columns on `project` (calendar days, no
  FK/index — matches the issue end-state after migration 112)
- sqlc CreateProject / UpdateProject carry the dates; UpdateProject uses
  narg so an explicit null clears
- handler: parse YYYY-MM-DD (400 on bad format), rawFields-presence clear on
  update, and the hand-scanned SearchProjects query returns the columns
- CLI: `project create/update --start-date/--due-date` (empty clears on update)
- frontend + mobile types/zod schemas: the two new schema fields are
  nullable().default(null) so a project from an older backend (frontend
  deploys before backend) parses to null instead of degrading the batch to
  the empty fallback; added a search schema drift test
- projects skill / CLI docs

Part of #5227

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 13:22:02 +08:00

156 lines
5.9 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// decodeProject decodes a ProjectResponse from a recorder, failing the test on
// a non-expected status or a decode error.
func decodeProject(t *testing.T, w *httptest.ResponseRecorder, wantStatus int) ProjectResponse {
t.Helper()
if w.Code != wantStatus {
t.Fatalf("expected status %d, got %d: %s", wantStatus, w.Code, w.Body.String())
}
var p ProjectResponse
if err := json.NewDecoder(w.Body).Decode(&p); err != nil {
t.Fatalf("decode ProjectResponse: %v", err)
}
return p
}
// Project start_date / due_date follow the same calendar-day contract as the
// issue dates: create echoes them, GET persists them, an update with the key
// present but empty clears the date while an absent key leaves it untouched.
func TestProjectStartDueDateLifecycle(t *testing.T) {
// Create with both dates set.
w := httptest.NewRecorder()
testHandler.CreateProject(w, newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "dated project",
"start_date": "2026-03-01",
"due_date": "2026-03-31",
}))
created := decodeProject(t, w, http.StatusCreated)
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, created.ID)
})
if created.StartDate == nil || *created.StartDate != "2026-03-01" {
t.Fatalf("create start_date = %v, want 2026-03-01", created.StartDate)
}
if created.DueDate == nil || *created.DueDate != "2026-03-31" {
t.Fatalf("create due_date = %v, want 2026-03-31", created.DueDate)
}
// GET persists both dates.
w = httptest.NewRecorder()
getReq := withURLParam(newRequest("GET", "/api/projects/"+created.ID, nil), "id", created.ID)
testHandler.GetProject(w, getReq)
got := decodeProject(t, w, http.StatusOK)
if got.StartDate == nil || *got.StartDate != "2026-03-01" || got.DueDate == nil || *got.DueDate != "2026-03-31" {
t.Fatalf("get dates = (%v, %v), want (2026-03-01, 2026-03-31)", got.StartDate, got.DueDate)
}
// Update due_date only; start_date is absent from the body and must persist.
w = httptest.NewRecorder()
putReq := withURLParam(newRequest("PUT", "/api/projects/"+created.ID, map[string]any{
"due_date": "2026-04-15",
}), "id", created.ID)
testHandler.UpdateProject(w, putReq)
updated := decodeProject(t, w, http.StatusOK)
if updated.DueDate == nil || *updated.DueDate != "2026-04-15" {
t.Fatalf("update due_date = %v, want 2026-04-15", updated.DueDate)
}
if updated.StartDate == nil || *updated.StartDate != "2026-03-01" {
t.Fatalf("start_date must be untouched by an absent key, got %v", updated.StartDate)
}
// Clearing: an explicit empty string nulls start_date, due_date untouched.
w = httptest.NewRecorder()
clearReq := withURLParam(newRequest("PUT", "/api/projects/"+created.ID, map[string]any{
"start_date": "",
}), "id", created.ID)
testHandler.UpdateProject(w, clearReq)
cleared := decodeProject(t, w, http.StatusOK)
if cleared.StartDate != nil {
t.Fatalf("start_date should be cleared, got %v", cleared.StartDate)
}
if cleared.DueDate == nil || *cleared.DueDate != "2026-04-15" {
t.Fatalf("due_date must survive clearing start_date, got %v", cleared.DueDate)
}
}
// SearchProjects hand-scans an explicit column list (not SELECT *), so a
// column/scan misalignment after adding start_date / due_date would surface
// here as a scan error or shifted values. Exercise the real query + scan.
func TestSearchProjectsCarriesDates(t *testing.T) {
w := httptest.NewRecorder()
testHandler.CreateProject(w, newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "zzsearchdated project",
"start_date": "2026-05-01",
"due_date": "2026-05-31",
}))
created := decodeProject(t, w, http.StatusCreated)
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, created.ID)
})
w = httptest.NewRecorder()
testHandler.SearchProjects(w, newRequest("GET", "/api/projects/search?q=zzsearchdated", nil))
if w.Code != http.StatusOK {
t.Fatalf("search status = %d: %s", w.Code, w.Body.String())
}
var resp struct {
Projects []SearchProjectResponse `json:"projects"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode search: %v", err)
}
var found *SearchProjectResponse
for i := range resp.Projects {
if resp.Projects[i].ID == created.ID {
found = &resp.Projects[i]
break
}
}
if found == nil {
t.Fatalf("created project not in search results: %s", w.Body.String())
}
if found.StartDate == nil || *found.StartDate != "2026-05-01" || found.DueDate == nil || *found.DueDate != "2026-05-31" {
t.Fatalf("search dates = (%v, %v), want (2026-05-01, 2026-05-31)", found.StartDate, found.DueDate)
}
}
// A malformed calendar day is a 400 on both create and update, never a 500.
func TestProjectInvalidDateReturns400(t *testing.T) {
w := httptest.NewRecorder()
testHandler.CreateProject(w, newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "bad date project",
"start_date": "03/01/2026",
}))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid create start_date, got %d: %s", w.Code, w.Body.String())
}
// Seed a valid project, then reject a malformed update due_date.
w = httptest.NewRecorder()
testHandler.CreateProject(w, newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "bad update date project",
}))
project := decodeProject(t, w, http.StatusCreated)
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, project.ID)
})
w = httptest.NewRecorder()
putReq := withURLParam(newRequest("PUT", "/api/projects/"+project.ID, map[string]any{
"due_date": "not-a-date",
}), "id", project.ID)
testHandler.UpdateProject(w, putReq)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid update due_date, got %d: %s", w.Code, w.Body.String())
}
}