212 lines
6.0 KiB
TypeScript
212 lines
6.0 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { NOTES_MCP_TOOL_NAMES } from './note-tool-contracts.js';
|
|
|
|
const { listNotesMock, getNoteMock, createNoteMock, createNoteAgentActionMock } = vi.hoisted(() => ({
|
|
listNotesMock: vi.fn(),
|
|
getNoteMock: vi.fn(),
|
|
createNoteMock: vi.fn(),
|
|
createNoteAgentActionMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
|
vi.mock('../modules/notes/repository.js', () => ({
|
|
listNotes: listNotesMock,
|
|
getNote: getNoteMock,
|
|
createNote: createNoteMock,
|
|
}));
|
|
vi.mock('../modules/note-agent-actions/repository.js', () => ({
|
|
createNoteAgentAction: createNoteAgentActionMock,
|
|
}));
|
|
|
|
import { NotesExecutableMcpTools, getNotesExecutableMcpTool } from './note-tools.js';
|
|
|
|
const req = {
|
|
id: 'req_1',
|
|
headers: {},
|
|
jwtPayload: { sub: 'user_1', role: 'admin', productId: 'notelett' },
|
|
log: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
};
|
|
|
|
describe('note executable MCP tools', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('exposes executable tools for each core note contract', () => {
|
|
expect(NotesExecutableMcpTools.map(tool => tool.name)).toEqual([
|
|
NOTES_MCP_TOOL_NAMES.list,
|
|
NOTES_MCP_TOOL_NAMES.get,
|
|
NOTES_MCP_TOOL_NAMES.search,
|
|
NOTES_MCP_TOOL_NAMES.createDraft,
|
|
NOTES_MCP_TOOL_NAMES.updateNote,
|
|
NOTES_MCP_TOOL_NAMES.linkNotes,
|
|
NOTES_MCP_TOOL_NAMES.extractTasks,
|
|
NOTES_MCP_TOOL_NAMES.attachArtifact,
|
|
NOTES_MCP_TOOL_NAMES.suggestTags,
|
|
NOTES_MCP_TOOL_NAMES.checkDuplicates,
|
|
NOTES_MCP_TOOL_NAMES.suggestLinks,
|
|
NOTES_MCP_TOOL_NAMES.runPrompt,
|
|
]);
|
|
});
|
|
|
|
it('lists notes through the repository', async () => {
|
|
listNotesMock.mockResolvedValue({
|
|
items: [
|
|
{
|
|
id: 'note_1',
|
|
productId: 'notelett',
|
|
workspaceId: 'ws_1',
|
|
userId: 'user_1',
|
|
title: 'Draft',
|
|
body: 'Body',
|
|
status: 'draft',
|
|
tags: ['alpha'],
|
|
links: [],
|
|
createdAt: '2026-03-10T00:00:00.000Z',
|
|
updatedAt: '2026-03-10T00:00:00.000Z',
|
|
createdBy: 'user_1',
|
|
updatedBy: 'user_1',
|
|
},
|
|
],
|
|
total: 1,
|
|
});
|
|
|
|
const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.list);
|
|
const result = await tool?.execute(
|
|
tool.inputSchema.parse({ workspaceId: 'ws_1', limit: 10, offset: 0 }),
|
|
req
|
|
);
|
|
|
|
expect(listNotesMock).toHaveBeenCalledWith('user_1', 'notelett', {
|
|
workspaceId: 'ws_1',
|
|
limit: 10,
|
|
offset: 0,
|
|
});
|
|
expect(result).toMatchObject({ total: 1, limit: 10, offset: 0 });
|
|
});
|
|
|
|
it('searches notes and computes match fields', async () => {
|
|
listNotesMock.mockResolvedValue({
|
|
items: [
|
|
{
|
|
id: 'note_1',
|
|
productId: 'notelett',
|
|
workspaceId: 'ws_1',
|
|
userId: 'user_1',
|
|
title: 'Retention policy',
|
|
body: 'Body text',
|
|
status: 'active',
|
|
tags: ['compliance'],
|
|
links: [],
|
|
createdAt: '2026-03-10T00:00:00.000Z',
|
|
updatedAt: '2026-03-10T00:00:00.000Z',
|
|
createdBy: 'user_1',
|
|
updatedBy: 'user_1',
|
|
},
|
|
],
|
|
total: 1,
|
|
});
|
|
|
|
const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.search);
|
|
const result = await tool?.execute(
|
|
tool.inputSchema.parse({ workspaceId: 'ws_1', query: 'retention' }),
|
|
req
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
query: 'retention',
|
|
items: [{ matchFields: ['title'] }],
|
|
});
|
|
});
|
|
|
|
it('gets a scoped note', async () => {
|
|
getNoteMock.mockResolvedValue({
|
|
id: 'note_1',
|
|
productId: 'notelett',
|
|
workspaceId: 'ws_1',
|
|
userId: 'user_1',
|
|
title: 'Note',
|
|
body: 'Body',
|
|
status: 'draft',
|
|
tags: [],
|
|
links: [],
|
|
createdAt: '2026-03-10T00:00:00.000Z',
|
|
updatedAt: '2026-03-10T00:00:00.000Z',
|
|
createdBy: 'user_1',
|
|
updatedBy: 'user_1',
|
|
});
|
|
|
|
const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.get);
|
|
const result = await tool?.execute(
|
|
tool.inputSchema.parse({ noteId: 'note_1', workspaceId: 'ws_1' }),
|
|
req
|
|
);
|
|
|
|
expect(result).toMatchObject({ id: 'note_1', workspaceId: 'ws_1' });
|
|
});
|
|
|
|
it('returns a dry-run draft without persistence', async () => {
|
|
const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.createDraft);
|
|
const result = await tool?.execute(
|
|
tool.inputSchema.parse({
|
|
workspaceId: 'ws_1',
|
|
title: 'Draft title',
|
|
body: 'Draft body',
|
|
dryRun: true,
|
|
}),
|
|
req
|
|
);
|
|
|
|
expect(createNoteMock).not.toHaveBeenCalled();
|
|
expect(createNoteAgentActionMock).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({ dryRun: true, state: 'proposed' });
|
|
});
|
|
|
|
it('creates a persisted draft and audit action', async () => {
|
|
createNoteMock.mockImplementation(async (doc: unknown) => doc);
|
|
createNoteAgentActionMock.mockResolvedValue(undefined);
|
|
|
|
const tool = getNotesExecutableMcpTool(NOTES_MCP_TOOL_NAMES.createDraft);
|
|
const result = await tool?.execute(
|
|
tool.inputSchema.parse({
|
|
workspaceId: 'ws_1',
|
|
title: 'Draft title',
|
|
body: 'Draft body',
|
|
agentId: 'agent_1',
|
|
idempotencyKey: 'idem_1',
|
|
correlationId: 'corr_1',
|
|
}),
|
|
req
|
|
);
|
|
|
|
expect(createNoteMock).toHaveBeenCalledTimes(1);
|
|
expect(createNoteAgentActionMock).toHaveBeenCalledTimes(1);
|
|
expect(createNoteAgentActionMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
productId: 'notelett',
|
|
workspaceId: 'ws_1',
|
|
userId: 'user_1',
|
|
actorId: 'agent_1',
|
|
actorType: 'agent',
|
|
toolName: NOTES_MCP_TOOL_NAMES.createDraft,
|
|
actionType: 'create',
|
|
state: 'proposed',
|
|
idempotencyKey: 'idem_1',
|
|
correlationId: 'corr_1',
|
|
workflowId: 'req_1',
|
|
})
|
|
);
|
|
expect(result).toMatchObject({
|
|
dryRun: false,
|
|
state: 'draft',
|
|
idempotencyKey: 'idem_1',
|
|
correlationId: 'corr_1',
|
|
});
|
|
});
|
|
});
|