Files
TREK/server/tests/unit/mcp/tools-transit.test.ts
T
Maurice b3a085df3f refactor(server): make the state ports async and route every reader through the provided store
The process-state ports were meant to let a store shared between
processes replace the in-memory one, but they could not. The readers
outside the container (the broadcast functions, the backup restore's
permissions-cache flush, the pending-code sweep, hand-built consumers)
went straight to the module-level in-memory instances, so an overridden
provider was used by the gateway and the services while the broadcasts
and the restore flush kept using the old store. And every port was
synchronous, while a store in the database or on the network is async,
so plugging one in would have changed every signature anyway.

RateLimitStore, PendingCodeStore, OidcFlowStore and
PermissionsCacheStore now return promises, and RateLimitService,
OauthService, OidcService, PermissionsService and their callers await
them. The in-memory stores still decide synchronously, so who gets a
429, a code or a cached level is unchanged. RoomRegistry stays
synchronous: membership holds live sockets and broadcast() is a
synchronous call every domain makes, so several processes need a
fan-out in front of broadcast(), not a different place for the sets.

The process-wide ports get a ProcessStoreSlot. PermissionsModule,
OauthModule and RealtimeGatewayModule install the store the container
resolved and release it on shutdown, and the free functions, the sweep
and hand-built consumers read the slot, so overriding the provider
changes every reader. Tests provide fake stores and check that the
broadcasts, the restore flush and the sweep use them.

Two callers needed more than an await. The transit MCP tools counted in
a RateLimitService of their own that no provider could reach; they now
take the one RateLimitModule provides (their mcp_transit_* buckets never
overlap the REST ones). The link preview claimed its in-flight slot only
after the budget check, which now yields, so simultaneous askers would
each have been charged; it now claims the slot first, and an asker that
joined someone out of budget asks again on its own budget.

The unused putPendingCode/takePendingCode helpers are gone.
2026-10-08 18:41:01 +02:00

522 lines
21 KiB
TypeScript

