mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
537 lines
22 KiB
TypeScript
537 lines
22 KiB
TypeScript
import { runMigrations } from '../../../src/db/migrations';
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache';
|
|
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { DatabaseService } from '../../../src/nest/database/database.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';
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: (tripId: number, userId: number) =>
|
|
db
|
|
.prepare(
|
|
'SELECT t.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: number, userId: number) =>
|
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
|
};
|
|
return { testDb: db, dbMock: mock };
|
|
});
|
|
|
|
const { broadcastMock } = vi.hoisted(() => ({
|
|
broadcastMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
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';
|
|
|
|
// 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.
|
|
const permissionsService = new PermissionsService(new DatabaseService(testDb));
|
|
const 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(() => {});
|
|
|
|
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,
|
|
},
|
|
],
|
|
};
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
geocodeMock.mockReset();
|
|
planMock.mockReset();
|
|
broadcastMock.mockReset();
|
|
// mockReset would fall back to the real notification write — keep it stubbed.
|
|
notifyBookingChangeMock.mockReset().mockImplementation(() => {});
|
|
delete process.env.DEMO_MODE;
|
|
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] });
|
|
planMock.mockResolvedValue({
|
|
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');
|
|
expect(geocodeMock).toHaveBeenCalledWith('Namba', 'ja', '34.67,135.5');
|
|
|
|
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' }));
|
|
|
|
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');
|
|
});
|
|
|
|
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] });
|
|
|
|
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] });
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|