chore: move transitItineraryService into nest/transit as pure helpers

This commit is contained in:
jubnl
2026-08-01 12:45:35 +02:00
parent 57cc676e8a
commit e80ae79b77
8 changed files with 468 additions and 35 deletions
+18 -4
View File
@@ -29,7 +29,7 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
- **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,
transit, place — see the migration recipe below.
transit, place, transit-itinerary — see the migration recipe below.
## Cross-cutting Foundation pieces
@@ -244,7 +244,8 @@ Transitous/MOTIS proxy became a dep-free `TransitService` (no
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
downstream legacy `transitItineraryService` needed no bridge (since relocated
into the domain as `transit-itinerary.helpers.ts`); 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
@@ -278,9 +279,22 @@ empty-list short-circuit stays reachable, and retired three bespoke 400
strings — 'Place name is required', 'ids must be an array of numbers' and
'URL is required' — in favour of the pipe envelope, accepting that a
malformed body now 400s ahead of the trip-access 404 (the todo/trips trade).
transitItineraryService followed (the first pure-helpers relocation with no
service fold at all — the 287-line module is 100% pure, so the recipe's SQL /
bridge / DTO / plugin-host steps were all no-ops: the Zod itinerary schemas +
endpoint/metadata builders moved byte-identical to
`transit-itinerary.helpers.ts`, next to `transit.helpers.ts`; the schemas
**must** stay module-level plain exports because `transit.mcp.ts` consumes
them inside `@Tool({ inputSchema })` decorators, which evaluate at module load
before any container exists; the sole consumer — the in-container
`transit.mcp.ts` — was a one-import repoint, closing step 4 of the
dependency-honest order; the legacy module had no direct suite, so a new
21-case `TRANSIT-ITIN-*` characterization suite now pins all 12 superRefine
error strings, the `??` time fallbacks, the coordinate tolerances and the
reservation endpoint/metadata builder).
Repeat these steps per
service (next up: **transitItineraryService** —
or the notifications fan-in — per the dependency-honest order in
service (next up: **the notifications fan-in** —
per the dependency-honest order in
`migration-graph.md`). This is a
**pure relocation** — byte-identical
SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a
@@ -1,7 +1,16 @@
import { haversineKm } from './distanceService';
import type { EndpointInput } from '../nest/reservations/reservations.bridge';
import { localParts, resolveTimeZone } from './timezoneService';
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from '../nest/transit/transit.helpers';
/**
* Pure transit-itinerary helpers — the wire schemas for provider itineraries
* plus the endpoint/metadata builder for automated transit reservations.
* Moved 1:1 from the legacy services/transitItineraryService.ts (identical
* schemas, tolerances, error strings and output shapes). Kept as plain
* exports (the maps.helpers.ts precedent): the schemas are consumed inside
* transit.mcp.ts's @Tool inputSchema decorators, which evaluate at module
* load — before any DI container exists — so they must stay module-level.
*/
import { haversineKm } from '../../services/distanceService';
import type { EndpointInput } from '../reservations/reservations.service';
import { localParts, resolveTimeZone } from '../../services/timezoneService';
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transit.helpers';
import { z } from 'zod';
+4 -5
View File
@@ -1,9 +1,8 @@
/**
* 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.
* Pure transit helpers shared by TransitService and the itinerary
* validation/reservation builders in transit-itinerary.helpers.ts. Kept as
* plain functions/consts (the maps.helpers.ts / files.constants.ts
* precedent) — helpers never block a migration.
*/
// Modes the client may request — a strict whitelist so the proxy can't be used
+2 -3
View File
@@ -12,7 +12,7 @@ import {
transitCoordinatesSchema,
transitItinerarySchema,
transitPlaceSchema,
} from '../../services/transitItineraryService';
} from './transit-itinerary.helpers';
import { hasTripPermission, noAccess, permissionDenied, safeBroadcast } from '../../mcp/tools/_shared';
import { RateLimitService } from '../auth/rate-limit.service';
import { DatabaseService } from '../database/database.service';
@@ -54,8 +54,7 @@ function rateLimit(userId: number, bucket: string, max: number) {
* 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.
* schemas/helpers live in the colocated transit-itinerary.helpers.ts.
*/
@McpController()
export class TransitMcp {
+1 -1
View File
@@ -126,7 +126,7 @@ function mapStop(p: MotisPlaceRaw | undefined, kind: 'departure' | 'arrival'): T
// 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
// would ripple through transit-itinerary.helpers' 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,
@@ -0,0 +1,364 @@
/**
* Characterization tests for the pure transit-itinerary helpers —
* TRANSIT-ITIN-001 through TRANSIT-ITIN-021. The module moved 1:1 from the
* legacy services/transitItineraryService.ts, which had no direct suite (its
* behaviour was pinned only through the MCP transit tools); these cases pin
* every superRefine error string and path, the ?? time fallbacks, the
* coordinate tolerances and the reservation endpoint/metadata builder so the
* relocation is provably byte-identical. Everything under test is pure — no
* DB, no container; the db mock only satisfies distanceService's module-top
* import of the connection proxy.
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../../src/db/database', () => ({ db: {} }));
import {
buildTransitReservationParts,
cleanTransitItineraryNames,
effectiveTransitStopTime,
transitCoordinatesMatch,
transitItinerarySchema,
} from '../../../src/nest/transit/transit-itinerary.helpers';
import type { TransitItinerary, TransitLeg, TransitLegStop } from '../../../src/nest/transit/transit.helpers';
// All fixtures sit in Asia/Tokyo (+09:00) so local dates/times are
// deterministic regardless of the host timezone.
const stop = (name: string, lat: number, lng: number, time: string | null, extra: Partial<TransitLegStop> = {}): TransitLegStop => ({
name,
lat,
lng,
time,
scheduledTime: null,
track: null,
...extra,
});
const leg = (mode: string, from: TransitLegStop, to: TransitLegStop, duration: number, extra: Partial<TransitLeg> = {}): TransitLeg => ({
mode,
from,
to,
duration,
distance: null,
headsign: null,
line: null,
lineColor: null,
lineTextColor: null,
agency: null,
intermediateStops: 0,
geometry: null,
geometryPrecision: 6,
...extra,
});
const at = (hhmm: string) => `2026-04-01T${hhmm}:00+09:00`;
const namba = { name: 'Namba', lat: 34.667, lng: 135.501 };
const umeda = { name: 'Umeda', lat: 34.702, lng: 135.496 };
/** Canonical valid single-leg itinerary: one RAIL leg spanning the itinerary. */
const singleLeg = (overrides: Partial<TransitItinerary> = {}): TransitItinerary => ({
startTime: at('09:00'),
endTime: at('09:30'),
duration: 1800,
transfers: 0,
walkSeconds: 0,
legs: [leg('RAIL', stop('Namba', 34.667, 135.501, at('09:00')), stop('Umeda', 34.702, 135.496, at('09:30')), 1800)],
...overrides,
});
/** Two connected RAIL legs (transfer at Umeda), 09:00 → 09:58. */
const twoLegs = (overrides: Partial<TransitItinerary> = {}): TransitItinerary => ({
startTime: at('09:00'),
endTime: at('09:58'),
duration: 3480,
transfers: 1,
walkSeconds: 0,
legs: [
leg('RAIL', stop('Namba', 34.667, 135.501, at('09:00')), stop('Umeda', 34.702, 135.496, at('09:26')), 1560),
leg('RAIL', stop('Umeda', 34.702, 135.496, at('09:30')), stop('Kyoto', 35.0116, 135.7681, at('09:58')), 1680),
],
...overrides,
});
const issuesOf = (itinerary: TransitItinerary) => {
const result = transitItinerarySchema.safeParse(itinerary);
return result.success ? [] : result.error.issues.map((issue) => ({ message: issue.message, path: issue.path }));
};
describe('transitItinerarySchema', () => {
it('TRANSIT-ITIN-001: accepts a canonical itinerary (single leg and two-leg transfer)', () => {
expect(transitItinerarySchema.safeParse(singleLeg()).success).toBe(true);
expect(transitItinerarySchema.safeParse(twoLegs()).success).toBe(true);
});
it('TRANSIT-ITIN-002: rejects endTime not after startTime', () => {
expect(issuesOf(singleLeg({ endTime: at('09:00') }))).toContainEqual({
message: 'endTime must be after startTime',
path: ['endTime'],
});
});
it('TRANSIT-ITIN-003: rejects an all-WALK itinerary', () => {
const walkOnly = singleLeg();
walkOnly.legs = [{ ...walkOnly.legs[0], mode: 'WALK' }];
expect(issuesOf(walkOnly)).toContainEqual({
message: 'At least one scheduled transit leg is required',
path: ['legs'],
});
});
it('TRANSIT-ITIN-004: rejects transfers exceeding the transit leg count', () => {
expect(issuesOf(singleLeg({ transfers: 5 }))).toContainEqual({
message: 'transfers exceeds the number of transit legs',
path: ['transfers'],
});
});
it('TRANSIT-ITIN-005: caps combined geometry at 60000 chars (per-leg 60000 alone passes)', () => {
const half = 'x'.repeat(40_000);
const oversized = twoLegs();
oversized.legs = [
{ ...oversized.legs[0], geometry: half },
{ ...oversized.legs[1], geometry: half },
];
expect(issuesOf(oversized)).toContainEqual({
message: 'Combined transit geometry is too large',
path: ['legs'],
});
// Exactly 60000 combined is not "too large" (strict >), and a single leg
// may carry the full budget without tripping the per-leg max.
const boundary = singleLeg();
boundary.legs = [{ ...boundary.legs[0], geometry: 'x'.repeat(60_000) }];
expect(transitItinerarySchema.safeParse(boundary).success).toBe(true);
});
it('TRANSIT-ITIN-006: rejects a leg missing departure or arrival times', () => {
const timeless = singleLeg();
timeless.legs = [{ ...timeless.legs[0], from: stop('Namba', 34.667, 135.501, null) }];
expect(issuesOf(timeless)).toContainEqual({
message: 'Every leg requires departure and arrival times',
path: ['legs', 0],
});
});
it('TRANSIT-ITIN-007: rejects a leg arriving before it departs', () => {
const backwards = singleLeg({ endTime: at('10:00') });
backwards.legs = [
leg('RAIL', stop('Namba', 34.667, 135.501, at('09:30')), stop('Umeda', 34.702, 135.496, at('09:10')), 1800),
];
expect(issuesOf(backwards)).toContainEqual({
message: 'Leg arrival must not precede departure',
path: ['legs', 0],
});
});
it('TRANSIT-ITIN-008: rejects a duration off by more than the 60s tolerance (exactly 60s passes)', () => {
const drifted = singleLeg();
drifted.legs = [{ ...drifted.legs[0], duration: 1919 }]; // 119s off the 1800s wall-clock
expect(issuesOf(drifted)).toContainEqual({
message: 'Leg duration does not match its times',
path: ['legs', 0, 'duration'],
});
const withinTolerance = singleLeg();
withinTolerance.legs = [{ ...withinTolerance.legs[0], duration: 1860 }]; // exactly 60s off
expect(transitItinerarySchema.safeParse(withinTolerance).success).toBe(true);
});
it('TRANSIT-ITIN-009: rejects leg times outside the itinerary window', () => {
expect(issuesOf(singleLeg({ startTime: at('09:10'), duration: 1200 }))).toContainEqual({
message: 'Leg times must stay within the itinerary',
path: ['legs', 0],
});
});
it('TRANSIT-ITIN-010: rejects adjacent legs more than 1km apart', () => {
const gapped = twoLegs();
// Second leg departs ~55km away from the first leg's arrival stop.
gapped.legs = [gapped.legs[0], { ...gapped.legs[1], from: stop('Kobe', 34.69, 135.19, at('09:30')) }];
expect(issuesOf(gapped)).toContainEqual({
message: 'Adjacent legs are not connected',
path: ['legs', 1, 'from'],
});
});
it('TRANSIT-ITIN-011: rejects adjacent legs out of chronological order', () => {
const overlapping = twoLegs();
// Second leg departs at 09:20, before the first leg arrives at 09:26.
overlapping.legs = [
overlapping.legs[0],
leg('RAIL', stop('Umeda', 34.702, 135.496, at('09:20')), stop('Kyoto', 35.0116, 135.7681, at('09:48')), 1680),
];
overlapping.endTime = at('09:48');
overlapping.duration = 2880;
expect(issuesOf(overlapping)).toContainEqual({
message: 'Adjacent legs are not chronological',
path: ['legs', 1, 'from', 'time'],
});
});
it('TRANSIT-ITIN-012: rejects a first leg starting away from the itinerary start', () => {
expect(issuesOf(singleLeg({ startTime: at('08:50'), duration: 2400 }))).toContainEqual({
message: 'First leg must start with the itinerary',
path: ['legs', 0, 'from', 'time'],
});
});
it('TRANSIT-ITIN-013: rejects a last leg ending away from the itinerary end', () => {
expect(issuesOf(singleLeg({ endTime: at('09:40'), duration: 2400 }))).toContainEqual({
message: 'Last leg must end with the itinerary',
path: ['legs', 0, 'to', 'time'],
});
});
it('TRANSIT-ITIN-014: accepts provider modes outside the request whitelist, rejects non-uppercase tokens', () => {
const airplane = singleLeg();
airplane.legs = [{ ...airplane.legs[0], mode: 'AIRPLANE' }];
expect(transitItinerarySchema.safeParse(airplane).success).toBe(true);
const lowercase = singleLeg();
lowercase.legs = [{ ...lowercase.legs[0], mode: 'rail' }];
const result = transitItinerarySchema.safeParse(lowercase);
expect(result.success).toBe(false);
expect(result.error!.issues.some((issue) => issue.path.join('.') === 'legs.0.mode')).toBe(true);
});
});
describe('effectiveTransitStopTime', () => {
it('TRANSIT-ITIN-015: prefers realtime, falls back to scheduled via ?? (empty string passes through)', () => {
expect(effectiveTransitStopTime({ time: at('09:00'), scheduledTime: at('08:55') })).toBe(at('09:00'));
expect(effectiveTransitStopTime({ time: null, scheduledTime: at('08:55') })).toBe(at('08:55'));
expect(effectiveTransitStopTime({ scheduledTime: at('08:55') })).toBe(at('08:55'));
expect(effectiveTransitStopTime({ time: null, scheduledTime: null })).toBeNull();
expect(effectiveTransitStopTime({})).toBeNull();
// ?? not || — an empty string is a value, not a miss.
expect(effectiveTransitStopTime({ time: '', scheduledTime: at('08:55') })).toBe('');
});
});
describe('transitCoordinatesMatch', () => {
it('TRANSIT-ITIN-016: matches within 100m, rejects beyond', () => {
expect(transitCoordinatesMatch(namba, { ...namba })).toBe(true);
// ~55m north — inside the 0.1km tolerance.
expect(transitCoordinatesMatch(namba, { ...namba, lat: namba.lat + 0.0005 })).toBe(true);
// ~555m north — outside.
expect(transitCoordinatesMatch(namba, { ...namba, lat: namba.lat + 0.005 })).toBe(false);
});
});
describe('cleanTransitItineraryNames', () => {
it('TRANSIT-ITIN-017: replaces START/END sentinels without mutating the input', () => {
const itinerary = singleLeg();
itinerary.legs = [
leg('RAIL', stop('START', 34.667, 135.501, at('09:00')), stop('END', 34.702, 135.496, at('09:30')), 1800),
];
const cleaned = cleanTransitItineraryNames(itinerary, 'Namba', 'Umeda');
expect(cleaned.legs[0].from.name).toBe('Namba');
expect(cleaned.legs[0].to.name).toBe('Umeda');
// Non-sentinel names pass through untouched.
const untouched = cleanTransitItineraryNames(singleLeg(), 'A', 'B');
expect(untouched.legs[0].from.name).toBe('Namba');
expect(untouched.legs[0].to.name).toBe('Umeda');
// The input itinerary is not mutated.
expect(itinerary.legs[0].from.name).toBe('START');
expect(itinerary.legs[0].to.name).toBe('END');
});
});
describe('buildTransitReservationParts', () => {
it('TRANSIT-ITIN-018: builds ordered endpoints and the transit metadata for a walk+rail+rail journey', () => {
const kyoto = { name: 'Kyoto', lat: 35.0116, lng: 135.7681 };
const itinerary: TransitItinerary = {
startTime: at('09:00'),
endTime: at('09:58'),
duration: 3480,
transfers: 1,
walkSeconds: 300,
legs: [
leg('WALK', stop('Namba entrance', 34.667, 135.501, at('09:00')), stop('Namba station', 34.6675, 135.5015, at('09:05')), 300),
leg(
'RAIL',
stop('Namba station', 34.6675, 135.5015, at('09:06')),
// Realtime-less transfer stop: the scheduledTime must drive both the
// stop endpoint and the metadata leg times (?? fallback).
stop('Osaka', 34.702, 135.496, null, { scheduledTime: at('09:26'), track: '3' }),
1200,
),
leg('RAIL', stop('Osaka', 34.702, 135.496, at('09:30')), stop('Kyoto', 35.0116, 135.7681, at('09:58')), 1680, {
line: 'Special Rapid',
geometry: 'abc',
geometryPrecision: 5,
}),
],
};
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
expect(endpoints).toEqual([
{ role: 'from', sequence: 0, name: 'Namba', code: null, lat: 34.667, lng: 135.501, timezone: 'Asia/Tokyo', local_date: '2026-04-01', local_time: '09:00' },
{ role: 'stop', sequence: 1, name: 'Osaka', code: null, lat: 34.702, lng: 135.496, timezone: 'Asia/Tokyo', local_date: '2026-04-01', local_time: '09:26' },
{ role: 'to', sequence: 2, name: 'Kyoto', code: null, lat: 35.0116, lng: 135.7681, timezone: 'Asia/Tokyo', local_date: '2026-04-01', local_time: '09:58' },
]);
expect(metadata).toEqual({
transit: {
provider: 'transitous',
duration: 3480,
transfers: 1,
walk_seconds: 300,
legs: [
{
mode: 'WALK', line: null, line_color: null, line_text_color: null, headsign: null, agency: null,
duration: 300, stops: 0,
from: { name: 'Namba entrance', time: '09:00', track: null },
to: { name: 'Namba station', time: '09:05', track: null },
geometry: null, geometry_precision: 6,
},
{
mode: 'RAIL', line: null, line_color: null, line_text_color: null, headsign: null, agency: null,
duration: 1200, stops: 0,
from: { name: 'Namba station', time: '09:06', track: null },
to: { name: 'Osaka', time: '09:26', track: '3' },
geometry: null, geometry_precision: 6,
},
{
mode: 'RAIL', line: 'Special Rapid', line_color: null, line_text_color: null, headsign: null, agency: null,
duration: 1680, stops: 0,
from: { name: 'Osaka', time: '09:30', track: null },
to: { name: 'Kyoto', time: '09:58', track: null },
geometry: 'abc', geometry_precision: 5,
},
],
},
});
});
it('TRANSIT-ITIN-019: a transfer stop with no times at all yields null local date/time', () => {
const kyoto = { name: 'Kyoto', lat: 35.0116, lng: 135.7681 };
const itinerary = twoLegs();
itinerary.legs = [
{ ...itinerary.legs[0], to: stop('Umeda', 34.702, 135.496, null) },
itinerary.legs[1],
];
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
expect(endpoints[1]).toEqual({
role: 'stop', sequence: 1, name: 'Umeda', code: null, lat: 34.702, lng: 135.496,
timezone: 'Asia/Tokyo', local_date: null, local_time: null,
});
expect(metadata.transit.legs[0].to.time).toBeNull();
});
it('TRANSIT-ITIN-020: an unresolvable endpoint timezone throws with the coordinates in the message', () => {
expect(() => buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg())).toThrow(
'Unable to resolve timezone for NaN,135.501.',
);
});
it('TRANSIT-ITIN-021: an unconvertible itinerary time throws with the ISO string and timezone', () => {
expect(() => buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }))).toThrow(
'Unable to convert not-a-date to local time in Asia/Tokyo.',
);
});
});