import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { addTripMember, createDay, createTrip, createUser } from '../../helpers/factories';
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';
import { db as testDb } from '../../../src/db/database';
const { broadcastMock } = vi.hoisted(() => ({
broadcastMock: vi.fn(),
}));
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import type { TransitPlace } from '../../../src/nest/transit/transit.helpers';
import { TransitService } from '../../../src/nest/transit/transit.service';
import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
// savePermissions is no longer bridged; write through a service instance — the
// permissions cache is module-scoped, so the MCP _shared checkPermission path
// sees the write immediately.
let permissionsService: PermissionsService;
let savePermissions: typeof permissionsService.savePermissions;
beforeAll(async () => {
permissionsService = new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb));
savePermissions = permissionsService.savePermissions.bind(permissionsService);
});
// The transit tools live on the DI-discovered transit.mcp.ts since the transit
// fold; the test registry builds a real TransitService (and injects a real
// ReservationsService over the mocked db proxy), so stub the provider methods
// on the prototype (no auto-restore in the vitest config — these survive
// across tests, exactly like the old module mocks did).
const geocodeMock = vi.spyOn(TransitService.prototype, 'geocode');
const planMock = vi.spyOn(TransitService.prototype, 'plan');
const notifyBookingChangeMock = vi
.spyOn(ReservationsService.prototype, 'notifyBookingChange')
.mockImplementation(async () => {});
const from = { name: 'Namba', lat: 34.667, lng: 135.501 };
const to = { name: 'Umeda', lat: 34.702, lng: 135.496 };
const itinerary = {
startTime: '2026-12-03T00:00:00Z',
endTime: '2026-12-03T00:30:00Z',
duration: 1800,
transfers: 0,
walkSeconds: 300,
legs: [
{
mode: 'WALK',
from: { ...from, name: 'START', time: '2026-12-03T00:00:00Z', scheduledTime: null, track: null },
to: {
name: 'Namba Station',
lat: 34.666,
lng: 135.5,
time: '2026-12-03T00:05:00Z',
scheduledTime: null,
track: null,
},
duration: 300,
distance: 300,
headsign: null,
line: null,
lineColor: null,
lineTextColor: null,
agency: null,
intermediateStops: 0,
geometry: null,
geometryPrecision: 6,
},
{
mode: 'SUBWAY',
from: {
name: 'Namba Station',
lat: 34.666,
lng: 135.5,
time: '2026-12-03T00:05:00Z',
scheduledTime: null,
track: '1',
},
to: { ...to, name: 'END', time: '2026-12-03T00:30:00Z', scheduledTime: null, track: '2' },
duration: 1500,
distance: 5000,
headsign: 'Umeda',
line: 'M',
lineColor: '#E5171F',
lineTextColor: '#FFFFFF',
agency: 'Osaka Metro',
intermediateStops: 3,
geometry: 'encoded',
geometryPrecision: 6,
},
],
};
beforeEach(async () => {
resetTestDb(testDb);
geocodeMock.mockReset();
planMock.mockReset();
broadcastMock.mockReset();
// mockReset would fall back to the real notification write — keep it stubbed.
notifyBookingChangeMock.mockReset().mockImplementation(async () => {});
delete process.env.DEMO_MODE;
await invalidatePermissionsCache();
});
afterAll(() => testDb.close());
async function withHarness(userId: number, scopes: string[] | null, fn: (harness: McpHarness) => Promise<void>) {
const harness = await createMcpHarness({ userId, scopes, withResources: false });
try {
await fn(harness);
} finally {
await harness.cleanup();
}
}
describe('MCP transit tools', () => {
it('registers search tools for geo scope and create tool for reservations scope', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, ['geo:read'], async (harness) => {
const names = (await harness.client.listTools()).tools.map((tool) => tool.name);
expect(names).toContain('search_transit_stops');
expect(names).toContain('search_transit_routes');
expect(names).not.toContain('create_transit_journey');
});
await withHarness(user.id, ['reservations:write'], async (harness) => {
const tools = (await harness.client.listTools()).tools;
const names = tools.map((tool) => tool.name);
expect(names).toContain('create_transit_journey');
expect(names).not.toContain('search_transit_routes');
expect(tools.find((tool) => tool.name === 'create_transit_journey')?.annotations?.openWorldHint).toBe(true);
});
});
it('forwards stop and route searches and replaces provider endpoint names', async () => {
const { user } = createUser(testDb);
// `from` is a tool-input place (name/lat/lng). A geocode result is a TransitPlace,
// which also carries `type` and `area`; the stop search only reads the name back out,
// so the fixture stays as it is instead of growing fields nothing here looks at.
geocodeMock.mockResolvedValue({ results: [from as TransitPlace], provider: 'transitous' });
planMock.mockResolvedValue({
provider: 'transitous',
itineraries: [
itinerary,
{ ...itinerary, legs: [itinerary.legs[0]] },
{
...itinerary,
legs: itinerary.legs.map((leg, index) => (index === 0 ? { ...leg, from: { ...leg.from, lat: 35.5 } } : leg)),
},
],
});
await withHarness(user.id, ['geo:read'], async (harness) => {
const stops = parseToolResult(
await harness.client.callTool({
name: 'search_transit_stops',
arguments: { query: 'Namba', language: 'ja', near: { lat: 34.67, lng: 135.5 } },
}),
) as any;
expect(stops.results[0].name).toBe('Namba');
// The caller's id trails the query so the Google backend (#1699) can
// resolve the install's key; the Transitous path ignores it.
expect(geocodeMock).toHaveBeenCalledWith('Namba', 'ja', '34.67,135.5', user.id);
const routes = parseToolResult(
await harness.client.callTool({
name: 'search_transit_routes',
arguments: { from, to, time: '2026-12-03T09:00:00+09:00', modes: ['SUBWAY'] },
}),
) as any;
expect(routes.itineraries[0].legs[0].from.name).toBe('Namba');
expect(routes.itineraries[0].legs[1].to.name).toBe('Umeda');
expect(routes.itineraries).toHaveLength(1);
expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' }), undefined, user.id);
const invalidNear = await harness.client.callTool({
name: 'search_transit_stops',
arguments: { query: 'Namba', near: { lat: 999, lng: 999 } },
});
expect(invalidNear.isError).toBe(true);
});
});
it('persists a selected itinerary with local dates, endpoints, and transit metadata', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2026-12-03') as any;
await withHarness(user.id, ['reservations:write'], async (harness) => {
const result = parseToolResult(
await harness.client.callTool({
name: 'create_transit_journey',
arguments: {
tripId: trip.id,
dayId: day.id,
from,
to,
itinerary: { ...itinerary, duration: 1, walkSeconds: 1 },
},
}),
) as any;
expect(result.reservation.type).toBe('transit');
expect(result.reservation.status).toBe('confirmed');
expect(result.reservation.reservation_time).toBe('2026-12-03T09:00');
expect(result.reservation.endpoints).toHaveLength(2);
expect(result.reservation.endpoints[0].timezone).toBe('Asia/Tokyo');
const metadata = JSON.parse(result.reservation.metadata);
expect(metadata.transit.provider).toBe('transitous');
expect(metadata.transit.duration).toBe(1800);
expect(metadata.transit.transfers).toBe(0);
expect(metadata.transit.walk_seconds).toBe(300);
expect(metadata.transit.legs[1].line_color).toBe('#E5171F');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:created', expect.anything());
expect(notifyBookingChangeMock).toHaveBeenCalledWith(trip.id, user.id, 'Namba → Umeda', 'transit');
});
});
it('rejects dateless, mismatched, and out-of-range journey dates', async () => {
const { user } = createUser(testDb);
const datelessTrip = createTrip(testDb, user.id);
const datelessDay = createDay(testDb, datelessTrip.id);
const datedTrip = createTrip(testDb, user.id, { start_date: '2026-12-02', end_date: '2026-12-03' });
const datedDay = testDb
.prepare('SELECT * FROM days WHERE trip_id = ? AND date = ?')
.get(datedTrip.id, '2026-12-02') as any;
await withHarness(user.id, ['reservations:write'], async (harness) => {
const dateless = await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: datelessTrip.id, dayId: datelessDay.id, from, to, itinerary },
});
expect(dateless.isError).toBe(true);
expect((dateless.content[0] as any).text).toContain('dated trip day');
const mismatch = await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: datedTrip.id, dayId: datedDay.id, from, to, itinerary },
});
expect(mismatch.isError).toBe(true);
expect((mismatch.content[0] as any).text).toContain('departs on 2026-12-03');
const nextDayItinerary = {
...itinerary,
endTime: '2026-12-04T00:30:00Z',
legs: itinerary.legs.map((leg, index) =>
index === itinerary.legs.length - 1
? { ...leg, duration: 87_900, to: { ...leg.to, time: '2026-12-04T00:30:00Z' } }
: leg,
),
};
const startDay = testDb
.prepare('SELECT * FROM days WHERE trip_id = ? AND date = ?')
.get(datedTrip.id, '2026-12-03') as any;
const outside = await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: datedTrip.id, dayId: startDay.id, from, to, itinerary: nextDayItinerary },
});
expect(outside.isError).toBe(true);
expect((outside.content[0] as any).text).toContain('No trip day exists');
});
});
it('rejects malformed provider data before persistence', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-03' });
const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any;
const allWalk = { ...itinerary, legs: [itinerary.legs[0]] };
await withHarness(user.id, ['reservations:write'], async (harness) => {
const result = await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary: allWalk },
});
expect(result.isError).toBe(true);
expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({
count: 0,
});
const wrongDestination = {
...itinerary,
legs: itinerary.legs.map((leg, index) =>
index === itinerary.legs.length - 1 ? { ...leg, to: { ...leg.to, lat: 35.0 } } : leg,
),
};
const mismatch = await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary: wrongDestination },
});
expect(mismatch.isError).toBe(true);
expect((mismatch.content[0] as any).text).toContain('does not match');
expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({
count: 0,
});
const invalidTime = {
...itinerary,
legs: itinerary.legs.map((leg, index) =>
index === 0 ? { ...leg, from: { ...leg.from, time: '09:00' } } : leg,
),
};
const malformedTime = await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary: invalidTime },
});
expect(malformedTime.isError).toBe(true);
const excessiveTransfers = await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary: { ...itinerary, transfers: 9 } },
});
expect(excessiveTransfers.isError).toBe(true);
const unanchored = await harness.client.callTool({
name: 'create_transit_journey',
arguments: {
tripId: trip.id,
dayId: day.id,
from,
to,
itinerary: { ...itinerary, endTime: '2026-12-04T00:30:00Z' },
},
});
expect(unanchored.isError).toBe(true);
const wrongDuration = await harness.client.callTool({
name: 'create_transit_journey',
arguments: {
tripId: trip.id,
dayId: day.id,
from,
to,
itinerary: {
...itinerary,
legs: itinerary.legs.map((leg, index) => (index === 0 ? { ...leg, duration: 1 } : leg)),
},
},
});
expect(wrongDuration.isError).toBe(true);
const overlappingLegs = await harness.client.callTool({
name: 'create_transit_journey',
arguments: {
tripId: trip.id,
dayId: day.id,
from,
to,
itinerary: {
...itinerary,
legs: itinerary.legs.map((leg, index) =>
index === 0 ? { ...leg, duration: 600, to: { ...leg.to, time: '2026-12-03T00:10:00Z' } } : leg,
),
},
},
});
expect(overlappingLegs.isError).toBe(true);
const missingTimes = await harness.client.callTool({
name: 'create_transit_journey',
arguments: {
tripId: trip.id,
dayId: day.id,
from,
to,
itinerary: {
...itinerary,
legs: itinerary.legs.map((leg, index) =>
index === 1 ? { ...leg, from: { ...leg.from, time: null, scheduledTime: null } } : leg,
),
},
},
});
expect(missingTimes.isError).toBe(true);
const disconnected = {
...itinerary,
legs: itinerary.legs.map((leg, index) => (index === 1 ? { ...leg, from: { ...leg.from, lat: 35.5 } } : leg)),
};
const disconnectedResult = await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary: disconnected },
});
expect(disconnectedResult.isError).toBe(true);
expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({
count: 0,
});
});
});
it('enforces demo, trip access, and reservation permissions', async () => {
const { user: owner } = createUser(testDb);
const { user: demo } = createUser(testDb, { email: 'demo@trek.app' });
const { user: stranger } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { start_date: '2026-12-03', end_date: '2026-12-03' });
const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any;
addTripMember(testDb, trip.id, demo.id);
addTripMember(testDb, trip.id, member.id);
process.env.DEMO_MODE = 'true';
await withHarness(demo.id, ['reservations:write'], async (harness) => {
const result = await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary },
});
expect(result.isError).toBe(true);
expect((result.content[0] as any).text).toContain('demo mode');
});
delete process.env.DEMO_MODE;
await withHarness(stranger.id, ['reservations:write'], async (harness) => {
const result = await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary },
});
expect(result.isError).toBe(true);
expect((result.content[0] as any).text).toContain('access denied');
});
await savePermissions({ reservation_edit: 'trip_owner' });
await withHarness(member.id, ['reservations:write'], async (harness) => {
const result = await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary },
});
expect(result.isError).toBe(true);
expect((result.content[0] as any).text).toContain('permission');
});
expect(testDb.prepare("SELECT COUNT(*) AS count FROM reservations WHERE type = 'transit'").get()).toEqual({
count: 0,
});
expect(broadcastMock).not.toHaveBeenCalled();
expect(notifyBookingChangeMock).not.toHaveBeenCalled();
});
it('uses scheduled times when realtime stop times are unavailable', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-03' });
const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any;
const scheduledOnly = {
...itinerary,
legs: itinerary.legs.map((leg) => ({
...leg,
from: { ...leg.from, time: null, scheduledTime: leg.from.time },
to: { ...leg.to, time: null, scheduledTime: leg.to.time },
})),
};
await withHarness(user.id, ['reservations:write'], async (harness) => {
const result = parseToolResult(
await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary: scheduledOnly },
}),
) as any;
expect(result.reservation.reservation_time).toBe('2026-12-03T09:00');
const metadata = JSON.parse(result.reservation.metadata);
expect(metadata.transit.legs[1].from.time).toBe('09:05');
});
});
// The provider's response taxonomy is wider than the mode whitelist a caller may request:
// MOTIS's default TRANSIT expands to include AIRPLANE, ODM, RIDE_SHARING and OTHER. Norway's
// GTFS, for one, routes Trondheim → Ålesund over AIRPLANE legs. Those itineraries must survive
// the search and be persistable, exactly as they are in the web app.
it('accepts provider leg modes outside the requestable mode whitelist', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-03' });
const day = testDb.prepare('SELECT * FROM days WHERE trip_id = ?').get(trip.id) as any;
const flying = {
...itinerary,
legs: itinerary.legs.map((leg) => (leg.mode === 'WALK' ? leg : { ...leg, mode: 'AIRPLANE' })),
};
planMock.mockResolvedValue({ itineraries: [flying], provider: 'transitous' });
await withHarness(user.id, ['geo:read'], async (harness) => {
const routes = parseToolResult(
await harness.client.callTool({ name: 'search_transit_routes', arguments: { from, to } }),
) as any;
expect(routes.itineraries).toHaveLength(1);
expect(routes.itineraries[0].legs[1].mode).toBe('AIRPLANE');
expect(routes.dropped).toBe(0);
});
await withHarness(user.id, ['reservations:write'], async (harness) => {
const result = parseToolResult(
await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary: flying },
}),
) as any;
expect(result.reservation.type).toBe('transit');
const metadata = JSON.parse(result.reservation.metadata);
expect(metadata.transit.legs[1].mode).toBe('AIRPLANE');
});
});
it('reports how many provider itineraries failed validation', async () => {
const { user } = createUser(testDb);
const walkOnly = { ...itinerary, legs: [itinerary.legs[0]] };
planMock.mockResolvedValue({ itineraries: [itinerary, walkOnly], provider: 'transitous' });
await withHarness(user.id, ['geo:read'], async (harness) => {
const routes = parseToolResult(
await harness.client.callTool({ name: 'search_transit_routes', arguments: { from, to } }),
) as any;
expect(routes.itineraries).toHaveLength(1);
expect(routes.dropped).toBe(1);
});
});
});