/** * 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(); 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): 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 { return JSON.parse(fetchMock.mock.calls[fetchMock.mock.calls.length - 1][1].body); } function lastHeaders(): Record { 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).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 ", 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([]); }); });