Replace all db.prepare() calls in mcp/index.ts, mcp/resources.ts, and mcp/tools.ts with calls to the service layer. Add missing service functions: - authService: isDemoUser, verifyMcpToken, verifyJwtToken - adminService: isAddonEnabled - atlasService: listVisitedCountries - tripService: getTripSummary, listTrips with null archived param Also fix getAssignmentWithPlace and formatAssignmentWithPlace to expose place_id, assignment_time, and assignment_end_time at the top level, and fix updateDay to correctly handle null title for clearing. Add comprehensive unit and integration test suite for the MCP layer (821 tests all passing).
432 lines
18 KiB
TypeScript
432 lines
18 KiB
TypeScript
/**
|
|
* Unit tests for MCP note tools: create_day_note, update_day_note, delete_day_note,
|
|
* create_collab_note, update_collab_note, delete_collab_note.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.exec('PRAGMA busy_timeout = 5000');
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: (tripId: any, userId: number) =>
|
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
|
isOwner: (tripId: any, userId: number) =>
|
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
|
};
|
|
return { testDb: db, dbMock: mock };
|
|
});
|
|
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
|
|
const { broadcastMock, unlinkSyncMock } = vi.hoisted(() => ({
|
|
broadcastMock: vi.fn(),
|
|
unlinkSyncMock: vi.fn(),
|
|
}));
|
|
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
|
vi.mock('fs', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('fs')>();
|
|
return { ...actual, unlinkSync: unlinkSyncMock };
|
|
});
|
|
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createUser, createTrip, createDay, createDayNote, createCollabNote } from '../../helpers/factories';
|
|
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
broadcastMock.mockClear();
|
|
unlinkSyncMock.mockClear();
|
|
delete process.env.DEMO_MODE;
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
|
const h = await createMcpHarness({ userId, withResources: false });
|
|
try { await fn(h); } finally { await h.cleanup(); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// create_day_note
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: create_day_note', () => {
|
|
it('creates a note on a day', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_day_note',
|
|
arguments: { tripId: trip.id, dayId: day.id, text: 'Check in at noon', time: '12:00', icon: '🏨' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.note.text).toBe('Check in at noon');
|
|
expect(data.note.time).toBe('12:00');
|
|
expect(data.note.icon).toBe('🏨');
|
|
});
|
|
});
|
|
|
|
it('defaults icon to 📝', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_day_note',
|
|
arguments: { tripId: trip.id, dayId: day.id, text: 'A note' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.note.icon).toBe('📝');
|
|
});
|
|
});
|
|
|
|
it('broadcasts dayNote:created event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'create_day_note', arguments: { tripId: trip.id, dayId: day.id, text: 'Note' } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'dayNote:created', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns error when day does not belong to trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip1 = createTrip(testDb, user.id);
|
|
const trip2 = createTrip(testDb, user.id);
|
|
const dayFromTrip2 = createDay(testDb, trip2.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'create_day_note', arguments: { tripId: trip1.id, dayId: dayFromTrip2.id, text: 'Note' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const day = createDay(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'create_day_note', arguments: { tripId: trip.id, dayId: day.id, text: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// update_day_note
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: update_day_note', () => {
|
|
it('updates note text, time, icon', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const note = createDayNote(testDb, day.id, trip.id, { text: 'Old text', time: '09:00', icon: '📝' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_day_note',
|
|
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'New text', time: '14:00', icon: '🍽️' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.note.text).toBe('New text');
|
|
expect(data.note.time).toBe('14:00');
|
|
expect(data.note.icon).toBe('🍽️');
|
|
});
|
|
});
|
|
|
|
it('trims text whitespace', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const note = createDayNote(testDb, day.id, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_day_note',
|
|
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: ' Trimmed ' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.note.text).toBe('Trimmed');
|
|
});
|
|
});
|
|
|
|
it('broadcasts dayNote:updated event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const note = createDayNote(testDb, day.id, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Updated' } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'dayNote:updated', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns error when note not found', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: 99999, text: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const note = createDayNote(testDb, day.id, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// delete_day_note
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: delete_day_note', () => {
|
|
it('deletes a day note', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const note = createDayNote(testDb, day.id, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } });
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.success).toBe(true);
|
|
expect(testDb.prepare('SELECT id FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('broadcasts dayNote:deleted event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const note = createDayNote(testDb, day.id, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'dayNote:deleted', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns error when note not found', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: 99999 } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const day = createDay(testDb, trip.id);
|
|
const note = createDayNote(testDb, day.id, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// create_collab_note
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: create_collab_note', () => {
|
|
it('creates a collab note with all fields', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_collab_note',
|
|
arguments: { tripId: trip.id, title: 'Ideas', content: 'Visit museums', category: 'Culture', color: '#3b82f6' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.note.title).toBe('Ideas');
|
|
expect(data.note.content).toBe('Visit museums');
|
|
expect(data.note.category).toBe('Culture');
|
|
expect(data.note.color).toBe('#3b82f6');
|
|
});
|
|
});
|
|
|
|
it('defaults category to "General" and color to "#6366f1"', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'create_collab_note', arguments: { tripId: trip.id, title: 'Quick note' } });
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.note.category).toBe('General');
|
|
expect(data.note.color).toBe('#6366f1');
|
|
});
|
|
});
|
|
|
|
it('broadcasts collab:note:created event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'create_collab_note', arguments: { tripId: trip.id, title: 'Note' } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:note:created', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'create_collab_note', arguments: { tripId: trip.id, title: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// update_collab_note
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: update_collab_note', () => {
|
|
it('updates collab note fields', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const note = createCollabNote(testDb, trip.id, user.id, { title: 'Old', color: '#6366f1' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_collab_note',
|
|
arguments: { tripId: trip.id, noteId: note.id, title: 'New Title', pinned: true, color: '#3b82f6' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.note.title).toBe('New Title');
|
|
expect(data.note.pinned).toBe(1);
|
|
expect(data.note.color).toBe('#3b82f6');
|
|
});
|
|
});
|
|
|
|
it('broadcasts collab:note:updated event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const note = createCollabNote(testDb, trip.id, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, title: 'Updated' } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:note:updated', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns error when note not found', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: 99999, title: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const note = createCollabNote(testDb, trip.id, other.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, title: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// delete_collab_note
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: delete_collab_note', () => {
|
|
it('deletes a collab note', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const note = createCollabNote(testDb, trip.id, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.success).toBe(true);
|
|
expect(testDb.prepare('SELECT id FROM collab_notes WHERE id = ?').get(note.id)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('deletes associated trip_files records from the database', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const note = createCollabNote(testDb, trip.id, user.id);
|
|
// Insert a trip_file linked to this note
|
|
testDb.prepare(
|
|
`INSERT INTO trip_files (trip_id, note_id, filename, original_name, mime_type, file_size) VALUES (?, ?, ?, ?, ?, ?)`
|
|
).run(trip.id, note.id, 'test-file.pdf', 'document.pdf', 'application/pdf', 1024);
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
|
|
expect((parseToolResult(result) as any).success).toBe(true);
|
|
});
|
|
|
|
// trip_files rows are deleted as part of the transaction
|
|
expect(testDb.prepare('SELECT id FROM trip_files WHERE note_id = ?').all(note.id)).toHaveLength(0);
|
|
// note itself is deleted
|
|
expect(testDb.prepare('SELECT id FROM collab_notes WHERE id = ?').get(note.id)).toBeUndefined();
|
|
});
|
|
|
|
it('broadcasts collab:note:deleted event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const note = createCollabNote(testDb, trip.id, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:note:deleted', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns error when note not found', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: 99999 } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const note = createCollabNote(testDb, trip.id, other.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|