diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 23:46:26 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 23:46:26 +0000 |
| commit | 392c7c1ada310b2f928dca89b75ba628478f7694 (patch) | |
| tree | c2b32420ce5df0da79d6d4b014fe2c784f2bd4be /internal/api/epics_test.go | |
| parent | 997cd8b56bc086a02b9c7c006dd62b07b9fcd2f3 (diff) | |
feat(story): add Epic/Story data model + REST CRUD (Phase 7a)
Pure data model + CRUD -- no orchestration behavior yet (that's Phase 7b).
Revives ADR-007's stories concept (adapted; ADR-007 itself was deleted as
"more machinery than the usage pattern needed") while staying lean: reuses
the existing events/projects/task-tree machinery rather than building a
parallel hierarchy.
- internal/story: Epic/Story types. Epic is a loosely-scoped initiative
(OPEN/CLOSED, no execution semantics). Story is a shippable slice of work
realized by a task tree rooted at root_task_id; epic_id/project_id/
root_task_id are loose references (no FK enforcement, matching the
codebase's existing tolerance for unmatched reference strings like
tasks.project).
- storage: new epics/stories tables (additive migrations) + CRUD methods.
Story status is intentionally unvalidated pass-through in this phase --
no task.ValidTransition-style state machine, since there's no orchestrator
yet to make transitions meaningful.
- event: 9 new Kind constants for the ceremony this layer will eventually
drive (epic_proposed, discovery_proposed, framing_decided, groomed,
prioritized, eval_verdict, arbitration_decided, retro_captured,
human_accepted) -- defined but nothing emits them yet.
- api: GET/POST /api/epics, GET/PUT /api/epics/{id}, GET /api/epics/{id}/stories,
GET/POST /api/stories, GET/PUT /api/stories/{id}, and GET
/api/stories/{id}/task-tree -- BFS walk from root_task_id following both
parent_task_id children and depends_on-edge dependents (visited-set
guarded), returning a flat node list for a later UI phase to render as a
graph. Unauthenticated, matching the existing projects/tasks endpoints'
posture.
go build/vet/test -race -count=1 all pass, full suite.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/api/epics_test.go')
| -rw-r--r-- | internal/api/epics_test.go | 202 |
1 files changed, 202 insertions, 0 deletions
diff --git a/internal/api/epics_test.go b/internal/api/epics_test.go new file mode 100644 index 0000000..bae7468 --- /dev/null +++ b/internal/api/epics_test.go @@ -0,0 +1,202 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/thepeterstone/claudomator/internal/story" +) + +// TestServer_CreateEpic_AndGetEpic mirrors the projects endpoint test +// pattern: POST creates, GET retrieves what was created. +func TestServer_CreateEpic_AndGetEpic(t *testing.T) { + srv, _ := testServer(t) + + body, _ := json.Marshal(map[string]any{ + "name": "Bigger Auth Rework", + "description": "Overhaul the auth flow.", + "discovery_source": "human", + }) + req := httptest.NewRequest("POST", "/api/epics", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var created story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create response: %v", err) + } + if created.ID == "" { + t.Fatal("expected server-assigned ID") + } + if created.Status != "OPEN" { + t.Errorf("expected default status OPEN, got %q", created.Status) + } + + req = httptest.NewRequest("GET", "/api/epics/"+created.ID, nil) + w = httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("get: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode get response: %v", err) + } + if got.Name != "Bigger Auth Rework" { + t.Errorf("Name: want %q, got %q", "Bigger Auth Rework", got.Name) + } + if got.DiscoverySource != "human" { + t.Errorf("DiscoverySource: want human, got %q", got.DiscoverySource) + } +} + +func TestServer_CreateEpic_MissingName_Returns400(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("POST", "/api/epics", bytes.NewReader([]byte(`{}`))) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestServer_GetEpic_NotFound(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/epics/does-not-exist", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestServer_ListEpics_FiltersByStatus confirms ?status=OPEN narrows results. +func TestServer_ListEpics_FiltersByStatus(t *testing.T) { + srv, store := testServer(t) + + if err := store.CreateEpic(&story.Epic{ID: "e1", Name: "one", Status: "OPEN"}); err != nil { + t.Fatal(err) + } + if err := store.CreateEpic(&story.Epic{ID: "e2", Name: "two", Status: "CLOSED"}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/epics", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var all []story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &all); err != nil { + t.Fatalf("decode: %v", err) + } + if len(all) != 2 { + t.Fatalf("want 2 epics, got %d", len(all)) + } + + req = httptest.NewRequest("GET", "/api/epics?status=OPEN", nil) + w = httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var open []story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &open); err != nil { + t.Fatalf("decode: %v", err) + } + if len(open) != 1 || open[0].ID != "e1" { + t.Fatalf("want exactly [e1], got %+v", open) + } +} + +func TestServer_UpdateEpic(t *testing.T) { + srv, store := testServer(t) + if err := store.CreateEpic(&story.Epic{ID: "e1", Name: "original", Status: "OPEN"}); err != nil { + t.Fatal(err) + } + + body, _ := json.Marshal(map[string]any{ + "name": "updated", + "status": "CLOSED", + }) + req := httptest.NewRequest("PUT", "/api/epics/e1", bytes.NewReader(body)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Name != "updated" || got.Status != "CLOSED" { + t.Errorf("expected updated fields, got %+v", got) + } + if got.ID != "e1" { + t.Errorf("ID must not change via body: got %q", got.ID) + } +} + +func TestServer_UpdateEpic_NotFound(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("PUT", "/api/epics/does-not-exist", bytes.NewReader([]byte(`{"name":"x"}`))) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestServer_ListEpicStories confirms GET /api/epics/{id}/stories returns +// only stories whose epic_id matches the path {id}. +func TestServer_ListEpicStories(t *testing.T) { + srv, store := testServer(t) + if err := store.CreateEpic(&story.Epic{ID: "epic-a", Name: "a"}); err != nil { + t.Fatal(err) + } + if err := store.CreateStory(&story.Story{ID: "s1", EpicID: "epic-a", Name: "one", Status: "DISCOVERY"}); err != nil { + t.Fatal(err) + } + if err := store.CreateStory(&story.Story{ID: "s2", EpicID: "epic-b", Name: "two", Status: "DISCOVERY"}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/epics/epic-a/stories", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got []story.Story + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 || got[0].ID != "s1" { + t.Fatalf("want exactly [s1], got %+v", got) + } +} + +// TestServer_ListEpicStories_UnknownEpic_ReturnsEmptyList mirrors +// handleListSubtasks: no existence check on the parent, just an empty list. +func TestServer_ListEpicStories_UnknownEpic_ReturnsEmptyList(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/epics/does-not-exist/stories", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var got []story.Story + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 0 { + t.Fatalf("want empty list, got %+v", got) + } +} |
