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/storage/story_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/storage/story_test.go')
| -rw-r--r-- | internal/storage/story_test.go | 211 |
1 files changed, 211 insertions, 0 deletions
diff --git a/internal/storage/story_test.go b/internal/storage/story_test.go new file mode 100644 index 0000000..9f2c723 --- /dev/null +++ b/internal/storage/story_test.go @@ -0,0 +1,211 @@ +package storage + +import ( + "encoding/json" + "testing" + + "github.com/thepeterstone/claudomator/internal/story" +) + +func TestCreateStory_AndGetStory(t *testing.T) { + db := testDB(t) + + st := &story.Story{ + ID: "story-1", + EpicID: "epic-1", + ProjectID: "proj-1", + Name: "Add login page", + BranchName: "story/add-login-page", + Status: "DISCOVERY", + DiscoverySource: "agent", + Spec: "Product: users need to log in. Arch: reuse existing session middleware.", + AcceptanceCriteria: []string{"User can log in with email/password", "Invalid creds show an error"}, + Validation: json.RawMessage(`{"type":"curl","steps":["GET /login returns 200"],"success_criteria":"all steps pass"}`), + Priority: "high", + RootTaskID: "task-1", + } + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory: %v", err) + } + + got, err := db.GetStory("story-1") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.Name != "Add login page" { + t.Errorf("Name: want %q, got %q", "Add login page", got.Name) + } + if got.EpicID != "epic-1" { + t.Errorf("EpicID: want epic-1, got %q", got.EpicID) + } + if got.ProjectID != "proj-1" { + t.Errorf("ProjectID: want proj-1, got %q", got.ProjectID) + } + if got.BranchName != "story/add-login-page" { + t.Errorf("BranchName: want story/add-login-page, got %q", got.BranchName) + } + if got.Status != "DISCOVERY" { + t.Errorf("Status: want DISCOVERY, got %q", got.Status) + } + if len(got.AcceptanceCriteria) != 2 { + t.Errorf("AcceptanceCriteria: want 2 items, got %d: %+v", len(got.AcceptanceCriteria), got.AcceptanceCriteria) + } + if string(got.Validation) == "" { + t.Errorf("expected Validation to round-trip, got empty") + } + if got.RootTaskID != "task-1" { + t.Errorf("RootTaskID: want task-1, got %q", got.RootTaskID) + } + if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() { + t.Errorf("expected CreatedAt/UpdatedAt to be set, got %+v", got) + } +} + +// TestCreateStory_LooseRefsToleratesUnmatchedIDs confirms epic_id, +// project_id, and root_task_id are plain nullable-loose-ref strings with no +// FK enforcement — a story referencing IDs that don't exist anywhere else +// must still be created successfully, matching how tasks.project already +// tolerates an unmatched string. +func TestCreateStory_LooseRefsToleratesUnmatchedIDs(t *testing.T) { + db := testDB(t) + + st := &story.Story{ + ID: "story-loose", + EpicID: "epic-does-not-exist", + ProjectID: "project-does-not-exist", + Name: "Loose ref story", + Status: "DISCOVERY", + RootTaskID: "task-does-not-exist", + } + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory with unmatched loose refs should succeed, got: %v", err) + } + got, err := db.GetStory("story-loose") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.EpicID != "epic-does-not-exist" || got.ProjectID != "project-does-not-exist" || got.RootTaskID != "task-does-not-exist" { + t.Errorf("expected loose refs stored verbatim, got %+v", got) + } +} + +func TestCreateStory_DefaultsAcceptanceCriteriaToEmptyList(t *testing.T) { + db := testDB(t) + + st := &story.Story{ID: "story-nil-ac", Name: "No AC set", Status: "DISCOVERY"} + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory: %v", err) + } + got, err := db.GetStory("story-nil-ac") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.AcceptanceCriteria == nil { + t.Fatal("expected AcceptanceCriteria to be an empty slice, got nil") + } + if len(got.AcceptanceCriteria) != 0 { + t.Errorf("expected empty AcceptanceCriteria, got %+v", got.AcceptanceCriteria) + } +} + +func TestGetStory_NotFound(t *testing.T) { + db := testDB(t) + if _, err := db.GetStory("nope"); err == nil { + t.Fatal("expected error for missing story, got nil") + } +} + +func TestListStories_FilterByStatusAndEpic(t *testing.T) { + db := testDB(t) + + stories := []*story.Story{ + {ID: "s1", EpicID: "epic-a", Name: "one", Status: "DISCOVERY"}, + {ID: "s2", EpicID: "epic-a", Name: "two", Status: "IN_PROGRESS"}, + {ID: "s3", EpicID: "epic-b", Name: "three", Status: "DISCOVERY"}, + } + for _, st := range stories { + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory(%s): %v", st.ID, err) + } + } + + all, err := db.ListStories(StoryFilter{}) + if err != nil { + t.Fatalf("ListStories: %v", err) + } + if len(all) != 3 { + t.Errorf("want 3 stories, got %d", len(all)) + } + + byEpic, err := db.ListStories(StoryFilter{EpicID: "epic-a"}) + if err != nil { + t.Fatalf("ListStories(epic-a): %v", err) + } + if len(byEpic) != 2 { + t.Errorf("want 2 stories for epic-a, got %d", len(byEpic)) + } + + byStatus, err := db.ListStories(StoryFilter{Status: "DISCOVERY"}) + if err != nil { + t.Fatalf("ListStories(DISCOVERY): %v", err) + } + if len(byStatus) != 2 { + t.Errorf("want 2 DISCOVERY stories, got %d", len(byStatus)) + } + + byBoth, err := db.ListStories(StoryFilter{EpicID: "epic-a", Status: "DISCOVERY"}) + if err != nil { + t.Fatalf("ListStories(epic-a, DISCOVERY): %v", err) + } + if len(byBoth) != 1 || byBoth[0].ID != "s1" { + t.Errorf("want exactly [s1], got %+v", byBoth) + } +} + +// TestUpdateStory_UnvalidatedStatusPassthrough confirms UpdateStory writes +// whatever status string it is given with no state-machine enforcement +// (unlike task.ValidTransition/UpdateTaskState) — this phase explicitly +// defers that validation to a later orchestration phase. +func TestUpdateStory_UnvalidatedStatusPassthrough(t *testing.T) { + db := testDB(t) + + st := &story.Story{ID: "s1", Name: "original", Status: "DISCOVERY"} + if err := db.CreateStory(st); err != nil { + t.Fatalf("CreateStory: %v", err) + } + origCreatedAt := st.CreatedAt + + // A nonsensical jump (DISCOVERY -> DONE, skipping every intermediate + // status) must still be accepted verbatim. + st.Name = "updated" + st.Status = "DONE" + st.EpicID = "epic-x" + st.RootTaskID = "task-x" + st.AcceptanceCriteria = []string{"criterion one"} + if err := db.UpdateStory(st); err != nil { + t.Fatalf("UpdateStory: %v", err) + } + + got, err := db.GetStory("s1") + if err != nil { + t.Fatalf("GetStory: %v", err) + } + if got.Status != "DONE" { + t.Errorf("Status after update: want DONE (unvalidated passthrough), got %q", got.Status) + } + if got.Name != "updated" { + t.Errorf("Name after update: want updated, got %q", got.Name) + } + if got.EpicID != "epic-x" { + t.Errorf("EpicID after update: want epic-x, got %q", got.EpicID) + } + if got.RootTaskID != "task-x" { + t.Errorf("RootTaskID after update: want task-x, got %q", got.RootTaskID) + } + if len(got.AcceptanceCriteria) != 1 || got.AcceptanceCriteria[0] != "criterion one" { + t.Errorf("AcceptanceCriteria after update: want [criterion one], got %+v", got.AcceptanceCriteria) + } + if !got.CreatedAt.Equal(origCreatedAt) { + t.Errorf("CreatedAt must not change on update: want %v, got %v", origCreatedAt, got.CreatedAt) + } +} |
