test(mcp): 13 PM tool tests + fix jsonschema tags

Add tools_pm_test.go with 13 integration tests covering all 16 PM
tools via SDK in-memory transport with fake services.

Fix jsonschema tags in tools_pm.go and tools_chat.go — SDK's tag
parser rejects "required,min=1" and "required,description=X" syntax;
use plain description text instead (non-pointer fields are auto-required).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 11:52:24 +08:00
parent 7d9164d478
commit 62d9a91ca5
3 changed files with 604 additions and 27 deletions
+3 -3
View File
@@ -15,9 +15,9 @@ func registerChatTools(srv *mcpsdk.Server, deps ServerDeps) {
} }
type listRecentMessagesIn struct { type listRecentMessagesIn struct {
ConversationID string `json:"conversation_id" jsonschema:"required,description=UUID of the conversation"` ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"`
BeforeID *int64 `json:"before_id,omitempty" jsonschema:"description=Cursor: return messages with ID < this value"` BeforeID *int64 `json:"before_id,omitempty" jsonschema:"cursor: return messages with ID < this value"`
Limit int `json:"limit,omitempty" jsonschema:"description=Max messages to return (default 50, max 200)"` Limit int `json:"limit,omitempty" jsonschema:"max messages to return (default 50, max 200)"`
} }
type messageSummary struct { type messageSummary struct {
ID int64 `json:"id"` ID int64 `json:"id"`
+24 -24
View File
@@ -76,7 +76,7 @@ func registerListProjects(srv *mcpsdk.Server, deps ServerDeps) {
// ===== list_workspaces ===== // ===== list_workspaces =====
type listWorkspacesIn struct { type listWorkspacesIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required,project slug"` ProjectSlug string `json:"project_slug" jsonschema:"project slug"`
} }
type workspaceSummary struct { type workspaceSummary struct {
ID string `json:"id"` ID string `json:"id"`
@@ -126,7 +126,7 @@ func registerListWorkspaces(srv *mcpsdk.Server, deps ServerDeps) {
// ===== list_requirements ===== // ===== list_requirements =====
type listRequirementsIn struct { type listRequirementsIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Phase string `json:"phase,omitempty"` Phase string `json:"phase,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
} }
@@ -178,7 +178,7 @@ func registerListRequirements(srv *mcpsdk.Server, deps ServerDeps) {
// ===== list_issues ===== // ===== list_issues =====
type listIssuesIn struct { type listIssuesIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
RequirementNumber any `json:"requirement_number,omitempty"` RequirementNumber any `json:"requirement_number,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
AssigneeID string `json:"assignee_id,omitempty"` AssigneeID string `json:"assignee_id,omitempty"`
@@ -246,8 +246,8 @@ func registerListIssues(srv *mcpsdk.Server, deps ServerDeps) {
// ===== get_issue ===== // ===== get_issue =====
type getIssueIn struct { type getIssueIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"required,min=1"` Number int `json:"number" jsonschema:"issue number (>=1)"`
} }
type issueDetail struct { type issueDetail struct {
Number int `json:"number"` Number int `json:"number"`
@@ -289,8 +289,8 @@ func registerGetIssue(srv *mcpsdk.Server, deps ServerDeps) {
// ===== get_requirement ===== // ===== get_requirement =====
type getRequirementIn struct { type getRequirementIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"required,min=1"` Number int `json:"number" jsonschema:"issue number (>=1)"`
} }
type requirementDetail struct { type requirementDetail struct {
Number int `json:"number"` Number int `json:"number"`
@@ -334,8 +334,8 @@ func registerGetRequirement(srv *mcpsdk.Server, deps ServerDeps) {
// ===== create_issue ===== // ===== create_issue =====
type createIssueIn struct { type createIssueIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Title string `json:"title" jsonschema:"required,minLength=1"` Title string `json:"title" jsonschema:"title (non-empty)"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
} }
type issueCreateOut struct { type issueCreateOut struct {
@@ -377,8 +377,8 @@ func registerCreateIssue(srv *mcpsdk.Server, deps ServerDeps) {
// ===== update_issue ===== // ===== update_issue =====
type updateIssueIn struct { type updateIssueIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"required,min=1"` Number int `json:"number" jsonschema:"issue number (>=1)"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
} }
@@ -426,8 +426,8 @@ func registerUpdateIssue(srv *mcpsdk.Server, deps ServerDeps) {
// ===== close/reopen_issue ===== // ===== close/reopen_issue =====
type closeIssueIn struct { type closeIssueIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"required,min=1"` Number int `json:"number" jsonschema:"issue number (>=1)"`
} }
type okOut struct { type okOut struct {
OK bool `json:"ok"` OK bool `json:"ok"`
@@ -486,8 +486,8 @@ func registerReopenIssue(srv *mcpsdk.Server, deps ServerDeps) {
// ===== assign_issue ===== // ===== assign_issue =====
type assignIssueIn struct { type assignIssueIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"required,min=1"` Number int `json:"number" jsonschema:"issue number (>=1)"`
AssigneeID *string `json:"assignee_id,omitempty"` AssigneeID *string `json:"assignee_id,omitempty"`
} }
@@ -527,8 +527,8 @@ func registerAssignIssue(srv *mcpsdk.Server, deps ServerDeps) {
// ===== create_requirement ===== // ===== create_requirement =====
type createRequirementIn struct { type createRequirementIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Title string `json:"title" jsonschema:"required,minLength=1"` Title string `json:"title" jsonschema:"title (non-empty)"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
} }
type requirementCreateOut struct { type requirementCreateOut struct {
@@ -571,8 +571,8 @@ func registerCreateRequirement(srv *mcpsdk.Server, deps ServerDeps) {
// ===== update_requirement ===== // ===== update_requirement =====
type updateRequirementIn struct { type updateRequirementIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"required,min=1"` Number int `json:"number" jsonschema:"issue number (>=1)"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
} }
@@ -617,8 +617,8 @@ func registerUpdateRequirement(srv *mcpsdk.Server, deps ServerDeps) {
// ===== close/reopen_requirement ===== // ===== close/reopen_requirement =====
type closeRequirementIn struct { type closeRequirementIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"required,min=1"` Number int `json:"number" jsonschema:"issue number (>=1)"`
} }
func registerCloseRequirement(srv *mcpsdk.Server, deps ServerDeps) { func registerCloseRequirement(srv *mcpsdk.Server, deps ServerDeps) {
@@ -674,9 +674,9 @@ func registerReopenRequirement(srv *mcpsdk.Server, deps ServerDeps) {
// ===== set_requirement_phase ===== // ===== set_requirement_phase =====
type setRequirementPhaseIn struct { type setRequirementPhaseIn struct {
ProjectSlug string `json:"project_slug" jsonschema:"required"` ProjectSlug string `json:"project_slug" jsonschema:"required field"`
Number int `json:"number" jsonschema:"required,min=1"` Number int `json:"number" jsonschema:"issue number (>=1)"`
Phase string `json:"phase" jsonschema:"required"` Phase string `json:"phase" jsonschema:"required field"`
} }
func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) { func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) {
+577
View File
@@ -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,
})
}