feat(transit): fall back between configured public transit providers

This commit is contained in:
Raz Erlich
2026-10-28 08:01:53 +02:00
committed by Maurice
parent 9f155a3191
commit 209f8fe5cc
9 changed files with 367 additions and 16 deletions
@@ -91,6 +91,27 @@ beforeEach(() => {
})
describe('TransitSearchPanel', () => {
it.each(['google', 'transitous'] as const)('updates attribution when a later search returns %s fallback routes', async (provider) => {
const user = userEvent.setup()
const onAdd = vi.fn().mockResolvedValue({})
const previous = provider === 'google' ? 'transitous' : 'google'
transitApiMock.plan
.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: previous })
.mockResolvedValueOnce({ itineraries: [ITINERARY], provider })
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await screen.findByText(previous === 'google' ? 'Google Maps' : 'Transitous')
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByText(provider === 'google' ? 'Google Maps' : 'Transitous')).toBeInTheDocument()
expect(screen.queryByText(previous === 'google' ? 'Google Maps' : 'Transitous')).not.toBeInTheDocument()
await user.click(await screen.findByText(/08:30 – 09:00/))
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
await waitFor(() => expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({
metadata: expect.objectContaining({ transit: expect.objectContaining({ provider }) }),
})))
})
it('FE-PLANNER-TRANSIT-001: renders from/to pickers, modes and preferences', () => {
render(<TransitSearchPanel {...makeProps()} />)
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2)
@@ -268,6 +268,11 @@ export class GoogleTransitProvider {
return !!(await this.resolveKey(userId)).key;
}
/** Fallback may bill only an operator or instance key, never a personal key. */
async isAvailableForFallback(): Promise<boolean> {
return !!(await this.resolveKey(0)).key;
}
private async call(
endpoint: string,
label: string,
+10 -5
View File
@@ -22,7 +22,7 @@ import {
} from './transit-itinerary.helpers';
import { SCHEDULED_TRANSIT_MODES, type TransitItinerary } from './transit.helpers';
import { TransitService } from './transit.service';
import { idSchema } from '@trek/shared';
import { adminTransitProviderRequestSchema, idSchema, type TransitProvider } from '@trek/shared';
import { z } from 'zod';
@@ -178,13 +178,16 @@ export class TransitMcp {
@Tool({
name: 'create_transit_journey',
description:
'Add one itinerary returned by search_transit_routes to a trip day as a first-class automated public-transit journey.',
'Add one itinerary returned by search_transit_routes to a trip day as a first-class automated public-transit journey. Pass the search response provider unchanged so fallback routes retain their source.',
inputSchema: {
tripId: idSchema,
dayId: idSchema.describe('Trip day on which the journey departs'),
from: transitPlaceInput,
to: transitPlaceInput,
itinerary: transitItinerarySchema,
provider: adminTransitProviderRequestSchema.shape.provider
.optional()
.describe('Provider returned by search_transit_routes'),
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
@@ -197,6 +200,7 @@ export class TransitMcp {
from,
to,
itinerary,
provider,
notes,
}: {
tripId: number;
@@ -206,6 +210,7 @@ export class TransitMcp {
// The schema validates exactly the wire TransitItinerary shape; its
// z.infer prints looser optionals, so name the contract type directly.
itinerary: TransitItinerary;
provider?: TransitProvider;
notes?: string;
},
ctx: McpContext,
@@ -228,11 +233,11 @@ export class TransitMcp {
isError: true,
};
}
// The itinerary came from search_transit_routes, which the active backend answered.
const provider = await this.transit.activeProvider(ctx.userId);
// New callers carry the actual search source; omission keeps legacy callers working.
const itineraryProvider = provider ?? (await this.transit.activeProvider(ctx.userId));
let reservationParts: ReturnType<typeof buildTransitReservationParts>;
try {
reservationParts = buildTransitReservationParts(from, to, cleaned, provider);
reservationParts = buildTransitReservationParts(from, to, cleaned, itineraryProvider);
} catch (err) {
return errorResult(err, 'Unable to resolve the transit journey timezones.');
}
+29 -6
View File
@@ -25,8 +25,8 @@ import type { TransitProvider } from '@trek/shared';
* regions Transitous has no feed for. This class stays the entry point and the
* validator either way: the request is checked here, then dispatched, so both
* backends are held to the same coordinate/mode/transfer contract and answer in
* the same compact shape. Transitous remains the default and the fallback —
* see GoogleTransitProvider.isActive.
* the same compact shape. Transitous remains the default. Empty plans try the
* other backend when available; Google fallback requires an admin-set key.
*
* Both responses name the backend that answered. The fallback is silent by
* design, so without this an empty result is indistinguishable between "this
@@ -159,7 +159,7 @@ export class TransitService {
@Inject(transitConfig.KEY) private readonly transitEnv: ConfigType<typeof transitConfig>,
) {}
/** The backend that answers this user's searches: Google where it is set up and keyed, Transitous otherwise. */
/** Configured primary for this user; an empty plan may be answered by the other provider. */
async activeProvider(userId = 0): Promise<TransitProvider> {
return (await this.google.isActive(userId)) ? 'google' : 'transitous';
}
@@ -264,10 +264,33 @@ export class TransitService {
// After validation on purpose: whichever backend answers, the caller is held
// to the same coordinate/mode/transfer contract and gets the same 400s.
if ((await this.activeProvider(userId)) === 'google') {
return { ...(await this.google.plan(q, language, userId)), provider: 'google' };
}
const googlePrimary = (await this.activeProvider(userId)) === 'google';
const primary = googlePrimary
? { ...(await this.google.plan(q, language, userId)), provider: 'google' as const }
: await this.planTransitous(params);
if (primary.itineraries.length > 0) return primary;
// An empty successful response alone triggers fallback. Primary errors keep
// propagating; a secondary outage must not replace a valid empty response.
try {
if (googlePrimary) {
const secondary = await this.planTransitous(params);
if (secondary.itineraries.length > 0) return secondary;
} else if (await this.google.isAvailableForFallback()) {
// Resolve again without a user id so a changed instance key can never
// cause the fallback to bill the caller's personal key.
const secondary = await this.google.plan(q, language, 0);
if (secondary.itineraries.length > 0) return { ...secondary, provider: 'google' };
}
} catch {
// Keep the primary's empty result and its provider attribution.
}
return primary;
}
private async planTransitous(
params: URLSearchParams,
): Promise<{ itineraries: TransitItinerary[]; provider: TransitProvider }> {
const key = `plan:${params.toString()}`;
const cached = cacheGet(key);
if (cached) return { ...(cached as { itineraries: TransitItinerary[] }), provider: 'transitous' };
@@ -11,6 +11,7 @@ import { db } from '../../src/db/database';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { GoogleQuotaService } from '../../src/nest/google-quota/google-quota.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { clearGoogleTransitCache } from '../../src/nest/transit/google-transit.provider';
import { TransitModule } from '../../src/nest/transit/transit.module';
@@ -75,6 +76,7 @@ describe('Transit backend switch e2e (#1699)', () => {
});
beforeEach(async () => {
vi.restoreAllMocks();
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
// MOTIS and Routes shapes both parse as "no itineraries", which is all this
@@ -137,4 +139,101 @@ describe('Transit backend switch e2e (#1699)', () => {
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');
});
const motisRoute = {
itineraries: [
{
startTime: '2026-09-10T09:00:00Z',
endTime: '2026-09-10T09:08:00Z',
legs: [
{
mode: 'SUBWAY',
duration: 480,
from: { name: 'A', lat: 34.69, lon: 135.49 },
to: { name: 'B', lat: 34.68, lon: 135.51 },
},
],
},
],
};
const googleRoute = {
routes: [
{
legs: [
{
steps: [
{
travelMode: 'TRANSIT',
staticDuration: '480s',
transitDetails: {
stopDetails: {
departureStop: { name: 'A', location: { latLng: { latitude: 34.69, longitude: 135.49 } } },
arrivalStop: { name: 'B', location: { latLng: { latitude: 34.68, longitude: 135.51 } } },
departureTime: '2026-09-10T09:00:00Z',
arrivalTime: '2026-09-10T09:08:00Z',
},
transitLine: { vehicle: { type: 'SUBWAY' } },
},
},
],
},
],
},
],
};
const okJson = (data: unknown) => ({ ok: true, status: 200, headers: { get: () => null }, json: async () => data });
describe.each(['google', 'transitous'] as const)('%s primary fallback', (primary) => {
it.each(['success', 'primary-success', 'empty', 'secondary-error', 'primary-error'])('%s', async (outcome) => {
await setAppSetting(orm, 'transit_provider', primary);
await setAppSetting(orm, 'maps_api_key', 'instance-key');
const secondary = primary === 'google' ? 'transitous' : 'google';
fetchMock.mockImplementation(async (url: string) => {
const provider = String(url).includes('googleapis') ? 'google' : 'transitous';
if (
(outcome === 'primary-error' && provider === primary) ||
(outcome === 'secondary-error' && provider === secondary)
) {
return { ok: false, status: 500, headers: { get: () => null }, json: async () => ({}) };
}
const hasRoutes =
(outcome === 'success' && provider === secondary) || (outcome === 'primary-success' && provider === primary);
return okJson(hasRoutes ? (provider === 'google' ? googleRoute : motisRoute) : {});
});
const res = await request(server).get(nextPlanUrl()).set('Cookie', sessionCookie(ADMIN));
expect(res.status).toBe(outcome === 'primary-error' ? 502 : 200);
if (outcome !== 'primary-error') {
expect(res.body.provider).toBe(outcome === 'success' ? secondary : primary);
expect(res.body.itineraries).toHaveLength(['success', 'primary-success'].includes(outcome) ? 1 : 0);
}
expect(fetchMock).toHaveBeenCalledTimes(outcome.startsWith('primary-') ? 1 : 2);
expect(String(fetchMock.mock.calls[0][0]).includes('googleapis')).toBe(primary === 'google');
if (secondary === 'google' && outcome === 'success') {
expect(fetchMock.mock.calls[1][1].headers['X-Goog-Api-Key']).toBe('instance-key');
}
});
});
it.each(['absent', 'personal-only', 'quota-exhausted'])('does not fall back to Google when %s', async (state) => {
if (state === 'personal-only') await updateRows(orm, Users, { id: ADMIN }, { maps_api_key: 'personal-key' });
if (state === 'quota-exhausted') {
await setAppSetting(orm, 'maps_api_key', 'instance-key');
vi.spyOn(app.get(GoogleQuotaService), 'exhausted').mockResolvedValue(true);
}
await planUpstream();
expect(lastBody.provider).toBe('transitous');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('uses the operator key for Google fallback', async () => {
vi.stubEnv('PLACES_API_KEY', 'operator-key');
try {
fetchMock.mockResolvedValueOnce(okJson({})).mockResolvedValueOnce(okJson(googleRoute));
await planUpstream();
expect(lastBody.provider).toBe('google');
expect(fetchMock.mock.calls[1][1].headers['X-Goog-Api-Key']).toBe('operator-key');
} finally {
vi.unstubAllEnvs();
}
});
});
@@ -146,6 +146,21 @@ async function withHarness(userId: number, scopes: string[] | null, fn: (harness
}
describe('MCP transit tools', () => {
it.each(['google', 'transitous'] as const)('preserves the %s route provider', async (provider) => {
const { user } = createUser(testDb);
planMock.mockResolvedValue({ itineraries: [itinerary], provider });
await withHarness(user.id, ['geo:read'], async (harness) => {
const routes = parseToolResult(
await harness.client.callTool({
name: 'search_transit_routes',
arguments: { from, to },
}),
) as { provider: string; itineraries: unknown[]; dropped: number };
expect(routes.provider).toBe(provider);
expect(routes.itineraries).toHaveLength(1);
expect(routes.dropped).toBe(0);
});
});
it('registers search tools for geo scope and create tool for reservations scope', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, ['geo:read'], async (harness) => {
@@ -266,6 +281,66 @@ describe('MCP transit tools', () => {
}
});
it.each(['google', 'transitous'] as const)(
'saves the %s fallback source from search rather than the configured primary',
async (provider) => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
const day = await dayOf(trip.id, '2026-12-03');
const primary = provider === 'google' ? 'transitous' : 'google';
const active = vi.spyOn(TransitService.prototype, 'activeProvider').mockResolvedValue(primary);
planMock.mockResolvedValue({ provider, itineraries: [itinerary] });
try {
await withHarness(user.id, ['geo:read', 'reservations:write'], async (harness) => {
const routes = parseToolResult(
await harness.client.callTool({
name: 'search_transit_routes',
arguments: { from, to },
}),
) as { provider: typeof provider; itineraries: (typeof itinerary)[] };
const result = parseToolResult(
await harness.client.callTool({
name: 'create_transit_journey',
arguments: {
tripId: trip.id,
dayId: day.id,
from,
to,
itinerary: routes.itineraries[0],
provider: routes.provider,
},
}),
) as { reservation: { metadata: string } };
expect(JSON.parse(result.reservation.metadata).transit.provider).toBe(provider);
});
expect(active).not.toHaveBeenCalled();
} finally {
active.mockRestore();
}
},
);
it('rejects an unknown itinerary provider before creating a journey', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
const day = await dayOf(trip.id, '2026-12-03');
await withHarness(user.id, ['reservations:write'], async (harness) => {
const result = await harness.client.callTool({
name: 'create_transit_journey',
arguments: {
tripId: trip.id,
dayId: day.id,
from,
to,
itinerary,
provider: 'unknown',
},
});
expect(result.isError).toBe(true);
expect(await transitCount()).toBe(0);
});
});
it('rejects dateless, mismatched, and out-of-range journey dates', async () => {
const { user } = createUser(testDb);
const datelessTrip = createTrip(testDb, user.id);
@@ -44,14 +44,16 @@ const GOOGLE_SETTINGS = { transit_provider: 'google', maps_api_key: 'test-key' }
* `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.
* optional personal key exercises the last tier without reading another user.
*/
function makeProvider(settings: Record<string, string | undefined>): GoogleTransitProvider {
function makeProvider(
settings: Record<string, string | undefined>,
personalKey: string | null = null,
): GoogleTransitProvider {
const appSettingsStub = {
getValue: async (key: string) => settings[key] ?? null,
} as unknown as AppSettingsRepository;
const usersStub = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
const usersStub = { getApiKeyColumn: async () => personalKey } as unknown as UsersRepository;
return new GoogleTransitProvider(appSettingsStub, usersStub, noGoogleQuota);
}
@@ -180,6 +182,26 @@ function subwayRoute(): FixtureRoutes {
}
describe('activation', () => {
it('allows an instance key for fallback without selecting Google', async () => {
expect(await makeProvider({ maps_api_key: 'test-key' }).isAvailableForFallback()).toBe(true);
expect(await makeProvider({}).isAvailableForFallback()).toBe(false);
});
it('refuses personal-only fallback but preserves personal keys for Google primary', async () => {
const provider = makeProvider({ transit_provider: 'google' }, 'personal-key');
expect(await provider.isAvailableForFallback()).toBe(false);
expect(await provider.isActive(1)).toBe(true);
});
it('checks quota before making Google available for fallback', async () => {
const provider = makeProvider({ maps_api_key: 'test-key' });
const exhausted = vi.spyOn(noGoogleQuota, 'exhausted').mockResolvedValue(true);
try {
expect(await provider.isAvailableForFallback()).toBe(false);
} finally {
exhausted.mockRestore();
}
});
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);
@@ -16,6 +16,7 @@ import { GoogleTransitProvider } from '../../../src/nest/transit/google-transit.
import { deriveTransitStats, type TransitLeg } from '../../../src/nest/transit/transit.helpers';
import { TransitService } from '../../../src/nest/transit/transit.service';
import { noGoogleQuota } from '../../helpers/google-quota';
import { Test } from '@nestjs/testing';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -290,3 +291,103 @@ describe('upstream base URL', () => {
expect(String(fetchMock.mock.calls[0][0])).toMatch(/^https:\/\/api\.transitous\.org\/api\/v1\/geocode\?/);
});
});
describe('empty route fallback', () => {
let queryNo = 0;
const route = { startTime: '2026-07-13T08:00:00Z', endTime: '2026-07-13T08:30:00Z', legs: [] };
describe.each(['google', 'transitous'] as const)('%s primary', (primary) => {
it.each(['primary-success', 'primary-error', 'empty', 'secondary-error', 'unavailable'])('%s', async (outcome) => {
const googlePrimary = primary === 'google';
const google = {
isActive: vi.fn().mockResolvedValue(googlePrimary),
isAvailableForFallback: vi.fn().mockResolvedValue(true),
plan: vi.fn(),
};
const module = await Test.createTestingModule({
providers: [
TransitService,
{ provide: GoogleTransitProvider, useValue: google },
{ provide: transitConfig.KEY, useValue: transitConfig() },
],
}).compile();
try {
const service = module.get(TransitService);
google.plan.mockReset();
fetchMock.mockReset();
google.isAvailableForFallback.mockClear().mockResolvedValue(outcome !== 'unavailable');
const q = { from: `50.${queryNo++},13`, to: '52,14' };
const error = new Error('provider unavailable');
const primaryData = { itineraries: outcome === 'primary-success' ? [route] : [] };
if (googlePrimary) {
if (outcome === 'primary-error') google.plan.mockRejectedValue(error);
else google.plan.mockResolvedValue(primaryData);
if (outcome === 'secondary-error') fetchMock.mockRejectedValue(error);
else fetchMock.mockResolvedValue(okJson({ itineraries: [] }));
} else {
if (outcome === 'primary-error') fetchMock.mockRejectedValue(error);
else fetchMock.mockResolvedValue(okJson(primaryData));
if (outcome === 'secondary-error') google.plan.mockRejectedValue(error);
else google.plan.mockResolvedValue({ itineraries: [] });
}
if (outcome === 'primary-error') {
await expect(service.plan(q)).rejects.toBe(error);
} else {
const result = await service.plan(q);
expect(result.provider).toBe(primary);
expect(result.itineraries).toHaveLength(outcome === 'primary-success' ? 1 : 0);
}
if (outcome.startsWith('primary-')) {
expect(googlePrimary ? fetchMock : google.plan).not.toHaveBeenCalled();
}
if (!googlePrimary && outcome === 'unavailable') expect(google.plan).not.toHaveBeenCalled();
} finally {
await module.close();
}
});
});
it.each(['google', 'transitous'] as const)('falls back from %s and preserves request options', async (primary) => {
const google = {
isActive: vi.fn().mockResolvedValue(primary === 'google'),
isAvailableForFallback: vi.fn().mockResolvedValue(true),
plan: vi.fn().mockResolvedValue({ itineraries: primary === 'google' ? [] : [route] }),
};
const module = await Test.createTestingModule({
providers: [
TransitService,
{ provide: GoogleTransitProvider, useValue: google },
{ provide: transitConfig.KEY, useValue: transitConfig() },
],
}).compile();
try {
fetchMock.mockResolvedValue(okJson({ itineraries: primary === 'google' ? [route] : [] }));
const q = {
from: `51.${queryNo++},13`,
to: '52,14',
time: '2026-07-13T08:00:00Z',
arriveBy: true,
modes: 'BUS',
maxTransfers: 2,
};
const service = module.get(TransitService);
const result = await service.plan(q, 'de', 42);
expect(result.provider).toBe(primary === 'google' ? 'transitous' : 'google');
expect(result.itineraries).toHaveLength(1);
expect(google.plan).toHaveBeenCalledWith(q, 'de', primary === 'google' ? 42 : 0);
const params = new URL(String(fetchMock.mock.calls[0][0])).searchParams;
expect(Object.fromEntries(params)).toMatchObject({
fromPlace: q.from,
toPlace: q.to,
arriveBy: 'true',
transitModes: 'BUS',
maxTransfers: '2',
time: '2026-07-13T08:00:00.000Z',
});
await service.plan(q, 'de', 42);
expect(fetchMock).toHaveBeenCalledTimes(1);
} finally {
await module.close();
}
});
});
+1 -1
View File
@@ -134,7 +134,7 @@ Transit search is powered by Transitous and uses the existing `geo:read` and `re
|---|---|---|
| `search_transit_stops` | `geo:read` | Search real public-transit stops and stations, optionally biased around coordinates. |
| `search_transit_routes` | `geo:read` | Search scheduled routes between two coordinates with time, mode, and transfer filters. Also returns `provider` (`transitous` or `google`, the backend that answered) and `dropped`, the number of provider itineraries that failed validation and are absent from the results. |
| `create_transit_journey` | `reservations:write` | Save a selected route as a first-class automated transit journey on a trip day, recording the backend that plans routes for the caller as its provider. |
| `create_transit_journey` | `reservations:write` | Save a selected route as a first-class automated transit journey on a trip day. Pass `provider` from `search_transit_routes` to preserve the actual source, including fallback results. Older callers that omit it retain the configured-provider behavior. |
### Reservations