package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/thepeterstone/claudomator/internal/event" "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()) } } // TestServer_AcceptStory_SucceedsFromReviewReady is verification item (e) // from the Phase 7b task description: POST /api/stories/{id}/accept // transitions REVIEW_READY -> DONE and emits a KindHumanAccepted event // attached to the story's ID. func TestServer_AcceptStory_SucceedsFromReviewReady(t *testing.T) { srv, store := testServer(t) if err := store.CreateStory(&story.Story{ID: "s1", Name: "story", Status: "REVIEW_READY"}); err != nil { t.Fatal(err) } req := httptest.NewRequest("POST", "/api/stories/s1/accept", 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()) } got, err := store.GetStory("s1") if err != nil { t.Fatal(err) } if got.Status != "DONE" { t.Errorf("Status: want DONE, got %q", got.Status) } evs, err := store.ListEvents("s1", 0) if err != nil { t.Fatal(err) } var found bool for _, e := range evs { if e.Kind == event.KindHumanAccepted { found = true var payload struct { StoryID string `json:"story_id"` From string `json:"from"` To string `json:"to"` } if err := json.Unmarshal(e.Payload, &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } if payload.StoryID != "s1" || payload.From != "REVIEW_READY" || payload.To != "DONE" { t.Errorf("unexpected payload: %+v", payload) } } } if !found { t.Error("expected a KindHumanAccepted event attached to the story's event stream") } } // TestServer_AcceptStory_FailsFromOtherStatuses is the other half of // verification item (e): accept must be rejected (409) from any status other // than REVIEW_READY. func TestServer_AcceptStory_FailsFromOtherStatuses(t *testing.T) { for _, status := range []string{"DISCOVERY", "VALIDATING", "NEEDS_FIX", "DONE", "IN_PROGRESS"} { t.Run(status, func(t *testing.T) { srv, store := testServer(t) id := "s-" + status if err := store.CreateStory(&story.Story{ID: id, Name: "story", Status: status}); err != nil { t.Fatal(err) } req := httptest.NewRequest("POST", "/api/stories/"+id+"/accept", nil) w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) if w.Code != http.StatusConflict { t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String()) } got, err := store.GetStory(id) if err != nil { t.Fatal(err) } if got.Status != status { t.Errorf("status must not change on rejected accept: want %q, got %q", status, got.Status) } }) } } func TestServer_AcceptStory_NotFound(t *testing.T) { srv, _ := testServer(t) req := httptest.NewRequest("POST", "/api/stories/does-not-exist/accept", 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_ListStoryEvents_ReturnsEvalVerdictAndArbitrationEvents proves // GET /api/stories/{id}/events surfaces events written against the story's // own ID (not any task's ID) — the attachment point // internal/scheduler.StoryOrchestrator uses for KindEvalVerdict/ // KindArbitrationDecided. func TestServer_ListStoryEvents_ReturnsEvalVerdictAndArbitrationEvents(t *testing.T) { srv, store := testServer(t) if err := store.CreateStory(&story.Story{ID: "s1", Name: "story", Status: "VALIDATING"}); err != nil { t.Fatal(err) } if err := store.CreateEvent(&event.Event{TaskID: "s1", Kind: event.KindEvalVerdict, Actor: event.ActorSystem, Payload: []byte(`{"role":"evaluator_quality"}`)}); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/api/stories/s1/events", 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 []event.Event if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } if len(got) != 1 || got[0].Kind != event.KindEvalVerdict { t.Fatalf("want 1 eval_verdict event, got %+v", got) } } func TestServer_ListStoryEvents_UnknownStory_Returns404(t *testing.T) { srv, _ := testServer(t) req := httptest.NewRequest("GET", "/api/stories/does-not-exist/events", 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()) } }