diff options
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/epics.go | 90 | ||||
| -rw-r--r-- | internal/api/epics_test.go | 202 | ||||
| -rw-r--r-- | internal/api/server.go | 10 | ||||
| -rw-r--r-- | internal/api/stories.go | 170 | ||||
| -rw-r--r-- | internal/api/stories_test.go | 278 |
5 files changed, 750 insertions, 0 deletions
diff --git a/internal/api/epics.go b/internal/api/epics.go new file mode 100644 index 0000000..e22a377 --- /dev/null +++ b/internal/api/epics.go @@ -0,0 +1,90 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/story" +) + +func (s *Server) handleListEpics(w http.ResponseWriter, r *http.Request) { + status := r.URL.Query().Get("status") + epics, err := s.store.ListEpics(status) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if epics == nil { + epics = []*story.Epic{} + } + writeJSON(w, http.StatusOK, epics) +} + +func (s *Server) handleCreateEpic(w http.ResponseWriter, r *http.Request) { + var e story.Epic + if err := json.NewDecoder(r.Body).Decode(&e); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + if e.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + if e.ID == "" { + e.ID = uuid.New().String() + } + if err := s.store.CreateEpic(&e); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, e) +} + +func (s *Server) handleGetEpic(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + e, err := s.store.GetEpic(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "epic not found"}) + return + } + writeJSON(w, http.StatusOK, e) +} + +func (s *Server) handleUpdateEpic(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + existing, err := s.store.GetEpic(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "epic not found"}) + return + } + createdAt := existing.CreatedAt + if err := json.NewDecoder(r.Body).Decode(existing); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + existing.ID = id // ensure ID cannot be changed via body + existing.CreatedAt = createdAt + if err := s.store.UpdateEpic(existing); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, existing) +} + +// handleListEpicStories lists stories whose epic_id matches the path {id}. +// Mirrors handleListSubtasks: no existence check on the epic itself, just +// returns an empty list if there are no matching stories. +func (s *Server) handleListEpicStories(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + stories, err := s.store.ListStories(storage.StoryFilter{EpicID: id}) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if stories == nil { + stories = []*story.Story{} + } + writeJSON(w, http.StatusOK, stories) +} diff --git a/internal/api/epics_test.go b/internal/api/epics_test.go new file mode 100644 index 0000000..bae7468 --- /dev/null +++ b/internal/api/epics_test.go @@ -0,0 +1,202 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/thepeterstone/claudomator/internal/story" +) + +// TestServer_CreateEpic_AndGetEpic mirrors the projects endpoint test +// pattern: POST creates, GET retrieves what was created. +func TestServer_CreateEpic_AndGetEpic(t *testing.T) { + srv, _ := testServer(t) + + body, _ := json.Marshal(map[string]any{ + "name": "Bigger Auth Rework", + "description": "Overhaul the auth flow.", + "discovery_source": "human", + }) + req := httptest.NewRequest("POST", "/api/epics", 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.Epic + 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") + } + if created.Status != "OPEN" { + t.Errorf("expected default status OPEN, got %q", created.Status) + } + + req = httptest.NewRequest("GET", "/api/epics/"+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.Epic + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode get response: %v", err) + } + if got.Name != "Bigger Auth Rework" { + t.Errorf("Name: want %q, got %q", "Bigger Auth Rework", got.Name) + } + if got.DiscoverySource != "human" { + t.Errorf("DiscoverySource: want human, got %q", got.DiscoverySource) + } +} + +func TestServer_CreateEpic_MissingName_Returns400(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("POST", "/api/epics", 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_GetEpic_NotFound(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/epics/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()) + } +} + +// TestServer_ListEpics_FiltersByStatus confirms ?status=OPEN narrows results. +func TestServer_ListEpics_FiltersByStatus(t *testing.T) { + srv, store := testServer(t) + + if err := store.CreateEpic(&story.Epic{ID: "e1", Name: "one", Status: "OPEN"}); err != nil { + t.Fatal(err) + } + if err := store.CreateEpic(&story.Epic{ID: "e2", Name: "two", Status: "CLOSED"}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/epics", 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 all []story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &all); err != nil { + t.Fatalf("decode: %v", err) + } + if len(all) != 2 { + t.Fatalf("want 2 epics, got %d", len(all)) + } + + req = httptest.NewRequest("GET", "/api/epics?status=OPEN", 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 open []story.Epic + if err := json.Unmarshal(w.Body.Bytes(), &open); err != nil { + t.Fatalf("decode: %v", err) + } + if len(open) != 1 || open[0].ID != "e1" { + t.Fatalf("want exactly [e1], got %+v", open) + } +} + +func TestServer_UpdateEpic(t *testing.T) { + srv, store := testServer(t) + if err := store.CreateEpic(&story.Epic{ID: "e1", Name: "original", Status: "OPEN"}); err != nil { + t.Fatal(err) + } + + body, _ := json.Marshal(map[string]any{ + "name": "updated", + "status": "CLOSED", + }) + req := httptest.NewRequest("PUT", "/api/epics/e1", 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.Epic + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Name != "updated" || got.Status != "CLOSED" { + t.Errorf("expected updated fields, got %+v", got) + } + if got.ID != "e1" { + t.Errorf("ID must not change via body: got %q", got.ID) + } +} + +func TestServer_UpdateEpic_NotFound(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("PUT", "/api/epics/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()) + } +} + +// TestServer_ListEpicStories confirms GET /api/epics/{id}/stories returns +// only stories whose epic_id matches the path {id}. +func TestServer_ListEpicStories(t *testing.T) { + srv, store := testServer(t) + if err := store.CreateEpic(&story.Epic{ID: "epic-a", Name: "a"}); err != nil { + t.Fatal(err) + } + if err := store.CreateStory(&story.Story{ID: "s1", EpicID: "epic-a", Name: "one", Status: "DISCOVERY"}); err != nil { + t.Fatal(err) + } + if err := store.CreateStory(&story.Story{ID: "s2", EpicID: "epic-b", Name: "two", Status: "DISCOVERY"}); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/epics/epic-a/stories", 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) + } +} + +// TestServer_ListEpicStories_UnknownEpic_ReturnsEmptyList mirrors +// handleListSubtasks: no existence check on the parent, just an empty list. +func TestServer_ListEpicStories_UnknownEpic_ReturnsEmptyList(t *testing.T) { + srv, _ := testServer(t) + req := httptest.NewRequest("GET", "/api/epics/does-not-exist/stories", 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) != 0 { + t.Fatalf("want empty list, got %+v", got) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 0e5b185..9f0eb7c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -172,6 +172,16 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/projects", s.handleCreateProject) s.mux.HandleFunc("GET /api/projects/{id}", s.handleGetProject) s.mux.HandleFunc("PUT /api/projects/{id}", s.handleUpdateProject) + s.mux.HandleFunc("GET /api/epics", s.handleListEpics) + s.mux.HandleFunc("POST /api/epics", s.handleCreateEpic) + s.mux.HandleFunc("GET /api/epics/{id}", s.handleGetEpic) + s.mux.HandleFunc("PUT /api/epics/{id}", s.handleUpdateEpic) + s.mux.HandleFunc("GET /api/epics/{id}/stories", s.handleListEpicStories) + s.mux.HandleFunc("GET /api/stories", s.handleListStories) + s.mux.HandleFunc("POST /api/stories", s.handleCreateStory) + s.mux.HandleFunc("GET /api/stories/{id}", s.handleGetStory) + s.mux.HandleFunc("PUT /api/stories/{id}", s.handleUpdateStory) + s.mux.HandleFunc("GET /api/stories/{id}/task-tree", s.handleStoryTaskTree) s.mux.HandleFunc("POST /api/roles/{role}/versions", s.handleCreateRoleVersion) s.mux.HandleFunc("GET /api/roles/{role}/versions", s.handleListRoleVersions) s.mux.HandleFunc("POST /api/roles/{role}/activate", s.handleActivateRoleVersion) diff --git a/internal/api/stories.go b/internal/api/stories.go new file mode 100644 index 0000000..337da21 --- /dev/null +++ b/internal/api/stories.go @@ -0,0 +1,170 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/story" +) + +func (s *Server) handleListStories(w http.ResponseWriter, r *http.Request) { + filter := storage.StoryFilter{ + Status: r.URL.Query().Get("status"), + EpicID: r.URL.Query().Get("epic_id"), + } + stories, err := s.store.ListStories(filter) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if stories == nil { + stories = []*story.Story{} + } + writeJSON(w, http.StatusOK, stories) +} + +func (s *Server) handleCreateStory(w http.ResponseWriter, r *http.Request) { + var st story.Story + if err := json.NewDecoder(r.Body).Decode(&st); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + if st.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + if st.ID == "" { + st.ID = uuid.New().String() + } + if err := s.store.CreateStory(&st); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, st) +} + +func (s *Server) handleGetStory(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + st, err := s.store.GetStory(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) + return + } + writeJSON(w, http.StatusOK, st) +} + +// handleUpdateStory replaces all editable fields on a story (everything +// except id/created_at). No status-machine validation is performed in this +// phase — the status string is written as given, same trust level as +// handleUpdateProject. +func (s *Server) handleUpdateStory(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + existing, err := s.store.GetStory(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) + return + } + createdAt := existing.CreatedAt + if err := json.NewDecoder(r.Body).Decode(existing); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + existing.ID = id // ensure ID cannot be changed via body + existing.CreatedAt = createdAt + if err := s.store.UpdateStory(existing); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, existing) +} + +// taskTreeNode is one task in a story's task-tree/graph response. +type taskTreeNode struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + AgentType string `json:"agent_type"` + Role string `json:"role,omitempty"` + ParentTaskID string `json:"parent_task_id,omitempty"` + DependsOn []string `json:"depends_on"` +} + +// taskTreeResponse is the payload for GET /api/stories/{id}/task-tree. +type taskTreeResponse struct { + StoryID string `json:"story_id"` + RootTaskID string `json:"root_task_id"` + Nodes []taskTreeNode `json:"nodes"` +} + +// handleStoryTaskTree walks the task graph realizing a story, starting at +// its root_task_id and following both parent_task_id children (via +// ListSubtasks) and depends_on edges in either direction (via +// ListDependents, so a sibling task that merely depends on something already +// in the tree is still discovered even if it isn't a literal +// parent_task_id child). Returns one flat node list; clients reconstruct +// the graph from each node's parent_task_id/depends_on. If the story has no +// root_task_id set, returns an empty node list, not an error. +func (s *Server) handleStoryTaskTree(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + st, err := s.store.GetStory(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) + return + } + + resp := taskTreeResponse{StoryID: id, RootTaskID: st.RootTaskID, Nodes: []taskTreeNode{}} + if st.RootTaskID == "" { + writeJSON(w, http.StatusOK, resp) + return + } + + visited := map[string]bool{} + queue := []string{st.RootTaskID} + for len(queue) > 0 { + tid := queue[0] + queue = queue[1:] + if visited[tid] { + continue + } + visited[tid] = true + + t, err := s.store.GetTask(tid) + if err != nil { + // A referenced task ID no longer resolves (e.g. deleted); skip it + // rather than failing the whole tree. + continue + } + dependsOn := t.DependsOn + if dependsOn == nil { + dependsOn = []string{} + } + resp.Nodes = append(resp.Nodes, taskTreeNode{ + ID: t.ID, + Name: t.Name, + State: string(t.State), + AgentType: t.Agent.Type, + Role: t.Agent.Role, + ParentTaskID: t.ParentTaskID, + DependsOn: dependsOn, + }) + + if children, err := s.store.ListSubtasks(tid); err == nil { + for _, c := range children { + if !visited[c.ID] { + queue = append(queue, c.ID) + } + } + } + if dependents, err := s.store.ListDependents(tid); err == nil { + for _, d := range dependents { + if !visited[d.ID] { + queue = append(queue, d.ID) + } + } + } + } + + writeJSON(w, http.StatusOK, resp) +} 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()) + } +} |
