summaryrefslogtreecommitdiff
path: root/internal/storage/story_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/storage/story_test.go')
-rw-r--r--internal/storage/story_test.go211
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)
+ }
+}