mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
refactor(mcp): extract all MCP tools into dedicated modules and add shared helpers
This commit is contained in:
@@ -321,6 +321,32 @@ export function createCollabNote(
|
||||
return db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid) as TestCollabNote;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Todo Items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestTodoItem {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
name: string;
|
||||
checked: number;
|
||||
category: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export function createTodoItem(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
overrides: Partial<{ name: string; category: string; checked: number }> = {}
|
||||
): TestTodoItem {
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, overrides.name ?? 'Test Todo', overrides.checked ?? 0, overrides.category ?? null, sortOrder);
|
||||
return db.prepare('SELECT * FROM todo_items WHERE id = ?').get(result.lastInsertRowid) as TestTodoItem;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day Assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Unit tests for MCP extra assignment/reservation tools:
|
||||
* move_assignment, get_assignment_participants, set_assignment_participants, reorder_reservations.
|
||||
*/
|
||||
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 } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createReservation } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.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(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// move_assignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: move_assignment', () => {
|
||||
it('moves assignment to a different day and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day1 = createDay(testDb, trip.id);
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day1.id, place.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'move_assignment',
|
||||
arguments: { tripId: trip.id, assignmentId: assignment.id, newDayId: day2.id, oldDayId: day1.id, orderIndex: 0 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignment).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:moved', expect.any(Object));
|
||||
// Verify the assignment was moved
|
||||
const updated = testDb.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(assignment.id) as any;
|
||||
expect(updated.day_id).toBe(day2.id);
|
||||
});
|
||||
});
|
||||
|
||||
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: 'move_assignment',
|
||||
arguments: { tripId: trip.id, assignmentId: 1, newDayId: day.id, oldDayId: day.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
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: 'move_assignment',
|
||||
arguments: { tripId: trip.id, assignmentId: 1, newDayId: day.id, oldDayId: day.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_assignment_participants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_assignment_participants', () => {
|
||||
it('returns empty participants array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_assignment_participants',
|
||||
arguments: { tripId: trip.id, assignmentId: assignment.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.participants)).toBe(true);
|
||||
expect(data.participants).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
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: 'get_assignment_participants', arguments: { tripId: trip.id, assignmentId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_assignment_participants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_assignment_participants', () => {
|
||||
it('sets participants and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_assignment_participants',
|
||||
arguments: { tripId: trip.id, assignmentId: assignment.id, userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.participants)).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:participants', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('empty array clears participants', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
// First set
|
||||
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignment.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_assignment_participants',
|
||||
arguments: { tripId: trip.id, assignmentId: assignment.id, userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.participants).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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: 'set_assignment_participants',
|
||||
arguments: { tripId: trip.id, assignmentId: 1, userIds: [] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reorder_reservations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: reorder_reservations', () => {
|
||||
it('returns success and broadcasts reservation:positions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res1 = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' });
|
||||
const res2 = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reorder_reservations',
|
||||
arguments: {
|
||||
tripId: trip.id,
|
||||
positions: [
|
||||
{ id: res1.id, day_plan_position: 1 },
|
||||
{ id: res2.id, day_plan_position: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:positions', 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: 'reorder_reservations',
|
||||
arguments: { tripId: trip.id, positions: [{ id: 1, day_plan_position: 0 }] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Unit tests for MCP atlas expanded tools (atlas addon-gated):
|
||||
* get_atlas_stats, list_visited_regions, mark_region_visited, unmark_region_visited,
|
||||
* get_country_atlas_places, update_bucket_list_item.
|
||||
* Also covers resources trek://atlas/stats and trek://atlas/regions.
|
||||
*/
|
||||
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 } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.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(); }
|
||||
}
|
||||
|
||||
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withTools: false, withResources: true });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_atlas_stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_atlas_stats', () => {
|
||||
it('returns stats object without error for empty data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: {} });
|
||||
expect(result.isError).toBeFalsy();
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.stats).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_visited_regions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_visited_regions', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.regions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns regions after they have been inserted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare(
|
||||
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
|
||||
).run(user.id, 'FR-75', 'Paris', 'FR');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.regions).toHaveLength(1);
|
||||
expect(data.regions[0].region_code).toBe('FR-75');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mark_region_visited
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: mark_region_visited', () => {
|
||||
it('inserts region and returns region object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_region_visited',
|
||||
arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.region).toBeDefined();
|
||||
expect(data.region.region_code).toBe('US-CA');
|
||||
expect(data.region.region_name).toBe('California');
|
||||
expect(data.region.country_code).toBe('US');
|
||||
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA');
|
||||
expect(row).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_region_visited',
|
||||
arguments: { regionCode: 'DE-BY', regionName: 'Bavaria', countryCode: 'DE' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// unmark_region_visited
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: unmark_region_visited', () => {
|
||||
it('removes region and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare(
|
||||
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
|
||||
).run(user.id, 'IT-LO', 'Lombardy', 'IT');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'unmark_region_visited',
|
||||
arguments: { regionCode: 'IT-LO' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'IT-LO');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('succeeds even when region was not marked (no-op)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'unmark_region_visited',
|
||||
arguments: { regionCode: 'XX-YY' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_country_atlas_places
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_country_atlas_places', () => {
|
||||
it('returns empty places array for a new user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_country_atlas_places',
|
||||
arguments: { countryCode: 'JP' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.places).toBeDefined();
|
||||
expect(Array.isArray(data.places)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_bucket_list_item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_bucket_list_item', () => {
|
||||
it('updates notes and returns item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = testDb.prepare(
|
||||
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
|
||||
).run(user.id, 'Visit Tokyo');
|
||||
const itemId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_bucket_list_item',
|
||||
arguments: { itemId, notes: 'Cherry blossom season preferred' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item).toBeDefined();
|
||||
expect(data.item.notes).toBe('Cherry blossom season preferred');
|
||||
});
|
||||
});
|
||||
|
||||
it('updates name of existing item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = testDb.prepare(
|
||||
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
|
||||
).run(user.id, 'Old Name');
|
||||
const itemId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_bucket_list_item',
|
||||
arguments: { itemId, name: 'New Name' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('New Name');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError for non-existent item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_bucket_list_item',
|
||||
arguments: { itemId: 99999, notes: 'Will not work' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const r = testDb.prepare(
|
||||
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
|
||||
).run(user.id, 'Bucket Item');
|
||||
const itemId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_bucket_list_item',
|
||||
arguments: { itemId, notes: 'blocked' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource: trek://atlas/stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://atlas/stats', () => {
|
||||
it('returns stats object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://atlas/stats' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource: trek://atlas/regions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://atlas/regions', () => {
|
||||
it('returns regions array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns inserted regions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare(
|
||||
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
|
||||
).run(user.id, 'ES-CT', 'Catalonia', 'ES');
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].region_code).toBe('ES-CT');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Unit tests for MCP budget advanced tools:
|
||||
* set_budget_item_members, toggle_budget_member_paid.
|
||||
* Resources: trek://trips/{tripId}/budget/per-person, trek://trips/{tripId}/budget/settlement.
|
||||
*/
|
||||
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 } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.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(); }
|
||||
}
|
||||
|
||||
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: true });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_budget_item_members
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_budget_item_members', () => {
|
||||
it('sets members and broadcasts budget:members-updated', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 500 });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_budget_item_members',
|
||||
arguments: { tripId: trip.id, itemId: item.id, userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('empty array clears members', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id) VALUES (?, ?)').run(item.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_budget_item_members',
|
||||
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item).toBeDefined();
|
||||
const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_item_members WHERE budget_item_id = ?').get(item.id) as any;
|
||||
expect(remaining.cnt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_budget_item_members',
|
||||
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_budget_item_members',
|
||||
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle_budget_member_paid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: toggle_budget_member_paid', () => {
|
||||
it('flips paid flag and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { total_price: 200 });
|
||||
// Add member first
|
||||
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)').run(item.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_budget_member_paid',
|
||||
arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.member).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:member-paid-updated', 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);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_budget_member_paid',
|
||||
arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-person resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/budget/per-person', () => {
|
||||
it('returns array for trip with no items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` });
|
||||
const data = JSON.parse(result.contents[0].text as string);
|
||||
expect(Array.isArray(data)).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);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` });
|
||||
const data = JSON.parse(result.contents[0].text as string);
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settlement resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/budget/settlement', () => {
|
||||
it('returns settlement object for trip with no items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/settlement` });
|
||||
const data = JSON.parse(result.contents[0].text as string);
|
||||
expect(data).toBeDefined();
|
||||
expect(Array.isArray(data.balances) || Array.isArray(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* Unit tests for MCP collab polls and chat tools (collab addon-gated):
|
||||
* list_collab_polls, create_collab_poll, vote_collab_poll, close_collab_poll,
|
||||
* delete_collab_poll, list_collab_messages, send_collab_message,
|
||||
* delete_collab_message, react_collab_message.
|
||||
* Resources: trek://trips/{tripId}/collab/polls, trek://trips/{tripId}/collab/messages.
|
||||
*/
|
||||
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 } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.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(); }
|
||||
}
|
||||
|
||||
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: true });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_collab_polls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_collab_polls', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_collab_polls',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.polls)).toBe(true);
|
||||
expect(data.polls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
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: 'list_collab_polls', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_collab_poll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_collab_poll', () => {
|
||||
it('inserts poll with votes structure and broadcasts', 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_poll',
|
||||
arguments: {
|
||||
tripId: trip.id,
|
||||
question: 'Where should we eat?',
|
||||
options: ['Pizza', 'Sushi', 'Tacos'],
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.poll).toBeDefined();
|
||||
expect(data.poll.question).toBe('Where should we eat?');
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_collab_poll',
|
||||
arguments: { tripId: trip.id, question: 'Q?', options: ['A', 'B'] },
|
||||
});
|
||||
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);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_collab_poll',
|
||||
arguments: { tripId: trip.id, question: 'Q?', options: ['A', 'B'] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vote_collab_poll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: vote_collab_poll', () => {
|
||||
it('records vote and broadcasts collab:poll:voted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Create a poll directly in the DB
|
||||
const pollId = (testDb.prepare(
|
||||
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'Best city?', JSON.stringify(['Paris', 'Rome'])) as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'vote_collab_poll',
|
||||
arguments: { tripId: trip.id, pollId: Number(pollId), optionIndex: 0 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.poll).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:voted', 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: 'vote_collab_poll',
|
||||
arguments: { tripId: trip.id, pollId: 1, optionIndex: 0 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// close_collab_poll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: close_collab_poll', () => {
|
||||
it('sets closed flag and broadcasts collab:poll:closed', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const pollId = (testDb.prepare(
|
||||
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'Vote now?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'close_collab_poll',
|
||||
arguments: { tripId: trip.id, pollId: Number(pollId) },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.poll).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:closed', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for non-existent poll', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'close_collab_poll',
|
||||
arguments: { tripId: trip.id, pollId: 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);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'close_collab_poll', arguments: { tripId: trip.id, pollId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_collab_poll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_collab_poll', () => {
|
||||
it('removes poll and broadcasts collab:poll:deleted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const pollId = (testDb.prepare(
|
||||
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'Delete me?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_collab_poll',
|
||||
arguments: { tripId: trip.id, pollId: Number(pollId) },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:deleted', expect.objectContaining({ pollId: Number(pollId) }));
|
||||
expect(testDb.prepare('SELECT id FROM collab_polls WHERE id = ?').get(Number(pollId))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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: 'delete_collab_poll', arguments: { tripId: trip.id, pollId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_collab_messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_collab_messages', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_collab_messages',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.messages)).toBe(true);
|
||||
expect(data.messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
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: 'list_collab_messages', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// send_collab_message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: send_collab_message', () => {
|
||||
it('inserts message and broadcasts collab:message:created', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'send_collab_message',
|
||||
arguments: { tripId: trip.id, text: 'Hello team!' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.message).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('sends message with replyTo when parent exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const msgId = (testDb.prepare(
|
||||
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'Original message') as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'send_collab_message',
|
||||
arguments: { tripId: trip.id, text: 'Reply here', replyTo: Number(msgId) },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'send_collab_message',
|
||||
arguments: { tripId: trip.id, text: 'Hello!' },
|
||||
});
|
||||
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);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'send_collab_message', arguments: { tripId: trip.id, text: 'Hi' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_collab_message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_collab_message', () => {
|
||||
it('soft-deletes message and broadcasts collab:message:deleted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const msgId = (testDb.prepare(
|
||||
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'To be deleted') as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_collab_message',
|
||||
arguments: { tripId: trip.id, messageId: Number(msgId) },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:deleted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when message belongs to different user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Add other as trip member
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, other.id);
|
||||
const msgId = (testDb.prepare(
|
||||
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'Owner message') as any).lastInsertRowid;
|
||||
|
||||
await withHarness(other.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_collab_message',
|
||||
arguments: { tripId: trip.id, messageId: Number(msgId) },
|
||||
});
|
||||
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);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_collab_message', arguments: { tripId: trip.id, messageId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// react_collab_message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: react_collab_message', () => {
|
||||
it('toggles reaction and broadcasts collab:message:reacted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const msgId = (testDb.prepare(
|
||||
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'React to me') as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'react_collab_message',
|
||||
arguments: { tripId: trip.id, messageId: Number(msgId), emoji: '👍' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.reactions).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:reacted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for non-existent message', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'react_collab_message',
|
||||
arguments: { tripId: trip.id, messageId: 99999, emoji: '👍' },
|
||||
});
|
||||
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);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'react_collab_message', arguments: { tripId: trip.id, messageId: 1, emoji: '👍' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/collab/polls', () => {
|
||||
it('returns polls list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/polls` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(Array.isArray(data)).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);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/polls` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/collab/messages', () => {
|
||||
it('returns messages list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/messages` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Unit tests for MCP day and accommodation tools:
|
||||
* create_day, delete_day,
|
||||
* create_accommodation, update_accommodation, delete_accommodation.
|
||||
*/
|
||||
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 } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createDayAccommodation } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_day', () => {
|
||||
it('creates a day with a date', 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_day',
|
||||
arguments: { tripId: trip.id, date: '2025-06-15', notes: 'Arrival day' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.day).toBeDefined();
|
||||
expect(data.day.date).toBe('2025-06-15');
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a dateless day', 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_day',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.day).toBeDefined();
|
||||
expect(data.day.date).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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_day', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_day
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_day', () => {
|
||||
it('deletes a day and broadcasts', 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',
|
||||
arguments: { tripId: trip.id, dayId: day.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:deleted', { id: day.id });
|
||||
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_accommodation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_accommodation', () => {
|
||||
it('creates an accommodation and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel du Louvre' });
|
||||
const day1 = createDay(testDb, trip.id, { date: '2025-06-15' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2025-06-17' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_accommodation',
|
||||
arguments: {
|
||||
tripId: trip.id,
|
||||
place_id: place.id,
|
||||
start_day_id: day1.id,
|
||||
end_day_id: day2.id,
|
||||
check_in: '15:00',
|
||||
check_out: '11:00',
|
||||
confirmation: 'CONF123',
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.accommodation).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation: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);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_accommodation',
|
||||
arguments: { tripId: trip.id, place_id: place.id, start_day_id: day.id, end_day_id: day.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_accommodation',
|
||||
arguments: { tripId: trip.id, place_id: place.id, start_day_id: day.id, end_day_id: day.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_accommodation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_accommodation', () => {
|
||||
it('updates accommodation fields and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const day1 = createDay(testDb, trip.id);
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const acc = createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_accommodation',
|
||||
arguments: { tripId: trip.id, accommodationId: acc.id, confirmation: 'NEW-CONF', check_in: '14:00' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.accommodation).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for non-existent accommodation', 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_accommodation',
|
||||
arguments: { tripId: trip.id, accommodationId: 99999, confirmation: '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);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_accommodation',
|
||||
arguments: { tripId: trip.id, accommodationId: 1, confirmation: 'X' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_accommodation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_accommodation', () => {
|
||||
it('deletes accommodation and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const day1 = createDay(testDb, trip.id);
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const acc = createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_accommodation',
|
||||
arguments: { tripId: trip.id, accommodationId: acc.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:deleted', expect.objectContaining({ id: acc.id }));
|
||||
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(acc.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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: 'delete_accommodation', arguments: { tripId: trip.id, accommodationId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Unit tests for MCP file tools:
|
||||
* list_files, update_file_metadata, toggle_file_star, trash_file, restore_file,
|
||||
* permanent_delete_file, empty_trash, link_file, unlink_file, list_file_links.
|
||||
* Note: actual file-system deletion is not tested (files don't exist on disk in tests).
|
||||
*/
|
||||
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 } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.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(); }
|
||||
}
|
||||
|
||||
/** Helper: insert a fake file row directly (no actual file on disk needed) */
|
||||
function createFileRow(tripId: number, overrides: Partial<{
|
||||
filename: string; original_name: string; deleted_at: string | null; starred: number
|
||||
}> = {}) {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO trip_files (trip_id, filename, original_name, file_size, mime_type)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
tripId,
|
||||
overrides.filename ?? `test-${Date.now()}.txt`,
|
||||
overrides.original_name ?? 'test.txt',
|
||||
1024,
|
||||
'text/plain'
|
||||
);
|
||||
const id = result.lastInsertRowid as number;
|
||||
if (overrides.starred !== undefined) {
|
||||
testDb.prepare('UPDATE trip_files SET starred = ? WHERE id = ?').run(overrides.starred, id);
|
||||
}
|
||||
if (overrides.deleted_at !== undefined) {
|
||||
testDb.prepare('UPDATE trip_files SET deleted_at = ? WHERE id = ?').run(overrides.deleted_at, id);
|
||||
}
|
||||
return testDb.prepare('SELECT * FROM trip_files WHERE id = ?').get(id) as any;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_files', () => {
|
||||
it('returns empty list for a new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_files', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.files).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns active files', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createFileRow(trip.id, { original_name: 'doc.pdf' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_files', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.files).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns trash when showTrash=true', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createFileRow(trip.id, { deleted_at: new Date().toISOString() });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_files', arguments: { tripId: trip.id, showTrash: true } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.files).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
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: 'list_files', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_file_metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_file_metadata', () => {
|
||||
it('updates file description', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_file_metadata',
|
||||
arguments: { tripId: trip.id, fileId: file.id, description: 'My document' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.file.description).toBe('My document');
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts file:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'update_file_metadata',
|
||||
arguments: { tripId: trip.id, fileId: file.id, description: 'Updated' },
|
||||
});
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'file:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for file 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_file_metadata',
|
||||
arguments: { tripId: trip.id, fileId: 99999, description: 'X' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_file_metadata',
|
||||
arguments: { tripId: trip.id, fileId: file.id, description: 'X' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle_file_star
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: toggle_file_star', () => {
|
||||
it('stars an unstarred file', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id, { starred: 0 });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'toggle_file_star', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.file.starred).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('unstars a starred file', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id, { starred: 1 });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'toggle_file_star', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.file.starred).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts file:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'toggle_file_star', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'file:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for file 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: 'toggle_file_star', arguments: { tripId: trip.id, fileId: 99999 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// trash_file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: trash_file', () => {
|
||||
it('soft-deletes a file', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'trash_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const dbFile = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file.id) as any;
|
||||
expect(dbFile.deleted_at).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts file:deleted event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'trash_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'file:deleted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for file 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: 'trash_file', arguments: { tripId: trip.id, fileId: 99999 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// restore_file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: restore_file', () => {
|
||||
it('restores a trashed file', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id, { deleted_at: new Date().toISOString() });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'restore_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.file).toBeTruthy();
|
||||
const dbFile = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file.id) as any;
|
||||
expect(dbFile.deleted_at).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts file:created event on restore', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id, { deleted_at: new Date().toISOString() });
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'restore_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'file:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for file not in trash', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id); // not in trash
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'restore_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// permanent_delete_file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: permanent_delete_file', () => {
|
||||
it('permanently removes a trashed file', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id, { deleted_at: new Date().toISOString() });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'permanent_delete_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(file.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for file not in trash', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id); // active file
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'permanent_delete_file', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// empty_trash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: empty_trash', () => {
|
||||
it('deletes all trashed files', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createFileRow(trip.id, { deleted_at: new Date().toISOString() });
|
||||
createFileRow(trip.id, { deleted_at: new Date().toISOString() });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'empty_trash', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.deleted).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 0 when trash is already empty', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'empty_trash', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.deleted).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// link_file / unlink_file / list_file_links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: link_file', () => {
|
||||
it('creates a link to a place', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id);
|
||||
// Insert a fake place
|
||||
const placeResult = testDb.prepare("INSERT INTO places (trip_id, name) VALUES (?, 'Test Place')").run(trip.id);
|
||||
const placeId = placeResult.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'link_file',
|
||||
arguments: { tripId: trip.id, fileId: file.id, place_id: placeId },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(Array.isArray(data.links)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for file 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: 'link_file', arguments: { tripId: trip.id, fileId: 99999, place_id: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: unlink_file', () => {
|
||||
it('removes a file link', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id);
|
||||
// Insert a real place then a link
|
||||
const placeRes = testDb.prepare("INSERT INTO places (trip_id, name) VALUES (?, 'P')").run(trip.id);
|
||||
const placeId = placeRes.lastInsertRowid as number;
|
||||
const linkResult = testDb.prepare(
|
||||
'INSERT INTO file_links (file_id, place_id) VALUES (?, ?)'
|
||||
).run(file.id, placeId);
|
||||
const linkId = linkResult.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'unlink_file', arguments: { tripId: trip.id, fileId: file.id, linkId } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM file_links WHERE id = ?').get(linkId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: list_file_links', () => {
|
||||
it('returns links for a file', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id);
|
||||
// Insert a real place then a link
|
||||
const placeRes = testDb.prepare("INSERT INTO places (trip_id, name) VALUES (?, 'P')").run(trip.id);
|
||||
const placeId = placeRes.lastInsertRowid as number;
|
||||
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file.id, placeId);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_file_links', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.links).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array for file with no links', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const file = createFileRow(trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_file_links', arguments: { tripId: trip.id, fileId: file.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.links).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Unit tests for MCP notification tools:
|
||||
* list_notifications, get_unread_notification_count, mark_notification_read,
|
||||
* mark_notification_unread, mark_all_notifications_read, delete_notification,
|
||||
* delete_all_notifications.
|
||||
* Also covers the resource trek://notifications/in-app.
|
||||
*/
|
||||
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: () => {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: insert a notification directly into the DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createNotification(db: any, userId: number, overrides: any = {}) {
|
||||
const r = db.prepare(
|
||||
`INSERT INTO notifications (type, scope, target, recipient_id, title_key, text_key, is_read)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)`
|
||||
).run(
|
||||
overrides.type ?? 'simple',
|
||||
overrides.scope ?? 'user',
|
||||
overrides.target ?? 0,
|
||||
userId,
|
||||
overrides.title_key ?? 'notification.test.title',
|
||||
overrides.text_key ?? 'notification.test.body'
|
||||
);
|
||||
return db.prepare('SELECT * FROM notifications WHERE id = ?').get(r.lastInsertRowid);
|
||||
}
|
||||
|
||||
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(); }
|
||||
}
|
||||
|
||||
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withTools: false, withResources: true });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_notifications', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_notifications', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.notifications).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns notifications when they exist', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createNotification(testDb, user.id, { title_key: 'notif.first' });
|
||||
createNotification(testDb, user.id, { title_key: 'notif.second' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_notifications', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.notifications).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only unread notifications when unread_only is true', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createNotification(testDb, user.id);
|
||||
const read = createNotification(testDb, user.id) as any;
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(read.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_notifications', arguments: { unread_only: true } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.notifications).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_unread_notification_count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_unread_notification_count', () => {
|
||||
it('returns 0 initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_unread_notification_count', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 1 after inserting one unread notification', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createNotification(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_unread_notification_count', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.count).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mark_notification_read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: mark_notification_read', () => {
|
||||
it('flips is_read to 1 and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const notif = createNotification(testDb, user.id) as any;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_notification_read',
|
||||
arguments: { notificationId: notif.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any;
|
||||
expect(row.is_read).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError for non-existent notification', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_notification_read',
|
||||
arguments: { notificationId: 99999 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const notif = createNotification(testDb, user.id) as any;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_notification_read',
|
||||
arguments: { notificationId: notif.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mark_notification_unread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: mark_notification_unread', () => {
|
||||
it('flips is_read to 0', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const notif = createNotification(testDb, user.id) as any;
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(notif.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_notification_unread',
|
||||
arguments: { notificationId: notif.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any;
|
||||
expect(row.is_read).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError for non-existent notification', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_notification_unread',
|
||||
arguments: { notificationId: 99999 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mark_all_notifications_read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: mark_all_notifications_read', () => {
|
||||
it('marks all notifications read and returns count', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createNotification(testDb, user.id);
|
||||
createNotification(testDb, user.id);
|
||||
createNotification(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'mark_all_notifications_read', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.count).toBe(3);
|
||||
const unread = (testDb.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ? AND is_read = 0').get(user.id) as any).c;
|
||||
expect(unread).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns count 0 when nothing to mark', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'mark_all_notifications_read', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_notification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_notification', () => {
|
||||
it('removes the notification row and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const notif = createNotification(testDb, user.id) as any;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_notification',
|
||||
arguments: { notificationId: notif.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM notifications WHERE id = ?').get(notif.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError for non-existent notification', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_notification',
|
||||
arguments: { notificationId: 99999 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_all_notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_all_notifications', () => {
|
||||
it('clears all notifications for user and returns count', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
createNotification(testDb, user.id);
|
||||
createNotification(testDb, user.id);
|
||||
createNotification(testDb, other.id); // should not be deleted
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_all_notifications', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.count).toBe(2);
|
||||
const remaining = (testDb.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ?').get(user.id) as any).c;
|
||||
expect(remaining).toBe(0);
|
||||
const otherRemaining = (testDb.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ?').get(other.id) as any).c;
|
||||
expect(otherRemaining).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource: trek://notifications/in-app
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://notifications/in-app', () => {
|
||||
it('returns notifications list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createNotification(testDb, user.id, { title_key: 'notif.test' });
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://notifications/in-app' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.notifications).toBeDefined();
|
||||
expect(Array.isArray(data.notifications)).toBe(true);
|
||||
expect(data.notifications).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty notifications for user with none', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://notifications/in-app' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.notifications).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Unit tests for MCP packing advanced tools:
|
||||
* reorder_packing_items, list_packing_bags, create_packing_bag, update_packing_bag,
|
||||
* delete_packing_bag, set_bag_members, get_packing_category_assignees,
|
||||
* set_packing_category_assignees, apply_packing_template, save_packing_template,
|
||||
* bulk_import_packing.
|
||||
*/
|
||||
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 } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.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(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reorder_packing_items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: reorder_packing_items', () => {
|
||||
it('reorders packing items and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item1 = createPackingItem(testDb, trip.id, { name: 'Shirt' });
|
||||
const item2 = createPackingItem(testDb, trip.id, { name: 'Pants' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reorder_packing_items',
|
||||
arguments: { tripId: trip.id, orderedIds: [item2.id, item1.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:reordered', 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);
|
||||
const item = createPackingItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reorder_packing_items',
|
||||
arguments: { tripId: trip.id, orderedIds: [item.id] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_packing_bags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_packing_bags', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_packing_bags',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.bags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns bags that exist', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Carry-on', '#ff0000');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_packing_bags',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.bags).toHaveLength(1);
|
||||
expect(data.bags[0].name).toBe('Carry-on');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_packing_bag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_packing_bag', () => {
|
||||
it('creates a bag and broadcasts', 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_packing_bag',
|
||||
arguments: { tripId: trip.id, name: 'Checked bag', color: '#3b82f6' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.bag).toBeDefined();
|
||||
expect(data.bag.name).toBe('Checked bag');
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-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_packing_bag',
|
||||
arguments: { tripId: trip.id, name: 'Bag' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_packing_bag',
|
||||
arguments: { tripId: trip.id, name: 'Bag' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_packing_bag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_packing_bag', () => {
|
||||
it('updates bag name and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Old Name', '#aabbcc');
|
||||
const bag = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(r.lastInsertRowid) as any;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_packing_bag',
|
||||
arguments: { tripId: trip.id, bagId: bag.id, name: 'New Name' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.bag).toBeDefined();
|
||||
expect(data.bag.name).toBe('New Name');
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-updated', 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: 'update_packing_bag',
|
||||
arguments: { tripId: trip.id, bagId: 1, name: 'X' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_packing_bag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_packing_bag', () => {
|
||||
it('deletes a bag and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Delete Me', '#000000');
|
||||
const bagId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_packing_bag',
|
||||
arguments: { tripId: trip.id, bagId },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-deleted', expect.any(Object));
|
||||
expect(testDb.prepare('SELECT id FROM packing_bags WHERE id = ?').get(bagId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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: 'delete_packing_bag',
|
||||
arguments: { tripId: trip.id, bagId: 1 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_bag_members
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_bag_members', () => {
|
||||
it('sets bag members and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
|
||||
const bagId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_bag_members',
|
||||
arguments: { tripId: trip.id, bagId, userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('clears bag members when passed empty array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
|
||||
const bagId = r.lastInsertRowid as number;
|
||||
testDb.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)').run(bagId, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_bag_members',
|
||||
arguments: { tripId: trip.id, bagId, userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_packing_category_assignees
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_packing_category_assignees', () => {
|
||||
it('returns empty object initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_packing_category_assignees',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignees).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_packing_category_assignees
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_packing_category_assignees', () => {
|
||||
it('sets category assignees and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_packing_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('clears assignees when passed empty array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare('INSERT INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Clothing', user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_packing_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).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);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_packing_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Electronics', userIds: [] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply_packing_template
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: apply_packing_template', () => {
|
||||
it('returns error for non-existent template', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'apply_packing_template',
|
||||
arguments: { tripId: trip.id, templateId: 99999 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// save_packing_template
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: save_packing_template', () => {
|
||||
it('saves the current packing list as a template', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'save_packing_template',
|
||||
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).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);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'save_packing_template',
|
||||
arguments: { tripId: trip.id, templateName: 'X' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bulk_import_packing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: bulk_import_packing', () => {
|
||||
it('imports multiple packing items and count matches', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const items = [
|
||||
{ name: 'Passport', category: 'Documents' },
|
||||
{ name: 'Charger', category: 'Electronics' },
|
||||
{ name: 'Sunscreen', category: 'Toiletries' },
|
||||
];
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'bulk_import_packing',
|
||||
arguments: { tripId: trip.id, items },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.count).toBe(items.length);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', 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: 'bulk_import_packing',
|
||||
arguments: { tripId: trip.id, items: [{ name: 'Item' }] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'bulk_import_packing',
|
||||
arguments: { tripId: trip.id, items: [{ name: 'Item' }] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Unit tests for MCP tag, maps extras, and weather tools:
|
||||
* list_tags, create_tag, update_tag, delete_tag,
|
||||
* get_place_details, reverse_geocode, resolve_maps_url,
|
||||
* get_weather, get_detailed_weather.
|
||||
*/
|
||||
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 } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/mapsService', () => ({
|
||||
searchPlaces: vi.fn(),
|
||||
getPlaceDetails: vi.fn().mockResolvedValue({ name: 'Eiffel Tower', address: 'Paris' }),
|
||||
reverseGeocode: vi.fn().mockResolvedValue({ name: 'Paris', address: 'France' }),
|
||||
resolveGoogleMapsUrl: vi.fn().mockResolvedValue({ lat: 48.8566, lng: 2.3522, name: 'Paris' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/services/weatherService', () => ({
|
||||
getWeather: vi.fn().mockResolvedValue({ temp: 20, condition: 'sunny' }),
|
||||
getDetailedWeather: vi.fn().mockResolvedValue({ hourly: [] }),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import * as mapsService from '../../../src/services/mapsService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.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(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_tags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_tags', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_tags', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.tags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only tags belonging to the current user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'My Tag', '#ff0000');
|
||||
testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(other.id, 'Other Tag', '#00ff00');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_tags', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.tags).toHaveLength(1);
|
||||
expect(data.tags[0].name).toBe('My Tag');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_tag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_tag', () => {
|
||||
it('creates a tag and returns the tag object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_tag',
|
||||
arguments: { name: 'Adventure', color: '#ff5500' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.tag).toBeDefined();
|
||||
expect(data.tag.name).toBe('Adventure');
|
||||
expect(data.tag.color).toBe('#ff5500');
|
||||
expect(data.tag.user_id).toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a tag with only a name', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_tag',
|
||||
arguments: { name: 'Food' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.tag.name).toBe('Food');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_tag',
|
||||
arguments: { name: 'Blocked' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_tag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_tag', () => {
|
||||
it('updates tag name and color', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'Old Name', '#aaaaaa');
|
||||
const tagId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_tag',
|
||||
arguments: { tagId, name: 'New Name', color: '#bbbbbb' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.tag).toBeDefined();
|
||||
expect(data.tag.name).toBe('New Name');
|
||||
expect(data.tag.color).toBe('#bbbbbb');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError for non-existent tagId', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_tag',
|
||||
arguments: { tagId: 99999, name: 'X' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_tag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_tag', () => {
|
||||
it('removes the tag row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'To Delete', '#cccccc');
|
||||
const tagId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_tag',
|
||||
arguments: { tagId },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(tagId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_place_details
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_place_details', () => {
|
||||
it('returns details from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_place_details',
|
||||
arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.details).toBeDefined();
|
||||
expect(data.details.name).toBe('Eiffel Tower');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError when service returns null', async () => {
|
||||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||||
(getPlaceDetails as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_place_details',
|
||||
arguments: { placeId: 'nonexistent-place-id' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reverse_geocode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: reverse_geocode', () => {
|
||||
it('returns result from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reverse_geocode',
|
||||
arguments: { lat: 48.8566, lng: 2.3522 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.name).toBe('Paris');
|
||||
expect(data.address).toBe('France');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolve_maps_url
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: resolve_maps_url', () => {
|
||||
it('returns result from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'resolve_maps_url',
|
||||
arguments: { url: 'https://maps.app.goo.gl/example' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.lat).toBe(48.8566);
|
||||
expect(data.lng).toBe(2.3522);
|
||||
expect(data.name).toBe('Paris');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_weather
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_weather', () => {
|
||||
it('returns weather from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_weather',
|
||||
arguments: { lat: 48.8566, lng: 2.3522, date: '2025-07-01' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.weather).toBeDefined();
|
||||
expect(data.weather.temp).toBe(20);
|
||||
expect(data.weather.condition).toBe('sunny');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_detailed_weather
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_detailed_weather', () => {
|
||||
it('returns detailed weather from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_detailed_weather',
|
||||
arguments: { lat: 48.8566, lng: 2.3522, date: '2025-07-01' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.weather).toBeDefined();
|
||||
expect(Array.isArray(data.weather.hourly)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Unit tests for MCP todo tools:
|
||||
* create_todo, update_todo, toggle_todo, delete_todo, reorder_todos,
|
||||
* list_todos, get_todo_category_assignees, set_todo_category_assignees.
|
||||
*/
|
||||
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 } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createTodoItem } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.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(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_todos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_todos', () => {
|
||||
it('returns empty list for a new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns todos ordered by sort_order', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createTodoItem(testDb, trip.id, { name: 'First' });
|
||||
createTodoItem(testDb, trip.id, { name: 'Second' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.items).toHaveLength(2);
|
||||
expect(data.items[0].name).toBe('First');
|
||||
});
|
||||
});
|
||||
|
||||
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: 'list_todos', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_todo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_todo', () => {
|
||||
it('creates a todo item 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_todo',
|
||||
arguments: {
|
||||
tripId: trip.id,
|
||||
name: 'Book hotel',
|
||||
category: 'Booking',
|
||||
due_date: '2025-06-01',
|
||||
description: 'Find a good deal',
|
||||
priority: 2,
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('Book hotel');
|
||||
expect(data.item.category).toBe('Booking');
|
||||
expect(data.item.due_date).toBe('2025-06-01');
|
||||
expect(data.item.priority).toBe(2);
|
||||
expect(data.item.checked).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a minimal todo item', 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_todo',
|
||||
arguments: { tripId: trip.id, name: 'Pack bags' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('Pack bags');
|
||||
expect(data.item.checked).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts todo: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_todo', arguments: { tripId: trip.id, name: 'Test' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo: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_todo', arguments: { tripId: trip.id, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_todo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_todo', () => {
|
||||
it('updates todo name and category', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id, { name: 'Old name', category: 'General' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_todo',
|
||||
arguments: { tripId: trip.id, itemId: item.id, name: 'New name', category: 'Booking' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('New name');
|
||||
expect(data.item.category).toBe('Booking');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears due_date when passed null', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("INSERT INTO todo_items (trip_id, name, checked, sort_order, due_date) VALUES (?, 'Task', 0, 0, '2025-01-01')").run(trip.id);
|
||||
const item = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as any;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_todo',
|
||||
arguments: { tripId: trip.id, itemId: item.id, due_date: null },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.due_date).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts todo:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item 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_todo', arguments: { tripId: trip.id, itemId: 99999, name: '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 item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle_todo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: toggle_todo', () => {
|
||||
it('marks a todo as done', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_todo',
|
||||
arguments: { tripId: trip.id, itemId: item.id, checked: true },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.checked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('unchecks a done todo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id, { checked: 1 });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_todo',
|
||||
arguments: { tripId: trip.id, itemId: item.id, checked: false },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.checked).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts todo:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'toggle_todo', arguments: { tripId: trip.id, itemId: item.id, checked: true } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item 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: 'toggle_todo', arguments: { tripId: trip.id, itemId: 99999, checked: true } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_todo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_todo', () => {
|
||||
it('deletes an existing todo item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM todo_items WHERE id = ?').get(item.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts todo:deleted event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:deleted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item 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_todo', arguments: { tripId: trip.id, itemId: 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 item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reorder_todos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: reorder_todos', () => {
|
||||
it('reorders todo items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item1 = createTodoItem(testDb, trip.id, { name: 'First' });
|
||||
const item2 = createTodoItem(testDb, trip.id, { name: 'Second' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reorder_todos',
|
||||
arguments: { tripId: trip.id, orderedIds: [item2.id, item1.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
// item2 should now have sort_order 0
|
||||
const updated = testDb.prepare('SELECT sort_order FROM todo_items WHERE id = ?').get(item2.id) as any;
|
||||
expect(updated.sort_order).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
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: 'reorder_todos', arguments: { tripId: trip.id, orderedIds: [1] } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_todo_category_assignees
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_todo_category_assignees', () => {
|
||||
it('returns empty object for a new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_todo_category_assignees', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignees).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_todo_category_assignees
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_todo_category_assignees', () => {
|
||||
it('sets category assignees and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_todo_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Booking', userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.assignees)).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:assignees', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('clears assignees when passed empty array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Set then clear
|
||||
testDb.prepare('INSERT INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Booking', user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_todo_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Booking', userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignees).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_todo_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Test', userIds: [] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Unit tests for MCP trip member, copy, ICS, and share-link tools:
|
||||
* list_trip_members, add_trip_member, remove_trip_member,
|
||||
* copy_trip, export_trip_ics, get_share_link, create_share_link, delete_share_link.
|
||||
*/
|
||||
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 } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.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(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_trip_members
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_trip_members', () => {
|
||||
it('returns owner and empty members list for own trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.owner.id).toBe(user.id);
|
||||
expect(data.owner.role).toBe('owner');
|
||||
expect(Array.isArray(data.members)).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);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// add_trip_member
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: add_trip_member', () => {
|
||||
it('adds a member by username', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'add_trip_member',
|
||||
arguments: { tripId: trip.id, identifier: member.username },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.member.username).toBe(member.username);
|
||||
expect(data.member.role).toBe('member');
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts member:added event', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'add_trip_member',
|
||||
arguments: { tripId: trip.id, identifier: member.email },
|
||||
});
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:added', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when user not found', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'add_trip_member',
|
||||
arguments: { tripId: trip.id, identifier: 'nonexistent@example.com' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when non-owner tries to add', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(member.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'add_trip_member',
|
||||
arguments: { tripId: trip.id, identifier: outsider.username },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'add_trip_member',
|
||||
arguments: { tripId: trip.id, identifier: 'someone@example.com' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// remove_trip_member
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: remove_trip_member', () => {
|
||||
it('removes a member', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'remove_trip_member',
|
||||
arguments: { tripId: trip.id, memberId: member.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts member:removed event', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
await h.client.callTool({ name: 'remove_trip_member', arguments: { tripId: trip.id, memberId: member.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:removed', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when non-owner tries to remove', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(member.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'remove_trip_member',
|
||||
arguments: { tripId: trip.id, memberId: owner.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// copy_trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: copy_trip', () => {
|
||||
it('duplicates a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Original', start_date: '2025-01-01', end_date: '2025-01-03' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.trip).toBeTruthy();
|
||||
// New trip should be a different row
|
||||
const count = testDb.prepare('SELECT COUNT(*) as cnt FROM trips').get() as any;
|
||||
expect(count.cnt).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses custom title when provided', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Original' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id, title: 'My Copy' } });
|
||||
const newTrip = testDb.prepare("SELECT * FROM trips WHERE title = 'My Copy'").get() as any;
|
||||
expect(newTrip).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
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: 'copy_trip', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// export_trip_ics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: export_trip_ics', () => {
|
||||
it('returns ICS content for a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2025-06-01', end_date: '2025-06-05' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.ics).toContain('BEGIN:VCALENDAR');
|
||||
expect(data.ics).toContain('Paris Trip');
|
||||
expect(data.filename).toMatch(/\.ics$/);
|
||||
});
|
||||
});
|
||||
|
||||
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: 'export_trip_ics', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_share_link / create_share_link / delete_share_link
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_share_link', () => {
|
||||
it('returns null when no share link exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.link).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns share link info when it exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Create a share link directly
|
||||
testDb.prepare(
|
||||
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
|
||||
).run(trip.id, 'test-token-123', user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.link.token).toBe('test-token-123');
|
||||
expect(data.link.share_map).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: create_share_link', () => {
|
||||
it('creates a new share link', 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_share_link',
|
||||
arguments: { tripId: trip.id, share_map: true, share_bookings: false, share_packing: false },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.token).toBeTruthy();
|
||||
expect(data.created).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates existing share link permissions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare(
|
||||
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
|
||||
).run(trip.id, 'existing-token', user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_share_link',
|
||||
arguments: { tripId: trip.id, share_packing: true },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.created).toBe(false); // updated, not created
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: delete_share_link', () => {
|
||||
it('revokes the share link', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare(
|
||||
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
|
||||
).run(trip.id, 'to-delete', user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(trip.id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -337,4 +337,18 @@ describe('Tool: get_trip_summary', () => {
|
||||
expect(data.trip.title).toBe('Demo Trip');
|
||||
});
|
||||
});
|
||||
|
||||
it('includes todos, files, pollCount, messageCount in response', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Summary Test' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_trip_summary', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.todos)).toBe(true);
|
||||
expect(Array.isArray(data.files)).toBe(true);
|
||||
expect(typeof data.pollCount).toBe('number');
|
||||
expect(typeof data.messageCount).toBe('number');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Unit tests for MCP vacay tools (vacay addon-gated):
|
||||
* get_vacay_plan, update_vacay_plan, set_vacay_color,
|
||||
* list_vacay_years, add_vacay_year, delete_vacay_year,
|
||||
* get_vacay_entries, toggle_vacay_entry, toggle_company_holiday,
|
||||
* get_vacay_stats, update_vacay_stats,
|
||||
* add_holiday_calendar, update_holiday_calendar, delete_holiday_calendar,
|
||||
* list_holiday_countries, list_holidays.
|
||||
* Resources: trek://vacay/plan, trek://vacay/entries/{year}.
|
||||
*/
|
||||
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 } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
// Mock async service functions that make external calls
|
||||
vi.mock('../../../src/services/vacayService', async (importOriginal) => {
|
||||
const original = await importOriginal() as Record<string, unknown>;
|
||||
return {
|
||||
...original,
|
||||
updatePlan: vi.fn().mockResolvedValue(undefined),
|
||||
getCountries: vi.fn().mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }),
|
||||
getHolidays: vi.fn().mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }),
|
||||
};
|
||||
});
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.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(); }
|
||||
}
|
||||
|
||||
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: true });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_vacay_plan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_vacay_plan', () => {
|
||||
it('returns plan data object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_vacay_plan', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.plan).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_vacay_plan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_vacay_plan', () => {
|
||||
it('calls updatePlan and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_vacay_plan',
|
||||
arguments: { block_weekends: true, holidays_enabled: false },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_vacay_plan', arguments: { block_weekends: true } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_vacay_color
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_vacay_color', () => {
|
||||
it('updates color and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#6366f1' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#ff0000' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_vacay_years
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_vacay_years', () => {
|
||||
it('returns years array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_vacay_years', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.years)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// add_vacay_year
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: add_vacay_year', () => {
|
||||
it('adds year to list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.years)).toBe(true);
|
||||
expect(data.years).toContain(2025);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_vacay_year
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_vacay_year', () => {
|
||||
it('removes year from list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
// Add year first
|
||||
await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
|
||||
const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.years)).toBe(true);
|
||||
expect(data.years).not.toContain(2025);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_vacay_entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_vacay_entries', () => {
|
||||
it('returns entries array (empty initially)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_vacay_entries', arguments: { year: 2025 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.entries).toBeDefined();
|
||||
expect(Array.isArray(data.entries.entries)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle_vacay_entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: toggle_vacay_entry', () => {
|
||||
it('toggles entry and returns action', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-15' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.action).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-15' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle_company_holiday
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: toggle_company_holiday', () => {
|
||||
it('toggles company holiday and returns action', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_company_holiday',
|
||||
arguments: { date: '2025-12-25', note: 'Christmas' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.action).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'toggle_company_holiday', arguments: { date: '2025-12-25' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_vacay_stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_vacay_stats', () => {
|
||||
it('returns stats object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_vacay_stats', arguments: { year: 2025 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.stats).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_vacay_stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_vacay_stats', () => {
|
||||
it('updates vacation days allowance and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 25 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 20 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// add_holiday_calendar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: add_holiday_calendar', () => {
|
||||
it('inserts calendar row and returns calendar', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'add_holiday_calendar',
|
||||
arguments: { region: 'US', label: 'US Holidays', color: '#ff0000' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.calendar).toBeDefined();
|
||||
expect(data.calendar.region).toBe('US');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'US' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_holiday_calendar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_holiday_calendar', () => {
|
||||
it('updates label and color', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
// First add a calendar
|
||||
const addResult = await h.client.callTool({
|
||||
name: 'add_holiday_calendar',
|
||||
arguments: { region: 'DE', label: 'Germany' },
|
||||
});
|
||||
const added = parseToolResult(addResult) as any;
|
||||
const calId = added.calendar.id;
|
||||
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_holiday_calendar',
|
||||
arguments: { calendarId: calId, label: 'German Holidays', color: '#00ff00' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.calendar).toBeDefined();
|
||||
expect(data.calendar.label).toBe('German Holidays');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_holiday_calendar', arguments: { calendarId: 1, label: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_holiday_calendar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_holiday_calendar', () => {
|
||||
it('removes calendar and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const addResult = await h.client.callTool({
|
||||
name: 'add_holiday_calendar',
|
||||
arguments: { region: 'FR' },
|
||||
});
|
||||
const added = parseToolResult(addResult) as any;
|
||||
const calId = added.calendar.id;
|
||||
|
||||
const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: calId } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_holiday_countries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_holiday_countries', () => {
|
||||
it('returns countries from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_holiday_countries', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.countries).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_holidays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_holidays', () => {
|
||||
it('returns holidays from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_holidays', arguments: { country: 'US', year: 2025 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.holidays).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://vacay/plan', () => {
|
||||
it('returns plan data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://vacay/plan' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://vacay/entries/{year}', () => {
|
||||
it('returns entries for a year', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://vacay/entries/2025' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data).toBeDefined();
|
||||
expect(Array.isArray(data.entries)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user