package mcp import ( "context" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/codeindex" "github.com/yan1h/agent-coding-workflow/internal/projectmemory" "github.com/yan1h/agent-coding-workflow/internal/workspace" ) // ===== fakes ===== type fakeCodeIndexSvc struct { snippets []codeindex.Snippet lastWS uuid.UUID lastQuery codeindex.SearchQuery enqueuedWS uuid.UUID } func (f *fakeCodeIndexSvc) EnqueueBuild(_ context.Context, wsID uuid.UUID, _ *uuid.UUID, _ string) (*codeindex.Run, error) { f.enqueuedWS = wsID return &codeindex.Run{ID: uuid.New(), WorkspaceID: wsID}, nil } func (f *fakeCodeIndexSvc) Search(_ context.Context, wsID uuid.UUID, q codeindex.SearchQuery) ([]codeindex.Snippet, error) { f.lastWS = wsID f.lastQuery = q return f.snippets, nil } func (f *fakeCodeIndexSvc) MarkStale(context.Context, uuid.UUID) error { return nil } type fakeMemorySvc struct { written *projectmemory.WriteInput searched *projectmemory.Query entries []projectmemory.Entry } func (f *fakeMemorySvc) Write(_ context.Context, in projectmemory.WriteInput) (*projectmemory.Entry, error) { f.written = &in return &projectmemory.Entry{ID: uuid.New(), ProjectID: in.ProjectID, Kind: in.Kind, Title: in.Title}, nil } func (f *fakeMemorySvc) Search(_ context.Context, q projectmemory.Query) ([]projectmemory.Entry, error) { f.searched = &q return f.entries, nil } func (f *fakeMemorySvc) List(_ context.Context, _ uuid.UUID, _ string) ([]projectmemory.Entry, error) { return f.entries, nil } func (f *fakeMemorySvc) Delete(context.Context, uuid.UUID) error { return nil } func (f *fakeMemorySvc) RenderAgentsMD(context.Context, uuid.UUID, *uuid.UUID) (string, error) { return "", nil } // depsWithRAG builds testDeps plus a workspace + the code index/memory services. func depsWithRAG(ci codeindex.Service, mem projectmemory.Service) ServerDeps { deps := testDeps() ws := &workspace.Workspace{ ID: testWSID, ProjectID: testPID, Slug: "ws-1", Name: "WS", DefaultBranch: "main", SyncStatus: workspace.SyncStatusIdle, } deps.Workspaces = &fakeWorkspaceSvc{items: []*workspace.Workspace{ws}} deps.CodeIndex = ci deps.Memory = mem return deps } // ===== search_code ===== func TestSearchCode_HappyPath(t *testing.T) { ci := &fakeCodeIndexSvc{snippets: []codeindex.Snippet{ {FilePath: "a.go", StartLine: 1, EndLine: 10, Content: "func A", Score: 0.9, CommitSHA: "abc"}, }} deps := depsWithRAG(ci, &fakeMemorySvc{}) result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, "search_code", map[string]any{"workspace_id": testWSID.String(), "query": "find A", "top_k": 5}) require.NoError(t, err) require.False(t, result.IsError) var out searchCodeOut unmarshalStructured(t, result, &out) require.Len(t, out.Results, 1) assert.Equal(t, "a.go", out.Results[0].File) assert.Equal(t, "find A", ci.lastQuery.Text) assert.Equal(t, 5, ci.lastQuery.TopK) assert.Equal(t, testWSID, ci.lastWS) } func TestSearchCode_CrossTenantRejected(t *testing.T) { deps := depsWithRAG(&fakeCodeIndexSvc{}, &fakeMemorySvc{}) // Scope allows only a different project → project scope check must reject. result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps, "search_code", map[string]any{"workspace_id": testWSID.String(), "query": "q"}) if err == nil { require.True(t, result.IsError, "cross-tenant workspace must be rejected") } } func TestSearchCode_ToolScopeDenied(t *testing.T) { deps := depsWithRAG(&fakeCodeIndexSvc{}, &fakeMemorySvc{}) // Token scope only allows memory_list → search_code denied. result, err := callTool(ctxWithAuth(Scope{Tools: []string{"memory_list"}}), deps, "search_code", map[string]any{"workspace_id": testWSID.String(), "query": "q"}) if err == nil { require.True(t, result.IsError) } } func TestReindexWorkspace_HappyPath(t *testing.T) { ci := &fakeCodeIndexSvc{} deps := depsWithRAG(ci, &fakeMemorySvc{}) result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, "reindex_workspace", map[string]any{"workspace_id": testWSID.String()}) require.NoError(t, err) require.False(t, result.IsError) var out reindexWorkspaceOut unmarshalStructured(t, result, &out) assert.True(t, out.OK) assert.NotEmpty(t, out.RunID) assert.Equal(t, testWSID, ci.enqueuedWS) } // When CodeIndex is nil the tool is not registered → call errors (unknown tool). func TestSearchCode_NotRegisteredWhenDisabled(t *testing.T) { deps := testDeps() deps.Memory = &fakeMemorySvc{} // memory present, code index nil result, err := callTool(ctxWithAuth(Scope{}), deps, "search_code", map[string]any{"workspace_id": testWSID.String(), "query": "q"}) if err == nil { require.True(t, result.IsError, "disabled tool must not succeed") } } // ===== memory tools ===== func TestMemoryWrite_HappyPath(t *testing.T) { mem := &fakeMemorySvc{} deps := depsWithRAG(&fakeCodeIndexSvc{}, mem) result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, "memory_write", map[string]any{ "project_slug": "test-project", "kind": "decision", "title": "Use X", "body": "because Y", "tags": []string{"a", "b"}, }) require.NoError(t, err) require.False(t, result.IsError) var out memoryWriteOut unmarshalStructured(t, result, &out) assert.True(t, out.OK) require.NotNil(t, mem.written) assert.Equal(t, testPID, mem.written.ProjectID) assert.Equal(t, "decision", mem.written.Kind) assert.Equal(t, []string{"a", "b"}, mem.written.Tags) } func TestMemoryWrite_ByWorkspace(t *testing.T) { mem := &fakeMemorySvc{} deps := depsWithRAG(&fakeCodeIndexSvc{}, mem) result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, "memory_write", map[string]any{ "workspace_id": testWSID.String(), "kind": "gotcha", "title": "T", "body": "B", }) require.NoError(t, err) require.False(t, result.IsError) require.NotNil(t, mem.written) assert.Equal(t, testPID, mem.written.ProjectID) require.NotNil(t, mem.written.WorkspaceID) assert.Equal(t, testWSID, *mem.written.WorkspaceID) } func TestMemorySearch_HappyPath(t *testing.T) { mem := &fakeMemorySvc{entries: []projectmemory.Entry{ {ID: uuid.New(), Kind: "decision", Title: "hit", Body: "x", Tags: []string{}}, }} deps := depsWithRAG(&fakeCodeIndexSvc{}, mem) result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, "memory_search", map[string]any{"project_slug": "test-project", "query": "hit"}) require.NoError(t, err) require.False(t, result.IsError) var out memorySearchOut unmarshalStructured(t, result, &out) require.Len(t, out.Results, 1) assert.Equal(t, "hit", out.Results[0].Title) require.NotNil(t, mem.searched) assert.Equal(t, testPID, mem.searched.ProjectID) } func TestMemoryList_HappyPath(t *testing.T) { mem := &fakeMemorySvc{entries: []projectmemory.Entry{ {ID: uuid.New(), Kind: "convention", Title: "c", Tags: []string{}}, }} deps := depsWithRAG(&fakeCodeIndexSvc{}, mem) result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, "memory_list", map[string]any{"project_slug": "test-project"}) require.NoError(t, err) require.False(t, result.IsError) var out memoryListOut unmarshalStructured(t, result, &out) require.Len(t, out.Entries, 1) } func TestMemoryWrite_CrossTenantRejected(t *testing.T) { deps := depsWithRAG(&fakeCodeIndexSvc{}, &fakeMemorySvc{}) result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps, "memory_write", map[string]any{ "project_slug": "test-project", "kind": "decision", "title": "T", "body": "B", }) if err == nil { require.True(t, result.IsError, "cross-tenant project must be rejected") } } var ( _ codeindex.Service = (*fakeCodeIndexSvc)(nil) _ projectmemory.Service = (*fakeMemorySvc)(nil) )