mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: move transit backend to proper DI
Includes the quirk-repair pass (upstream timeout + size cap, LRU cache, NaN-duration guard) that maps landed as a sibling fix commit.
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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<typeof buildTransitReservationParts>;
|
||||
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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TransitItinerary, 'duration' | 'transfers' | 'walkSeconds'> {
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
@@ -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<typeof transitPlaceSchema>;
|
||||
to: z.infer<typeof transitPlaceSchema>;
|
||||
time?: string;
|
||||
arriveBy?: boolean;
|
||||
modes?: z.infer<typeof transitModes>[];
|
||||
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<typeof transitPlaceSchema>;
|
||||
to: z.infer<typeof transitPlaceSchema>;
|
||||
// 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<typeof buildTransitReservationParts>;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<string, { at: number; data: unknown }>();
|
||||
|
||||
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<unknown> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<string, { at: number; data: unknown }>();
|
||||
|
||||
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<unknown> {
|
||||
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<TransitItinerary, 'duration' | 'transfers' | 'walkSeconds'> {
|
||||
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;
|
||||
}
|
||||
@@ -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<ReturnType<typeof build>>;
|
||||
let geocodeSpy: ReturnType<typeof vi.spyOn>;
|
||||
let planSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
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<typeof vi.spyOn>;
|
||||
planSpy = vi.spyOn(transit, 'plan') as ReturnType<typeof vi.spyOn>;
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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<typeof import('../../../src/services/transitService')>()),
|
||||
geocode: geocodeMock,
|
||||
plan: planMock,
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
vi.mock('../../../src/nest/reservations/reservations.bridge', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/nest/reservations/reservations.bridge')>()),
|
||||
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();
|
||||
});
|
||||
|
||||
+74
-20
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user