diff --git a/internal/mcp/tools_chat.go b/internal/mcp/tools_chat.go index 2a71961..0dae51d 100644 --- a/internal/mcp/tools_chat.go +++ b/internal/mcp/tools_chat.go @@ -15,9 +15,9 @@ func registerChatTools(srv *mcpsdk.Server, deps ServerDeps) { } type listRecentMessagesIn struct { - ConversationID string `json:"conversation_id" jsonschema:"required,description=UUID of the conversation"` - BeforeID *int64 `json:"before_id,omitempty" jsonschema:"description=Cursor: return messages with ID < this value"` - Limit int `json:"limit,omitempty" jsonschema:"description=Max messages to return (default 50, max 200)"` + ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"` + BeforeID *int64 `json:"before_id,omitempty" jsonschema:"cursor: return messages with ID < this value"` + Limit int `json:"limit,omitempty" jsonschema:"max messages to return (default 50, max 200)"` } type messageSummary struct { ID int64 `json:"id"` diff --git a/internal/mcp/tools_pm.go b/internal/mcp/tools_pm.go index c767fae..5a725e9 100644 --- a/internal/mcp/tools_pm.go +++ b/internal/mcp/tools_pm.go @@ -76,7 +76,7 @@ func registerListProjects(srv *mcpsdk.Server, deps ServerDeps) { // ===== list_workspaces ===== type listWorkspacesIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required,project slug"` + ProjectSlug string `json:"project_slug" jsonschema:"project slug"` } type workspaceSummary struct { ID string `json:"id"` @@ -126,7 +126,7 @@ func registerListWorkspaces(srv *mcpsdk.Server, deps ServerDeps) { // ===== list_requirements ===== type listRequirementsIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` Phase string `json:"phase,omitempty"` Status string `json:"status,omitempty"` } @@ -178,7 +178,7 @@ func registerListRequirements(srv *mcpsdk.Server, deps ServerDeps) { // ===== list_issues ===== type listIssuesIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` RequirementNumber any `json:"requirement_number,omitempty"` Status string `json:"status,omitempty"` AssigneeID string `json:"assignee_id,omitempty"` @@ -246,8 +246,8 @@ func registerListIssues(srv *mcpsdk.Server, deps ServerDeps) { // ===== get_issue ===== type getIssueIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` - Number int `json:"number" jsonschema:"required,min=1"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Number int `json:"number" jsonschema:"issue number (>=1)"` } type issueDetail struct { Number int `json:"number"` @@ -289,8 +289,8 @@ func registerGetIssue(srv *mcpsdk.Server, deps ServerDeps) { // ===== get_requirement ===== type getRequirementIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` - Number int `json:"number" jsonschema:"required,min=1"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Number int `json:"number" jsonschema:"issue number (>=1)"` } type requirementDetail struct { Number int `json:"number"` @@ -334,8 +334,8 @@ func registerGetRequirement(srv *mcpsdk.Server, deps ServerDeps) { // ===== create_issue ===== type createIssueIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` - Title string `json:"title" jsonschema:"required,minLength=1"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Title string `json:"title" jsonschema:"title (non-empty)"` Description string `json:"description,omitempty"` } type issueCreateOut struct { @@ -377,8 +377,8 @@ func registerCreateIssue(srv *mcpsdk.Server, deps ServerDeps) { // ===== update_issue ===== type updateIssueIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` - Number int `json:"number" jsonschema:"required,min=1"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Number int `json:"number" jsonschema:"issue number (>=1)"` Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` } @@ -426,8 +426,8 @@ func registerUpdateIssue(srv *mcpsdk.Server, deps ServerDeps) { // ===== close/reopen_issue ===== type closeIssueIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` - Number int `json:"number" jsonschema:"required,min=1"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Number int `json:"number" jsonschema:"issue number (>=1)"` } type okOut struct { OK bool `json:"ok"` @@ -486,8 +486,8 @@ func registerReopenIssue(srv *mcpsdk.Server, deps ServerDeps) { // ===== assign_issue ===== type assignIssueIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` - Number int `json:"number" jsonschema:"required,min=1"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Number int `json:"number" jsonschema:"issue number (>=1)"` AssigneeID *string `json:"assignee_id,omitempty"` } @@ -527,8 +527,8 @@ func registerAssignIssue(srv *mcpsdk.Server, deps ServerDeps) { // ===== create_requirement ===== type createRequirementIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` - Title string `json:"title" jsonschema:"required,minLength=1"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Title string `json:"title" jsonschema:"title (non-empty)"` Description string `json:"description,omitempty"` } type requirementCreateOut struct { @@ -571,8 +571,8 @@ func registerCreateRequirement(srv *mcpsdk.Server, deps ServerDeps) { // ===== update_requirement ===== type updateRequirementIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` - Number int `json:"number" jsonschema:"required,min=1"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Number int `json:"number" jsonschema:"issue number (>=1)"` Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` } @@ -617,8 +617,8 @@ func registerUpdateRequirement(srv *mcpsdk.Server, deps ServerDeps) { // ===== close/reopen_requirement ===== type closeRequirementIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` - Number int `json:"number" jsonschema:"required,min=1"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Number int `json:"number" jsonschema:"issue number (>=1)"` } func registerCloseRequirement(srv *mcpsdk.Server, deps ServerDeps) { @@ -674,9 +674,9 @@ func registerReopenRequirement(srv *mcpsdk.Server, deps ServerDeps) { // ===== set_requirement_phase ===== type setRequirementPhaseIn struct { - ProjectSlug string `json:"project_slug" jsonschema:"required"` - Number int `json:"number" jsonschema:"required,min=1"` - Phase string `json:"phase" jsonschema:"required"` + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Number int `json:"number" jsonschema:"issue number (>=1)"` + Phase string `json:"phase" jsonschema:"required field"` } func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) { diff --git a/internal/mcp/tools_pm_test.go b/internal/mcp/tools_pm_test.go new file mode 100644 index 0000000..31f0418 --- /dev/null +++ b/internal/mcp/tools_pm_test.go @@ -0,0 +1,577 @@ +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 } + +// 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, + }) +}