Files
TREK/server/tests/unit/nest/google-transit.provider.test.ts
T
Maurice aa8d9b4037 refactor(server): inject the transit base URL and the kitinerary paths as config tokens
The ownership rule says a boot-stable value that only Nest providers read
belongs to a registerAs token, yet TransitService froze TRANSIT_API_URL in a
module-top readEnv() const and KitineraryExtractorService read
KITINERARY_EXTRACTOR_PATH and PATH through readEnv() in its probe. Both now
come from tokens: transitConfig and kitineraryConfig, derived in
boot-derive.ts and dropped from deriveIntegrations, so readEnv() no longer
reads those variables at all. TransitModule and KitineraryExtractorModule
import AppConfigModule, which keeps them buildable on their own in tests.

Behaviour is unchanged: the same defaults, the same trailing-slash strip and
PATH split, now frozen per built app instead of at import. The app-config
README names each boot-stable variable that stays on readEnv() together with
the reader the container cannot inject into.
2026-10-08 18:41:01 +02:00

735 lines
33 KiB
TypeScript

/**
* Unit tests for the optional Google transit backend (#1699) — the Routes API
* plan and the Places Text Search picker that answer /api/transit when an admin
* switches the instance over.
*
* Coverage centres on the three things that are easy to get wrong and expensive
* to get wrong: the activation chain (setting AND key, else Transitous), the
* mapping from Google's step list onto TREK's compact itinerary — Google gives
* no journey-level clock, so start/end are derived from the scheduled legs —
* and the request shape, whose field masks are what the install is billed for.
*
* The response cache is module-scoped, so each case uses its own coordinates
* and clearGoogleTransitCache() runs between tests.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GoogleTransitProvider, clearGoogleTransitCache } from '../../../src/nest/transit/google-transit.provider';
import { decodePolyline, encodePolyline } from '../../../src/nest/transit/transit.helpers';
import { TransitService } from '../../../src/nest/transit/transit.service';
import { transitConfig } from '../../../src/nest/app-config/tokens';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import { noGoogleQuota } from '../../helpers/google-quota';
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
return {
...actual,
getAppUrl: () => 'https://trek.example.com',
// No operator-env key, so resolveApiKey falls through to the stub DB below.
readEnv: () => ({ ...actual.readEnv(), maps: { ...actual.readEnv().maps, placesApiKey: undefined } }),
};
});
const fetchMock = vi.fn();
const GOOGLE_SETTINGS = { transit_provider: 'google', maps_api_key: 'test-key' };
/**
* resolveKey (google-transit.provider.ts) reads AppSettingsRepository/
* UsersRepository directly (instance-api-keys.ts's resolveApiKey, Plan 3a
* Task 5); isActive's own `readTransitProvider` call does too, since Plan 4
* Task 1 — both go through the SAME `appSettingsStub` below now that
* `GoogleTransitProvider` no longer takes a `DatabaseService` at all. The
* settings object already carries exactly the instance-tier value every test
* here configures, so `appSettingsStub.getValue` wraps it directly; the
* per-user fallback tier was never exercised by this file (none of these
* cases seed a per-user row), so it always answers null.
*/
function makeProvider(settings: Record<string, string | undefined>): GoogleTransitProvider {
const appSettingsStub = {
getValue: async (key: string) => settings[key] ?? null,
} as unknown as AppSettingsRepository;
const usersStub = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
return new GoogleTransitProvider(appSettingsStub, usersStub, noGoogleQuota);
}
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
clearGoogleTransitCache();
});
afterEach(() => vi.unstubAllGlobals());
function okJson(data: unknown) {
return { ok: true, status: 200, headers: { get: () => null }, json: async () => data };
}
function lastBody(): Record<string, unknown> {
return JSON.parse(fetchMock.mock.calls[fetchMock.mock.calls.length - 1][1].body);
}
function lastHeaders(): Record<string, string> {
return fetchMock.mock.calls[fetchMock.mock.calls.length - 1][1].headers;
}
/**
* The fixture's own shape, spelled out rather than inferred: cases below drop a
* stop name or a whole stopDetails to exercise the fallbacks, which an inferred
* literal type forbids.
*/
type FixtureLatLng = { latLng: { latitude: number; longitude: number } };
type FixtureStop = { name?: string; location?: FixtureLatLng };
interface FixtureRoutes {
routes: Array<{
legs: Array<{
steps: Array<{
travelMode: string;
staticDuration?: string;
distanceMeters?: number;
startLocation?: FixtureLatLng;
endLocation?: FixtureLatLng;
polyline?: { encodedPolyline: string };
transitDetails?: {
stopDetails: {
departureStop?: FixtureStop;
departureTime?: string;
arrivalStop?: FixtureStop;
arrivalTime?: string;
};
headsign?: string;
stopCount?: number;
transitLine: {
name?: string;
nameShort?: string;
color?: string;
textColor?: string;
agencies?: Array<{ name: string }>;
vehicle: { type: string };
};
};
}>;
}>;
}>;
}
// Nakanoshima → Temmabashi, the Osaka pair the request was filed about.
const FROM = '34.6937,135.4900';
const TO = '34.6875,135.5155';
function subwayRoute(): FixtureRoutes {
return {
routes: [
{
legs: [
{
steps: [
{
travelMode: 'WALK',
staticDuration: '180s',
distanceMeters: 210,
startLocation: { latLng: { latitude: 34.6937, longitude: 135.49 } },
endLocation: { latLng: { latitude: 34.6939, longitude: 135.4915 } },
polyline: { encodedPolyline: 'walkpoly' },
},
{
travelMode: 'TRANSIT',
staticDuration: '480s',
distanceMeters: 2100,
polyline: { encodedPolyline: 'railpoly' },
transitDetails: {
stopDetails: {
departureStop: { name: 'Nakanoshima', location: { latLng: { latitude: 34.6939, longitude: 135.4915 } } },
departureTime: '2026-09-10T09:00:00Z',
arrivalStop: { name: 'Temmabashi', location: { latLng: { latitude: 34.6871, longitude: 135.5142 } } },
arrivalTime: '2026-09-10T09:08:00Z',
},
headsign: 'Kadoma-shi',
stopCount: 3,
transitLine: {
name: 'Keihan Nakanoshima Line',
nameShort: 'KH',
color: '00A0E9',
textColor: 'FFFFFF',
agencies: [{ name: 'Keihan Electric Railway' }],
vehicle: { type: 'SUBWAY' },
},
},
},
{
travelMode: 'WALK',
staticDuration: '120s',
distanceMeters: 150,
startLocation: { latLng: { latitude: 34.6871, longitude: 135.5142 } },
endLocation: { latLng: { latitude: 34.6875, longitude: 135.5155 } },
polyline: { encodedPolyline: 'walkpoly2' },
},
],
},
],
},
],
};
}
describe('activation', () => {
it('GTRANSIT-001: stays off while the instance is on Transitous, even with a key', async () => {
const provider = makeProvider({ maps_api_key: 'test-key' });
expect(await provider.isActive(1)).toBe(false);
});
it('GTRANSIT-002: stays off when Google is selected but no key resolves', async () => {
const provider = makeProvider({ transit_provider: 'google' });
expect(await provider.isActive(1)).toBe(false);
});
it('GTRANSIT-003: is on only with both the setting and a key', async () => {
expect(await makeProvider(GOOGLE_SETTINGS).isActive(1)).toBe(true);
});
it('GTRANSIT-004: an unknown provider value falls back to Transitous', async () => {
const provider = makeProvider({ transit_provider: 'someday-maps', maps_api_key: 'test-key' });
expect(await provider.isActive(1)).toBe(false);
});
it('GTRANSIT-005: TransitService routes to Google only when it is active', async () => {
fetchMock.mockResolvedValue(okJson(subwayRoute()));
const service = new TransitService(makeProvider(GOOGLE_SETTINGS), transitConfig());
await service.plan({ from: FROM, to: TO });
expect(fetchMock.mock.calls[0][0]).toBe('https://routes.googleapis.com/directions/v2:computeRoutes');
});
it('GTRANSIT-006: TransitService still validates the request before dispatching', async () => {
const service = new TransitService(makeProvider(GOOGLE_SETTINGS), transitConfig());
await expect(service.plan({ from: 'nowhere', to: TO })).rejects.toThrow('from must be "lat,lng"');
await expect(service.plan({ from: FROM, to: TO, modes: 'ROCKET' })).rejects.toThrow('unsupported transit mode');
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('plan mapping', () => {
it('GTRANSIT-007: maps a walk/subway/walk route and derives the journey clock', async () => {
fetchMock.mockResolvedValue(okJson(subwayRoute()));
const { itineraries } = await makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1);
expect(itineraries).toHaveLength(1);
const it0 = itineraries[0];
// Google gives no journey-level clock: the opening walk is 180s before the
// 09:00 departure and the closing one 120s after the 09:08 arrival.
expect(it0.startTime).toBe('2026-09-10T08:57:00.000Z');
expect(it0.endTime).toBe('2026-09-10T09:10:00.000Z');
expect(it0.duration).toBe(780);
expect(it0.transfers).toBe(0);
expect(it0.walkSeconds).toBe(300);
expect(it0.legs.map((l) => l.mode)).toEqual(['WALK', 'SUBWAY', 'WALK']);
// START/END are the sentinels the web client and the MCP builder replace
// with the places the user actually picked.
expect(it0.legs[0].from.name).toBe('START');
expect(it0.legs[0].to.name).toBe('Nakanoshima');
expect(it0.legs[2].from.name).toBe('Temmabashi');
expect(it0.legs[2].to.name).toBe('END');
const rail = it0.legs[1];
expect(rail.line).toBe('KH');
expect(rail.agency).toBe('Keihan Electric Railway');
expect(rail.headsign).toBe('Kadoma-shi');
expect(rail.lineColor).toBe('#00A0E9');
expect(rail.lineTextColor).toBe('#FFFFFF');
// stopCount counts the arrival stop; intermediateStops does not.
expect(rail.intermediateStops).toBe(2);
expect(rail.geometry).toBe('railpoly');
// Google encodes at precision 5, MOTIS at 6 — the client reads it per leg.
expect(rail.geometryPrecision).toBe(5);
});
it('GTRANSIT-008: drops a walk-only route rather than offering it as transit', async () => {
fetchMock.mockResolvedValue(
okJson({ routes: [{ legs: [{ steps: [{ travelMode: 'WALK', staticDuration: '900s' }] }] }] }),
);
const { itineraries } = await makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1);
expect(itineraries).toEqual([]);
});
it('GTRANSIT-009: maps Google vehicle types onto TREK modes', async () => {
const withVehicle = (type: string) => {
const route = subwayRoute();
const details = route.routes[0].legs[0].steps[1].transitDetails;
if (details) details.transitLine.vehicle.type = type;
return route;
};
const provider = makeProvider(GOOGLE_SETTINGS);
for (const [google, trek] of [
['HEAVY_RAIL', 'RAIL'],
['HIGH_SPEED_TRAIN', 'HIGHSPEED_RAIL'],
['COMMUTER_TRAIN', 'SUBURBAN'],
['INTERCITY_BUS', 'COACH'],
['GONDOLA_LIFT', 'AERIAL_LIFT'],
['MONORAIL', 'SUBWAY'],
['SOMETHING_NEW', 'RAIL'],
]) {
clearGoogleTransitCache();
fetchMock.mockResolvedValue(okJson(withVehicle(google)));
const { itineraries } = await provider.plan({ from: FROM, to: TO }, 'en', 1);
expect(itineraries[0].legs[1].mode, google).toBe(trek);
}
});
});
describe('request shape', () => {
it('GTRANSIT-010: asks for transit, alternatives and the caller language', async () => {
fetchMock.mockResolvedValue(okJson(subwayRoute()));
await makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO, time: '2026-09-10T09:00:00Z' }, 'ja', 1);
const body = lastBody();
expect(body.travelMode).toBe('TRANSIT');
// Alternatives ride along inside the one request that is already billed.
expect(body.computeAlternativeRoutes).toBe(true);
expect(body.languageCode).toBe('ja');
expect(body.departureTime).toBe('2026-09-10T09:00:00.000Z');
expect(body.arrivalTime).toBeUndefined();
expect(lastHeaders()['X-Goog-Api-Key']).toBe('test-key');
});
it('GTRANSIT-011: arriveBy anchors the time at the destination', async () => {
fetchMock.mockResolvedValue(okJson(subwayRoute()));
await makeProvider(GOOGLE_SETTINGS).plan(
{ from: FROM, to: TO, time: '2026-09-10T09:00:00Z', arriveBy: true }, 'en', 1,
);
expect(lastBody().arrivalTime).toBe('2026-09-10T09:00:00.000Z');
expect(lastBody().departureTime).toBeUndefined();
});
it('GTRANSIT-012: the field mask stays inside the tier the itinerary needs', async () => {
fetchMock.mockResolvedValue(okJson(subwayRoute()));
await makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1);
const mask = lastHeaders()['X-Goog-FieldMask'];
expect(mask).toContain('routes.legs.steps.transitDetails.stopDetails');
// Enterprise-tier extras are what make a free-tier install pay.
expect(mask).not.toContain('travelAdvisory');
expect(mask).not.toContain('localizedValues');
expect(mask).not.toContain('routes.viewport');
});
it('GTRANSIT-013: sends the expressible modes and holds the rest on the response', async () => {
fetchMock.mockResolvedValue(okJson(subwayRoute()));
const provider = makeProvider(GOOGLE_SETTINGS);
const subwayOnly = await provider.plan({ from: FROM, to: TO, modes: 'SUBWAY' }, 'en', 1);
expect(lastBody().transitPreferences).toEqual({ allowedTravelModes: ['SUBWAY'] });
expect(subwayOnly.itineraries).toHaveLength(1);
clearGoogleTransitCache();
// Google cannot express a ferry-only filter, so nothing is sent for it and
// the subway itinerary it answers with is refused here instead.
const ferryOnly = await provider.plan({ from: FROM, to: TO, modes: 'FERRY' }, 'en', 1);
expect(lastBody().transitPreferences).toBeUndefined();
expect(ferryOnly.itineraries).toEqual([]);
});
it('GTRANSIT-014: enforces maxTransfers, which the Routes API has no parameter for', async () => {
const twoTransfers = subwayRoute();
const rail = twoTransfers.routes[0].legs[0].steps[1];
twoTransfers.routes[0].legs[0].steps.splice(2, 0, JSON.parse(JSON.stringify(rail)));
fetchMock.mockResolvedValue(okJson(twoTransfers));
const provider = makeProvider(GOOGLE_SETTINGS);
const capped = await provider.plan({ from: FROM, to: TO, maxTransfers: 0 }, 'en', 1);
expect(capped.itineraries).toEqual([]);
// A low cap also nudges Google itself, so the one billed call is likelier
// to come back with something the filter keeps.
expect((lastBody().transitPreferences as Record<string, unknown>).routingPreference).toBe('FEWER_TRANSFERS');
clearGoogleTransitCache();
const allowed = await provider.plan({ from: FROM, to: TO, maxTransfers: 2 }, 'en', 1);
expect(allowed.itineraries[0].transfers).toBe(1);
});
it('GTRANSIT-015: an identical plan is answered from cache, unbilled', async () => {
fetchMock.mockResolvedValue(okJson(subwayRoute()));
const provider = makeProvider(GOOGLE_SETTINGS);
await provider.plan({ from: FROM, to: TO }, 'en', 1);
await provider.plan({ from: FROM, to: TO }, 'en', 1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('GTRANSIT-016: a provider error surfaces with Google\'s own message', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 403,
headers: { get: () => null },
json: async () => ({ error: { message: 'Routes API has not been used in project 1' } }),
});
await expect(makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1)).rejects.toThrow(
'Routes API has not been used in project 1',
);
});
it('GTRANSIT-017: upstream rate limiting stays a 429', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 429, headers: { get: () => null }, json: async () => ({}) });
await expect(
makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1),
).rejects.toMatchObject({ status: 429 });
});
});
/**
* MOTIS names a train by its service, Google by its vehicle. The client's train
* chip therefore asks for the fine-grained rail modes and never for RAIL, while
* Google labels every conventional train HEAVY_RAIL, which lands on RAIL. The
* response filter has to read both as the same family, or the one billed call
* comes back with the trains the user asked for and throws them all away.
*/
describe('rail mode filter', () => {
/** The subway fixture with its one transit leg relabelled as another vehicle. */
function withVehicle(type: string): FixtureRoutes {
const route = subwayRoute();
const details = route.routes[0].legs[0].steps[1].transitDetails;
if (details) details.transitLine.vehicle.type = type;
return route;
}
// What the train chip sends, verbatim.
const TRAIN_CHIP = 'HIGHSPEED_RAIL,LONG_DISTANCE,NIGHT_RAIL,REGIONAL_RAIL,SUBURBAN';
it('GTRANSIT-039: the train filter keeps the trains Google labels HEAVY_RAIL', async () => {
const provider = makeProvider(GOOGLE_SETTINGS);
for (const vehicle of ['HEAVY_RAIL', 'RAIL', 'COMMUTER_TRAIN', 'HIGH_SPEED_TRAIN']) {
clearGoogleTransitCache();
fetchMock.mockResolvedValue(okJson(withVehicle(vehicle)));
const { itineraries } = await provider.plan({ from: FROM, to: TO, modes: TRAIN_CHIP }, 'en', 1);
expect(itineraries, vehicle).toHaveLength(1);
}
// The request side is unchanged: the five fine modes collapse to Google's one TRAIN bucket.
expect(lastBody().transitPreferences).toEqual({ allowedTravelModes: ['TRAIN'] });
});
it('GTRANSIT-040: the RAIL umbrella keeps the fine-grained rail legs, and neither side leaks a subway', async () => {
const provider = makeProvider(GOOGLE_SETTINGS);
// An MCP caller may name the umbrella; Google still answers by vehicle.
for (const vehicle of ['COMMUTER_TRAIN', 'HIGH_SPEED_TRAIN', 'HEAVY_RAIL']) {
clearGoogleTransitCache();
fetchMock.mockResolvedValue(okJson(withVehicle(vehicle)));
const { itineraries } = await provider.plan({ from: FROM, to: TO, modes: 'RAIL' }, 'en', 1);
expect(itineraries, vehicle).toHaveLength(1);
}
clearGoogleTransitCache();
fetchMock.mockResolvedValue(okJson(withVehicle('HEAVY_RAIL')));
expect((await provider.plan({ from: FROM, to: TO, modes: 'SUBWAY' }, 'en', 1)).itineraries).toEqual([]);
clearGoogleTransitCache();
fetchMock.mockResolvedValue(okJson(withVehicle('SUBWAY')));
expect((await provider.plan({ from: FROM, to: TO, modes: TRAIN_CHIP }, 'en', 1)).itineraries).toEqual([]);
});
it('GTRANSIT-041: a bus-only filter still refuses a train', async () => {
fetchMock.mockResolvedValue(okJson(withVehicle('HEAVY_RAIL')));
const provider = makeProvider(GOOGLE_SETTINGS);
const { itineraries } = await provider.plan({ from: FROM, to: TO, modes: 'BUS,COACH' }, 'en', 1);
expect(itineraries).toEqual([]);
});
});
describe('geocode', () => {
const places = {
places: [
{
displayName: { text: 'Nakanoshima Station' },
formattedAddress: '2 Chome Nakanoshima, Kita Ward, Osaka',
location: { latitude: 34.6939, longitude: 135.4915 },
types: ['subway_station', 'transit_station'],
},
{
displayName: { text: 'Nakanoshima Park' },
formattedAddress: '1 Nakanoshima, Kita Ward, Osaka',
location: { latitude: 34.6921, longitude: 135.5069 },
types: ['park'],
},
{ displayName: { text: 'No coordinates' }, types: ['park'] },
],
};
it('GTRANSIT-018: maps places, marking stations STOP and everything else PLACE', async () => {
fetchMock.mockResolvedValue(okJson(places));
const { results } = await makeProvider(GOOGLE_SETTINGS).geocode('Nakanoshima', 'en', undefined, 1);
expect(results).toEqual([
{ name: 'Nakanoshima Station', lat: 34.6939, lng: 135.4915, type: 'STOP', area: '2 Chome Nakanoshima, Kita Ward, Osaka' },
{ name: 'Nakanoshima Park', lat: 34.6921, lng: 135.5069, type: 'PLACE', area: '1 Nakanoshima, Kita Ward, Osaka' },
]);
});
it('GTRANSIT-019: biases on `near` and asks only for the cheap Text Search fields', async () => {
fetchMock.mockResolvedValue(okJson(places));
await makeProvider(GOOGLE_SETTINGS).geocode('Temmabashi', 'ja', '34.6875,135.5155', 1);
const body = lastBody();
expect(body.languageCode).toBe('ja');
expect(body.pageSize).toBe(8);
expect(body.locationBias).toEqual({ circle: { center: { latitude: 34.6875, longitude: 135.5155 }, radius: 50000 } });
// Rating/website/phone would bill Text Search at Enterprise, and a station
// picker shows none of them.
const mask = lastHeaders()['X-Goog-FieldMask'];
expect(mask).toBe('places.displayName,places.formattedAddress,places.location,places.types');
});
it('GTRANSIT-020: repeat lookups of the same station are answered from cache', async () => {
fetchMock.mockResolvedValue(okJson(places));
const service = new TransitService(makeProvider(GOOGLE_SETTINGS), transitConfig());
await service.geocode('Kyobashi', 'en', undefined, 1);
await service.geocode('Kyobashi', 'en', undefined, 1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('GTRANSIT-021: a short query never reaches Google', async () => {
const service = new TransitService(makeProvider(GOOGLE_SETTINGS), transitConfig());
// The provider is still named: the answer comes from the length guard, not
// from a backend that declined.
expect(await service.geocode('a', 'en', undefined, 1)).toEqual({ results: [], provider: 'google' });
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('guards', () => {
it('GTRANSIT-022: refuses rather than calling Google when no key resolves', async () => {
const provider = makeProvider({ transit_provider: 'google' });
await expect(provider.plan({ from: FROM, to: TO }, 'en', 1)).rejects.toThrow('no Google API key configured');
await expect(provider.geocode('Namba', 'en', undefined, 1)).rejects.toThrow('no Google API key configured');
expect(fetchMock).not.toHaveBeenCalled();
});
it('GTRANSIT-023: an oversized response is refused before it is parsed', async () => {
const json = vi.fn();
fetchMock.mockResolvedValue({ ok: true, status: 200, headers: { get: () => '9000000' }, json });
await expect(makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1)).rejects.toThrow(
'response too large',
);
expect(json).not.toHaveBeenCalled();
});
it('GTRANSIT-024: a route with no steps, and one whose transit leg has no clock, are both dropped', async () => {
const provider = makeProvider(GOOGLE_SETTINGS);
fetchMock.mockResolvedValue(okJson({ routes: [{ legs: [{ steps: [] }] }] }));
expect((await provider.plan({ from: FROM, to: TO }, 'en', 1)).itineraries).toEqual([]);
clearGoogleTransitCache();
const timeless = subwayRoute();
const details = timeless.routes[0].legs[0].steps[1].transitDetails;
if (details) details.stopDetails = { departureStop: { name: 'Nakanoshima' }, arrivalStop: { name: 'Temmabashi' } };
fetchMock.mockResolvedValue(okJson(timeless));
expect((await provider.plan({ from: FROM, to: TO, arriveBy: true }, 'en', 1)).itineraries).toEqual([]);
});
it('GTRANSIT-025: an unnamed interior walk endpoint falls back to a usable name', async () => {
const unnamed = subwayRoute();
const details = unnamed.routes[0].legs[0].steps[1].transitDetails;
if (details) {
details.stopDetails.departureStop = { location: { latLng: { latitude: 34.6939, longitude: 135.4915 } } };
details.stopDetails.arrivalStop = { location: { latLng: { latitude: 34.6871, longitude: 135.5142 } } };
}
fetchMock.mockResolvedValue(okJson(unnamed));
const { itineraries } = await makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1);
// Every name is non-empty — transitStopSchema rejects a blank one, which
// would drop the itinerary at the MCP boundary.
for (const leg of itineraries[0].legs) {
expect(leg.from.name).not.toBe('');
expect(leg.to.name).not.toBe('');
}
expect(itineraries[0].legs[1].from.name).toBe('Transfer');
});
it('GTRANSIT-026: a missing or malformed step duration counts as zero, never NaN', async () => {
const noDuration = subwayRoute();
delete noDuration.routes[0].legs[0].steps[0].staticDuration;
noDuration.routes[0].legs[0].steps[2].staticDuration = 'not-a-duration';
fetchMock.mockResolvedValue(okJson(noDuration));
const { itineraries } = await makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1);
expect(itineraries[0].legs[0].duration).toBe(0);
expect(itineraries[0].legs[2].duration).toBe(0);
expect(itineraries[0].walkSeconds).toBe(0);
});
it('GTRANSIT-027: a cache entry past its TTL is re-fetched, not served stale', async () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000);
fetchMock.mockResolvedValue(okJson(subwayRoute()));
const provider = makeProvider(GOOGLE_SETTINGS);
await provider.plan({ from: FROM, to: TO }, 'en', 1);
now.mockReturnValue(1_000_000 + 6 * 60 * 1000);
await provider.plan({ from: FROM, to: TO }, 'en', 1);
expect(fetchMock).toHaveBeenCalledTimes(2);
now.mockRestore();
});
it('GTRANSIT-028: the cache evicts rather than growing without bound', async () => {
fetchMock.mockResolvedValue(okJson({ places: [] }));
const provider = makeProvider(GOOGLE_SETTINGS);
for (let i = 0; i < 220; i++) await provider.geocode(`station-${i}`, 'en', undefined, 1);
expect(fetchMock).toHaveBeenCalledTimes(220);
// The oldest entry is gone; the newest is still a cache hit.
await provider.geocode('station-219', 'en', undefined, 1);
expect(fetchMock).toHaveBeenCalledTimes(220);
await provider.geocode('station-0', 'en', undefined, 1);
expect(fetchMock).toHaveBeenCalledTimes(221);
});
});
/**
* The Routes API returns walking as turn-by-turn navigation steps, so a single
* walk to the platform arrives as several. Everything downstream expects MOTIS's
* one-leg-per-walk shape — including transitItinerarySchema's 20-leg cap, which
* a finely-sliced walk would blow through, dropping the whole itinerary.
*/
describe('walk coalescing', () => {
/** A walk step whose polyline runs between the two given points. */
function walkStep(seconds: number, metres: number, from: [number, number], to: [number, number]) {
return {
travelMode: 'WALK',
staticDuration: `${seconds}s`,
distanceMeters: metres,
startLocation: { latLng: { latitude: from[0], longitude: from[1] } },
endLocation: { latLng: { latitude: to[0], longitude: to[1] } },
polyline: { encodedPolyline: encodePolyline([from, to], 5) },
};
}
function routeWithSlicedWalks() {
const route = subwayRoute();
const steps = route.routes[0].legs[0].steps;
const rail = steps[1];
route.routes[0].legs[0].steps = [
walkStep(60, 70, [34.6937, 135.49], [34.6938, 135.4905]),
walkStep(60, 70, [34.6938, 135.4905], [34.69385, 135.491]),
walkStep(60, 70, [34.69385, 135.491], [34.6939, 135.4915]),
rail,
walkStep(45, 60, [34.6871, 135.5142], [34.6873, 135.5149]),
walkStep(75, 90, [34.6873, 135.5149], [34.6875, 135.5155]),
];
return route;
}
it('GTRANSIT-029: a walk split across navigation steps becomes one leg', async () => {
fetchMock.mockResolvedValue(okJson(routeWithSlicedWalks()));
const { itineraries } = await makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1);
const legs = itineraries[0].legs;
expect(legs.map((l) => l.mode)).toEqual(['WALK', 'SUBWAY', 'WALK']);
// Durations and distances add up rather than being lost with the extra legs.
expect(legs[0].duration).toBe(180);
expect(legs[0].distance).toBe(210);
expect(legs[2].duration).toBe(120);
expect(legs[2].distance).toBe(150);
expect(itineraries[0].walkSeconds).toBe(300);
});
it('GTRANSIT-030: the merged walk keeps the endpoints of the whole run', async () => {
fetchMock.mockResolvedValue(okJson(routeWithSlicedWalks()));
const { itineraries } = await makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1);
const [opening, , closing] = itineraries[0].legs;
expect(opening.from.name).toBe('START');
expect(opening.to.name).toBe('Nakanoshima');
expect(closing.from.name).toBe('Temmabashi');
expect(closing.to.name).toBe('END');
// The merged leg starts where the run started and ends where it ended.
expect(opening.from.lat).toBeCloseTo(34.6937, 4);
expect(closing.to.lng).toBeCloseTo(135.5155, 4);
});
it('GTRANSIT-031: the merged walk draws the whole path, with no doubled seam', async () => {
fetchMock.mockResolvedValue(okJson(routeWithSlicedWalks()));
const { itineraries } = await makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1);
const points = decodePolyline(itineraries[0].legs[0].geometry ?? '', 5);
// Three two-point steps sharing their seams — four points, not six.
expect(points).toHaveLength(4);
expect(points[0][0]).toBeCloseTo(34.6937, 5);
expect(points[3][1]).toBeCloseTo(135.4915, 5);
});
it('GTRANSIT-032: a walk run keeps its shape when only some steps carry geometry', async () => {
const partial = routeWithSlicedWalks();
delete partial.routes[0].legs[0].steps[1].polyline;
fetchMock.mockResolvedValue(okJson(partial));
const { itineraries } = await makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1);
expect(decodePolyline(itineraries[0].legs[0].geometry ?? '', 5).length).toBeGreaterThan(0);
expect(itineraries[0].legs[0].duration).toBe(180);
});
it('GTRANSIT-033: a heavily sliced walk stays inside the 20-leg itinerary cap', async () => {
const many = subwayRoute();
const rail = many.routes[0].legs[0].steps[1];
const crumbs = Array.from({ length: 15 }, (_, i) =>
walkStep(20, 25, [34.6937 + i * 0.0001, 135.49], [34.6937 + (i + 1) * 0.0001, 135.49]));
many.routes[0].legs[0].steps = [...crumbs, rail, ...crumbs];
fetchMock.mockResolvedValue(okJson(many));
const { itineraries } = await makeProvider(GOOGLE_SETTINGS).plan({ from: FROM, to: TO }, 'en', 1);
expect(itineraries[0].legs).toHaveLength(3);
});
});
/**
* The empty state reads "No connections found via <provider>", so an empty
* result has to carry the name of whichever backend produced it — that is the
* whole point of the field.
*/
describe('provider reporting', () => {
it('GTRANSIT-036: names Google when Google answered', async () => {
fetchMock.mockResolvedValue(okJson(subwayRoute()));
const service = new TransitService(makeProvider(GOOGLE_SETTINGS), transitConfig());
const planned = await service.plan({ from: FROM, to: TO }, 'en', 1);
expect(planned.provider).toBe('google');
fetchMock.mockResolvedValue(okJson({ places: [] }));
expect((await service.geocode('Namba', 'en', undefined, 1)).provider).toBe('google');
});
it('GTRANSIT-037: names Transitous when the silent fallback took over', async () => {
fetchMock.mockResolvedValue(okJson({ itineraries: [] }));
// Google selected, but no key resolves — the request goes to Transitous and
// says so, instead of leaving an empty result to be blamed on Google.
const service = new TransitService(makeProvider({ transit_provider: 'google' }), transitConfig());
const planned = await service.plan({ from: '48.8583,2.3470', to: '48.8809,2.3553' }, 'en', 1);
expect(planned.provider).toBe('transitous');
expect(planned.itineraries).toEqual([]);
expect(String(fetchMock.mock.calls[0][0])).toContain('transitous');
});
it('GTRANSIT-038: a cached answer still names its backend', async () => {
fetchMock.mockResolvedValue(okJson({ itineraries: [] }));
const service = new TransitService(makeProvider({}), transitConfig());
const first = await service.plan({ from: '48.1,2.1', to: '48.2,2.2' }, 'en', 1);
const second = await service.plan({ from: '48.1,2.1', to: '48.2,2.2' }, 'en', 1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(second.provider).toBe(first.provider);
expect(second.provider).toBe('transitous');
});
});
describe('polyline codec', () => {
it('GTRANSIT-034: round-trips coordinates at the precision it was given', () => {
const path: [number, number][] = [[34.6937, 135.49], [34.69385, 135.4915], [-34.6, -135.5155]];
const back = decodePolyline(encodePolyline(path, 5), 5);
expect(back).toHaveLength(3);
for (const [i, [lat, lng]] of path.entries()) {
expect(back[i][0]).toBeCloseTo(lat, 5);
expect(back[i][1]).toBeCloseTo(lng, 5);
}
});
it('GTRANSIT-035: a truncated polyline decodes to what it can, without hanging', () => {
const encoded = encodePolyline([[34.6937, 135.49], [34.7, 135.5]], 5);
expect(decodePolyline(encoded.slice(0, 3), 5).length).toBeLessThanOrEqual(1);
expect(decodePolyline('', 5)).toEqual([]);
});
});