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) } }