Files
TREK/server/tests/unit/mcp/tools-roadtrip.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

409 lines
16 KiB
TypeScript

/**
* The five road-trip MCP tools, and the shared driving settings beside them.
*
* They had no test at all, and the shared harness seeds the addon disabled, so
* `when: roadtripAddonOn` kept them from ever attaching: the handler bodies had
* never run. What matters here is the same thing the REST e2e pins, because the
* repo requires the two surfaces to move together: the addon gate, the
* day-belongs-to-this-trip check, the `day_edit` permission, and the cross-trip
* track check on the batch tool.
*/
import { ADDON_IDS } from '../../../src/addons';
import { db as testDb } from '../../../src/db/database';
import { Places } from '../../../src/db/entities/Places.entity';
import { RoadtripPreferences } from '../../../src/db/entities/RoadtripPreferences.entity';
import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity';
import { DEMO_EMAIL_PRIMARY } from '../../../src/nest/common/demo';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { createUser, createTrip, createDay, createPlace } from '../../helpers/factories';
import { addTripMember } from '../../helpers/factories';
import { countRows, findRow, updateRows } from '../../helpers/factories/rows';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
let orm: TestOrm;
// The permissions cache is module-scoped, so a write through any instance is
// what the tool's own check reads back.
let savePermissions: PermissionsService['savePermissions'];
beforeAll(async () => {
orm = await createTestOrm(testDb);
const permissionsService = new PermissionsService(
await createTestAppSettingsRepo(testDb),
await createTestUnitOfWork(testDb),
);
savePermissions = permissionsService.savePermissions.bind(permissionsService);
});
beforeEach(() => {
resetTestDb(testDb);
// resetTestDb leaves the addons table alone, so each run restates it. The
// addon ships disabled, which is why the tools were invisible before.
setAddonEnabled(testDb, ADDON_IDS.ROADTRIP, true);
});
afterAll(async () => {
await orm.close();
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false, scopes: null });
try {
await fn(h);
} finally {
await h.cleanup();
}
}
/** A trip with one day, plus a place carrying a route geometry to use as a track. */
async function scenario() {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Norway' });
const day = createDay(testDb, trip.id, { day_number: 1 });
const track = createPlace(testDb, trip.id, { name: 'Scenic' });
await updateRows(orm, Places, { id: track.id }, { route_geometry: '[[1,2],[3,4]]' });
return { user, trip, day, track };
}
describe('road-trip MCP tools', () => {
it('MCP-ROADTRIP-001: the five tools are gone while the addon is off', async () => {
const { user } = createUser(testDb);
setAddonEnabled(testDb, ADDON_IDS.ROADTRIP, false);
await withHarness(user.id, async (h) => {
const names = (await h.client.listTools()).tools.map((t) => t.name);
for (const tool of [
'list_route_vias',
'add_route_via',
'add_route_vias',
'reanchor_route_vias',
'remove_route_via',
]) {
expect(names, tool).not.toContain(tool);
}
});
});
it('MCP-ROADTRIP-002: and there while it is on', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const names = (await h.client.listTools()).tools.map((t) => t.name);
for (const tool of [
'list_route_vias',
'add_route_via',
'add_route_vias',
'reanchor_route_vias',
'remove_route_via',
]) {
expect(names, tool).toContain(tool);
}
});
});
it('MCP-ROADTRIP-003: adding a via, then reading it back for the day and for the trip', async () => {
const { user, trip, day } = await scenario();
await withHarness(user.id, async (h) => {
const added = await h.client.callTool({
name: 'add_route_via',
arguments: { tripId: trip.id, dayId: day.id, after_order_index: 0, lat: 53, lng: 10 },
});
expect(added.isError).toBeFalsy();
expect((parseToolResult(added) as { via: { day_id: number } }).via).toMatchObject({ day_id: day.id, lat: 53 });
const forDay = parseToolResult(
await h.client.callTool({
name: 'list_route_vias',
arguments: { tripId: trip.id, dayId: day.id },
}),
) as { vias: unknown[] };
expect(forDay.vias).toHaveLength(1);
// Without a day, the answer covers the whole trip and carries the tracks
// alongside, which is the shape the client reads on every load.
const forTrip = parseToolResult(
await h.client.callTool({
name: 'list_route_vias',
arguments: { tripId: trip.id },
}),
) as { vias: unknown[]; tracks: unknown[] };
expect(forTrip.vias).toHaveLength(1);
expect(forTrip.tracks).toEqual([]);
});
});
it('MCP-ROADTRIP-004: a day from another trip is refused on every tool', async () => {
// The same check the REST routes make, which is what the repo means by the
// two surfaces moving together.
const { user, trip } = await scenario();
const other = createTrip(testDb, user.id, { title: 'Elsewhere' });
const otherDay = createDay(testDb, other.id, { day_number: 1 });
await withHarness(user.id, async (h) => {
const calls: [string, Record<string, unknown>][] = [
['list_route_vias', { tripId: trip.id, dayId: otherDay.id }],
['add_route_via', { tripId: trip.id, dayId: otherDay.id, after_order_index: 0, lat: 53, lng: 10 }],
['add_route_vias', { tripId: trip.id, dayId: otherDay.id, vias: [{ after_order_index: 0, lat: 53, lng: 10 }] }],
['reanchor_route_vias', { tripId: trip.id, dayId: otherDay.id, vias: [] }],
['remove_route_via', { tripId: trip.id, dayId: otherDay.id, viaId: 1 }],
];
for (const [name, args] of calls) {
const res = await h.client.callTool({ name, arguments: args });
expect(res.isError, name).toBeTruthy();
}
expect(await countRows(orm, RoadtripVias)).toBe(0);
});
});
it('MCP-ROADTRIP-005: a trip the caller cannot reach is refused', async () => {
const { user } = createUser(testDb);
const stranger = createUser(testDb, { email: 'other@example.test' });
const theirs = createTrip(testDb, stranger.user.id, { title: 'Theirs' });
const theirDay = createDay(testDb, theirs.id, { day_number: 1 });
await withHarness(user.id, async (h) => {
const res = await h.client.callTool({
name: 'add_route_via',
arguments: { tripId: theirs.id, dayId: theirDay.id, after_order_index: 0, lat: 53, lng: 10 },
});
expect(res.isError).toBeTruthy();
});
});
it('MCP-ROADTRIP-006: a chain lands in one call and records its track', async () => {
const { user, trip, day, track } = await scenario();
await withHarness(user.id, async (h) => {
const res = await h.client.callTool({
name: 'add_route_vias',
arguments: {
tripId: trip.id,
dayId: day.id,
vias: [
{ after_order_index: 0, lat: 53, lng: 10 },
{ after_order_index: 0, lat: 53.5, lng: 10.5 },
],
track: { place_id: track.id, stray_km: 1.5 },
},
});
expect(res.isError).toBeFalsy();
expect((parseToolResult(res) as { vias: unknown[] }).vias).toHaveLength(2);
const all = parseToolResult(
await h.client.callTool({
name: 'list_route_vias',
arguments: { tripId: trip.id },
}),
) as { tracks: { day_id: number; place_id: number; stray_km: number }[] };
expect(all.tracks).toEqual([{ day_id: day.id, place_id: track.id, stray_km: 1.5 }]);
});
});
it('MCP-ROADTRIP-007: a track from another trip cannot label this day', async () => {
const { user, trip, day } = await scenario();
const stranger = createUser(testDb, { email: 'other2@example.test' });
const theirs = createTrip(testDb, stranger.user.id, { title: 'Theirs' });
const theirTrack = createPlace(testDb, theirs.id, { name: 'Not yours' });
await updateRows(orm, Places, { id: theirTrack.id }, { route_geometry: '[[1,2]]' });
await withHarness(user.id, async (h) => {
const res = await h.client.callTool({
name: 'add_route_vias',
arguments: {
tripId: trip.id,
dayId: day.id,
vias: [{ after_order_index: 0, lat: 53, lng: 10 }],
track: { place_id: theirTrack.id },
},
});
expect(res.isError).toBeTruthy();
expect(await countRows(orm, RoadtripVias)).toBe(0);
});
});
it('MCP-ROADTRIP-008: re-anchoring moves what it names and removes what it lists', async () => {
const { user, trip, day } = await scenario();
await withHarness(user.id, async (h) => {
const first = parseToolResult(
await h.client.callTool({
name: 'add_route_via',
arguments: { tripId: trip.id, dayId: day.id, after_order_index: 0, lat: 53, lng: 10 },
}),
) as { via: { id: number } };
const second = parseToolResult(
await h.client.callTool({
name: 'add_route_via',
arguments: { tripId: trip.id, dayId: day.id, after_order_index: 1, lat: 54, lng: 11 },
}),
) as { via: { id: number } };
const res = parseToolResult(
await h.client.callTool({
name: 'reanchor_route_vias',
arguments: {
tripId: trip.id,
dayId: day.id,
vias: [{ id: second.via.id, after_order_index: 0 }],
remove: [first.via.id],
},
}),
) as { vias: { id: number; after_order_index: number }[] };
expect(res.vias).toHaveLength(1);
expect(res.vias[0]).toMatchObject({ id: second.via.id, after_order_index: 0 });
});
});
it('MCP-ROADTRIP-010: a member without day_edit may read but not write', async () => {
// The permission the controller demands is the permission the tool demands:
// an assistant must never be able to do through a tool what the person it
// is acting for cannot do through the UI.
const { user, trip, day } = await scenario();
const member = createUser(testDb, { email: 'member@example.test' });
addTripMember(testDb, trip.id, member.user.id);
await savePermissions({ day_edit: 'trip_owner' });
try {
await withHarness(member.user.id, async (h) => {
const read = await h.client.callTool({
name: 'list_route_vias',
arguments: { tripId: trip.id, dayId: day.id },
});
expect(read.isError).toBeFalsy();
for (const name of ['add_route_via', 'add_route_vias', 'reanchor_route_vias', 'remove_route_via']) {
const args: Record<string, unknown> = { tripId: trip.id, dayId: day.id };
if (name === 'add_route_via') Object.assign(args, { after_order_index: 0, lat: 53, lng: 10 });
if (name === 'add_route_vias') args.vias = [{ after_order_index: 0, lat: 53, lng: 10 }];
if (name === 'reanchor_route_vias') args.vias = [];
if (name === 'remove_route_via') args.viaId = 1;
const res = await h.client.callTool({ name, arguments: args });
expect(res.isError, name).toBeTruthy();
}
});
expect(await countRows(orm, RoadtripVias)).toBe(0);
} finally {
await savePermissions({ day_edit: 'trip_member' });
}
// The owner is unaffected.
await withHarness(user.id, async (h) => {
const res = await h.client.callTool({
name: 'add_route_via',
arguments: { tripId: trip.id, dayId: day.id, after_order_index: 0, lat: 53, lng: 10 },
});
expect(res.isError).toBeFalsy();
});
});
it('MCP-ROADTRIP-011: the demo account may look but never write', async () => {
const { trip, day } = await scenario();
const demo = createUser(testDb, { email: DEMO_EMAIL_PRIMARY });
addTripMember(testDb, trip.id, demo.user.id);
process.env.DEMO_MODE = 'true';
try {
await withHarness(demo.user.id, async (h) => {
const read = await h.client.callTool({
name: 'list_route_vias',
arguments: { tripId: trip.id, dayId: day.id },
});
expect(read.isError).toBeFalsy();
const write = await h.client.callTool({
name: 'add_route_via',
arguments: { tripId: trip.id, dayId: day.id, after_order_index: 0, lat: 53, lng: 10 },
});
expect(write.isError).toBeTruthy();
expect((write.content as { text: string }[])[0].text).toContain('demo');
});
} finally {
delete process.env.DEMO_MODE;
}
expect(await countRows(orm, RoadtripVias)).toBe(0);
});
it('MCP-ROADTRIP-012: the stay switch is off until it is set, and round-trips through the settings tools', async () => {
const { user, trip } = await scenario();
const stored = async () => {
const row = await findRow(orm, RoadtripPreferences, { trip: trip.id, key: 'roadtrip_hotel_bookends' });
return row ? { value: row.value } : undefined;
};
await withHarness(user.id, async (h) => {
const before = parseToolResult(
await h.client.callTool({ name: 'get_roadtrip_settings', arguments: { tripId: trip.id } }),
) as {
settings: Record<string, unknown>;
};
// Missing means off: nothing is written for a trip that never touched it.
expect(before.settings).not.toHaveProperty('roadtrip_hotel_bookends');
expect(await stored()).toBeUndefined();
const saved = await h.client.callTool({
name: 'update_roadtrip_settings',
arguments: { tripId: trip.id, settings: { roadtrip_hotel_bookends: true } },
});
expect(saved.isError).toBeFalsy();
const after = parseToolResult(
await h.client.callTool({ name: 'get_roadtrip_settings', arguments: { tripId: trip.id } }),
) as {
settings: Record<string, unknown>;
};
expect(after.settings.roadtrip_hotel_bookends).toBe(true);
});
expect(await stored()).toEqual({ value: 'true' });
});
it('MCP-ROADTRIP-013: a stay switch that is not a boolean is refused, and nothing is stored', async () => {
const { user, trip } = await scenario();
await withHarness(user.id, async (h) => {
for (const value of ['yes', 1, null]) {
const res = await h.client.callTool({
name: 'update_roadtrip_settings',
arguments: { tripId: trip.id, settings: { roadtrip_hotel_bookends: value } },
});
expect(res.isError, String(value)).toBeTruthy();
}
});
expect(await countRows(orm, RoadtripPreferences, { trip: trip.id })).toBe(0);
});
it('MCP-ROADTRIP-014: both via tools say that a via on the drive into a booked night is kept and not used', async () => {
// The planner refuses that click with a sentence; a tool stores the via as asked, so
// the note on the tool is the only place the assistant can learn it changes nothing.
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const tools = (await h.client.listTools()).tools;
for (const name of ['add_route_via', 'add_route_vias']) {
const description = tools.find((t) => t.name === name)?.description ?? '';
expect(description, name).toContain('roadtrip_hotel_bookends');
expect(description, name).toContain('kept but not used');
}
});
});
it('MCP-ROADTRIP-009: removing one that is not on the day is refused, not silently ignored', async () => {
const { user, trip, day } = await scenario();
await withHarness(user.id, async (h) => {
const res = await h.client.callTool({
name: 'remove_route_via',
arguments: { tripId: trip.id, dayId: day.id, viaId: 9999 },
});
expect(res.isError).toBeTruthy();
});
});
});