You've already forked agentic-coding-workflow
313 lines
10 KiB
Go
313 lines
10 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|