Files
agentic-coding-workflow/internal/mcp/tools_pm_test.go
T
2026-06-09 21:22:28 +08:00

603 lines
19 KiB
Go

package mcp
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/project"
"github.com/yan1h/agent-coding-workflow/internal/user"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
)
// ===== fakes =====
// fakeUserSvc implements user.Service for tests.
type fakeUserSvc struct{ users map[uuid.UUID]*user.User }
func (s *fakeUserSvc) Login(_ context.Context, _, _, _ string) (string, *user.User, error) {
return "", nil, nil
}
func (s *fakeUserSvc) Logout(_ context.Context, _ string) error { return nil }
func (s *fakeUserSvc) ResolveSession(_ context.Context, _ string) (uuid.UUID, bool, error) {
return uuid.Nil, false, nil
}
func (s *fakeUserSvc) Get(_ context.Context, id uuid.UUID) (*user.User, error) {
if u, ok := s.users[id]; ok {
return u, nil
}
return nil, errs.New(errs.CodeNotFound, "user")
}
func (s *fakeUserSvc) Bootstrap(_ context.Context, _, _ string) (*user.User, error) {
return nil, nil
}
func (s *fakeUserSvc) ListAdmins(_ context.Context) ([]*user.User, error) { return nil, nil }
func (s *fakeUserSvc) ListUsers(_ context.Context) ([]*user.User, error) { return nil, nil }
func (s *fakeUserSvc) CreateUser(_ context.Context, _ user.CreateUserInput) (*user.User, error) {
return nil, nil
}
func (s *fakeUserSvc) UpdateProfile(_ context.Context, _ uuid.UUID, _ string) (*user.User, error) {
return nil, nil
}
func (s *fakeUserSvc) SetAdmin(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) {
return nil, nil
}
func (s *fakeUserSvc) SetEnabled(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) {
return nil, nil
}
func (s *fakeUserSvc) AdminResetPassword(_ context.Context, _ uuid.UUID) (string, error) {
return "", nil
}
func (s *fakeUserSvc) ChangePassword(_ context.Context, _ uuid.UUID, _, _ string) error { return nil }
func (s *fakeUserSvc) DeleteUser(_ context.Context, _, _ uuid.UUID) error { return nil }
// fakeProjectSvc implements project.ProjectService.
type fakeProjectSvc struct {
projects []*project.Project
slugMap map[string]*project.Project
idMap map[uuid.UUID]*project.Project
}
func newFakeProjectSvc(ps ...*project.Project) *fakeProjectSvc {
f := &fakeProjectSvc{slugMap: map[string]*project.Project{}, idMap: map[uuid.UUID]*project.Project{}}
for _, p := range ps {
f.projects = append(f.projects, p)
f.slugMap[p.Slug] = p
f.idMap[p.ID] = p
}
return f
}
func (f *fakeProjectSvc) Create(_ context.Context, _ project.Caller, _ project.CreateProjectInput) (*project.Project, error) {
panic("not implemented")
}
func (f *fakeProjectSvc) Get(_ context.Context, _ project.Caller, slug string) (*project.Project, error) {
if p, ok := f.slugMap[slug]; ok {
return p, nil
}
return nil, errs.New(errs.CodeNotFound, "project")
}
func (f *fakeProjectSvc) GetByID(_ context.Context, _ project.Caller, id uuid.UUID) (*project.Project, error) {
if p, ok := f.idMap[id]; ok {
return p, nil
}
return nil, errs.New(errs.CodeNotFound, "project")
}
func (f *fakeProjectSvc) List(_ context.Context, _ project.Caller, _ bool) ([]*project.Project, error) {
return f.projects, nil
}
func (f *fakeProjectSvc) Update(_ context.Context, _ project.Caller, _ string, _ project.UpdateProjectInput) (*project.Project, error) {
panic("not implemented")
}
func (f *fakeProjectSvc) Archive(_ context.Context, _ project.Caller, _ string) error {
panic("not implemented")
}
func (f *fakeProjectSvc) Unarchive(_ context.Context, _ project.Caller, _ string) error {
panic("not implemented")
}
// fakeRequirementSvc implements project.RequirementService.
type fakeRequirementSvc struct {
items []*project.Requirement
}
func (f *fakeRequirementSvc) Create(_ context.Context, _ project.Caller, _ string, in project.CreateRequirementInput) (*project.Requirement, error) {
r := &project.Requirement{
ID: uuid.New(), Number: len(f.items) + 1,
Title: in.Title, Description: in.Description,
Phase: project.PhasePlanning, Status: project.StatusOpen,
CreatedAt: time.Now(),
}
f.items = append(f.items, r)
return r, nil
}
func (f *fakeRequirementSvc) Get(_ context.Context, _ project.Caller, slug string, number int) (*project.Requirement, error) {
for _, r := range f.items {
if r.Number == number {
return r, nil
}
}
return nil, errs.New(errs.CodeNotFound, "requirement")
}
func (f *fakeRequirementSvc) List(_ context.Context, _ project.Caller, _ string, _ project.RequirementFilter) ([]*project.Requirement, error) {
return f.items, nil
}
func (f *fakeRequirementSvc) Update(_ context.Context, _ project.Caller, slug string, num int, in project.UpdateRequirementInput) (*project.Requirement, error) {
r, err := f.Get(context.Background(), project.Caller{}, slug, num)
if err != nil {
return nil, err
}
if in.Title != nil {
r.Title = *in.Title
}
if in.Description != nil {
r.Description = *in.Description
}
return r, nil
}
func (f *fakeRequirementSvc) ChangePhase(_ context.Context, _ project.Caller, slug string, num int, to project.Phase) (*project.Requirement, error) {
r, err := f.Get(context.Background(), project.Caller{}, slug, num)
if err != nil {
return nil, err
}
r.Phase = to
return r, nil
}
func (f *fakeRequirementSvc) Close(_ context.Context, _ project.Caller, slug string, num int) error {
r, err := f.Get(context.Background(), project.Caller{}, slug, num)
if err != nil {
return err
}
r.Status = project.StatusClosed
return nil
}
func (f *fakeRequirementSvc) Reopen(_ context.Context, _ project.Caller, slug string, num int) error {
r, err := f.Get(context.Background(), project.Caller{}, slug, num)
if err != nil {
return err
}
r.Status = project.StatusOpen
return nil
}
// fakeIssueSvc implements project.IssueService.
type fakeIssueSvc struct {
items []*project.Issue
}
func (f *fakeIssueSvc) Create(_ context.Context, _ project.Caller, _ string, in project.CreateIssueInput) (*project.Issue, error) {
i := &project.Issue{
ID: uuid.New(), Number: len(f.items) + 1,
Title: in.Title, Description: in.Description,
Status: project.StatusOpen,
CreatedAt: time.Now(),
}
f.items = append(f.items, i)
return i, nil
}
func (f *fakeIssueSvc) Get(_ context.Context, _ project.Caller, slug string, number int) (*project.Issue, error) {
for _, i := range f.items {
if i.Number == number {
return i, nil
}
}
return nil, errs.New(errs.CodeNotFound, "issue")
}
func (f *fakeIssueSvc) List(_ context.Context, _ project.Caller, _ string, _ project.IssueFilter) ([]*project.Issue, error) {
return f.items, nil
}
func (f *fakeIssueSvc) Update(_ context.Context, _ project.Caller, slug string, num int, in project.UpdateIssueInput) (*project.Issue, error) {
i, err := f.Get(context.Background(), project.Caller{}, slug, num)
if err != nil {
return nil, err
}
if in.Title != nil {
i.Title = *in.Title
}
if in.Description != nil {
i.Description = *in.Description
}
return i, nil
}
func (f *fakeIssueSvc) Assign(_ context.Context, _ project.Caller, slug string, num int, assignee *uuid.UUID) (*project.Issue, error) {
i, err := f.Get(context.Background(), project.Caller{}, slug, num)
if err != nil {
return nil, err
}
i.AssigneeID = assignee
return i, nil
}
func (f *fakeIssueSvc) Close(_ context.Context, _ project.Caller, slug string, num int) error {
i, err := f.Get(context.Background(), project.Caller{}, slug, num)
if err != nil {
return err
}
i.Status = project.StatusClosed
return nil
}
func (f *fakeIssueSvc) Reopen(_ context.Context, _ project.Caller, slug string, num int) error {
i, err := f.Get(context.Background(), project.Caller{}, slug, num)
if err != nil {
return err
}
i.Status = project.StatusOpen
return nil
}
// fakeWorkspaceSvc implements workspace.WorkspaceService.
type fakeWorkspaceSvc struct {
items []*workspace.Workspace
}
func (f *fakeWorkspaceSvc) Create(_ context.Context, _ workspace.Caller, _ string, _ workspace.CreateWorkspaceInput) (*workspace.Workspace, error) {
panic("not implemented")
}
func (f *fakeWorkspaceSvc) Get(_ context.Context, _ workspace.Caller, id uuid.UUID) (*workspace.Workspace, error) {
for _, w := range f.items {
if w.ID == id {
return w, nil
}
}
return nil, errs.New(errs.CodeNotFound, "workspace")
}
func (f *fakeWorkspaceSvc) List(_ context.Context, _ workspace.Caller, _ string) ([]*workspace.Workspace, error) {
return f.items, nil
}
func (f *fakeWorkspaceSvc) Update(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) {
panic("not implemented")
}
func (f *fakeWorkspaceSvc) Delete(_ context.Context, _ workspace.Caller, _ uuid.UUID) error {
panic("not implemented")
}
func (f *fakeWorkspaceSvc) Sync(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Workspace, error) {
panic("not implemented")
}
func (f *fakeWorkspaceSvc) UpsertCredential(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpsertCredentialInput) (*workspace.Credential, error) {
panic("not implemented")
}
func (f *fakeWorkspaceSvc) GetCredentialMeta(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Credential, error) {
panic("not implemented")
}
// ===== test helpers =====
var (
testUID = uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
testPID = uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
testWSID = uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc")
)
// testDeps assembles ServerDeps with fake services.
func testDeps() ServerDeps {
proj := &project.Project{
ID: testPID, Slug: "test-project", Name: "Test Project",
Visibility: project.VisibilityPrivate, OwnerID: testUID,
CreatedAt: time.Now(),
}
uSvc := &fakeUserSvc{users: map[uuid.UUID]*user.User{
testUID: {ID: testUID, IsAdmin: true},
}}
return ServerDeps{
Caller: NewCallerResolver(uSvc),
Projects: newFakeProjectSvc(proj),
Reqs: &fakeRequirementSvc{},
Issues: &fakeIssueSvc{},
Workspaces: &fakeWorkspaceSvc{},
}
}
// callTool wires up an MCP server+client via in-memory transport,
// injects an AuthSession into the server's receiving middleware,
// and calls a tool by name.
// Each call creates a fresh server to avoid middleware accumulation.
func callTool(ctx context.Context, deps ServerDeps, toolName string, args map[string]any) (*mcpsdk.CallToolResult, error) {
// Extract AuthSession from caller's ctx and inject into server-side via middleware.
sess, _ := FromContext(ctx)
srv := NewMCPServer(deps)
srv.AddReceivingMiddleware(func(next mcpsdk.MethodHandler) mcpsdk.MethodHandler {
return func(ctx context.Context, method string, req mcpsdk.Request) (mcpsdk.Result, error) {
if sess != nil {
ctx = context.WithValue(ctx, AuthContextKey, sess)
}
return next(ctx, method, req)
}
})
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test-client", Version: "0.0.1"}, nil)
t1, t2 := mcpsdk.NewInMemoryTransports()
if _, err := srv.Connect(ctx, t1, nil); err != nil {
return nil, err
}
session, err := client.Connect(ctx, t2, nil)
if err != nil {
return nil, err
}
defer session.Close()
return session.CallTool(ctx, &mcpsdk.CallToolParams{
Name: toolName,
Arguments: args,
})
}
// unmarshalStructured extracts the StructuredContent from a CallToolResult.
func unmarshalStructured(t *testing.T, result *mcpsdk.CallToolResult, out any) {
t.Helper()
data, err := json.Marshal(result.StructuredContent)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(data, out))
}
// ===== tests =====
func TestListProjects(t *testing.T) {
deps := testDeps()
result, err := callTool(ctxWithAuth(Scope{}), deps, "list_projects", map[string]any{})
require.NoError(t, err)
assert.False(t, result.IsError)
var out listProjectsOut
unmarshalStructured(t, result, &out)
require.Len(t, out.Projects, 1)
assert.Equal(t, "test-project", out.Projects[0].Slug)
}
func TestListWorkspaces(t *testing.T) {
ws := &workspace.Workspace{
ID: testWSID, Slug: "ws-1", Name: "Workspace 1",
DefaultBranch: "main", SyncStatus: workspace.SyncStatusIdle,
}
deps := testDeps()
deps.Workspaces = &fakeWorkspaceSvc{items: []*workspace.Workspace{ws}}
result, err := callTool(ctxWithAuth(Scope{}), deps, "list_workspaces", map[string]any{
"project_slug": "test-project",
})
require.NoError(t, err)
assert.False(t, result.IsError)
var out listWorkspacesOut
unmarshalStructured(t, result, &out)
require.Len(t, out.Workspaces, 1)
assert.Equal(t, "ws-1", out.Workspaces[0].Slug)
}
func TestCreateAndGetIssue(t *testing.T) {
deps := testDeps()
result, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{
"project_slug": "test-project",
"title": "Bug report",
"description": "Something broke",
})
require.NoError(t, err)
assert.False(t, result.IsError)
var createOut issueCreateOut
unmarshalStructured(t, result, &createOut)
assert.True(t, createOut.OK)
assert.Equal(t, "Bug report", createOut.Issue.Title)
// Get
result, err = callTool(ctxWithAuth(Scope{}), deps, "get_issue", map[string]any{
"project_slug": "test-project",
"number": float64(1),
})
require.NoError(t, err)
var getOut issueDetail
unmarshalStructured(t, result, &getOut)
assert.Equal(t, "Bug report", getOut.Title)
}
func TestUpdateIssue(t *testing.T) {
deps := testDeps()
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{
"project_slug": "test-project", "title": "Original",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "update_issue", map[string]any{
"project_slug": "test-project", "number": float64(1), "title": "Updated",
})
require.NoError(t, err)
var out issueUpdateOut
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
assert.Equal(t, "Updated", out.Issue.Title)
}
func TestCloseReopenIssue(t *testing.T) {
deps := testDeps()
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{
"project_slug": "test-project", "title": "To close",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "close_issue", map[string]any{
"project_slug": "test-project", "number": float64(1),
})
require.NoError(t, err)
var out okOut
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
result, err = callTool(ctxWithAuth(Scope{}), deps, "reopen_issue", map[string]any{
"project_slug": "test-project", "number": float64(1),
})
require.NoError(t, err)
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
}
func TestAssignIssue(t *testing.T) {
deps := testDeps()
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{
"project_slug": "test-project", "title": "Assign me",
})
require.NoError(t, err)
assigneeID := uuid.New().String()
result, err := callTool(ctxWithAuth(Scope{}), deps, "assign_issue", map[string]any{
"project_slug": "test-project", "number": float64(1), "assignee_id": assigneeID,
})
require.NoError(t, err)
var out okOut
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
}
func TestCreateAndGetRequirement(t *testing.T) {
deps := testDeps()
result, err := callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{
"project_slug": "test-project", "title": "Auth module", "description": "Need auth",
})
require.NoError(t, err)
var createOut requirementCreateOut
unmarshalStructured(t, result, &createOut)
assert.True(t, createOut.OK)
assert.Equal(t, "Auth module", createOut.Requirement.Title)
assert.Equal(t, "planning", createOut.Requirement.Phase)
result, err = callTool(ctxWithAuth(Scope{}), deps, "get_requirement", map[string]any{
"project_slug": "test-project", "number": float64(1),
})
require.NoError(t, err)
var getOut requirementDetail
unmarshalStructured(t, result, &getOut)
assert.Equal(t, "Auth module", getOut.Title)
}
func TestUpdateRequirement(t *testing.T) {
deps := testDeps()
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{
"project_slug": "test-project", "title": "V1",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "update_requirement", map[string]any{
"project_slug": "test-project", "number": float64(1), "title": "V2",
})
require.NoError(t, err)
var out requirementCreateOut
unmarshalStructured(t, result, &out)
assert.Equal(t, "V2", out.Requirement.Title)
}
func TestCloseReopenRequirement(t *testing.T) {
deps := testDeps()
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{
"project_slug": "test-project", "title": "Close me",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "close_requirement", map[string]any{
"project_slug": "test-project", "number": float64(1),
})
require.NoError(t, err)
var out okOut
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
result, err = callTool(ctxWithAuth(Scope{}), deps, "reopen_requirement", map[string]any{
"project_slug": "test-project", "number": float64(1),
})
require.NoError(t, err)
unmarshalStructured(t, result, &out)
assert.True(t, out.OK)
}
func TestSetRequirementPhase(t *testing.T) {
deps := testDeps()
_, err := callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{
"project_slug": "test-project", "title": "Phase test",
})
require.NoError(t, err)
result, err := callTool(ctxWithAuth(Scope{}), deps, "set_requirement_phase", map[string]any{
"project_slug": "test-project", "number": float64(1), "phase": "auditing",
})
require.NoError(t, err)
var out requirementCreateOut
unmarshalStructured(t, result, &out)
assert.Equal(t, "auditing", out.Requirement.Phase)
}
func TestListRequirements(t *testing.T) {
deps := testDeps()
_, _ = callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{
"project_slug": "test-project", "title": "R1",
})
_, _ = callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{
"project_slug": "test-project", "title": "R2",
})
result, err := callTool(ctxWithAuth(Scope{}), deps, "list_requirements", map[string]any{
"project_slug": "test-project",
})
require.NoError(t, err)
var out listRequirementsOut
unmarshalStructured(t, result, &out)
assert.Len(t, out.Requirements, 2)
}
func TestListIssues(t *testing.T) {
deps := testDeps()
_, _ = callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{
"project_slug": "test-project", "title": "I1",
})
_, _ = callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{
"project_slug": "test-project", "title": "I2",
})
result, err := callTool(ctxWithAuth(Scope{}), deps, "list_issues", map[string]any{
"project_slug": "test-project",
})
require.NoError(t, err)
var out listIssuesOut
unmarshalStructured(t, result, &out)
assert.Len(t, out.Issues, 2)
}
func TestToolScopeCheck(t *testing.T) {
deps := testDeps()
// Restricted scope: only list_projects
restricted := Scope{Tools: []string{"list_projects"}}
result, err := callTool(ctxWithAuth(restricted), deps, "list_projects", map[string]any{})
require.NoError(t, err)
assert.False(t, result.IsError)
result, err = callTool(ctxWithAuth(restricted), deps, "create_issue", map[string]any{
"project_slug": "test-project", "title": "Blocked",
})
require.NoError(t, err)
assert.True(t, result.IsError)
}
// ctxWithAuth returns a context with an AuthSession for the test admin user.
func ctxWithAuth(scope Scope) context.Context {
return context.WithValue(context.Background(), AuthContextKey, &AuthSession{
UserID: testUID.String(),
TokenID: uuid.NewString(),
Scope: scope,
})
}