Files
agentic-coding-workflow/internal/mcp/tools_workspace_test.go
T
2026-06-22 08:55:57 +08:00

352 lines
12 KiB
Go

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:<wsID>" / "worktree:<wtID>"
lastCommit workspace.CommitInput
diffText string
}
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
}
func (f *fakeGitOpsSvc) DiffOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID, _ git.DiffOptions) (git.DiffResult, error) {
f.lastTarget = "main:" + wsID.String()
return git.DiffResult{Diff: f.diffText}, nil
}
func (f *fakeGitOpsSvc) DiffOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID, _ git.DiffOptions) (git.DiffResult, error) {
f.lastTarget = "worktree:" + wtID.String()
return git.DiffResult{Diff: f.diffText}, 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)
}
func TestGitDiff_MainAndScope(t *testing.T) {
deps, _, _, gitSvc := wsTestDeps()
gitSvc.diffText = "diff --git a/x b/x\n+added\n"
result, err := callTool(ctxWithAuth(Scope{}), deps, "git_diff", map[string]any{
"workspace_id": testWSID.String(),
})
require.NoError(t, err)
require.False(t, result.IsError)
var out gitDiffOut
unmarshalStructured(t, result, &out)
assert.Contains(t, out.Diff, "+added")
assert.Equal(t, "main:"+testWSID.String(), gitSvc.lastTarget)
// scope to a different project => denied
result, err = callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps, "git_diff", map[string]any{
"workspace_id": testWSID.String(),
})
require.NoError(t, err)
assert.True(t, result.IsError)
}