mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(transit): let an admin route transit search through Google
Transitous has no GTFS coverage for much of Asia, so a search between two Osaka subway stops finds nothing — the stops are not in the feed at all. Add Google as an optional backend behind an admin setting, reusing the install's existing Places key, and fall back to Transitous whenever no key resolves. Uses the Routes API rather than the legacy Directions endpoint the request named: every other Google call here already speaks the v1/X-Goog-Api-Key family, and Google closed the legacy endpoint to Cloud projects created after March 2025. Google returns walking as turn-by-turn navigation steps where MOTIS returns one leg per walking segment, so consecutive walk steps are coalesced into a single leg — otherwise a finely-sliced walk pushed a real journey past the 20-leg cap and the whole itinerary was dropped. Both responses now name the backend that answered. The fallback is silent by design, which left an empty result ambiguous between "no data here" and "the provider you picked never ran", and the admin panel warns when Google is selected but no key resolves — or when only the admin's own key is set, which serves them and nobody else. Cost shaped the implementation, since Google bills where Transitous does not: both field masks are cut to the cheapest SKU tier that still answers the question, cache TTLs are longer than the Transitous path's, and route alternatives ride along inside the already-billed request. Addresses #1699
This commit is contained in:
committed by
Maurice
parent
8e2840b29d
commit
2a0ceb2687
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Transit backend switch e2e (#1699) — the whole path a self-hoster actually
|
||||
* walks, through the real Nest stack: an admin flips Admin → Settings → Transit
|
||||
* Provider, and the next /api/transit/plan leaves for a different upstream.
|
||||
*
|
||||
* Unlike transit.e2e.test.ts, TransitService is NOT stubbed here — only the
|
||||
* outbound `fetch` is, so what is asserted is the URL the install would really
|
||||
* have called and the setting/key state that decided it.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
|
||||
maps_api_key TEXT, unsplash_api_key TEXT);`);
|
||||
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
||||
return { db: tmp };
|
||||
});
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
import { TransitModule } from '../../src/nest/transit/transit.module';
|
||||
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';
|
||||
import { clearGoogleTransitCache } from '../../src/nest/transit/google-transit.provider';
|
||||
|
||||
const ADMIN = 1;
|
||||
|
||||
// TransitService's Transitous cache is module-scoped with no reset hook, so
|
||||
// each case plans between its own pair of Osaka coordinates rather than being
|
||||
// answered by the previous one.
|
||||
let planNo = 0;
|
||||
const nextPlanUrl = () => `/api/transit/plan?from=34.69${planNo},135.4900&to=34.68${planNo++},135.5155`;
|
||||
|
||||
describe('Transit backend switch e2e (#1699)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [DatabaseModule, RealtimeModule, TransitModule],
|
||||
}).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: ADMIN, role: 'admin' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
afterAll(async () => { await app?.close(); });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
fetchMock.mockReset();
|
||||
// MOTIS and Routes shapes both parse as "no itineraries", which is all this
|
||||
// suite needs — it asserts where the request went, not how it mapped.
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true, status: 200, headers: { get: () => null }, json: async () => ({}),
|
||||
});
|
||||
db.prepare('DELETE FROM app_settings').run();
|
||||
db.prepare('UPDATE users SET maps_api_key = NULL WHERE id = ?').run(ADMIN);
|
||||
clearGoogleTransitCache();
|
||||
});
|
||||
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
|
||||
async function planUpstream(): Promise<string> {
|
||||
const res = await request(server).get(nextPlanUrl()).set('Cookie', sessionCookie(ADMIN));
|
||||
expect(res.status).toBe(200);
|
||||
lastBody = res.body;
|
||||
return String(fetchMock.mock.calls[0][0]);
|
||||
}
|
||||
|
||||
it('TRANSIT-PROV-E2E-001: an untouched install plans through Transitous', async () => {
|
||||
expect(await planUpstream()).toContain('transitous.org');
|
||||
expect(lastBody.provider).toBe('transitous');
|
||||
});
|
||||
|
||||
it('TRANSIT-PROV-E2E-002: selecting Google without a key still plans through Transitous', async () => {
|
||||
db.prepare("INSERT INTO app_settings (key, value) VALUES ('transit_provider', 'google')").run();
|
||||
expect(await planUpstream()).toContain('transitous.org');
|
||||
// The response says who really answered, so the empty state cannot blame
|
||||
// Google for a Transitous result.
|
||||
expect(lastBody.provider).toBe('transitous');
|
||||
});
|
||||
|
||||
it('TRANSIT-PROV-E2E-003: with the switch on and a key set, the plan leaves for the Routes API', async () => {
|
||||
db.prepare("INSERT INTO app_settings (key, value) VALUES ('transit_provider', 'google')").run();
|
||||
db.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'instance-key')").run();
|
||||
expect(await planUpstream()).toBe('https://routes.googleapis.com/directions/v2:computeRoutes');
|
||||
expect(lastBody.provider).toBe('google');
|
||||
});
|
||||
|
||||
it('TRANSIT-PROV-E2E-004: the picker follows the switch to Google Places', async () => {
|
||||
db.prepare("INSERT INTO app_settings (key, value) VALUES ('transit_provider', 'google')").run();
|
||||
db.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'instance-key')").run();
|
||||
|
||||
const res = await request(server)
|
||||
.get('/api/transit/geocode?q=Nakanoshima&lang=ja')
|
||||
.set('Cookie', sessionCookie(ADMIN));
|
||||
expect(res.status).toBe(200);
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe('https://places.googleapis.com/v1/places:searchText');
|
||||
});
|
||||
|
||||
it("TRANSIT-PROV-E2E-005: a member's own key is used when the instance has none", async () => {
|
||||
db.prepare("INSERT INTO app_settings (key, value) VALUES ('transit_provider', 'google')").run();
|
||||
db.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('personal-key', ADMIN);
|
||||
|
||||
expect(await planUpstream()).toBe('https://routes.googleapis.com/directions/v2:computeRoutes');
|
||||
expect(fetchMock.mock.calls[0][1].headers['X-Goog-Api-Key']).toBe('personal-key');
|
||||
});
|
||||
});
|
||||
@@ -76,18 +76,20 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => {
|
||||
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(geocodeSpy).toHaveBeenCalledWith('alex', 'de', '52.5,13.4');
|
||||
// The caller's id trails the query so the Google backend (#1699) can resolve
|
||||
// the install's Places key; the Transitous path ignores it.
|
||||
expect(geocodeSpy).toHaveBeenCalledWith('alex', 'de', '52.5,13.4', 1);
|
||||
});
|
||||
|
||||
it('plan passes all params through (arriveBy + maxTransfers coerced)', async () => {
|
||||
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')
|
||||
.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&lang=de')
|
||||
.set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
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,
|
||||
});
|
||||
}, 'de', 1);
|
||||
});
|
||||
|
||||
it('service validation errors propagate with their status', async () => {
|
||||
|
||||
@@ -46,6 +46,7 @@ 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 { GoogleTransitProvider } from '../../src/nest/transit/google-transit.provider';
|
||||
import { TransitService } from '../../src/nest/transit/transit.service';
|
||||
import { FilesService } from '../../src/nest/files/files.service';
|
||||
import { FilesMcp } from '../../src/nest/files/files.mcp';
|
||||
@@ -224,7 +225,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
new MapsMcp(mapsService),
|
||||
new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain, assignmentsService, guards),
|
||||
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage), dbService, authService, addonsService),
|
||||
new TransitMcp(new TransitService(), daysService, reservationsService, dbService, authService, guards),
|
||||
new TransitMcp(new TransitService(new GoogleTransitProvider(dbService)), daysService, reservationsService, dbService, authService, guards),
|
||||
new AtlasMcp(new AtlasService(dbService), addonsService, authService),
|
||||
new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService)), addonsService, authService, captureBackfill),
|
||||
new MemoriesMcp(immichService, synologyService, dbService, addonsService),
|
||||
|
||||
@@ -169,8 +169,9 @@ describe('MCP transit tools', () => {
|
||||
// `from` is a tool-input place (name/lat/lng). A geocode result is a TransitPlace,
|
||||
// which also carries `type` and `area`; the stop search only reads the name back out,
|
||||
// so the fixture stays as it is instead of growing fields nothing here looks at.
|
||||
geocodeMock.mockResolvedValue({ results: [from as TransitPlace] });
|
||||
geocodeMock.mockResolvedValue({ results: [from as TransitPlace], provider: 'transitous' });
|
||||
planMock.mockResolvedValue({
|
||||
provider: 'transitous',
|
||||
itineraries: [
|
||||
itinerary,
|
||||
{ ...itinerary, legs: [itinerary.legs[0]] },
|
||||
@@ -188,7 +189,9 @@ describe('MCP transit tools', () => {
|
||||
}),
|
||||
) as any;
|
||||
expect(stops.results[0].name).toBe('Namba');
|
||||
expect(geocodeMock).toHaveBeenCalledWith('Namba', 'ja', '34.67,135.5');
|
||||
// The caller's id trails the query so the Google backend (#1699) can
|
||||
// resolve the install's key; the Transitous path ignores it.
|
||||
expect(geocodeMock).toHaveBeenCalledWith('Namba', 'ja', '34.67,135.5', user.id);
|
||||
|
||||
const routes = parseToolResult(
|
||||
await harness.client.callTool({
|
||||
@@ -199,7 +202,7 @@ describe('MCP transit tools', () => {
|
||||
expect(routes.itineraries[0].legs[0].from.name).toBe('Namba');
|
||||
expect(routes.itineraries[0].legs[1].to.name).toBe('Umeda');
|
||||
expect(routes.itineraries).toHaveLength(1);
|
||||
expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' }));
|
||||
expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' }), undefined, user.id);
|
||||
|
||||
const invalidNear = await harness.client.callTool({
|
||||
name: 'search_transit_stops',
|
||||
@@ -496,7 +499,7 @@ describe('MCP transit tools', () => {
|
||||
...itinerary,
|
||||
legs: itinerary.legs.map((leg) => (leg.mode === 'WALK' ? leg : { ...leg, mode: 'AIRPLANE' })),
|
||||
};
|
||||
planMock.mockResolvedValue({ itineraries: [flying] });
|
||||
planMock.mockResolvedValue({ itineraries: [flying], provider: 'transitous' });
|
||||
|
||||
await withHarness(user.id, ['geo:read'], async (harness) => {
|
||||
const routes = parseToolResult(
|
||||
@@ -523,7 +526,7 @@ describe('MCP transit tools', () => {
|
||||
it('reports how many provider itineraries failed validation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const walkOnly = { ...itinerary, legs: [itinerary.legs[0]] };
|
||||
planMock.mockResolvedValue({ itineraries: [itinerary, walkOnly] });
|
||||
planMock.mockResolvedValue({ itineraries: [itinerary, walkOnly], provider: 'transitous' });
|
||||
|
||||
await withHarness(user.id, ['geo:read'], async (harness) => {
|
||||
const routes = parseToolResult(
|
||||
|
||||
@@ -421,3 +421,64 @@ describe('AddonsService places enrichment flag', () => {
|
||||
expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_enrich_enabled', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The transit backend (#1699) is a name, not a flag, so it needs the
|
||||
* unrecognised-value case the booleans get for free: anything that is not a
|
||||
* known provider must read as Transitous rather than silently billing the
|
||||
* install's Google key.
|
||||
*/
|
||||
describe('AddonsService transit provider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-086 an unset provider reads as Transitous, with no key anywhere', () => {
|
||||
dbMock._stmt.get.mockReturnValue(undefined);
|
||||
expect(svc().getTransitProvider()).toEqual({ provider: 'transitous', googleKeySource: null });
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-087 only a known provider name is honoured', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'google' }).mockReturnValue(undefined);
|
||||
expect(svc().getTransitProvider().provider).toBe('google');
|
||||
|
||||
for (const value of ['someday-maps', '', 'GOOGLE']) {
|
||||
dbMock._stmt.get.mockReset();
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value }).mockReturnValue(undefined);
|
||||
expect(svc().getTransitProvider().provider).toBe('transitous');
|
||||
}
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-088 the setter persists the name and echoes it back', () => {
|
||||
dbMock._stmt.get.mockReturnValue(undefined);
|
||||
expect(svc().updateTransitProvider('google').provider).toBe('google');
|
||||
expect(dbMock._stmt.run).toHaveBeenLastCalledWith('transit_provider', 'google');
|
||||
expect(svc().updateTransitProvider('transitous').provider).toBe('transitous');
|
||||
expect(dbMock._stmt.run).toHaveBeenLastCalledWith('transit_provider', 'transitous');
|
||||
});
|
||||
|
||||
/**
|
||||
* The warning the admin panel renders is driven entirely by this field, so
|
||||
* the instance/user-row split is the part worth pinning: only 'user-row'
|
||||
* means "works for this admin, Transitous for everybody else".
|
||||
*/
|
||||
it('ADDONS-SVC-089 reports where the Google key resolved from', () => {
|
||||
// provider row, then the instance maps_api_key row.
|
||||
dbMock._stmt.get.mockReset();
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'google' }).mockReturnValueOnce({ value: 'instance-key' });
|
||||
expect(svc().getTransitProvider(7).googleKeySource).toBe('instance');
|
||||
|
||||
// No instance row, but the caller's own users column has one.
|
||||
dbMock._stmt.get.mockReset();
|
||||
dbMock._stmt.get
|
||||
.mockReturnValueOnce({ value: 'google' })
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce({ maps_api_key: 'personal-key' });
|
||||
expect(svc().getTransitProvider(7).googleKeySource).toBe('user-row');
|
||||
|
||||
// Nothing anywhere.
|
||||
dbMock._stmt.get.mockReset();
|
||||
dbMock._stmt.get.mockReturnValue(undefined);
|
||||
expect(svc().getTransitProvider(7).googleKeySource).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,8 @@ const addonsStub = () => ({
|
||||
updatePlacesEnrich: vi.fn((enabled: boolean) => ({ enabled })),
|
||||
getCollabFeatures: vi.fn(() => ({ chat: false })),
|
||||
updateCollabFeatures: vi.fn(() => ({ features: { chat: true }, changed: true })),
|
||||
getTransitProvider: vi.fn(() => ({ provider: 'transitous', googleKeySource: null })),
|
||||
updateTransitProvider: vi.fn((provider: string) => ({ provider, googleKeySource: null })),
|
||||
}) as unknown as AddonsService;
|
||||
|
||||
// The MCP-token routes read TokenService now, not AdminService. Stubbed via a
|
||||
@@ -278,6 +280,20 @@ describe('AdminController feature toggles', () => {
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.places_enrich' }));
|
||||
});
|
||||
|
||||
it('ADMIN-TOGGLE-002c transit-provider reads and writes through the addons domain and is audited (#1699)', () => {
|
||||
const addons = addonsStub();
|
||||
const c = adminCtl(svc(), undefined, addons);
|
||||
expect(c.getTransitProvider(user)).toEqual({ provider: 'transitous', googleKeySource: null });
|
||||
expect(c.updateTransitProvider(user, { provider: 'google' }, req)).toEqual({ provider: 'google', googleKeySource: null });
|
||||
// The acting admin's id drives key resolution — the panel warns about the
|
||||
// key THEY would search with.
|
||||
expect(addons.getTransitProvider).toHaveBeenCalledWith(user.id);
|
||||
expect(addons.updateTransitProvider).toHaveBeenCalledWith('google', user.id);
|
||||
expect(writeAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'admin.transit_provider', details: { provider: 'google' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ADMIN-TOGGLE-003 collab-features invalidates MCP sessions only when a flag flipped (#1414)', () => {
|
||||
const invalidateMcpSessions = vi.fn();
|
||||
const c = adminCtl(svc({ invalidateMcpSessions } as Partial<AdminService>));
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* 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 type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
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();
|
||||
|
||||
/** app_settings reads only — the transit provider name and the instance Places key. */
|
||||
function stubDb(settings: Record<string, string | undefined>): DatabaseService {
|
||||
return {
|
||||
get: (_sql: string, ...params: unknown[]) => {
|
||||
const value = settings[String(params[0])];
|
||||
return value === undefined ? undefined : { value };
|
||||
},
|
||||
run: () => undefined,
|
||||
} as unknown as DatabaseService;
|
||||
}
|
||||
|
||||
const googleDb = () => stubDb({ transit_provider: 'google', maps_api_key: 'test-key' });
|
||||
|
||||
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', () => {
|
||||
const provider = new GoogleTransitProvider(stubDb({ maps_api_key: 'test-key' }));
|
||||
expect(provider.isActive(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('GTRANSIT-002: stays off when Google is selected but no key resolves', () => {
|
||||
const provider = new GoogleTransitProvider(stubDb({ transit_provider: 'google' }));
|
||||
expect(provider.isActive(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('GTRANSIT-003: is on only with both the setting and a key', () => {
|
||||
expect(new GoogleTransitProvider(googleDb()).isActive(1)).toBe(true);
|
||||
});
|
||||
|
||||
it('GTRANSIT-004: an unknown provider value falls back to Transitous', () => {
|
||||
const provider = new GoogleTransitProvider(stubDb({ transit_provider: 'someday-maps', maps_api_key: 'test-key' }));
|
||||
expect(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(new GoogleTransitProvider(googleDb()));
|
||||
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(new GoogleTransitProvider(googleDb()));
|
||||
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 new GoogleTransitProvider(googleDb()).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 new GoogleTransitProvider(googleDb()).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 = new GoogleTransitProvider(googleDb());
|
||||
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 new GoogleTransitProvider(googleDb()).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 new GoogleTransitProvider(googleDb()).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 new GoogleTransitProvider(googleDb()).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 = new GoogleTransitProvider(googleDb());
|
||||
|
||||
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 = new GoogleTransitProvider(googleDb());
|
||||
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 = new GoogleTransitProvider(googleDb());
|
||||
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(new GoogleTransitProvider(googleDb()).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(
|
||||
new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
});
|
||||
});
|
||||
|
||||
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 new GoogleTransitProvider(googleDb()).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 new GoogleTransitProvider(googleDb()).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(new GoogleTransitProvider(googleDb()));
|
||||
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(new GoogleTransitProvider(googleDb()));
|
||||
// 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 = new GoogleTransitProvider(stubDb({ 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(new GoogleTransitProvider(googleDb()).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 = new GoogleTransitProvider(googleDb());
|
||||
|
||||
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 new GoogleTransitProvider(googleDb()).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 new GoogleTransitProvider(googleDb()).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 = new GoogleTransitProvider(googleDb());
|
||||
|
||||
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 = new GoogleTransitProvider(googleDb());
|
||||
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 new GoogleTransitProvider(googleDb()).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 new GoogleTransitProvider(googleDb()).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 new GoogleTransitProvider(googleDb()).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 new GoogleTransitProvider(googleDb()).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 new GoogleTransitProvider(googleDb()).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(new GoogleTransitProvider(googleDb()));
|
||||
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(new GoogleTransitProvider(stubDb({ transit_provider: 'google' })));
|
||||
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(new GoogleTransitProvider(stubDb({})));
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,9 @@
|
||||
* every case uses its own coordinates/query to stay isolated.
|
||||
*/
|
||||
import { deriveTransitStats, type TransitLeg } from '../../../src/nest/transit/transit.helpers';
|
||||
import { GoogleTransitProvider } from '../../../src/nest/transit/google-transit.provider';
|
||||
import { TransitService } from '../../../src/nest/transit/transit.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
@@ -21,7 +23,10 @@ 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();
|
||||
// No `transit_provider` row means Transitous, so every case below keeps
|
||||
// exercising the MOTIS path — the Google branch has its own suite.
|
||||
const db = { get: () => undefined, run: () => undefined } as unknown as DatabaseService;
|
||||
const svc = new TransitService(new GoogleTransitProvider(db));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
Reference in New Issue
Block a user