package mcp import ( "context" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/infra/git" "github.com/yan1h/agent-coding-workflow/internal/workspace" ) // ===== fakes ===== // fakeWorktreeSvc implements workspace.WorktreeService. type fakeWorktreeSvc struct { items []*workspace.Worktree } func (f *fakeWorktreeSvc) Create(_ context.Context, _ workspace.Caller, wsID uuid.UUID, in workspace.CreateWorktreeInput) (*workspace.Worktree, error) { wt := &workspace.Worktree{ ID: uuid.New(), WorkspaceID: wsID, Branch: in.Branch, Status: workspace.WorktreeStatusIdle, CreatedAt: time.Now(), } f.items = append(f.items, wt) return wt, nil } func (f *fakeWorktreeSvc) List(_ context.Context, _ workspace.Caller, wsID uuid.UUID) ([]*workspace.Worktree, error) { out := make([]*workspace.Worktree, 0, len(f.items)) for _, wt := range f.items { if wt.WorkspaceID == wsID { out = append(out, wt) } } return out, nil } func (f *fakeWorktreeSvc) Delete(_ context.Context, _ workspace.Caller, wtID uuid.UUID) error { kept := f.items[:0] for _, wt := range f.items { if wt.ID != wtID { kept = append(kept, wt) } } f.items = kept return nil } func (f *fakeWorktreeSvc) Acquire(_ context.Context, c workspace.Caller, wtID uuid.UUID) (*workspace.Worktree, error) { for _, wt := range f.items { if wt.ID == wtID { wt.Status = workspace.WorktreeStatusActive wt.ActiveHolder = c.Holder() return wt, nil } } return nil, errs.New(errs.CodeNotFound, "worktree") } func (f *fakeWorktreeSvc) Release(_ context.Context, _ workspace.Caller, wtID uuid.UUID) (*workspace.Worktree, error) { for _, wt := range f.items { if wt.ID == wtID { wt.Status = workspace.WorktreeStatusIdle wt.ActiveHolder = "" return wt, nil } } return nil, errs.New(errs.CodeNotFound, "worktree") } func (f *fakeWorktreeSvc) ReleaseByHolder(_ context.Context, _ string) ([]*workspace.Worktree, error) { return nil, nil } // fakeGitOpsSvc implements workspace.GitOpsService 并记录最近一次调用目标。 type fakeGitOpsSvc struct { statusFiles []git.FileStatus lastTarget string // "main:" / "worktree:" lastCommit workspace.CommitInput } func (f *fakeGitOpsSvc) StatusOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID) ([]git.FileStatus, error) { f.lastTarget = "main:" + wsID.String() return f.statusFiles, nil } func (f *fakeGitOpsSvc) CommitOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID, in workspace.CommitInput) (string, error) { f.lastTarget = "main:" + wsID.String() f.lastCommit = in return "hash-main", nil } func (f *fakeGitOpsSvc) PushOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID) error { f.lastTarget = "main:" + wsID.String() return nil } func (f *fakeGitOpsSvc) StatusOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID) ([]git.FileStatus, error) { f.lastTarget = "worktree:" + wtID.String() return f.statusFiles, nil } func (f *fakeGitOpsSvc) CommitOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID, in workspace.CommitInput) (string, error) { f.lastTarget = "worktree:" + wtID.String() f.lastCommit = in return "hash-wt", nil } func (f *fakeGitOpsSvc) PushOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID) error { f.lastTarget = "worktree:" + wtID.String() return nil } func (f *fakeGitOpsSvc) LogOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID, _ int) ([]git.Commit, error) { f.lastTarget = "main:" + wsID.String() return nil, nil } func (f *fakeGitOpsSvc) LogOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID, _ int) ([]git.Commit, error) { f.lastTarget = "worktree:" + wtID.String() return nil, nil } // wsTestDeps 在 testDeps 基础上挂一个已存在的 workspace + worktree 服务。 func wsTestDeps() (ServerDeps, *fakeWorkspaceSvc, *fakeWorktreeSvc, *fakeGitOpsSvc) { deps := testDeps() wsSvc := &fakeWorkspaceSvc{ items: []*workspace.Workspace{{ ID: testWSID, ProjectID: testPID, Slug: "ws-1", Name: "Workspace 1", DefaultBranch: "main", SyncStatus: workspace.SyncStatusIdle, CreatedAt: time.Now(), }}, branches: []string{"main", "dev"}, } wtSvc := &fakeWorktreeSvc{} gitSvc := &fakeGitOpsSvc{} deps.Workspaces = wsSvc deps.Worktrees = wtSvc deps.GitOps = gitSvc return deps, wsSvc, wtSvc, gitSvc } // ===== tests ===== func TestGetWorkspace(t *testing.T) { deps, _, _, _ := wsTestDeps() result, err := callTool(ctxWithAuth(Scope{}), deps, "get_workspace", map[string]any{ "workspace_id": testWSID.String(), }) require.NoError(t, err) require.False(t, result.IsError) var out workspaceDetail unmarshalStructured(t, result, &out) assert.Equal(t, "ws-1", out.Slug) assert.Equal(t, testPID.String(), out.ProjectID) } func TestGetWorkspace_ScopeDenied(t *testing.T) { deps, _, _, _ := wsTestDeps() // token 限定到其他 project → 拒绝 result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps, "get_workspace", map[string]any{"workspace_id": testWSID.String()}) require.NoError(t, err) assert.True(t, result.IsError) } func TestCreateUpdateDeleteWorkspace(t *testing.T) { deps, wsSvc, _, _ := wsTestDeps() result, err := callTool(ctxWithAuth(Scope{}), deps, "create_workspace", map[string]any{ "project_slug": "test-project", "slug": "ws-2", "name": "Workspace 2", "git_remote_url": "https://example.com/repo.git", }) require.NoError(t, err) require.False(t, result.IsError) var createOut workspaceCreateOut unmarshalStructured(t, result, &createOut) assert.True(t, createOut.OK) assert.Equal(t, "ws-2", createOut.Workspace.Slug) require.Len(t, wsSvc.items, 2) result, err = callTool(ctxWithAuth(Scope{}), deps, "update_workspace", map[string]any{ "workspace_id": createOut.Workspace.ID, "name": "Renamed", }) require.NoError(t, err) require.False(t, result.IsError) unmarshalStructured(t, result, &createOut) assert.Equal(t, "Renamed", createOut.Workspace.Name) result, err = callTool(ctxWithAuth(Scope{}), deps, "delete_workspace", map[string]any{ "workspace_id": createOut.Workspace.ID, }) require.NoError(t, err) require.False(t, result.IsError) assert.Len(t, wsSvc.items, 1) } func TestListBranchesAndSync(t *testing.T) { deps, _, _, _ := wsTestDeps() result, err := callTool(ctxWithAuth(Scope{}), deps, "list_branches", map[string]any{ "workspace_id": testWSID.String(), }) require.NoError(t, err) var out listBranchesOut unmarshalStructured(t, result, &out) assert.Equal(t, []string{"main", "dev"}, out.Branches) result, err = callTool(ctxWithAuth(Scope{}), deps, "sync_workspace", map[string]any{ "workspace_id": testWSID.String(), }) require.NoError(t, err) assert.False(t, result.IsError) } func TestWorktreeLifecycle(t *testing.T) { deps, _, wtSvc, _ := wsTestDeps() result, err := callTool(ctxWithAuth(Scope{}), deps, "create_worktree", map[string]any{ "workspace_id": testWSID.String(), "branch": "feat/x", }) require.NoError(t, err) require.False(t, result.IsError) var createOut worktreeCreateOut unmarshalStructured(t, result, &createOut) assert.Equal(t, "feat/x", createOut.Worktree.Branch) wtID := createOut.Worktree.ID result, err = callTool(ctxWithAuth(Scope{}), deps, "list_worktrees", map[string]any{ "workspace_id": testWSID.String(), }) require.NoError(t, err) var listOut listWorktreesOut unmarshalStructured(t, result, &listOut) require.Len(t, listOut.Worktrees, 1) result, err = callTool(ctxWithAuth(Scope{}), deps, "acquire_worktree", map[string]any{ "workspace_id": testWSID.String(), "worktree_id": wtID, }) require.NoError(t, err) unmarshalStructured(t, result, &createOut) assert.Equal(t, string(workspace.WorktreeStatusActive), createOut.Worktree.Status) result, err = callTool(ctxWithAuth(Scope{}), deps, "release_worktree", map[string]any{ "workspace_id": testWSID.String(), "worktree_id": wtID, }) require.NoError(t, err) unmarshalStructured(t, result, &createOut) assert.Equal(t, string(workspace.WorktreeStatusIdle), createOut.Worktree.Status) result, err = callTool(ctxWithAuth(Scope{}), deps, "delete_worktree", map[string]any{ "workspace_id": testWSID.String(), "worktree_id": wtID, }) require.NoError(t, err) assert.False(t, result.IsError) assert.Empty(t, wtSvc.items) } func TestWorktreeMembershipEnforced(t *testing.T) { deps, _, wtSvc, _ := wsTestDeps() // worktree 属于另一个 workspace → 不可经本 workspace 操作 alien := &workspace.Worktree{ ID: uuid.New(), WorkspaceID: uuid.New(), Branch: "other", Status: workspace.WorktreeStatusIdle, CreatedAt: time.Now(), } wtSvc.items = append(wtSvc.items, alien) result, err := callTool(ctxWithAuth(Scope{}), deps, "delete_worktree", map[string]any{ "workspace_id": testWSID.String(), "worktree_id": alien.ID.String(), }) require.NoError(t, err) assert.True(t, result.IsError) assert.Len(t, wtSvc.items, 1) } func TestGitStatusCommitPush_Main(t *testing.T) { deps, _, _, gitSvc := wsTestDeps() gitSvc.statusFiles = []git.FileStatus{{Path: "a.go", IndexX: 'M', WorktreeY: ' '}} result, err := callTool(ctxWithAuth(Scope{}), deps, "git_status", map[string]any{ "workspace_id": testWSID.String(), }) require.NoError(t, err) var statusOut gitStatusOut unmarshalStructured(t, result, &statusOut) require.Len(t, statusOut.Files, 1) assert.Equal(t, "M", statusOut.Files[0].Staged) assert.Equal(t, "main:"+testWSID.String(), gitSvc.lastTarget) result, err = callTool(ctxWithAuth(Scope{}), deps, "git_commit", map[string]any{ "workspace_id": testWSID.String(), "message": "feat: x", }) require.NoError(t, err) var commitOut gitCommitOut unmarshalStructured(t, result, &commitOut) assert.Equal(t, "hash-main", commitOut.CommitHash) assert.True(t, gitSvc.lastCommit.AddAll, "add_all defaults to true") assert.False(t, gitSvc.lastCommit.Push) result, err = callTool(ctxWithAuth(Scope{}), deps, "git_push", map[string]any{ "workspace_id": testWSID.String(), }) require.NoError(t, err) assert.False(t, result.IsError) } func TestGitCommit_Worktree(t *testing.T) { deps, _, wtSvc, gitSvc := wsTestDeps() wt := &workspace.Worktree{ ID: uuid.New(), WorkspaceID: testWSID, Branch: "feat/y", Status: workspace.WorktreeStatusIdle, CreatedAt: time.Now(), } wtSvc.items = append(wtSvc.items, wt) addAll := false result, err := callTool(ctxWithAuth(Scope{}), deps, "git_commit", map[string]any{ "workspace_id": testWSID.String(), "worktree_id": wt.ID.String(), "message": "fix: y", "add_all": addAll, "push": true, }) require.NoError(t, err) var commitOut gitCommitOut unmarshalStructured(t, result, &commitOut) assert.Equal(t, "hash-wt", commitOut.CommitHash) assert.Equal(t, "worktree:"+wt.ID.String(), gitSvc.lastTarget) assert.False(t, gitSvc.lastCommit.AddAll) assert.True(t, gitSvc.lastCommit.Push) }