You've already forked agentic-coding-workflow
838 lines
26 KiB
Go
838 lines
26 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, c project.Caller, in project.CreateProjectInput) (*project.Project, error) {
|
|
v := in.Visibility
|
|
if v == "" {
|
|
v = project.VisibilityPrivate
|
|
}
|
|
p := &project.Project{
|
|
ID: uuid.New(), Slug: in.Slug, Name: in.Name, Description: in.Description,
|
|
Visibility: v, OwnerID: c.UserID, CreatedAt: time.Now(),
|
|
}
|
|
f.projects = append(f.projects, p)
|
|
f.slugMap[p.Slug] = p
|
|
f.idMap[p.ID] = p
|
|
return p, nil
|
|
}
|
|
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(ctx context.Context, c project.Caller, slug string, in project.UpdateProjectInput) (*project.Project, error) {
|
|
p, err := f.Get(ctx, c, slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if in.Name != nil {
|
|
p.Name = *in.Name
|
|
}
|
|
if in.Description != nil {
|
|
p.Description = *in.Description
|
|
}
|
|
if in.Visibility != nil {
|
|
p.Visibility = *in.Visibility
|
|
}
|
|
return p, nil
|
|
}
|
|
func (f *fakeProjectSvc) Archive(ctx context.Context, c project.Caller, slug string) error {
|
|
p, err := f.Get(ctx, c, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
p.ArchivedAt = &now
|
|
return nil
|
|
}
|
|
func (f *fakeProjectSvc) Unarchive(ctx context.Context, c project.Caller, slug string) error {
|
|
p, err := f.Get(ctx, c, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.ArchivedAt = nil
|
|
return nil
|
|
}
|
|
func (f *fakeProjectSvc) ListMembers(_ context.Context, _ project.Caller, _ string) ([]*project.Member, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeProjectSvc) AddMember(_ context.Context, _ project.Caller, _ string, _ project.AddMemberInput) (*project.Member, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeProjectSvc) RemoveMember(_ context.Context, _ project.Caller, _ string, _ uuid.UUID) error {
|
|
return nil
|
|
}
|
|
func (f *fakeProjectSvc) MemberRole(_ context.Context, _, _ uuid.UUID) (project.Role, error) {
|
|
return project.RoleNone, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// fakeArtifactSvc implements project.ArtifactService.
|
|
type fakeArtifactSvc struct {
|
|
items []*project.Artifact
|
|
}
|
|
|
|
func (f *fakeArtifactSvc) Create(_ context.Context, c project.Caller, _ string, reqNumber int, in project.CreateArtifactInput) (*project.Artifact, error) {
|
|
a := &project.Artifact{
|
|
ID: uuid.New(), Phase: in.Phase, Version: len(f.items) + 1,
|
|
Content: in.Content, Note: in.Note, CreatedBy: c.UserID,
|
|
CreatedAt: time.Now(), Verdict: project.VerdictNone,
|
|
}
|
|
f.items = append(f.items, a)
|
|
return a, nil
|
|
}
|
|
func (f *fakeArtifactSvc) List(_ context.Context, _ project.Caller, _ string, _ int, phase project.Phase) ([]*project.Artifact, error) {
|
|
out := make([]*project.Artifact, 0, len(f.items))
|
|
for _, a := range f.items {
|
|
if phase == "" || a.Phase == phase {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeArtifactSvc) GetByVersion(_ context.Context, _ project.Caller, _ string, _ int, phase project.Phase, version int) (*project.Artifact, error) {
|
|
for _, a := range f.items {
|
|
if a.Phase == phase && a.Version == version {
|
|
return a, nil
|
|
}
|
|
}
|
|
return nil, errs.New(errs.CodeNotFound, "artifact")
|
|
}
|
|
func (f *fakeArtifactSvc) Approve(_ context.Context, _ project.Caller, _ string, _ int, phase project.Phase, version int, verdict project.Verdict) (*project.Artifact, error) {
|
|
for _, a := range f.items {
|
|
if a.Phase == phase && a.Version == version {
|
|
a.Verdict = verdict
|
|
return a, nil
|
|
}
|
|
}
|
|
return nil, errs.New(errs.CodeNotFound, "artifact")
|
|
}
|
|
|
|
// fakeIssueSvc implements project.IssueService.
|
|
type fakeIssueSvc struct {
|
|
items []*project.Issue
|
|
deps []*project.Dependency
|
|
}
|
|
|
|
func (f *fakeIssueSvc) Create(_ context.Context, _ project.Caller, _ string, in project.CreateIssueInput) (*project.Issue, error) {
|
|
prio := 0
|
|
if in.Priority != nil {
|
|
prio = *in.Priority
|
|
}
|
|
i := &project.Issue{
|
|
ID: uuid.New(), Number: len(f.items) + 1,
|
|
Title: in.Title, Description: in.Description,
|
|
Status: project.StatusOpen,
|
|
Priority: prio,
|
|
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
|
|
}
|
|
func (f *fakeIssueSvc) CreateSubtask(ctx context.Context, c project.Caller, slug string, parentNumber int, in project.CreateIssueInput) (*project.Issue, error) {
|
|
parent, err := f.Get(ctx, c, slug, parentNumber)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
i, err := f.Create(ctx, c, slug, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pid := parent.ID
|
|
i.ParentID = &pid
|
|
return i, nil
|
|
}
|
|
func (f *fakeIssueSvc) AddDependency(ctx context.Context, c project.Caller, slug string, in project.AddDependencyInput) (*project.Dependency, error) {
|
|
if in.BlockedNumber == in.BlockerNumber {
|
|
return nil, errs.New(errs.CodeInvalidInput, "issue 不能依赖自身")
|
|
}
|
|
blocked, err := f.Get(ctx, c, slug, in.BlockedNumber)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
blocker, err := f.Get(ctx, c, slug, in.BlockerNumber)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d := &project.Dependency{ID: uuid.New(), BlockedID: blocked.ID, BlockerID: blocker.ID, CreatedAt: time.Now()}
|
|
f.deps = append(f.deps, d)
|
|
return d, nil
|
|
}
|
|
func (f *fakeIssueSvc) RemoveDependency(ctx context.Context, c project.Caller, slug string, blockedNumber, blockerNumber int) error {
|
|
blocked, err := f.Get(ctx, c, slug, blockedNumber)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
blocker, err := f.Get(ctx, c, slug, blockerNumber)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for idx, d := range f.deps {
|
|
if d.BlockedID == blocked.ID && d.BlockerID == blocker.ID {
|
|
f.deps = append(f.deps[:idx], f.deps[idx+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return errs.New(errs.CodeNotFound, "依赖不存在")
|
|
}
|
|
func (f *fakeIssueSvc) ListDependencies(ctx context.Context, c project.Caller, slug string, number int) ([]*project.Issue, []*project.Issue, error) {
|
|
iss, err := f.Get(ctx, c, slug, number)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
byID := func(id uuid.UUID) *project.Issue {
|
|
for _, x := range f.items {
|
|
if x.ID == id {
|
|
return x
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
var blockedBy, blocks []*project.Issue
|
|
for _, d := range f.deps {
|
|
if d.BlockedID == iss.ID {
|
|
if b := byID(d.BlockerID); b != nil {
|
|
blockedBy = append(blockedBy, b)
|
|
}
|
|
}
|
|
if d.BlockerID == iss.ID {
|
|
if b := byID(d.BlockedID); b != nil {
|
|
blocks = append(blocks, b)
|
|
}
|
|
}
|
|
}
|
|
return blockedBy, blocks, nil
|
|
}
|
|
|
|
// fakeWorkspaceSvc implements workspace.WorkspaceService.
|
|
type fakeWorkspaceSvc struct {
|
|
items []*workspace.Workspace
|
|
branches []string
|
|
}
|
|
|
|
func (f *fakeWorkspaceSvc) Create(_ context.Context, _ workspace.Caller, _ string, in workspace.CreateWorkspaceInput) (*workspace.Workspace, error) {
|
|
w := &workspace.Workspace{
|
|
ID: uuid.New(), ProjectID: testPID,
|
|
Slug: in.Slug, Name: in.Name, Description: in.Description,
|
|
GitRemoteURL: in.GitRemoteURL, DefaultBranch: in.DefaultBranch,
|
|
SyncStatus: workspace.SyncStatusIdle, CreatedAt: time.Now(),
|
|
}
|
|
f.items = append(f.items, w)
|
|
return w, nil
|
|
}
|
|
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(ctx context.Context, c workspace.Caller, id uuid.UUID, in workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) {
|
|
w, err := f.Get(ctx, c, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if in.Name != nil {
|
|
w.Name = *in.Name
|
|
}
|
|
if in.Description != nil {
|
|
w.Description = *in.Description
|
|
}
|
|
if in.DefaultBranch != nil {
|
|
w.DefaultBranch = *in.DefaultBranch
|
|
}
|
|
return w, nil
|
|
}
|
|
func (f *fakeWorkspaceSvc) Delete(ctx context.Context, c workspace.Caller, id uuid.UUID) error {
|
|
if _, err := f.Get(ctx, c, id); err != nil {
|
|
return err
|
|
}
|
|
kept := f.items[:0]
|
|
for _, w := range f.items {
|
|
if w.ID != id {
|
|
kept = append(kept, w)
|
|
}
|
|
}
|
|
f.items = kept
|
|
return nil
|
|
}
|
|
func (f *fakeWorkspaceSvc) Sync(ctx context.Context, c workspace.Caller, id uuid.UUID) (*workspace.Workspace, error) {
|
|
return f.Get(ctx, c, id)
|
|
}
|
|
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")
|
|
}
|
|
func (f *fakeWorkspaceSvc) ListBranches(_ context.Context, _ workspace.Caller, _ uuid.UUID) ([]string, error) {
|
|
return f.branches, nil
|
|
}
|
|
|
|
// ===== 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{},
|
|
Artifacts: &fakeArtifactSvc{},
|
|
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 TestApproveRequirementArtifact(t *testing.T) {
|
|
deps := testDeps()
|
|
|
|
_, err := callTool(ctxWithAuth(Scope{}), deps, "add_requirement_artifact", map[string]any{
|
|
"project_slug": "test-project", "number": float64(1), "phase": "auditing", "content": "# audit",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
result, err := callTool(ctxWithAuth(Scope{}), deps, "approve_requirement_artifact", map[string]any{
|
|
"project_slug": "test-project", "number": float64(1), "phase": "auditing",
|
|
"version": float64(1), "verdict": "pass",
|
|
})
|
|
require.NoError(t, err)
|
|
var out artifactCreateOut
|
|
unmarshalStructured(t, result, &out)
|
|
assert.True(t, out.OK)
|
|
assert.Equal(t, "pass", out.Artifact.Verdict)
|
|
assert.True(t, out.Artifact.Approved)
|
|
}
|
|
|
|
func TestApproveRequirementArtifact_ScopeGated(t *testing.T) {
|
|
deps := testDeps()
|
|
// scope 不含 approve_requirement_artifact → CheckToolFromCtx 拒绝(IsError)。
|
|
result, err := callTool(ctxWithAuth(Scope{Tools: []string{"add_requirement_artifact"}}), deps,
|
|
"approve_requirement_artifact", map[string]any{
|
|
"project_slug": "test-project", "number": float64(1), "phase": "auditing",
|
|
"version": float64(1), "verdict": "pass",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, result.IsError, "token scope without approve_requirement_artifact must deny")
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|