summaryrefslogtreecommitdiff
path: root/internal/api/stories_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/stories_test.go')
-rw-r--r--internal/api/stories_test.go278
1 files changed, 278 insertions, 0 deletions
diff --git a/internal/api/stories_test.go b/internal/api/stories_test.go
new file mode 100644
index 0000000..b346af5
--- /dev/null
+++ b/internal/api/stories_test.go
@@ -0,0 +1,278 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/story"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+func TestServer_CreateStory_AndGetStory(t *testing.T) {
+ srv, _ := testServer(t)
+
+ body, _ := json.Marshal(map[string]any{
+ "name": "Add login page",
+ "status": "DISCOVERY",
+ "epic_id": "epic-1",
+ "acceptance_criteria": []string{"User can log in"},
+ })
+ req := httptest.NewRequest("POST", "/api/stories", 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.Story
+ 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")
+ }
+
+ req = httptest.NewRequest("GET", "/api/stories/"+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.Story
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode get response: %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 len(got.AcceptanceCriteria) != 1 || got.AcceptanceCriteria[0] != "User can log in" {
+ t.Errorf("AcceptanceCriteria: want [User can log in], got %+v", got.AcceptanceCriteria)
+ }
+}
+
+func TestServer_CreateStory_MissingName_Returns400(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("POST", "/api/stories", 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_GetStory_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/stories/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())
+ }
+}
+
+func TestServer_ListStories_FiltersByStatusAndEpic(t *testing.T) {
+ srv, store := testServer(t)
+ for _, st := range []*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"},
+ } {
+ if err := store.CreateStory(st); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ req := httptest.NewRequest("GET", "/api/stories?epic_id=epic-a&status=DISCOVERY", 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)
+ }
+}
+
+func TestServer_UpdateStory_UnvalidatedStatusPassthrough(t *testing.T) {
+ srv, store := testServer(t)
+ if err := store.CreateStory(&story.Story{ID: "s1", Name: "original", Status: "DISCOVERY"}); err != nil {
+ t.Fatal(err)
+ }
+
+ // A nonsensical status jump must still be accepted — this phase performs
+ // no status-machine validation on stories.
+ body, _ := json.Marshal(map[string]any{
+ "name": "updated",
+ "status": "DONE",
+ "root_task_id": "task-x",
+ })
+ req := httptest.NewRequest("PUT", "/api/stories/s1", 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.Story
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.Status != "DONE" {
+ t.Errorf("Status: want DONE (unvalidated passthrough), got %q", got.Status)
+ }
+ if got.RootTaskID != "task-x" {
+ t.Errorf("RootTaskID: want task-x, got %q", got.RootTaskID)
+ }
+ if got.ID != "s1" {
+ t.Errorf("ID must not change via body: got %q", got.ID)
+ }
+}
+
+func TestServer_UpdateStory_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("PUT", "/api/stories/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())
+ }
+}
+
+func makeTreeTestTask(id, parentID string, dependsOn []string) *task.Task {
+ now := time.Now().UTC()
+ if dependsOn == nil {
+ dependsOn = []string{}
+ }
+ return &task.Task{
+ ID: id,
+ Name: "Task " + id,
+ Agent: task.AgentConfig{Type: "claude", Instructions: "x"},
+ Priority: task.PriorityNormal,
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"},
+ Tags: []string{},
+ DependsOn: dependsOn,
+ ParentTaskID: parentID,
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+}
+
+// TestServer_StoryTaskTree_WalksParentAndDependsOnEdges constructs a small
+// task tree by hand: a root task, a parent_task_id child of the root, and a
+// separate task (not a parent_task_id child of anything) whose
+// depends_on_json points at the root. Confirms the task-tree endpoint
+// discovers all three via the combination of ListSubtasks (parent_task_id)
+// and ListDependents (depends_on_json) traversal.
+func TestServer_StoryTaskTree_WalksParentAndDependsOnEdges(t *testing.T) {
+ srv, store := testServer(t)
+
+ root := makeTreeTestTask("tt-root", "", nil)
+ child := makeTreeTestTask("tt-child", "tt-root", nil)
+ sibling := makeTreeTestTask("tt-sibling", "", []string{"tt-root"})
+ for _, tk := range []*task.Task{root, child, sibling} {
+ if err := store.CreateTask(tk); err != nil {
+ t.Fatalf("CreateTask(%s): %v", tk.ID, err)
+ }
+ }
+
+ if err := store.CreateStory(&story.Story{ID: "story-1", Name: "story", Status: "IN_PROGRESS", RootTaskID: "tt-root"}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/stories/story-1/task-tree", 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 taskTreeResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.StoryID != "story-1" {
+ t.Errorf("StoryID: want story-1, got %q", got.StoryID)
+ }
+ if got.RootTaskID != "tt-root" {
+ t.Errorf("RootTaskID: want tt-root, got %q", got.RootTaskID)
+ }
+ if len(got.Nodes) != 3 {
+ t.Fatalf("want 3 nodes, got %d: %+v", len(got.Nodes), got.Nodes)
+ }
+
+ byID := map[string]taskTreeNode{}
+ for _, n := range got.Nodes {
+ byID[n.ID] = n
+ }
+ if _, ok := byID["tt-root"]; !ok {
+ t.Error("missing tt-root node")
+ }
+ childNode, ok := byID["tt-child"]
+ if !ok {
+ t.Fatal("missing tt-child node")
+ }
+ if childNode.ParentTaskID != "tt-root" {
+ t.Errorf("tt-child.ParentTaskID: want tt-root, got %q", childNode.ParentTaskID)
+ }
+ siblingNode, ok := byID["tt-sibling"]
+ if !ok {
+ t.Fatal("missing tt-sibling node")
+ }
+ if len(siblingNode.DependsOn) != 1 || siblingNode.DependsOn[0] != "tt-root" {
+ t.Errorf("tt-sibling.DependsOn: want [tt-root], got %+v", siblingNode.DependsOn)
+ }
+ for _, n := range got.Nodes {
+ if n.State == "" {
+ t.Errorf("node %s: expected non-empty state", n.ID)
+ }
+ if n.AgentType != "claude" {
+ t.Errorf("node %s: AgentType = %q, want claude", n.ID, n.AgentType)
+ }
+ }
+}
+
+// TestServer_StoryTaskTree_EmptyWhenNoRootTask confirms an unset
+// root_task_id returns an empty node list, not an error.
+func TestServer_StoryTaskTree_EmptyWhenNoRootTask(t *testing.T) {
+ srv, store := testServer(t)
+ if err := store.CreateStory(&story.Story{ID: "story-empty", Name: "story", Status: "DISCOVERY"}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/stories/story-empty/task-tree", 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 taskTreeResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.RootTaskID != "" {
+ t.Errorf("RootTaskID: want empty, got %q", got.RootTaskID)
+ }
+ if len(got.Nodes) != 0 {
+ t.Fatalf("want empty node list, got %+v", got.Nodes)
+ }
+}
+
+func TestServer_StoryTaskTree_UnknownStory_Returns404(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/stories/does-not-exist/task-tree", 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())
+ }
+}