summaryrefslogtreecommitdiff
path: root/internal/executor/channel_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/channel_test.go')
-rw-r--r--internal/executor/channel_test.go206
1 files changed, 206 insertions, 0 deletions
diff --git a/internal/executor/channel_test.go b/internal/executor/channel_test.go
index 157cb0c..e05228a 100644
--- a/internal/executor/channel_test.go
+++ b/internal/executor/channel_test.go
@@ -2,10 +2,14 @@ package executor
import (
"context"
+ "database/sql"
+ "encoding/json"
"errors"
"testing"
+ "github.com/thepeterstone/claudomator/internal/agentchannel"
"github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/story"
"github.com/thepeterstone/claudomator/internal/task"
)
@@ -13,6 +17,15 @@ type fakeChannelStore struct {
createdTasks []*task.Task
createdEvents []*event.Event
createTaskErr error
+
+ // Epic/story fakes back ProposeEpic (Phase 7c). epics is keyed by ID;
+ // GetEpicByName does a linear scan by Name, mirroring the real
+ // storage.DB.GetEpicByName's "exact match, simplest reasonable" behavior.
+ epics map[string]*story.Epic
+ createEpicErr error
+ stories map[string]*story.Story
+ updateStoryErr error
+ updatedStories []*story.Story
}
func (f *fakeChannelStore) CreateTask(t *task.Task) error {
@@ -28,6 +41,46 @@ func (f *fakeChannelStore) CreateEvent(e *event.Event) error {
return nil
}
+func (f *fakeChannelStore) CreateEpic(e *story.Epic) error {
+ if f.createEpicErr != nil {
+ return f.createEpicErr
+ }
+ if f.epics == nil {
+ f.epics = make(map[string]*story.Epic)
+ }
+ f.epics[e.ID] = e
+ return nil
+}
+
+func (f *fakeChannelStore) GetEpicByName(name string) (*story.Epic, error) {
+ for _, e := range f.epics {
+ if e.Name == name {
+ return e, nil
+ }
+ }
+ return nil, sql.ErrNoRows
+}
+
+func (f *fakeChannelStore) GetStory(id string) (*story.Story, error) {
+ st, ok := f.stories[id]
+ if !ok {
+ return nil, sql.ErrNoRows
+ }
+ return st, nil
+}
+
+func (f *fakeChannelStore) UpdateStory(st *story.Story) error {
+ if f.updateStoryErr != nil {
+ return f.updateStoryErr
+ }
+ if f.stories == nil {
+ f.stories = make(map[string]*story.Story)
+ }
+ f.stories[st.ID] = st
+ f.updatedStories = append(f.updatedStories, st)
+ return nil
+}
+
func TestStoreChannel_AskUser_BuffersAndBlocks(t *testing.T) {
ch := newStoreChannel(&fakeChannelStore{}, "task-1")
answer, err := ch.AskUser(context.Background(), `{"text":"q"}`)
@@ -204,3 +257,156 @@ func TestStoreChannel_RecordProgress_EmitsAgentMessage(t *testing.T) {
t.Errorf("TaskID: got %q want task-1", e.TaskID)
}
}
+
+// TestStoreChannel_ProposeEpic_CreatesEpicAndGroupsStories proves (a) from
+// the phase description: a new epic name creates a fresh epics row and sets
+// epic_id on the given stories.
+func TestStoreChannel_ProposeEpic_CreatesEpicAndGroupsStories(t *testing.T) {
+ store := &fakeChannelStore{
+ stories: map[string]*story.Story{
+ "story-1": {ID: "story-1", Name: "s1"},
+ "story-2": {ID: "story-2", Name: "s2"},
+ },
+ }
+ ch := newStoreChannel(store, "task-1")
+
+ epicID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{
+ Name: "Checkout revamp",
+ Description: "Everything to relaunch checkout",
+ StoryIDs: []string{"story-1", "story-2"},
+ })
+ if err != nil {
+ t.Fatalf("ProposeEpic: %v", err)
+ }
+ if epicID == "" {
+ t.Fatal("expected non-empty epic ID")
+ }
+ if len(store.epics) != 1 {
+ t.Fatalf("expected 1 created epic, got %d", len(store.epics))
+ }
+ epic := store.epics[epicID]
+ if epic == nil {
+ t.Fatalf("epic %q not found in store", epicID)
+ }
+ if epic.Name != "Checkout revamp" || epic.Description != "Everything to relaunch checkout" {
+ t.Errorf("epic fields not propagated: %+v", epic)
+ }
+ if epic.DiscoverySource != "agent" {
+ t.Errorf("DiscoverySource: want agent, got %q", epic.DiscoverySource)
+ }
+ if store.stories["story-1"].EpicID != epicID || store.stories["story-2"].EpicID != epicID {
+ t.Errorf("stories not grouped under epic: %+v / %+v", store.stories["story-1"], store.stories["story-2"])
+ }
+ if len(store.updatedStories) != 2 {
+ t.Errorf("expected 2 UpdateStory calls, got %d", len(store.updatedStories))
+ }
+}
+
+// TestStoreChannel_ProposeEpic_ReusesExistingEpicByName proves (b): calling
+// ProposeEpic again with the same name reuses the existing epic rather than
+// creating a duplicate.
+func TestStoreChannel_ProposeEpic_ReusesExistingEpicByName(t *testing.T) {
+ store := &fakeChannelStore{
+ stories: map[string]*story.Story{
+ "story-1": {ID: "story-1", Name: "s1"},
+ "story-3": {ID: "story-3", Name: "s3"},
+ },
+ }
+ ch := newStoreChannel(store, "task-1")
+
+ firstID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{
+ Name: "Checkout revamp",
+ StoryIDs: []string{"story-1"},
+ })
+ if err != nil {
+ t.Fatalf("first ProposeEpic: %v", err)
+ }
+
+ secondID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{
+ Name: "Checkout revamp",
+ StoryIDs: []string{"story-3"},
+ })
+ if err != nil {
+ t.Fatalf("second ProposeEpic: %v", err)
+ }
+
+ if firstID != secondID {
+ t.Errorf("expected the same epic ID to be reused, got %q then %q", firstID, secondID)
+ }
+ if len(store.epics) != 1 {
+ t.Fatalf("expected exactly 1 epic (no duplicate), got %d", len(store.epics))
+ }
+ if store.stories["story-3"].EpicID != firstID {
+ t.Errorf("story-3 not grouped under the reused epic: %+v", store.stories["story-3"])
+ }
+}
+
+// TestStoreChannel_ProposeEpic_SkipsUnresolvedStoryIDs proves that a story ID
+// which doesn't resolve is skipped rather than failing the whole call.
+func TestStoreChannel_ProposeEpic_SkipsUnresolvedStoryIDs(t *testing.T) {
+ store := &fakeChannelStore{
+ stories: map[string]*story.Story{
+ "story-1": {ID: "story-1", Name: "s1"},
+ },
+ }
+ ch := newStoreChannel(store, "task-1")
+
+ epicID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{
+ Name: "Checkout revamp",
+ StoryIDs: []string{"story-1", "does-not-exist"},
+ })
+ if err != nil {
+ t.Fatalf("ProposeEpic should not fail on one bad story ID: %v", err)
+ }
+ if store.stories["story-1"].EpicID != epicID {
+ t.Errorf("story-1 should still be grouped: %+v", store.stories["story-1"])
+ }
+ if len(store.updatedStories) != 1 {
+ t.Errorf("expected exactly 1 UpdateStory call (the bad ID skipped), got %d", len(store.updatedStories))
+ }
+}
+
+// TestStoreChannel_ProposeEpic_EmitsEpicProposedEvent proves (c): a
+// KindEpicProposed event is emitted, attached to the epic's own ID (not a
+// task ID), with a payload naming the epic and the stories actually grouped.
+func TestStoreChannel_ProposeEpic_EmitsEpicProposedEvent(t *testing.T) {
+ store := &fakeChannelStore{
+ stories: map[string]*story.Story{
+ "story-1": {ID: "story-1", Name: "s1"},
+ },
+ }
+ ch := newStoreChannel(store, "task-1")
+
+ epicID, err := ch.ProposeEpic(context.Background(), agentchannel.EpicProposal{
+ Name: "Checkout revamp",
+ StoryIDs: []string{"story-1", "missing"},
+ })
+ if err != nil {
+ t.Fatalf("ProposeEpic: %v", err)
+ }
+
+ if len(store.createdEvents) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(store.createdEvents))
+ }
+ ev := store.createdEvents[0]
+ if ev.Kind != event.KindEpicProposed || ev.Actor != event.ActorAgent {
+ t.Errorf("got kind=%v actor=%v want epic_proposed/agent", ev.Kind, ev.Actor)
+ }
+ if ev.TaskID != epicID {
+ t.Errorf("event should be attached to the epic ID %q, got %q", epicID, ev.TaskID)
+ }
+ var payload struct {
+ EpicID string `json:"epic_id"`
+ Name string `json:"name"`
+ StoryIDs []string `json:"story_ids"`
+ }
+ if err := json.Unmarshal(ev.Payload, &payload); err != nil {
+ t.Fatalf("unmarshal payload: %v", err)
+ }
+ if payload.EpicID != epicID || payload.Name != "Checkout revamp" {
+ t.Errorf("payload epic fields: %+v", payload)
+ }
+ if len(payload.StoryIDs) != 1 || payload.StoryIDs[0] != "story-1" {
+ t.Errorf("payload.StoryIDs should only include the resolved story, got %+v", payload.StoryIDs)
+ }
+}