You've already forked agentic-coding-workflow
220 lines
7.8 KiB
Go
220 lines
7.8 KiB
Go
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)
|
|
)
|