diff --git a/migrate.md b/migrate.md index 924aa040e..eab3addeb 100644 --- a/migrate.md +++ b/migrate.md @@ -32,7 +32,7 @@ Wave 4 — the coupled cluster (order matters here) Wave 5 — the heavyweights, last -10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService/mapsService (done 2026-07 — pulled forward as step 4 of the dependency-honest order: the 1429-line geo core folded into the wrapper `MapsService`, pure helpers to `maps.helpers.ts`, the 3 geo MCP tools to the decorator-driven `maps.mcp.ts` (weather/airport tools stay in the legacy registrar), BookingImportService injects it, and a 3-export `maps.bridge.ts` serves placeEnrichment + the places registrar — transitService → placeService now unblocked), and finally the auth stack (authService at 1494 lines with 18 MCP consumers, plus +10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService/mapsService (done 2026-07 — pulled forward as step 4 of the dependency-honest order: the 1429-line geo core folded into the wrapper `MapsService`, pure helpers to `maps.helpers.ts`, the 3 geo MCP tools to the decorator-driven `maps.mcp.ts` (weather/airport tools stay in the legacy registrar), BookingImportService injects it, and a 3-export `maps.bridge.ts` serves placeEnrichment + the places registrar — transitService → placeService now unblocked)/transitService (done 2026-07 — pulled forward as step 5 of the dependency-honest order: the first fully SQL-free domain fold — the 333-line Transitous/MOTIS proxy became a dep-free `TransitService` (no `DatabaseService`); pure `deriveTransitStats` + mode whitelist + itinerary types moved to `transit.helpers.ts` so the downstream `transitItineraryService` needs no bridge; the whole 3-tool registrar moved to the decorator-driven `transit.mcp.ts`, its days/reservations bridge imports becoming injected `DaysService`/`ReservationsService` — zero bridge files, **placeService → transitItineraryService now unblocked**), and finally the auth stack (authService at 1494 lines with 18 MCP consumers, plus oauthService/oidcService/passkeyService) — highest risk, most consumers, security-sensitive; do it when the pattern is thoroughly proven. backupService also stays late: it owns closeDb/reinitialize lifecycle, which is deliberately outside the DI wrapper. diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index e8810b59d..de14bad6f 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -6,7 +6,6 @@ import { registerAtlasTools } from './tools/atlas'; import { registerPlaceTools } from './tools/places'; import { registerCollectionTools } from './tools/collections'; import { registerTransportTools } from './tools/transports'; -import { registerTransitTools } from './tools/transit'; import { registerMcpPrompts } from './tools/prompts'; import { getMcpRegistry } from './registry-handoff'; @@ -46,7 +45,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[ registerTransportTools(server, userId, scopes); - registerTransitTools(server, userId, scopes); + // The transit tools moved to the DI-discovered src/nest/transit/transit.mcp.ts + // (@McpController, attached via the nest-mcp registry below). registerJourneyTools(server, userId, scopes); diff --git a/server/src/mcp/tools/transit.ts b/server/src/mcp/tools/transit.ts deleted file mode 100644 index 29c23a037..000000000 --- a/server/src/mcp/tools/transit.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { canAccessTrip } from '../../db/database'; -import { RateLimitService } from '../../nest/auth/rate-limit.service'; -import { isDemoUser } from '../../services/authService'; -import { getDay, listDays } from '../../nest/days/days.bridge'; -import { createReservation, notifyBookingChange } from '../../nest/reservations/reservations.bridge'; -import { - buildTransitReservationParts, - cleanTransitItineraryNames, - transitCoordinatesMatch, - transitCoordinatesSchema, - transitItinerarySchema, - transitPlaceSchema, -} from '../../services/transitItineraryService'; -import { geocode, plan, SCHEDULED_TRANSIT_MODES } from '../../services/transitService'; -import { canRead, canWrite } from '../scopes'; -import { - demoDenied, - hasTripPermission, - noAccess, - ok, - permissionDenied, - safeBroadcast, - TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT, - TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, -} from './_shared'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; - -import { z } from 'zod'; - -const TRANSIT_RATE_WINDOW = 15 * 60 * 1000; -const transitRateLimiter = new RateLimitService(); - -const transitModes = z.enum(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]); - -function errorResult(err: unknown, fallback: string) { - return { - content: [{ type: 'text' as const, text: err instanceof Error ? err.message : fallback }], - isError: true, - }; -} - -function rateLimit(userId: number, bucket: string, max: number) { - if (transitRateLimiter.check(bucket, String(userId), max, TRANSIT_RATE_WINDOW, Date.now())) return null; - return { - content: [{ type: 'text' as const, text: 'Too many transit requests. Please try again later.' }], - isError: true, - }; -} - -export function registerTransitTools(server: McpServer, userId: number, scopes: string[] | null): void { - if (canRead(scopes, 'geo')) { - server.registerTool( - 'search_transit_stops', - { - description: - 'Search real public-transit stops and stations via Transitous. Use the returned coordinates with search_transit_routes.', - inputSchema: { - query: z.string().min(2).max(200), - language: z.string().min(2).max(5).optional(), - near: z - .object(transitCoordinatesSchema.shape) - .optional() - .describe('Optional coordinates used to bias nearby results'), - }, - annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, - }, - async ({ query, language, near }) => { - const limited = rateLimit(userId, 'mcp_transit_geocode', 300); - if (limited) return limited; - try { - return ok(await geocode(query, language, near ? `${near.lat},${near.lng}` : undefined)); - } catch (err) { - return errorResult(err, 'Transit stop search failed.'); - } - }, - ); - - server.registerTool( - 'search_transit_routes', - { - description: - 'Search scheduled public-transit routes via Transitous between two coordinates. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.', - inputSchema: { - from: transitPlaceSchema, - to: transitPlaceSchema, - time: z - .string() - .datetime({ offset: true }) - .optional() - .describe('ISO 8601 departure or arrival time with timezone offset'), - arriveBy: z.boolean().optional().default(false), - modes: z.array(transitModes).max(14).optional(), - maxTransfers: z.number().int().min(0).max(10).optional(), - }, - annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, - }, - async ({ from, to, time, arriveBy, modes, maxTransfers }) => { - const limited = rateLimit(userId, 'mcp_transit_plan', 60); - if (limited) return limited; - try { - const result = await plan({ - from: `${from.lat},${from.lng}`, - to: `${to.lat},${to.lng}`, - time, - arriveBy, - modes: modes?.join(','), - maxTransfers, - }); - const itineraries = result.itineraries.flatMap((itinerary) => { - const parsed = transitItinerarySchema.safeParse(cleanTransitItineraryNames(itinerary, from.name, to.name)); - if (!parsed.success) return []; - const firstStop = parsed.data.legs[0].from; - const lastStop = parsed.data.legs[parsed.data.legs.length - 1].to; - return transitCoordinatesMatch(from, firstStop) && transitCoordinatesMatch(to, lastStop) - ? [parsed.data] - : []; - }); - // A rejected itinerary is provider data we could not vouch for, but dropping it - // silently is indistinguishable from "no routes exist" — report the count so the - // caller knows the difference. - return ok({ itineraries, dropped: result.itineraries.length - itineraries.length }); - } catch (err) { - return errorResult(err, 'Transit route search failed.'); - } - }, - ); - } - - if (!canWrite(scopes, 'reservations')) return; - - server.registerTool( - 'create_transit_journey', - { - description: - 'Add one itinerary returned by search_transit_routes to a trip day as a first-class automated public-transit journey.', - inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive().describe('Trip day on which the journey departs'), - from: transitPlaceSchema, - to: transitPlaceSchema, - itinerary: transitItinerarySchema, - notes: z.string().max(1000).optional(), - }, - annotations: TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT, - }, - async ({ tripId, dayId, from, to, itinerary, notes }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied(); - const day = getDay(dayId, tripId); - if (!day) { - return { content: [{ type: 'text' as const, text: 'dayId does not belong to this trip.' }], isError: true }; - } - - const cleaned = cleanTransitItineraryNames(itinerary, from.name, to.name); - const firstStop = cleaned.legs[0].from; - const lastStop = cleaned.legs[cleaned.legs.length - 1].to; - if (!transitCoordinatesMatch(from, firstStop) || !transitCoordinatesMatch(to, lastStop)) { - return { - content: [ - { type: 'text' as const, text: 'The itinerary does not match the requested origin and destination.' }, - ], - isError: true, - }; - } - let reservationParts: ReturnType; - try { - reservationParts = buildTransitReservationParts(from, to, cleaned); - } catch (err) { - return errorResult(err, 'Unable to resolve the transit journey timezones.'); - } - const { endpoints, metadata } = reservationParts; - const departure = endpoints[0]; - const arrival = endpoints[endpoints.length - 1]; - if (!day.date) { - return { - content: [{ type: 'text' as const, text: 'Automated transit requires a dated trip day.' }], - isError: true, - }; - } - if (departure.local_date !== day.date) { - return { - content: [ - { - type: 'text' as const, - text: `The journey departs on ${departure.local_date}, but dayId is ${day.date}.`, - }, - ], - isError: true, - }; - } - const endDay = listDays(tripId).days.find((day) => day.date === arrival.local_date); - if (!endDay) { - return { - content: [{ type: 'text' as const, text: `No trip day exists for the arrival date ${arrival.local_date}.` }], - isError: true, - }; - } - const { reservation } = createReservation(tripId, { - title: `${from.name} → ${to.name}`, - type: 'transit', - status: 'confirmed', - day_id: dayId, - end_day_id: endDay.id, - reservation_time: `${departure.local_date}T${departure.local_time}`, - reservation_end_time: `${arrival.local_date}T${arrival.local_time}`, - notes, - metadata, - endpoints, - needs_review: false, - }); - safeBroadcast(tripId, 'reservation:created', { reservation }); - notifyBookingChange(tripId, userId, reservation.title, reservation.type || ''); - return ok({ reservation }); - }, - ); -} diff --git a/server/src/nest/README.md b/server/src/nest/README.md index ae8bdc37e..385097bd2 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -28,8 +28,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express. - **Phase 2 (trip sub-domains):** vacay (addon), packing, todo. - **DI-native services (legacy `src/services/*` deleted):** tags, categories, todo, packing, day-notes, trip-invite, assignments, share, settings, files, - collab, vacay, reservations, day, permissions, audit, budget, trip, maps — see - the migration recipe below. + collab, vacay, reservations, day, permissions, audit, budget, trip, maps, + transit — see the migration recipe below. ## Cross-cutting Foundation pieces @@ -142,8 +142,9 @@ DI-native at the edge, so the 626-line legacy module folded into it — a 5-tool reservation import (`notifyBookingChange`) swapped for the injected service, `TripsService` and `BookingImportService` converted from function imports to injection, and a 9-export + 3-type `reservations.bridge.ts` for the legacy -`tripService`, the airtrail import/sync pair and the still-legacy transit + -transports registrars; the DTO ratchet for its 4 grandfathered body contracts +`tripService`, the airtrail import/sync pair and the still-legacy transports +registrar (the transit registrar has since migrated to `transit.mcp.ts`); the +DTO ratchet for its 4 grandfathered body contracts landed as a sibling commit, which also loosened the shared positions schema to the real wire contract — `day_plan_position` optional, pinned by RESV-006); day followed (the 592-line legacy `dayService` folded into the existing @@ -154,7 +155,8 @@ reorder/insert converted to `db.transaction()`; a 7-tool + 2-resource `days.mcp.ts`, the plugin host's 11-symbol import swapped for the injected service, `TripsService` + the assignments/reservations MCP controllers converted to injection, and a 6-export `days.bridge.ts` for the legacy -`tripService` and the still-legacy transit + transports registrars; the DTO +`tripService` and the still-legacy transports registrar (transit has since +migrated); the DTO ratchet for its 4 day + 2 accommodation grandfathered body contracts landed as a sibling commit); permissions followed (the first Wave-2 **cross-cutting** migration and the @@ -227,7 +229,7 @@ mapsService followed (the 1429-line geo core — Google Places, Nominatim, Overpass mirror racing, Wikimedia photos, Maps-URL resolution — folded into the wrapper `MapsService`; the pure parser/UA/POI-category helpers moved to `maps.helpers.ts` as plain exports (files.constants/client-ip precedent — -transitService's User-Agent imports from there, not a bridge), the module-scoped +the DI-native TransitService's User-Agent imports from there, not a bridge), the module-scoped POI cache / photo-fetch semaphore / frozen Overpass mirrors stayed module-scoped on purpose (permissions-cache precedent: the bridge instance and the DI singleton share them); the 3 geo tools left the mixed `mcp/tools/mapsWeather.ts` @@ -236,8 +238,21 @@ its weather + airport tools await their own migrations); BookingImportService injects `MapsService` for its Nominatim geocoding, and a 3-export `maps.bridge.ts` serves the legacy placeEnrichment helper and the places registrar until the place domain migrates). +transitService followed (the first fully SQL-free domain fold — the 333-line +Transitous/MOTIS proxy became a dep-free `TransitService` (no +`DatabaseService`; the ExchangeRatesService precedent), its response cache, +frozen-at-import `TRANSIT_API_BASE` and lazy User-Agent memo staying +module-scoped on purpose; the pure `deriveTransitStats` + mode whitelist + +itinerary types moved to `transit.helpers.ts` (maps.helpers precedent) so the +downstream legacy `transitItineraryService` needs no bridge; the whole 3-tool +`mcp/tools/transit.ts` registrar moved to `transit.mcp.ts` — the two geo +search tools on `access: { group: 'geo', mode: 'read' }` and +`create_transit_journey` on `reservations:write`, with its days/reservations +bridge imports becoming injected `DaysService`/`ReservationsService` (+ +`DatabaseService` for `canAccessTrip`) — leaving **zero bridge files** and the +transports registrar as `days.bridge.ts`'s last consumer). Repeat these steps per -service (next up: **transitService → placeService → transitItineraryService** — +service (next up: **placeService → transitItineraryService** — or the notifications fan-in — per the dependency-honest order in `migration-graph.md`). This is a **pure relocation** — byte-identical diff --git a/server/src/nest/days/days.bridge.ts b/server/src/nest/days/days.bridge.ts index accb7bf7a..8f056776e 100644 --- a/server/src/nest/days/days.bridge.ts +++ b/server/src/nest/days/days.bridge.ts @@ -6,15 +6,16 @@ import { PermissionsService } from '../permissions/permissions.service'; /** * Non-Nest entry point for the day domain — for code running OUTSIDE the - * Nest container (the legacy transit/transports MCP registrars in - * src/mcp/tools/; the day MCP tools and resources moved to the DI-discovered - * days.mcp.ts, the plugin RPC host injects DaysService via + * Nest container (the legacy transports MCP registrar in src/mcp/tools/; + * the day MCP tools and resources moved to the DI-discovered days.mcp.ts, + * the transit tools to the DI-discovered transit.mcp.ts (which injects + * DaysService), the plugin RPC host injects DaysService via * PluginHostDepsFactory, and the trip domain folded into the DI-native * TripsService, which injects DaysService — its bridge exports were pruned * with it). Exports only the legacy services/dayService names still consumed * outside the container, 1:1, so repointing a consumer is an import-path-only * diff. Inside the container, inject DaysService instead. Delete this file - * when the transit/transports registrars migrate. + * when the transports registrar migrates. * * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton. diff --git a/server/src/nest/reservations/reservations.bridge.ts b/server/src/nest/reservations/reservations.bridge.ts index 505b72562..43efe9c12 100644 --- a/server/src/nest/reservations/reservations.bridge.ts +++ b/server/src/nest/reservations/reservations.bridge.ts @@ -11,10 +11,11 @@ export type { EndpointInput, ReservationEndpoint, ReservationTraveler } from './ /** * Non-Nest entry point for the reservations domain — for code running OUTSIDE * the Nest container (the legacy tripService, the airtrail import/sync - * services, and the still-legacy transit + transports MCP registrars; the - * reservation MCP tools and resource moved to the DI-discovered - * reservations.mcp.ts, and the plugin RPC host injects ReservationsService via - * PluginHostDepsFactory). Exports only the legacy services/reservationService + * services, and the still-legacy transports MCP registrar; the reservation + * MCP tools and resource moved to the DI-discovered reservations.mcp.ts, the + * transit tools to the DI-discovered transit.mcp.ts (which injects + * ReservationsService), and the plugin RPC host injects ReservationsService + * via PluginHostDepsFactory). Exports only the legacy services/reservationService * names still consumed outside the container, 1:1, so repointing a consumer is * an import-path-only diff. Inside the container, inject ReservationsService * instead. Delete exports here as their consumers migrate. diff --git a/server/src/nest/transit/transit.controller.ts b/server/src/nest/transit/transit.controller.ts index 06a4d2818..36fd62360 100644 --- a/server/src/nest/transit/transit.controller.ts +++ b/server/src/nest/transit/transit.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, HttpException, Query, Req, UseGuards } from '@nestjs/c import type { Request } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RateLimitService } from '../auth/rate-limit.service'; -import * as transit from '../../services/transitService'; +import { TransitService } from './transit.service'; const RL_WINDOW = 15 * 60 * 1000; @@ -15,7 +15,10 @@ const RL_WINDOW = 15 * 60 * 1000; @Controller('api/transit') @UseGuards(JwtAuthGuard) export class TransitController { - constructor(private readonly rl: RateLimitService) {} + constructor( + private readonly rl: RateLimitService, + private readonly transit: TransitService, + ) {} private limit(bucket: string, req: Request, max: number): void { if (!this.rl.check(bucket, req.ip || 'unknown', max, RL_WINDOW, Date.now())) { @@ -38,7 +41,7 @@ export class TransitController { ) { this.limit('transit_geocode', req, 300); try { - return await transit.geocode(q || '', lang, near); + return await this.transit.geocode(q || '', lang, near); } catch (err) { this.rethrow(err); } } @@ -54,7 +57,7 @@ export class TransitController { ) { this.limit('transit_plan', req, 60); try { - return await transit.plan({ + return await this.transit.plan({ from: from || '', to: to || '', time, diff --git a/server/src/nest/transit/transit.helpers.ts b/server/src/nest/transit/transit.helpers.ts new file mode 100644 index 000000000..d6fe07f98 --- /dev/null +++ b/server/src/nest/transit/transit.helpers.ts @@ -0,0 +1,100 @@ +/** + * Pure transit helpers shared by TransitService and plain-module code outside + * the container (the legacy services/transitItineraryService imports + * deriveTransitStats + the itinerary types). Kept as plain functions/consts + * (the maps.helpers.ts / files.constants.ts precedent) so no bridge is needed — + * helpers never block a migration. + */ + +// Modes the client may request — a strict whitelist so the proxy can't be used +// to smuggle arbitrary query values upstream. TRANSIT covers everything; the +// others let the user filter (RAIL already includes subway/suburban etc.). +export const SCHEDULED_TRANSIT_MODES = [ + 'BUS', + 'COACH', + 'TRAM', + 'SUBWAY', + 'RAIL', + 'FERRY', + 'FUNICULAR', + 'AERIAL_LIFT', + // Fine-grained rail modes so "train without subway" is expressible (RAIL + // itself includes SUBWAY per the MOTIS mode taxonomy). + 'HIGHSPEED_RAIL', + 'LONG_DISTANCE', + 'NIGHT_RAIL', + 'REGIONAL_RAIL', + 'SUBURBAN', +] as const; + +export interface TransitPlace { + name: string; + lat: number; + lng: number; + type: string; + area: string | null; +} + +export interface TransitLegStop { + name: string; + lat: number; + lng: number; + time: string | null; + scheduledTime: string | null; + track: string | null; +} + +export interface TransitLeg { + mode: string; + from: TransitLegStop; + to: TransitLegStop; + duration: number; + distance: number | null; + headsign: string | null; + line: string | null; + lineColor: string | null; + lineTextColor: string | null; + agency: string | null; + intermediateStops: number; + /** Encoded polyline of the leg's real path (Google encoding) + its precision. */ + geometry: string | null; + geometryPrecision: number; +} + +export interface TransitItinerary { + startTime: string; + endTime: string; + duration: number; + transfers: number; + walkSeconds: number; + legs: TransitLeg[]; +} + +export interface PlanQuery { + from: string; + to: string; + time?: string; + arriveBy?: boolean; + modes?: string; + maxTransfers?: number; +} + +export function deriveTransitStats( + startTime: string, + endTime: string, + legs: TransitLeg[], + reportedTransfers?: number, +): Pick { + // Number.isFinite, not bare Math.max: an unparseable provider timestamp + // yields NaN, which Math.max passes through and JSON.stringify turns into + // null — report 0 instead of a null duration. + const wallClock = Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000); + return { + duration: Number.isFinite(wallClock) ? Math.max(0, wallClock) : 0, + transfers: + typeof reportedTransfers === 'number' + ? reportedTransfers + : Math.max(0, legs.filter((leg) => leg.mode !== 'WALK').length - 1), + walkSeconds: legs.filter((leg) => leg.mode === 'WALK').reduce((total, leg) => total + leg.duration, 0), + }; +} diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts new file mode 100644 index 000000000..6d09ce649 --- /dev/null +++ b/server/src/nest/transit/transit.mcp.ts @@ -0,0 +1,253 @@ +import { + McpController, Tool, type McpContext, + TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT, TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + demoDenied, ok, +} from '@trek/nest-mcp'; +import { z } from 'zod'; +import { isDemoUser } from '../../services/authService'; +import { + buildTransitReservationParts, + cleanTransitItineraryNames, + transitCoordinatesMatch, + transitCoordinatesSchema, + transitItinerarySchema, + transitPlaceSchema, +} from '../../services/transitItineraryService'; +import { hasTripPermission, noAccess, permissionDenied, safeBroadcast } from '../../mcp/tools/_shared'; +import { RateLimitService } from '../auth/rate-limit.service'; +import { DatabaseService } from '../database/database.service'; +import { DaysService } from '../days/days.service'; +import { ReservationsService } from '../reservations/reservations.service'; +import { SCHEDULED_TRANSIT_MODES, type TransitItinerary } from './transit.helpers'; +import { TransitService } from './transit.service'; + +const TRANSIT_RATE_WINDOW = 15 * 60 * 1000; +// Deliberately its own instance, separate from the REST controller's: the MCP +// buckets are keyed by userId (mcp_transit_*), the REST ones by req.ip. +const transitRateLimiter = new RateLimitService(); + +const transitModes = z.enum(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]); + +// Local 2-arg variant (the @trek/nest-mcp errorResult takes a string only): +// surface the provider/validation message when there is one, else the fallback. +function errorResult(err: unknown, fallback: string) { + return { + content: [{ type: 'text' as const, text: err instanceof Error ? err.message : fallback }], + isError: true, + }; +} + +function rateLimit(userId: number, bucket: string, max: number) { + if (transitRateLimiter.check(bucket, String(userId), max, TRANSIT_RATE_WINDOW, Date.now())) return null; + return { + content: [{ type: 'text' as const, text: 'Too many transit requests. Please try again later.' }], + isError: true, + }; +} + +/** + * Transit MCP surface — ported 1:1 from the legacy registrar + * src/mcp/tools/transit.ts (identical names, descriptions, schemas, + * annotations, error/payload shapes, rate limits and broadcasts). The + * registration-time gates map to the declarative access markers: the + * `canRead(scopes, 'geo')` wrap on the two search tools ('geo' is a read-only + * scope group — there is no geo:write) and the `canWrite(scopes, + * 'reservations')` early return on create_transit_journey. The + * days/reservations bridge imports became injected services; the itinerary + * schemas/helpers still come from the legacy transitItineraryService until + * that domain migrates. + */ +@McpController() +export class TransitMcp { + constructor( + private readonly transit: TransitService, + private readonly days: DaysService, + private readonly reservations: ReservationsService, + private readonly db: DatabaseService, + ) {} + + @Tool({ + name: 'search_transit_stops', + description: + 'Search real public-transit stops and stations via Transitous. Use the returned coordinates with search_transit_routes.', + inputSchema: { + query: z.string().min(2).max(200), + language: z.string().min(2).max(5).optional(), + near: z + .object(transitCoordinatesSchema.shape) + .optional() + .describe('Optional coordinates used to bias nearby results'), + }, + annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + access: { group: 'geo', mode: 'read' }, + }) + async searchTransitStops( + { query, language, near }: { query: string; language?: string; near?: { lat: number; lng: number } }, + ctx: McpContext, + ) { + const limited = rateLimit(ctx.userId, 'mcp_transit_geocode', 300); + if (limited) return limited; + try { + return ok(await this.transit.geocode(query, language, near ? `${near.lat},${near.lng}` : undefined)); + } catch (err) { + return errorResult(err, 'Transit stop search failed.'); + } + } + + @Tool({ + name: 'search_transit_routes', + description: + 'Search scheduled public-transit routes via Transitous between two coordinates. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.', + inputSchema: { + from: transitPlaceSchema, + to: transitPlaceSchema, + time: z + .string() + .datetime({ offset: true }) + .optional() + .describe('ISO 8601 departure or arrival time with timezone offset'), + arriveBy: z.boolean().optional().default(false), + modes: z.array(transitModes).max(14).optional(), + maxTransfers: z.number().int().min(0).max(10).optional(), + }, + annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + access: { group: 'geo', mode: 'read' }, + }) + async searchTransitRoutes( + { from, to, time, arriveBy, modes, maxTransfers }: { + from: z.infer; + to: z.infer; + time?: string; + arriveBy?: boolean; + modes?: z.infer[]; + maxTransfers?: number; + }, + ctx: McpContext, + ) { + const limited = rateLimit(ctx.userId, 'mcp_transit_plan', 60); + if (limited) return limited; + try { + const result = await this.transit.plan({ + from: `${from.lat},${from.lng}`, + to: `${to.lat},${to.lng}`, + time, + arriveBy, + modes: modes?.join(','), + maxTransfers, + }); + const itineraries = result.itineraries.flatMap((itinerary) => { + const parsed = transitItinerarySchema.safeParse(cleanTransitItineraryNames(itinerary, from.name, to.name)); + if (!parsed.success) return []; + const firstStop = parsed.data.legs[0].from; + const lastStop = parsed.data.legs[parsed.data.legs.length - 1].to; + return transitCoordinatesMatch(from, firstStop) && transitCoordinatesMatch(to, lastStop) + ? [parsed.data] + : []; + }); + // A rejected itinerary is provider data we could not vouch for, but dropping it + // silently is indistinguishable from "no routes exist" — report the count so the + // caller knows the difference. + return ok({ itineraries, dropped: result.itineraries.length - itineraries.length }); + } catch (err) { + return errorResult(err, 'Transit route search failed.'); + } + } + + @Tool({ + name: 'create_transit_journey', + description: + 'Add one itinerary returned by search_transit_routes to a trip day as a first-class automated public-transit journey.', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive().describe('Trip day on which the journey departs'), + from: transitPlaceSchema, + to: transitPlaceSchema, + itinerary: transitItinerarySchema, + notes: z.string().max(1000).optional(), + }, + annotations: TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT, + access: { group: 'reservations', mode: 'write' }, + }) + async createTransitJourney( + { tripId, dayId, from, to, itinerary, notes }: { + tripId: number; + dayId: number; + from: z.infer; + to: z.infer; + // The schema validates exactly the wire TransitItinerary shape; its + // z.infer prints looser optionals, so name the contract type directly. + itinerary: TransitItinerary; + notes?: string; + }, + ctx: McpContext, + ) { + if (isDemoUser(ctx.userId)) return demoDenied(); + if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + const day = this.days.getDay(dayId, tripId); + if (!day) { + return { content: [{ type: 'text' as const, text: 'dayId does not belong to this trip.' }], isError: true }; + } + + const cleaned = cleanTransitItineraryNames(itinerary, from.name, to.name); + const firstStop = cleaned.legs[0].from; + const lastStop = cleaned.legs[cleaned.legs.length - 1].to; + if (!transitCoordinatesMatch(from, firstStop) || !transitCoordinatesMatch(to, lastStop)) { + return { + content: [ + { type: 'text' as const, text: 'The itinerary does not match the requested origin and destination.' }, + ], + isError: true, + }; + } + let reservationParts: ReturnType; + try { + reservationParts = buildTransitReservationParts(from, to, cleaned); + } catch (err) { + return errorResult(err, 'Unable to resolve the transit journey timezones.'); + } + const { endpoints, metadata } = reservationParts; + const departure = endpoints[0]; + const arrival = endpoints[endpoints.length - 1]; + if (!day.date) { + return { + content: [{ type: 'text' as const, text: 'Automated transit requires a dated trip day.' }], + isError: true, + }; + } + if (departure.local_date !== day.date) { + return { + content: [ + { + type: 'text' as const, + text: `The journey departs on ${departure.local_date}, but dayId is ${day.date}.`, + }, + ], + isError: true, + }; + } + const endDay = this.days.list(tripId).days.find((day) => day.date === arrival.local_date); + if (!endDay) { + return { + content: [{ type: 'text' as const, text: `No trip day exists for the arrival date ${arrival.local_date}.` }], + isError: true, + }; + } + const { reservation } = this.reservations.create(tripId, { + title: `${from.name} → ${to.name}`, + type: 'transit', + status: 'confirmed', + day_id: dayId, + end_day_id: endDay.id, + reservation_time: `${departure.local_date}T${departure.local_time}`, + reservation_end_time: `${arrival.local_date}T${arrival.local_time}`, + notes, + metadata, + endpoints, + needs_review: false, + }); + safeBroadcast(tripId, 'reservation:created', { reservation }); + this.reservations.notifyBookingChange(tripId, ctx.userId, reservation.title, reservation.type || ''); + return ok({ reservation }); + } +} diff --git a/server/src/nest/transit/transit.module.ts b/server/src/nest/transit/transit.module.ts index 0b2f96d1f..42f10fc87 100644 --- a/server/src/nest/transit/transit.module.ts +++ b/server/src/nest/transit/transit.module.ts @@ -1,9 +1,21 @@ import { Module } from '@nestjs/common'; import { TransitController } from './transit.controller'; +import { TransitService } from './transit.service'; +import { TransitMcp } from './transit.mcp'; import { RateLimitService } from '../auth/rate-limit.service'; +import { DaysModule } from '../days/days.module'; +import { ReservationsModule } from '../reservations/reservations.module'; +/** + * Transit domain (#1065) — the Transitous/MOTIS proxy. TransitMcp carries the + * decorator-registered MCP tools; DaysModule/ReservationsModule feed + * create_transit_journey. Exports TransitService for in-container consumers. + */ @Module({ + // DaysModule + ReservationsModule: TransitMcp's create_transit_journey injects both. + imports: [DaysModule, ReservationsModule], controllers: [TransitController], - providers: [RateLimitService], + providers: [TransitService, TransitMcp, RateLimitService], + exports: [TransitService], }) export class TransitModule {} diff --git a/server/src/nest/transit/transit.service.ts b/server/src/nest/transit/transit.service.ts new file mode 100644 index 000000000..3799b40ab --- /dev/null +++ b/server/src/nest/transit/transit.service.ts @@ -0,0 +1,281 @@ +import { Injectable } from '@nestjs/common'; +import { getAppUrl, readEnv } from '../../app-config'; +import { buildUserAgent } from '../maps/maps.helpers'; +import { + deriveTransitStats, + SCHEDULED_TRANSIT_MODES, + type PlanQuery, + type TransitItinerary, + type TransitLeg, + type TransitLegStop, + type TransitPlace, +} from './transit.helpers'; + +/** + * Public transit routing (#1065) backed by Transitous (api.transitous.org), the + * community-run MOTIS instance over public GTFS feeds — free, no API key, fits + * TREK's no-paid-providers rule. Self-hosters can point TRANSIT_API_URL at their + * own MOTIS instance instead. + * + * This service is a thin, validating proxy: the browser never talks to + * Transitous directly (their usage policy wants an identifying User-Agent with + * contact info, which we send once from the server), and responses are mapped + * to a compact shape so the client isn't coupled to the MOTIS schema. + * + * Folded 1:1 from the legacy services/transitService.ts. The base URL, the + * lazy User-Agent memo and the response cache stay module-scoped on purpose + * (the permissions/exchange-rates precedent): the DI singleton and any future + * non-Nest caller share one cache, and the base URL keeps its frozen-at-import + * legacy timing. + */ + +// Frozen at import on purpose (legacy timing). +const TRANSIT_API_BASE = readEnv().integrations.transitApiBase; +let userAgent: string | null = null; + +function getUserAgent(): string { + userAgent ??= buildUserAgent(getAppUrl()); + return userAgent; +} + +const ALLOWED_MODES = new Set(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]); + +// Short-lived response cache: planning is the expensive call per the Transitous +// usage policy, and a user toggling filters re-requests identical plans. +const CACHE_TTL = 60 * 1000; +const CACHE_MAX = 200; +const cache = new Map(); + +function cacheGet(key: string): unknown | null { + const hit = cache.get(key); + if (!hit) return null; + if (Date.now() - hit.at > CACHE_TTL) { + cache.delete(key); + return null; + } + // Refresh recency (true LRU): geocode and plan share this Map, so without + // the re-insert a burst of geocoding evicts still-hot plan entries. + cache.delete(key); + cache.set(key, hit); + return hit.data; +} + +function cacheSet(key: string, data: unknown): void { + if (cache.size >= CACHE_MAX) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, { at: Date.now(), data }); +} + +const COORD_RE = /^-?\d{1,3}(\.\d+)?,-?\d{1,3}(\.\d+)?$/; + +function isCoord(v: string): boolean { + if (!COORD_RE.test(v)) return false; + const [lat, lng] = v.split(',').map(Number); + return Math.abs(lat) <= 90 && Math.abs(lng) <= 180; +} + +// A plan response with eight geometry-carrying itineraries stays well under a +// megabyte; anything bigger is a misbehaving provider, not data we want to map. +const MAX_RESPONSE_BYTES = 5_000_000; + +async function upstream(path: string, params: URLSearchParams): Promise { + const url = `${TRANSIT_API_BASE}${path}?${params}`; + const res = await fetch(url, { + headers: { 'User-Agent': getUserAgent(), Accept: 'application/json' }, + signal: AbortSignal.timeout(8000), + }); + if (!res.ok) { + const err = new Error(`Transit provider error (HTTP ${res.status})`) as Error & { status: number }; + err.status = res.status === 429 ? 429 : 502; + throw err; + } + const length = Number(res.headers?.get('content-length') ?? 0); + if (length > MAX_RESPONSE_BYTES) { + const err = new Error('Transit provider error (response too large)') as Error & { status: number }; + err.status = 502; + throw err; + } + return res.json(); +} + +// GTFS colors come as bare hex ("FF0000"), with hash, or empty — normalise to +// a #-prefixed value or null so the client can use them in CSS directly. +function safeColor(v: unknown): string | null { + if (typeof v !== 'string') return null; + const hex = v.trim().replace(/^#/, ''); + return /^[0-9a-fA-F]{6}$/.test(hex) || /^[0-9a-fA-F]{3}$/.test(hex) ? `#${hex}` : null; +} + +interface MotisPlaceRaw { + name?: string; + lat?: number; + lon?: number; + departure?: string; + arrival?: string; + scheduledDeparture?: string; + scheduledArrival?: string; + track?: string; + scheduledTrack?: string; +} + +function mapStop(p: MotisPlaceRaw | undefined, kind: 'departure' | 'arrival'): TransitLegStop { + return { + name: p?.name || '', + // A missing coordinate becomes 0 (null island) rather than null on + // purpose: TransitLegStop.lat/lng are non-nullable across the wire + // contract (transitStopSchema, the client picker) — making them nullable + // would ripple through transitItineraryService's validation and drop + // otherwise-usable itineraries. MOTIS places carry coordinates in + // practice; endpoint stops are re-checked by transitCoordinatesMatch. + lat: typeof p?.lat === 'number' ? p.lat : 0, + lng: typeof p?.lon === 'number' ? p.lon : 0, + time: (kind === 'departure' ? p?.departure : p?.arrival) || null, + scheduledTime: (kind === 'departure' ? p?.scheduledDeparture : p?.scheduledArrival) || null, + track: p?.track || p?.scheduledTrack || null, + }; +} + +@Injectable() +export class TransitService { + /** Station/place search for the from/to pickers. `near` biases results. */ + async geocode(query: string, language?: string, near?: string): Promise<{ results: TransitPlace[] }> { + const text = (query || '').trim(); + if (text.length < 2) return { results: [] }; + if (text.length > 200) { + const e = new Error('Query too long') as Error & { status: number }; + e.status = 400; + throw e; + } + + const params = new URLSearchParams({ text }); + if (language) params.set('language', language.slice(0, 5)); + if (near && isCoord(near)) params.set('place', near); + + const key = `geo:${params.toString()}`; + const cached = cacheGet(key); + if (cached) return cached as { results: TransitPlace[] }; + + const raw = (await upstream('/api/v1/geocode', params)) as Array<{ + name?: string; + lat?: number; + lon?: number; + type?: string; + areas?: Array<{ name?: string; matched?: boolean; default?: boolean }>; + }>; + + const results: TransitPlace[] = (Array.isArray(raw) ? raw : []).slice(0, 8).flatMap((m) => { + if (typeof m.lat !== 'number' || typeof m.lon !== 'number' || !m.name) return []; + const area = m.areas?.find((a) => a.default)?.name || m.areas?.[0]?.name || null; + return [{ name: m.name, lat: m.lat, lng: m.lon, type: m.type || 'PLACE', area }]; + }); + + const data = { results }; + cacheSet(key, data); + return data; + } + + /** Route search between two coordinates. Returns compact itineraries for the picker. */ + async plan(q: PlanQuery): Promise<{ itineraries: TransitItinerary[] }> { + const bad = (msg: string) => { + const e = new Error(msg) as Error & { status: number }; + e.status = 400; + throw e; + }; + if (!q.from || !isCoord(q.from)) bad('from must be "lat,lng"'); + if (!q.to || !isCoord(q.to)) bad('to must be "lat,lng"'); + + const params = new URLSearchParams({ fromPlace: q.from, toPlace: q.to, numItineraries: '8' }); + + if (q.time) { + const parsed = new Date(q.time); + if (isNaN(parsed.getTime())) bad('time must be an ISO date-time'); + params.set('time', parsed.toISOString()); + } + if (q.arriveBy) params.set('arriveBy', 'true'); + + if (q.modes) { + const modes = q.modes + .split(',') + .map((m) => m.trim().toUpperCase()) + .filter(Boolean); + if (modes.some((m) => !ALLOWED_MODES.has(m))) bad('unsupported transit mode'); + if (modes.length > 0) params.set('transitModes', modes.join(',')); + } + if (q.maxTransfers !== undefined && q.maxTransfers !== null) { + const n = Number(q.maxTransfers); + if (!Number.isInteger(n) || n < 0 || n > 10) bad('maxTransfers must be 0-10'); + params.set('maxTransfers', String(n)); + } + // We only want scheduled transit journeys in the results — a pure-walk + // "direct" connection is what the existing OSRM footpath routing is for. + params.set('directModes', 'WALK'); + + const key = `plan:${params.toString()}`; + const cached = cacheGet(key); + if (cached) return cached as { itineraries: TransitItinerary[] }; + + const raw = (await upstream('/api/v6/plan', params)) as { + itineraries?: Array<{ + duration?: number; + startTime?: string; + endTime?: string; + transfers?: number; + legs?: Array<{ + mode?: string; + duration?: number; + distance?: number; + headsign?: string; + routeShortName?: string; + displayName?: string; + routeColor?: string; + routeTextColor?: string; + agencyName?: string; + from?: MotisPlaceRaw; + to?: MotisPlaceRaw; + intermediateStops?: unknown[]; + legGeometry?: { points?: string; precision?: number }; + }>; + }>; + }; + + const itineraries: TransitItinerary[] = (raw.itineraries || []).flatMap((it) => { + if (!it.startTime || !it.endTime || !Array.isArray(it.legs)) return []; + const legs: TransitLeg[] = it.legs.map((leg) => ({ + mode: (leg.mode || 'WALK').toUpperCase(), + from: mapStop(leg.from, 'departure'), + to: mapStop(leg.to, 'arrival'), + duration: typeof leg.duration === 'number' ? leg.duration : 0, + distance: typeof leg.distance === 'number' ? Math.round(leg.distance) : null, + headsign: leg.headsign || null, + // displayName is the public identifier MOTIS resolved for the run, taking + // the trip number into account where the feed has one. Plenty of operators + // (German long distance among them) keep routeShortName as an internal line + // number, so preferring it showed "20" instead of "ICE 72" (#1715). + line: leg.displayName || leg.routeShortName || null, + lineColor: safeColor(leg.routeColor), + lineTextColor: safeColor(leg.routeTextColor), + agency: leg.agencyName || null, + intermediateStops: Array.isArray(leg.intermediateStops) ? leg.intermediateStops.length : 0, + geometry: leg.legGeometry?.points || null, + geometryPrecision: leg.legGeometry?.precision ?? 6, + })); + const stats = deriveTransitStats(it.startTime, it.endTime, legs, it.transfers); + return [ + { + startTime: it.startTime, + endTime: it.endTime, + // Wall-clock duration (start→end) so waits/transfers count — summing leg + // run-times would understate the journey and mis-slot it in the timeline. + ...stats, + legs, + }, + ]; + }); + + const data = { itineraries }; + cacheSet(key, data); + return data; + } +} diff --git a/server/src/services/transitItineraryService.ts b/server/src/services/transitItineraryService.ts index d14c74ee1..d48da6afa 100644 --- a/server/src/services/transitItineraryService.ts +++ b/server/src/services/transitItineraryService.ts @@ -1,7 +1,7 @@ import { haversineKm } from './distanceService'; import type { EndpointInput } from '../nest/reservations/reservations.bridge'; import { localParts, resolveTimeZone } from './timezoneService'; -import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transitService'; +import { deriveTransitStats, type TransitItinerary, type TransitLeg } from '../nest/transit/transit.helpers'; import { z } from 'zod'; diff --git a/server/src/services/transitService.ts b/server/src/services/transitService.ts deleted file mode 100644 index f0da642a8..000000000 --- a/server/src/services/transitService.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { readEnv } from '../app-config'; -import { buildUserAgent } from '../nest/maps/maps.helpers'; -import { getAppUrl } from '../app-config'; - -/** - * Public transit routing (#1065) backed by Transitous (api.transitous.org), the - * community-run MOTIS instance over public GTFS feeds — free, no API key, fits - * TREK's no-paid-providers rule. Self-hosters can point TRANSIT_API_URL at their - * own MOTIS instance instead. - * - * This service is a thin, validating proxy: the browser never talks to - * Transitous directly (their usage policy wants an identifying User-Agent with - * contact info, which we send once from the server), and responses are mapped - * to a compact shape so the client isn't coupled to the MOTIS schema. - */ - -// Frozen at import on purpose (legacy timing). -const TRANSIT_API_BASE = readEnv().integrations.transitApiBase; -let userAgent: string | null = null; - -function getUserAgent(): string { - userAgent ??= buildUserAgent(getAppUrl()); - return userAgent; -} - -// Modes the client may request — a strict whitelist so the proxy can't be used -// to smuggle arbitrary query values upstream. TRANSIT covers everything; the -// others let the user filter (RAIL already includes subway/suburban etc.). -export const SCHEDULED_TRANSIT_MODES = [ - 'BUS', - 'COACH', - 'TRAM', - 'SUBWAY', - 'RAIL', - 'FERRY', - 'FUNICULAR', - 'AERIAL_LIFT', - // Fine-grained rail modes so "train without subway" is expressible (RAIL - // itself includes SUBWAY per the MOTIS mode taxonomy). - 'HIGHSPEED_RAIL', - 'LONG_DISTANCE', - 'NIGHT_RAIL', - 'REGIONAL_RAIL', - 'SUBURBAN', -] as const; - -const ALLOWED_MODES = new Set(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]); - -// Short-lived response cache: planning is the expensive call per the Transitous -// usage policy, and a user toggling filters re-requests identical plans. -const CACHE_TTL = 60 * 1000; -const CACHE_MAX = 200; -const cache = new Map(); - -function cacheGet(key: string): unknown | null { - const hit = cache.get(key); - if (!hit) return null; - if (Date.now() - hit.at > CACHE_TTL) { - cache.delete(key); - return null; - } - return hit.data; -} - -function cacheSet(key: string, data: unknown): void { - if (cache.size >= CACHE_MAX) { - const oldest = cache.keys().next().value; - if (oldest !== undefined) cache.delete(oldest); - } - cache.set(key, { at: Date.now(), data }); -} - -const COORD_RE = /^-?\d{1,3}(\.\d+)?,-?\d{1,3}(\.\d+)?$/; - -function isCoord(v: string): boolean { - if (!COORD_RE.test(v)) return false; - const [lat, lng] = v.split(',').map(Number); - return Math.abs(lat) <= 90 && Math.abs(lng) <= 180; -} - -async function upstream(path: string, params: URLSearchParams): Promise { - const url = `${TRANSIT_API_BASE}${path}?${params}`; - const res = await fetch(url, { headers: { 'User-Agent': getUserAgent(), Accept: 'application/json' } }); - if (!res.ok) { - const err = new Error(`Transit provider error (HTTP ${res.status})`) as Error & { status: number }; - err.status = res.status === 429 ? 429 : 502; - throw err; - } - return res.json(); -} - -// ── Geocode ────────────────────────────────────────────────────────────────── - -export interface TransitPlace { - name: string; - lat: number; - lng: number; - type: string; - area: string | null; -} - -/** Station/place search for the from/to pickers. `near` biases results. */ -export async function geocode(query: string, language?: string, near?: string): Promise<{ results: TransitPlace[] }> { - const text = (query || '').trim(); - if (text.length < 2) return { results: [] }; - if (text.length > 200) { - const e = new Error('Query too long') as Error & { status: number }; - e.status = 400; - throw e; - } - - const params = new URLSearchParams({ text }); - if (language) params.set('language', language.slice(0, 5)); - if (near && isCoord(near)) params.set('place', near); - - const key = `geo:${params.toString()}`; - const cached = cacheGet(key); - if (cached) return cached as { results: TransitPlace[] }; - - const raw = (await upstream('/api/v1/geocode', params)) as Array<{ - name?: string; - lat?: number; - lon?: number; - type?: string; - areas?: Array<{ name?: string; matched?: boolean; default?: boolean }>; - }>; - - const results: TransitPlace[] = (Array.isArray(raw) ? raw : []).slice(0, 8).flatMap((m) => { - if (typeof m.lat !== 'number' || typeof m.lon !== 'number' || !m.name) return []; - const area = m.areas?.find((a) => a.default)?.name || m.areas?.[0]?.name || null; - return [{ name: m.name, lat: m.lat, lng: m.lon, type: m.type || 'PLACE', area }]; - }); - - const data = { results }; - cacheSet(key, data); - return data; -} - -// ── Plan ───────────────────────────────────────────────────────────────────── - -export interface TransitLegStop { - name: string; - lat: number; - lng: number; - time: string | null; - scheduledTime: string | null; - track: string | null; -} - -export interface TransitLeg { - mode: string; - from: TransitLegStop; - to: TransitLegStop; - duration: number; - distance: number | null; - headsign: string | null; - line: string | null; - lineColor: string | null; - lineTextColor: string | null; - agency: string | null; - intermediateStops: number; - /** Encoded polyline of the leg's real path (Google encoding) + its precision. */ - geometry: string | null; - geometryPrecision: number; -} - -export interface TransitItinerary { - startTime: string; - endTime: string; - duration: number; - transfers: number; - walkSeconds: number; - legs: TransitLeg[]; -} - -export interface PlanQuery { - from: string; - to: string; - time?: string; - arriveBy?: boolean; - modes?: string; - maxTransfers?: number; -} - -export function deriveTransitStats( - startTime: string, - endTime: string, - legs: TransitLeg[], - reportedTransfers?: number, -): Pick { - return { - duration: Math.max(0, Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000)), - transfers: - typeof reportedTransfers === 'number' - ? reportedTransfers - : Math.max(0, legs.filter((leg) => leg.mode !== 'WALK').length - 1), - walkSeconds: legs.filter((leg) => leg.mode === 'WALK').reduce((total, leg) => total + leg.duration, 0), - }; -} - -// GTFS colors come as bare hex ("FF0000"), with hash, or empty — normalise to -// a #-prefixed value or null so the client can use them in CSS directly. -function safeColor(v: unknown): string | null { - if (typeof v !== 'string') return null; - const hex = v.trim().replace(/^#/, ''); - return /^[0-9a-fA-F]{6}$/.test(hex) || /^[0-9a-fA-F]{3}$/.test(hex) ? `#${hex}` : null; -} - -interface MotisPlaceRaw { - name?: string; - lat?: number; - lon?: number; - departure?: string; - arrival?: string; - scheduledDeparture?: string; - scheduledArrival?: string; - track?: string; - scheduledTrack?: string; -} - -function mapStop(p: MotisPlaceRaw | undefined, kind: 'departure' | 'arrival'): TransitLegStop { - return { - name: p?.name || '', - lat: typeof p?.lat === 'number' ? p.lat : 0, - lng: typeof p?.lon === 'number' ? p.lon : 0, - time: (kind === 'departure' ? p?.departure : p?.arrival) || null, - scheduledTime: (kind === 'departure' ? p?.scheduledDeparture : p?.scheduledArrival) || null, - track: p?.track || p?.scheduledTrack || null, - }; -} - -/** Route search between two coordinates. Returns compact itineraries for the picker. */ -export async function plan(q: PlanQuery): Promise<{ itineraries: TransitItinerary[] }> { - const bad = (msg: string) => { - const e = new Error(msg) as Error & { status: number }; - e.status = 400; - throw e; - }; - if (!q.from || !isCoord(q.from)) bad('from must be "lat,lng"'); - if (!q.to || !isCoord(q.to)) bad('to must be "lat,lng"'); - - const params = new URLSearchParams({ fromPlace: q.from, toPlace: q.to, numItineraries: '8' }); - - if (q.time) { - const parsed = new Date(q.time); - if (isNaN(parsed.getTime())) bad('time must be an ISO date-time'); - params.set('time', parsed.toISOString()); - } - if (q.arriveBy) params.set('arriveBy', 'true'); - - if (q.modes) { - const modes = q.modes - .split(',') - .map((m) => m.trim().toUpperCase()) - .filter(Boolean); - if (modes.some((m) => !ALLOWED_MODES.has(m))) bad('unsupported transit mode'); - if (modes.length > 0) params.set('transitModes', modes.join(',')); - } - if (q.maxTransfers !== undefined && q.maxTransfers !== null) { - const n = Number(q.maxTransfers); - if (!Number.isInteger(n) || n < 0 || n > 10) bad('maxTransfers must be 0-10'); - params.set('maxTransfers', String(n)); - } - // We only want scheduled transit journeys in the results — a pure-walk - // "direct" connection is what the existing OSRM footpath routing is for. - params.set('directModes', 'WALK'); - - const key = `plan:${params.toString()}`; - const cached = cacheGet(key); - if (cached) return cached as { itineraries: TransitItinerary[] }; - - const raw = (await upstream('/api/v6/plan', params)) as { - itineraries?: Array<{ - duration?: number; - startTime?: string; - endTime?: string; - transfers?: number; - legs?: Array<{ - mode?: string; - duration?: number; - distance?: number; - headsign?: string; - routeShortName?: string; - displayName?: string; - routeColor?: string; - routeTextColor?: string; - agencyName?: string; - from?: MotisPlaceRaw; - to?: MotisPlaceRaw; - intermediateStops?: unknown[]; - legGeometry?: { points?: string; precision?: number }; - }>; - }>; - }; - - const itineraries: TransitItinerary[] = (raw.itineraries || []).flatMap((it) => { - if (!it.startTime || !it.endTime || !Array.isArray(it.legs)) return []; - const legs: TransitLeg[] = it.legs.map((leg) => ({ - mode: (leg.mode || 'WALK').toUpperCase(), - from: mapStop(leg.from, 'departure'), - to: mapStop(leg.to, 'arrival'), - duration: typeof leg.duration === 'number' ? leg.duration : 0, - distance: typeof leg.distance === 'number' ? Math.round(leg.distance) : null, - headsign: leg.headsign || null, - // displayName is the public identifier MOTIS resolved for the run, taking - // the trip number into account where the feed has one. Plenty of operators - // (German long distance among them) keep routeShortName as an internal line - // number, so preferring it showed "20" instead of "ICE 72" (#1715). - line: leg.displayName || leg.routeShortName || null, - lineColor: safeColor(leg.routeColor), - lineTextColor: safeColor(leg.routeTextColor), - agency: leg.agencyName || null, - intermediateStops: Array.isArray(leg.intermediateStops) ? leg.intermediateStops.length : 0, - geometry: leg.legGeometry?.points || null, - geometryPrecision: leg.legGeometry?.precision ?? 6, - })); - const stats = deriveTransitStats(it.startTime, it.endTime, legs, it.transfers); - return [ - { - startTime: it.startTime, - endTime: it.endTime, - // Wall-clock duration (start→end) so waits/transfers count — summing leg - // run-times would understate the journey and mis-slot it in the timeline. - ...stats, - legs, - }, - ]; - }); - - const data = { itineraries }; - cacheSet(key, data); - return data; -} diff --git a/server/tests/e2e/transit.e2e.test.ts b/server/tests/e2e/transit.e2e.test.ts index a75c9a2ef..2e5cc7d06 100644 --- a/server/tests/e2e/transit.e2e.test.ts +++ b/server/tests/e2e/transit.e2e.test.ts @@ -1,7 +1,8 @@ /** * Transit proxy e2e — /api/transit/geocode + /api/transit/plan through the real - * JwtAuthGuard against a temp SQLite db. The transit service is mocked; this - * focuses on auth (401), param pass-through and error propagation (#1065). + * JwtAuthGuard against a temp SQLite db. The DI-native TransitService's provider + * methods are stubbed via instance spies (no outbound HTTP); this focuses on + * auth (401), param pass-through and error propagation (#1065). */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; @@ -20,19 +21,28 @@ const { db } = vi.hoisted(() => { return { db: tmp }; }); vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); - -const { transitSvc } = vi.hoisted(() => ({ transitSvc: { geocode: vi.fn(), plan: vi.fn() } })); -vi.mock('../../src/services/transitService', () => transitSvc); +// TransitMcp (in the module graph, never invoked here) reaches _shared → +// src/websocket at import time — silence the real ws module. +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); import { TransitModule } from '../../src/nest/transit/transit.module'; +import { TransitService } from '../../src/nest/transit/transit.service'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; describe('Transit proxy e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; + let geocodeSpy: ReturnType; + let planSpy: ReturnType; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [TransitModule] }).compile(); + const moduleRef = await Test.createTestingModule({ + // DatabaseModule + RealtimeModule are @Global in the app graph; + // TransitModule's DaysModule/ReservationsModule imports need them here. + imports: [DatabaseModule, RealtimeModule, TransitModule], + }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); @@ -44,11 +54,14 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => { seedUser(db as never, { id: 1 }); app = await build(); server = app.getHttpServer(); + const transit = app.get(TransitService); + geocodeSpy = vi.spyOn(transit, 'geocode') as ReturnType; + planSpy = vi.spyOn(transit, 'plan') as ReturnType; }); beforeEach(() => { - transitSvc.geocode.mockReset(); - transitSvc.plan.mockReset(); + geocodeSpy.mockReset(); + planSpy.mockReset(); }); afterAll(async () => { await app.close(); }); @@ -59,20 +72,20 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => { }); it('geocode passes q/lang/near through and returns the service result', async () => { - transitSvc.geocode.mockResolvedValueOnce({ results: [{ name: 'Alexanderplatz' }] }); + geocodeSpy.mockResolvedValueOnce({ results: [{ name: 'Alexanderplatz' }] }); const res = await request(server).get('/api/transit/geocode?q=alex&lang=de&near=52.5,13.4').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body.results[0].name).toBe('Alexanderplatz'); - expect(transitSvc.geocode).toHaveBeenCalledWith('alex', 'de', '52.5,13.4'); + expect(geocodeSpy).toHaveBeenCalledWith('alex', 'de', '52.5,13.4'); }); it('plan passes all params through (arriveBy + maxTransfers coerced)', async () => { - transitSvc.plan.mockResolvedValueOnce({ itineraries: [] }); + planSpy.mockResolvedValueOnce({ itineraries: [] }); const res = await request(server) .get('/api/transit/plan?from=52.5,13.4&to=52.6,13.5&time=2026-07-13T09:00:00Z&arriveBy=true&modes=BUS&maxTransfers=2') .set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(transitSvc.plan).toHaveBeenCalledWith({ + expect(planSpy).toHaveBeenCalledWith({ from: '52.5,13.4', to: '52.6,13.5', time: '2026-07-13T09:00:00Z', arriveBy: true, modes: 'BUS', maxTransfers: 2, }); }); @@ -80,7 +93,7 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => { it('service validation errors propagate with their status', async () => { const err = new Error('from must be "lat,lng"') as Error & { status: number }; err.status = 400; - transitSvc.plan.mockRejectedValueOnce(err); + planSpy.mockRejectedValueOnce(err); const res = await request(server).get('/api/transit/plan?from=bad&to=1,2').set('Cookie', sessionCookie(1)); expect(res.status).toBe(400); expect(res.body.error).toContain('lat,lng'); @@ -89,7 +102,7 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => { it('upstream failures surface as 502', async () => { const err = new Error('Transit provider error (HTTP 500)') as Error & { status: number }; err.status = 502; - transitSvc.plan.mockRejectedValueOnce(err); + planSpy.mockRejectedValueOnce(err); const res = await request(server).get('/api/transit/plan?from=1,2&to=3,4').set('Cookie', sessionCookie(1)); expect(res.status).toBe(502); }); diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 1da107a18..7f7baf9cc 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -29,6 +29,8 @@ import { ShareMcp } from '../../src/nest/share/share.mcp'; import { ShareService } from '../../src/nest/share/share.service'; import { TodoMcp } from '../../src/nest/todo/todo.mcp'; import { TodoService } from '../../src/nest/todo/todo.service'; +import { TransitMcp } from '../../src/nest/transit/transit.mcp'; +import { TransitService } from '../../src/nest/transit/transit.service'; import { FilesService } from '../../src/nest/files/files.service'; import { TripsMcp } from '../../src/nest/trips/trips.mcp'; import { TripsService } from '../../src/nest/trips/trips.service'; @@ -53,6 +55,7 @@ export function createMcpTestRegistry(): McpRegistry { const todoService = new TodoService(dbService, permissionsService, realtimeService); const packingService = new PackingService(dbService, permissionsService, realtimeService); const collabService = new CollabService(dbService, permissionsService, realtimeService); + const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService); const tripsService = new TripsService( dbService, todoService, @@ -73,7 +76,7 @@ export function createMcpTestRegistry(): McpRegistry { new TodoMcp(todoService), new PackingMcp(packingService), new BudgetMcp(budgetService, exchangeRatesService, dbService), - new ReservationsMcp(new ReservationsService(dbService, permissionsService, budgetService, realtimeService), daysService, budgetService), + new ReservationsMcp(reservationsService, daysService, budgetService), new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService)), new DaysMcp(daysService, dbService), new AssignmentsMcp(new AssignmentsService(dbService, permissionsService, realtimeService), daysService), @@ -82,6 +85,7 @@ export function createMcpTestRegistry(): McpRegistry { new TripsMcp(tripsService, todoService, collabService), new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService)), new MapsMcp(new MapsService(dbService)), + new TransitMcp(new TransitService(), daysService, reservationsService, dbService), ], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }, ); diff --git a/server/tests/unit/mcp/tools-transit.test.ts b/server/tests/unit/mcp/tools-transit.test.ts index 9c6984f54..900fa052a 100644 --- a/server/tests/unit/mcp/tools-transit.test.ts +++ b/server/tests/unit/mcp/tools-transit.test.ts @@ -28,30 +28,32 @@ const { testDb, dbMock } = vi.hoisted(() => { return { testDb: db, dbMock: mock }; }); -const { geocodeMock, planMock, broadcastMock, notifyBookingChangeMock } = vi.hoisted(() => ({ - geocodeMock: vi.fn(), - planMock: vi.fn(), +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn(), - notifyBookingChangeMock: vi.fn(), })); vi.mock('../../../src/db/database', () => dbMock); -vi.mock('../../../src/services/transitService', async (importOriginal) => ({ - ...(await importOriginal()), - geocode: geocodeMock, - plan: planMock, -})); vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); -vi.mock('../../../src/nest/reservations/reservations.bridge', async (importOriginal) => ({ - ...(await importOriginal()), - notifyBookingChange: notifyBookingChangeMock, -})); 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 { TransitService } from '../../../src/nest/transit/transit.service'; + +// 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 = { @@ -118,7 +120,8 @@ beforeEach(() => { geocodeMock.mockReset(); planMock.mockReset(); broadcastMock.mockReset(); - notifyBookingChangeMock.mockReset(); + // mockReset would fall back to the real notification write — keep it stubbed. + notifyBookingChangeMock.mockReset().mockImplementation(() => {}); delete process.env.DEMO_MODE; invalidatePermissionsCache(); }); diff --git a/server/tests/unit/services/transitService.test.ts b/server/tests/unit/nest/transit.service.test.ts similarity index 62% rename from server/tests/unit/services/transitService.test.ts rename to server/tests/unit/nest/transit.service.test.ts index c589a1d4f..7940918e4 100644 --- a/server/tests/unit/services/transitService.test.ts +++ b/server/tests/unit/nest/transit.service.test.ts @@ -1,9 +1,16 @@ /** - * Unit tests for transitService — TRANSIT-SVC-001..010. - * The Transitous/MOTIS proxy (#1065): input validation, mode whitelist, - * response mapping (colors, walk time, wall-clock duration) and caching. + * Unit tests for the DI-native TransitService — TRANSIT-SVC-001..011, moved + * 1:1 from the legacy tests/unit/services/transitService.test.ts when the + * transit domain went DI-native. The Transitous/MOTIS proxy (#1065): input + * validation, mode whitelist, response mapping (colors, walk time, wall-clock + * duration) and caching. + * + * The response cache is deliberately MODULE-scoped (shared by every + * TransitService instance), so it persists across the tests in this file — + * every case uses its own coordinates/query to stay isolated. */ -import { deriveTransitStats, geocode, plan, type TransitLeg } from '../../../src/services/transitService'; +import { deriveTransitStats, type TransitLeg } from '../../../src/nest/transit/transit.helpers'; +import { TransitService } from '../../../src/nest/transit/transit.service'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -14,6 +21,7 @@ vi.mock('../../../src/app-config', async (importOriginal) => { vi.mock('../../../src/nest/maps/maps.helpers', () => ({ buildUserAgent: () => 'TREK-Test-UA' })); const fetchMock = vi.fn(); +const svc = new TransitService(); beforeEach(() => { vi.stubGlobal('fetch', fetchMock); @@ -21,13 +29,17 @@ beforeEach(() => { }); afterEach(() => vi.unstubAllGlobals()); -function okJson(data: unknown) { - return { ok: true, json: async () => data }; +function okJson(data: unknown, contentLength: number | null = null) { + return { + ok: true, + headers: { get: () => (contentLength === null ? null : String(contentLength)) }, + json: async () => data, + }; } describe('geocode', () => { it('TRANSIT-SVC-001: returns [] without calling upstream for short queries', async () => { - const r = await geocode('a'); + const r = await svc.geocode('a'); expect(r.results).toEqual([]); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -39,7 +51,7 @@ describe('geocode', () => { { name: 'no-coords' }, ]), ); - const r = await geocode('alexanderplatz-u1'); + const r = await svc.geocode('alexanderplatz-u1'); expect(r.results).toEqual([{ name: 'Alexanderplatz', lat: 52.52, lng: 13.41, type: 'STOP', area: 'Berlin' }]); const [url, init] = fetchMock.mock.calls[0]; expect(String(url)).toContain('/api/v1/geocode?'); @@ -48,30 +60,30 @@ describe('geocode', () => { it('TRANSIT-SVC-003: ignores an invalid near bias instead of forwarding it', async () => { fetchMock.mockResolvedValueOnce(okJson([])); - await geocode('hauptbahnhof-x1', undefined, 'not,coords'); + await svc.geocode('hauptbahnhof-x1', undefined, 'not,coords'); expect(String(fetchMock.mock.calls[0][0])).not.toContain('place='); }); }); describe('plan validation', () => { it('TRANSIT-SVC-004: rejects malformed coordinates with 400', async () => { - await expect(plan({ from: 'x', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 }); - await expect(plan({ from: '95,13.4', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 }); + await expect(svc.plan({ from: 'x', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 }); + await expect(svc.plan({ from: '95,13.4', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 }); expect(fetchMock).not.toHaveBeenCalled(); }); it('TRANSIT-SVC-005: rejects modes outside the whitelist', async () => { - await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', modes: 'BUS,CAR' })).rejects.toMatchObject({ + await expect(svc.plan({ from: '52.50,13.40', to: '52.51,13.41', modes: 'BUS,CAR' })).rejects.toMatchObject({ status: 400, }); expect(fetchMock).not.toHaveBeenCalled(); }); it('TRANSIT-SVC-006: rejects out-of-range maxTransfers and bad time', async () => { - await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', maxTransfers: 99 })).rejects.toMatchObject({ + await expect(svc.plan({ from: '52.50,13.40', to: '52.51,13.41', maxTransfers: 99 })).rejects.toMatchObject({ status: 400, }); - await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', time: 'not-a-date' })).rejects.toMatchObject({ + await expect(svc.plan({ from: '52.50,13.40', to: '52.51,13.41', time: 'not-a-date' })).rejects.toMatchObject({ status: 400, }); }); @@ -126,7 +138,7 @@ describe('plan mapping', () => { it('TRANSIT-SVC-007: maps legs compactly, normalises GTFS colors and counts walk seconds', async () => { fetchMock.mockResolvedValueOnce(okJson(motisResponse)); - const r = await plan({ from: '52.5000,13.4000', to: '52.5100,13.4100' }); + const r = await svc.plan({ from: '52.5000,13.4000', to: '52.5100,13.4100' }); expect(r.itineraries).toHaveLength(1); const it = r.itineraries[0]; // Wall-clock 08:00→08:30, not the reported 999s. @@ -173,7 +185,7 @@ describe('plan mapping', () => { ], }), ); - const r = await plan({ from: '49.0090,8.4004', to: '50.0510,8.5706' }); + const r = await svc.plan({ from: '49.0090,8.4004', to: '50.0510,8.5706' }); const [longDistance, regional] = r.itineraries[0].legs; expect(longDistance.line).toBe('ICE 72'); // Feeds that only supply routeShortName keep working. @@ -182,7 +194,7 @@ describe('plan mapping', () => { it('TRANSIT-SVC-008: forwards only whitelisted params and pins directModes=WALK', async () => { fetchMock.mockResolvedValueOnce(okJson({ itineraries: [] })); - await plan({ + await svc.plan({ from: '48.1000,11.5000', to: '48.2000,11.6000', modes: 'BUS,TRAM', @@ -200,13 +212,55 @@ describe('plan mapping', () => { it('TRANSIT-SVC-009: identical plans hit the cache (single upstream call)', async () => { fetchMock.mockResolvedValue(okJson({ itineraries: [] })); - await plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' }); - await plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' }); + await svc.plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' }); + await svc.plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('TRANSIT-SVC-010: upstream failure surfaces as a 502-style error', async () => { fetchMock.mockResolvedValueOnce({ ok: false, status: 500 }); - await expect(plan({ from: '41.0000,2.0000', to: '41.1000,2.1000' })).rejects.toMatchObject({ status: 502 }); + await expect(svc.plan({ from: '41.0000,2.0000', to: '41.1000,2.1000' })).rejects.toMatchObject({ status: 502 }); + }); +}); + +describe('quirk repairs (DI fold fix pass)', () => { + it('TRANSIT-SVC-012: upstream requests carry an abort-timeout signal', async () => { + fetchMock.mockResolvedValueOnce(okJson([])); + await svc.geocode('timeout-probe-station'); + const [, init] = fetchMock.mock.calls[0]; + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it('TRANSIT-SVC-013: unparseable provider timestamps yield duration 0, not NaN', () => { + const stats = deriveTransitStats('not-a-date', 'also-not-a-date', []); + expect(stats.duration).toBe(0); + // Parseable dates keep the wall-clock behaviour. + expect(deriveTransitStats('2026-07-13T08:00:00Z', '2026-07-13T08:30:00Z', []).duration).toBe(1800); + }); + + it('TRANSIT-SVC-014: an oversized upstream response is refused as a provider error', async () => { + fetchMock.mockResolvedValueOnce(okJson([], 6_000_000)); + await expect(svc.geocode('oversize-probe-station')).rejects.toMatchObject({ + status: 502, + message: 'Transit provider error (response too large)', + }); + }); + + it('TRANSIT-SVC-015: the cache is LRU — a re-read entry survives eviction pressure', async () => { + fetchMock.mockResolvedValue(okJson([])); + // Flush whatever earlier tests cached: 205 distinct fillers guarantee the + // 200-entry Map holds only fillers afterwards. + for (let i = 0; i < 205; i++) await svc.geocode(`lru-fill-${i}`); + // Insert the probe, then age it to the oldest position with 199 more inserts. + await svc.geocode('lru-probe-station'); + for (let i = 0; i < 199; i++) await svc.geocode(`lru-age-${i}`); + const fetchesBeforeTouch = fetchMock.mock.calls.length; + // Touch the probe (cache hit — no fetch) so LRU moves it off the eviction edge… + await svc.geocode('lru-probe-station'); + expect(fetchMock.mock.calls.length).toBe(fetchesBeforeTouch); + // …then trigger one eviction. FIFO would evict the probe; LRU evicts lru-age-0. + await svc.geocode('lru-evictor-station'); + await svc.geocode('lru-probe-station'); + expect(fetchMock.mock.calls.length).toBe(fetchesBeforeTouch + 1); }); });