package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/story" "github.com/thepeterstone/claudomator/internal/task" ) // chatbotClient starts the api server with the given token and returns a // connected MCP client session pointed at /chatbot/mcp. func chatbotClient(t *testing.T, srv *Server, token string) *mcp.ClientSession { t.Helper() ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil) cs, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{ Endpoint: ts.URL + "/chatbot/mcp", HTTPClient: &http.Client{Transport: tokenRT{token: token, base: http.DefaultTransport}}, }, nil) if err != nil { t.Fatalf("connect to /chatbot/mcp: %v", err) } t.Cleanup(func() { cs.Close() }) return cs } func callText(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) string { t.Helper() res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) if err != nil { t.Fatalf("CallTool %s transport error: %v", name, err) } if res.IsError { t.Fatalf("CallTool %s returned tool error: %s", name, toolText(res)) } return toolText(res) } func toolText(res *mcp.CallToolResult) string { if len(res.Content) == 0 { return "" } if tc, ok := res.Content[0].(*mcp.TextContent); ok { return tc.Text } return "" } func TestChatbotMCP_RequiresToken(t *testing.T) { srv, _ := testServer(t) srv.SetAPIToken("s3cret") ts := httptest.NewServer(srv.Handler()) defer ts.Close() client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil) _, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{ Endpoint: ts.URL + "/chatbot/mcp", HTTPClient: &http.Client{Transport: tokenRT{token: "wrong", base: http.DefaultTransport}}, }, nil) if err == nil { t.Fatal("expected connect with wrong token to be rejected") } } func TestChatbotMCP_SubmitTask_CreatesAndQueues(t *testing.T) { srv, store := testServer(t) srv.SetAPIToken("s3cret") cs := chatbotClient(t, srv, "s3cret") out := callText(t, cs, "submit_task", map[string]any{ "instructions": "fix the flaky test in package foo", "repository_url": "https://github.com/user/repo", }) var ct compactTask if err := json.Unmarshal([]byte(out), &ct); err != nil { t.Fatalf("unmarshal submit_task result %q: %v", out, err) } if ct.ID == "" { t.Fatal("submit_task returned empty task ID") } if ct.Name != "fix the flaky test in package foo" { t.Errorf("name defaulted wrong: %q", ct.Name) } if _, err := store.GetTask(ct.ID); err != nil { t.Fatalf("submitted task not persisted: %v", err) } } func TestChatbotMCP_SubmitTask_RequiresRepo(t *testing.T) { srv, _ := testServer(t) srv.SetAPIToken("s3cret") cs := chatbotClient(t, srv, "s3cret") res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ Name: "submit_task", Arguments: map[string]any{"instructions": "do a thing"}, }) if err != nil { t.Fatalf("transport error: %v", err) } if !res.IsError { t.Fatal("expected submit_task without repository_url to be a tool error") } } func TestChatbotMCP_GetAndListTasks(t *testing.T) { srv, store := testServer(t) srv.SetAPIToken("s3cret") createTaskWithState(t, store, "ct-ready", task.StateReady) cs := chatbotClient(t, srv, "s3cret") got := callText(t, cs, "get_task", map[string]any{"task_id": "ct-ready"}) var view map[string]any if err := json.Unmarshal([]byte(got), &view); err != nil { t.Fatalf("unmarshal get_task: %v", err) } if view["id"] != "ct-ready" { t.Errorf("get_task id: got %v", view["id"]) } listed := callText(t, cs, "list_tasks", map[string]any{"state": "READY"}) var tasks []compactTask if err := json.Unmarshal([]byte(listed), &tasks); err != nil { t.Fatalf("unmarshal list_tasks: %v", err) } if len(tasks) != 1 || tasks[0].ID != "ct-ready" { t.Errorf("list_tasks returned %+v", tasks) } } func TestChatbotMCP_GetTask_NotFound(t *testing.T) { srv, _ := testServer(t) srv.SetAPIToken("s3cret") cs := chatbotClient(t, srv, "s3cret") res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ Name: "get_task", Arguments: map[string]any{"task_id": "nope"}, }) if err != nil { t.Fatalf("transport error: %v", err) } if !res.IsError { t.Fatal("expected get_task on missing task to be a tool error") } } func TestChatbotMCP_GetEvents(t *testing.T) { srv, store := testServer(t) srv.SetAPIToken("s3cret") createTaskWithState(t, store, "ct-events", task.StateReady) cs := chatbotClient(t, srv, "s3cret") out := callText(t, cs, "get_events", map[string]any{"task_id": "ct-events"}) var events []map[string]any if err := json.Unmarshal([]byte(out), &events); err != nil { t.Fatalf("unmarshal get_events %q: %v", out, err) } // State walk to READY emits state_change events. if len(events) == 0 { t.Error("expected at least one event for a task that walked to READY") } } func TestChatbotMCP_AcceptTask(t *testing.T) { srv, store := testServer(t) srv.SetAPIToken("s3cret") createTaskWithState(t, store, "ct-accept", task.StateReady) cs := chatbotClient(t, srv, "s3cret") callText(t, cs, "accept_task", map[string]any{"task_id": "ct-accept"}) got, _ := store.GetTask("ct-accept") if got.State != task.StateCompleted { t.Errorf("after accept_task: want COMPLETED, got %s", got.State) } } func TestChatbotMCP_RejectTask(t *testing.T) { srv, store := testServer(t) srv.SetAPIToken("s3cret") createTaskWithState(t, store, "ct-reject", task.StateReady) cs := chatbotClient(t, srv, "s3cret") callText(t, cs, "reject_task", map[string]any{"task_id": "ct-reject", "comment": "needs work"}) got, _ := store.GetTask("ct-reject") if got.State != task.StatePending { t.Errorf("after reject_task: want PENDING, got %s", got.State) } if got.RejectionComment != "needs work" { t.Errorf("rejection comment: got %q", got.RejectionComment) } } func TestChatbotMCP_CancelTask(t *testing.T) { srv, store := testServer(t) srv.SetAPIToken("s3cret") createTaskWithState(t, store, "ct-cancel", task.StateQueued) cs := chatbotClient(t, srv, "s3cret") callText(t, cs, "cancel_task", map[string]any{"task_id": "ct-cancel"}) got, _ := store.GetTask("ct-cancel") if got.State != task.StateCancelled { t.Errorf("after cancel_task: want CANCELLED, got %s", got.State) } } func TestChatbotMCP_AnswerQuestion(t *testing.T) { srv, store := testServer(t) srv.SetAPIToken("s3cret") createTaskWithState(t, store, "ct-answer", task.StateBlocked) if err := store.CreateExecution(&storage.Execution{ ID: "ct-answer-exec", TaskID: "ct-answer", SessionID: "550e8400-e29b-41d4-a716-446655440099", Status: "BLOCKED", }); err != nil { t.Fatalf("create execution: %v", err) } cs := chatbotClient(t, srv, "s3cret") callText(t, cs, "answer_question", map[string]any{"task_id": "ct-answer", "answer": "use main"}) got, _ := store.GetTask("ct-answer") if got.State != task.StateQueued && got.State != task.StateRunning && got.State != task.StateReady { t.Errorf("after answer_question: want QUEUED/RUNNING/READY, got %s", got.State) } } func TestChatbotMCP_CreateStory_AndGetStory(t *testing.T) { srv, _ := testServer(t) srv.SetAPIToken("s3cret") cs := chatbotClient(t, srv, "s3cret") out := callText(t, cs, "create_story", map[string]any{ "name": "Add login page", "spec": "Users should be able to log in with email + password", "acceptance_criteria": []string{"User can log in", "Invalid credentials show an error"}, }) var created story.Story if err := json.Unmarshal([]byte(out), &created); err != nil { t.Fatalf("unmarshal create_story result %q: %v", out, err) } if created.ID == "" { t.Fatal("create_story returned empty story ID") } if created.Status != "DISCOVERY" { t.Errorf("Status: want DISCOVERY, got %q", created.Status) } out = callText(t, cs, "get_story", map[string]any{"story_id": created.ID}) var got story.Story if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("unmarshal get_story result %q: %v", out, err) } if got.Name != "Add login page" { t.Errorf("Name: want %q, got %q", "Add login page", got.Name) } if len(got.AcceptanceCriteria) != 2 { t.Errorf("AcceptanceCriteria: want 2 items, got %+v", got.AcceptanceCriteria) } } func TestChatbotMCP_CreateStory_MissingName_ReturnsToolError(t *testing.T) { srv, _ := testServer(t) srv.SetAPIToken("s3cret") cs := chatbotClient(t, srv, "s3cret") res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "create_story", Arguments: map[string]any{}}) if err != nil { t.Fatalf("CallTool transport error: %v", err) } if !res.IsError { t.Fatal("expected create_story with no name to return a tool error") } } func TestChatbotMCP_GetStory_NotFound_ReturnsToolError(t *testing.T) { srv, _ := testServer(t) srv.SetAPIToken("s3cret") cs := chatbotClient(t, srv, "s3cret") res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_story", Arguments: map[string]any{"story_id": "nope"}}) if err != nil { t.Fatalf("CallTool transport error: %v", err) } if !res.IsError { t.Fatal("expected get_story with unknown ID to return a tool error") } } func TestChatbotMCP_ListStories_FiltersByStatus(t *testing.T) { srv, store := testServer(t) srv.SetAPIToken("s3cret") cs := chatbotClient(t, srv, "s3cret") if err := store.CreateStory(&story.Story{ID: "s1", Name: "One", Status: "DISCOVERY", AcceptanceCriteria: []string{}}); err != nil { t.Fatalf("seed story 1: %v", err) } if err := store.CreateStory(&story.Story{ID: "s2", Name: "Two", Status: "DONE", AcceptanceCriteria: []string{}}); err != nil { t.Fatalf("seed story 2: %v", err) } out := callText(t, cs, "list_stories", map[string]any{"status": "DONE"}) var got []story.Story if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("unmarshal list_stories result %q: %v", out, err) } if len(got) != 1 || got[0].ID != "s2" { t.Errorf("expected only story s2, got %+v", got) } } func TestChatbotMCP_AcceptStory_SucceedsFromReviewReady(t *testing.T) { srv, store := testServer(t) srv.SetAPIToken("s3cret") cs := chatbotClient(t, srv, "s3cret") if err := store.CreateStory(&story.Story{ID: "s1", Name: "One", Status: "REVIEW_READY", AcceptanceCriteria: []string{}}); err != nil { t.Fatalf("seed story: %v", err) } callText(t, cs, "accept_story", map[string]any{"story_id": "s1"}) got, err := store.GetStory("s1") if err != nil { t.Fatalf("get story after accept: %v", err) } if got.Status != "DONE" { t.Errorf("Status: want DONE, got %q", got.Status) } events, err := store.ListEvents("s1", 0) if err != nil { t.Fatalf("list events: %v", err) } found := false for _, e := range events { if e.Kind == event.KindHumanAccepted && e.Actor == event.ActorChatbot { found = true } } if !found { t.Error("expected a KindHumanAccepted event with ActorChatbot") } } func TestChatbotMCP_AcceptStory_FailsFromOtherStatus(t *testing.T) { srv, store := testServer(t) srv.SetAPIToken("s3cret") cs := chatbotClient(t, srv, "s3cret") if err := store.CreateStory(&story.Story{ID: "s1", Name: "One", Status: "IN_PROGRESS", AcceptanceCriteria: []string{}}); err != nil { t.Fatalf("seed story: %v", err) } res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "accept_story", Arguments: map[string]any{"story_id": "s1"}}) if err != nil { t.Fatalf("CallTool transport error: %v", err) } if !res.IsError { t.Fatal("expected accept_story from IN_PROGRESS to return a tool error") } }