Files
TREK/server/tests/unit/mcp/tools-trip-warnings.test.ts
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

310 lines
12 KiB
TypeScript

/**
* Unit tests for the get_trip_warnings MCP tool (src/nest/plugins/contributions/
* trip-warnings.mcp.ts), the MCP counterpart of GET /api/trip-warnings/:tripId.
*/
import { db as testDb } from '../../../src/db/database';
import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import { addTripMember, createTrip, createUser } from '../../helpers/factories';
import { FakeRealtimeService } from '../../helpers/fake-realtime';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const { pluginsEnabled } = vi.hoisted(() => ({
pluginsEnabled: vi.fn(() => true),
}));
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
// The admin kill switch reads live env; drive it from the test instead of the process.
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
const realtime = new FakeRealtimeService();
const broadcastMock = realtime.broadcastMock;
// The harness builds the registry itself, so the fan-out is played on the prototype
// (the tools-transit.test.ts pattern). vitest is not configured to auto-restore, so
// every case restates what it wants in beforeEach.
const providersOfMock = vi.spyOn(PluginHooks.prototype, 'providersOf');
const tripWarningsMock = vi.spyOn(PluginHooks.prototype, 'tripWarnings');
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
pluginsEnabled.mockReturnValue(true);
providersOfMock.mockReset().mockReturnValue([]);
tripWarningsMock.mockReset().mockResolvedValue([]);
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>, scopes?: string[] | null) {
const h = await createMcpHarness({ realtime, userId, withResources: false, scopes: scopes ?? null });
try {
await fn(h);
} finally {
await h.cleanup();
}
}
interface WarningsPayload {
warnings: { pluginId: string; level: string; message: string; dayId?: number; placeId?: number }[];
}
const call = async (h: McpHarness, tripId: number) =>
h.client.callTool({ name: 'get_trip_warnings', arguments: { tripId } });
// ---------------------------------------------------------------------------
// Happy path
// ---------------------------------------------------------------------------
describe('Tool: get_trip_warnings', () => {
it('merges every provider, tags each warning with the plugin that raised it', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Kyoto' });
providersOfMock.mockReturnValue(['seasons', 'logistics']);
tripWarningsMock.mockImplementation(async (pluginId: string) =>
pluginId === 'seasons'
? [{ level: 'info', message: 'Rainy week ahead' }]
: [{ level: 'error', message: 'Day 3 has no way back', dayId: 31 }],
);
await withHarness(user.id, async (h) => {
const payload = parseToolResult(await call(h, trip.id)) as WarningsPayload;
expect(payload.warnings).toEqual([
{ pluginId: 'seasons', level: 'info', message: 'Rainy week ahead' },
{ pluginId: 'logistics', level: 'error', message: 'Day 3 has no way back', dayId: 31 },
]);
expect(providersOfMock).toHaveBeenCalledWith('warningProvider');
expect(tripWarningsMock).toHaveBeenCalledWith('seasons', trip.id, user.id);
});
});
it('carries the place link through and defaults an unknown level to warning', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
providersOfMock.mockReturnValue(['opening-hours']);
tripWarningsMock.mockResolvedValue([{ level: 'catastrophe', message: 'Museum closed Mondays', placeId: 7 }]);
await withHarness(user.id, async (h) => {
const payload = parseToolResult(await call(h, trip.id)) as WarningsPayload;
expect(payload.warnings).toEqual([
{ pluginId: 'opening-hours', level: 'warning', message: 'Museum closed Mondays', placeId: 7 },
]);
});
});
it('answers with an empty list when no plugin provides warnings', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const payload = parseToolResult(await call(h, trip.id)) as WarningsPayload;
expect(payload).toEqual({ warnings: [] });
expect(tripWarningsMock).not.toHaveBeenCalled();
});
});
it('is readable by a collaborator, not just the owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
providersOfMock.mockReturnValue(['logistics']);
tripWarningsMock.mockResolvedValue([{ level: 'warning', message: 'Tight connection' }]);
await withHarness(member.id, async (h) => {
const payload = parseToolResult(await call(h, trip.id)) as WarningsPayload;
expect(payload.warnings).toHaveLength(1);
expect(tripWarningsMock).toHaveBeenCalledWith('logistics', trip.id, member.id);
});
});
});
// ---------------------------------------------------------------------------
// Access
// ---------------------------------------------------------------------------
describe('get_trip_warnings access', () => {
it('refuses a trip the caller is not on, without asking any provider', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
providersOfMock.mockReturnValue(['logistics']);
await withHarness(stranger.id, async (h) => {
const result = await call(h, trip.id);
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain('Trip not found or access denied.');
expect(tripWarningsMock).not.toHaveBeenCalled();
});
});
it('refuses a trip that does not exist', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await call(h, 999999);
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain('Trip not found or access denied.');
});
});
it('checks trip access before the kill switch, so a stranger never learns the switch is off', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
pluginsEnabled.mockReturnValue(false);
await withHarness(stranger.id, async (h) => {
const result = await call(h, trip.id);
expect(result.isError).toBe(true);
});
});
it('rides trips:read: present with it, absent without it', async () => {
const { user } = createUser(testDb);
await withHarness(
user.id,
async (h) => {
const names = (await h.client.listTools()).tools.map((t) => t.name);
expect(names).toContain('get_trip_warnings');
},
['trips:read'],
);
await withHarness(
user.id,
async (h) => {
const names = (await h.client.listTools()).tools.map((t) => t.name);
expect(names).not.toContain('get_trip_warnings');
},
['places:read'],
);
});
});
// ---------------------------------------------------------------------------
// Degrading rather than failing
// ---------------------------------------------------------------------------
describe('get_trip_warnings resilience', () => {
it('answers empty when an admin has the plugin system switched off', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
pluginsEnabled.mockReturnValue(false);
providersOfMock.mockReturnValue(['logistics']);
await withHarness(user.id, async (h) => {
const payload = parseToolResult(await call(h, trip.id)) as WarningsPayload;
expect(payload).toEqual({ warnings: [] });
expect(providersOfMock).not.toHaveBeenCalled();
});
});
it('drops a provider that throws or times out and keeps the rest', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
providersOfMock.mockReturnValue(['broken', 'healthy']);
tripWarningsMock.mockImplementation(async (pluginId: string) => {
if (pluginId === 'broken') throw new Error('hook timed out after 5000ms');
return [{ level: 'warning', message: 'Still here' }];
});
await withHarness(user.id, async (h) => {
const result = await call(h, trip.id);
expect(result.isError).toBeFalsy();
const payload = parseToolResult(result) as WarningsPayload;
expect(payload.warnings).toEqual([{ pluginId: 'healthy', level: 'warning', message: 'Still here' }]);
});
});
it('ignores a provider that answers with something other than an array', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
providersOfMock.mockReturnValue(['confused']);
tripWarningsMock.mockResolvedValue({ warnings: 'lots' });
await withHarness(user.id, async (h) => {
const payload = parseToolResult(await call(h, trip.id)) as WarningsPayload;
expect(payload).toEqual({ warnings: [] });
});
});
it('skips null entries instead of losing the whole provider to them', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
providersOfMock.mockReturnValue(['sloppy']);
tripWarningsMock.mockResolvedValue([null, { level: 'info', message: 'Survives' }, 'nope']);
await withHarness(user.id, async (h) => {
const payload = parseToolResult(await call(h, trip.id)) as WarningsPayload;
expect(payload.warnings).toEqual([{ pluginId: 'sloppy', level: 'info', message: 'Survives' }]);
});
});
it('drops a warning with no message rather than emitting a blank line', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
providersOfMock.mockReturnValue(['sloppy']);
tripWarningsMock.mockResolvedValue([{ level: 'error' }, { level: 'error', message: '' }]);
await withHarness(user.id, async (h) => {
const payload = parseToolResult(await call(h, trip.id)) as WarningsPayload;
expect(payload).toEqual({ warnings: [] });
});
});
});
// ---------------------------------------------------------------------------
// Caps
// ---------------------------------------------------------------------------
describe('get_trip_warnings caps', () => {
it('caps a flooding provider at 20 warnings and truncates an oversized message', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
providersOfMock.mockReturnValue(['flood']);
tripWarningsMock.mockResolvedValue([
{ level: 'warning', message: 'z'.repeat(1000) },
...Array.from({ length: 50 }, (_v, i) => ({ level: 'info', message: `w${i}` })),
]);
await withHarness(user.id, async (h) => {
const payload = parseToolResult(await call(h, trip.id)) as WarningsPayload;
expect(payload.warnings).toHaveLength(20);
expect(payload.warnings[0].message).toHaveLength(300);
});
});
it('caps each provider separately, so two providers can contribute 40', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
providersOfMock.mockReturnValue(['a', 'b']);
tripWarningsMock.mockResolvedValue(Array.from({ length: 50 }, (_v, i) => ({ level: 'info', message: `w${i}` })));
await withHarness(user.id, async (h) => {
const payload = parseToolResult(await call(h, trip.id)) as WarningsPayload;
expect(payload.warnings).toHaveLength(40);
});
});
it('strips emojis, because the assistant relays this text as TREK chrome', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
providersOfMock.mockReturnValue(['loud']);
tripWarningsMock.mockResolvedValue([{ level: 'error', message: '🔥 Overbooked!' }]);
await withHarness(user.id, async (h) => {
const payload = parseToolResult(await call(h, trip.id)) as WarningsPayload;
expect(payload.warnings[0].message).toBe('Overbooked!');
});
});
});