learning_ai_notes/backend/src/mcp/note-tools.test.ts

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',
});
});
});