From 209f8fe5cc4b7841bd3b74a085304dc516bafb7e Mon Sep 17 00:00:00 2001 From: Raz Erlich Date: Wed, 28 Oct 2026 08:01:53 +0200 Subject: [PATCH] feat(transit): fall back between configured public transit providers --- .../Planner/TransitSearchPanel.test.tsx | 21 ++++ .../nest/transit/google-transit.provider.ts | 5 + server/src/nest/transit/transit.mcp.ts | 15 ++- server/src/nest/transit/transit.service.ts | 35 ++++-- server/tests/e2e/transit-provider.e2e.test.ts | 99 +++++++++++++++++ server/tests/unit/mcp/tools-transit.test.ts | 75 +++++++++++++ .../unit/nest/google-transit.provider.test.ts | 30 +++++- .../tests/unit/nest/transit.service.test.ts | 101 ++++++++++++++++++ wiki/MCP-Tools-and-Resources.md | 2 +- 9 files changed, 367 insertions(+), 16 deletions(-) diff --git a/client/src/components/Planner/TransitSearchPanel.test.tsx b/client/src/components/Planner/TransitSearchPanel.test.tsx index 7169c3afc..23bbc559a 100644 --- a/client/src/components/Planner/TransitSearchPanel.test.tsx +++ b/client/src/components/Planner/TransitSearchPanel.test.tsx @@ -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() + 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() expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2) diff --git a/server/src/nest/transit/google-transit.provider.ts b/server/src/nest/transit/google-transit.provider.ts index a749dc160..3ad4600dc 100644 --- a/server/src/nest/transit/google-transit.provider.ts +++ b/server/src/nest/transit/google-transit.provider.ts @@ -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 { + return !!(await this.resolveKey(0)).key; + } + private async call( endpoint: string, label: string, diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts index 9213f6b88..9d4e9a699 100644 --- a/server/src/nest/transit/transit.mcp.ts +++ b/server/src/nest/transit/transit.mcp.ts @@ -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; 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.'); } diff --git a/server/src/nest/transit/transit.service.ts b/server/src/nest/transit/transit.service.ts index ff105d5e5..69eb91098 100644 --- a/server/src/nest/transit/transit.service.ts +++ b/server/src/nest/transit/transit.service.ts @@ -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, ) {} - /** 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 { 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' }; diff --git a/server/tests/e2e/transit-provider.e2e.test.ts b/server/tests/e2e/transit-provider.e2e.test.ts index 86948abf5..f103d14c0 100644 --- a/server/tests/e2e/transit-provider.e2e.test.ts +++ b/server/tests/e2e/transit-provider.e2e.test.ts @@ -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(); + } + }); }); diff --git a/server/tests/unit/mcp/tools-transit.test.ts b/server/tests/unit/mcp/tools-transit.test.ts index 507b1233a..de7e8eacf 100644 --- a/server/tests/unit/mcp/tools-transit.test.ts +++ b/server/tests/unit/mcp/tools-transit.test.ts @@ -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); diff --git a/server/tests/unit/nest/google-transit.provider.test.ts b/server/tests/unit/nest/google-transit.provider.test.ts index 3d605d4a5..f084bea81 100644 --- a/server/tests/unit/nest/google-transit.provider.test.ts +++ b/server/tests/unit/nest/google-transit.provider.test.ts @@ -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): GoogleTransitProvider { +function makeProvider( + settings: Record, + 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); diff --git a/server/tests/unit/nest/transit.service.test.ts b/server/tests/unit/nest/transit.service.test.ts index f2abdc637..133b171f8 100644 --- a/server/tests/unit/nest/transit.service.test.ts +++ b/server/tests/unit/nest/transit.service.test.ts @@ -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(); + } + }); +}); diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 187795a02..18a0af192 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -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