mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
91f88270cc
* feat(mcp): add public transit planning tools * refactor(transit): reuse local time conversion * fix(mcp): harden transit journey validation * refactor(transit): centralize itinerary processing * fix(mcp): return the transit itineraries the provider actually offers search_transit_routes validated each itinerary leg's mode against SCHEDULED_TRANSIT_MODES, but that constant is the request-side filter whitelist — the modes a caller may ask for — not the modes MOTIS can return. Its default TRANSIT mode expands to TRAM,FERRY,AIRPLANE,BUS,COACH,RAIL,ODM, RIDE_SHARING,FUNICULAR,AERIAL_LIFT,OTHER, and street legs can be BIKE/CAR/ RENTAL. Any itinerary carrying one of those failed the parse and was dropped by the flatMap, so the tool reported fewer routes than exist — or none at all. Against the live provider, Trondheim → Ålesund returns 5 itineraries and 3 of them contain an AIRPLANE leg, so the tool silently discarded them; the web app shows all 5, because it treats mode as a free string and renders anything non-WALK as a transit leg. Accept any mode token on a leg and keep the existing "at least one non-WALK leg" rule as the real gate, which restores parity with the web app. Everything downstream keeps its mode !== 'WALK' semantics, so a journey created over MCP is identical to one created in the app. Dropping an itinerary is still possible when provider data fails the remaining consistency rules, but it is indistinguishable from "no routes exist" — so search_transit_routes now reports a `dropped` count alongside the results. --------- Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>
172 lines
6.6 KiB
TypeScript
172 lines
6.6 KiB
TypeScript
/**
|
|
* Unit tests for transitService — TRANSIT-SVC-001..010.
|
|
* The Transitous/MOTIS proxy (#1065): input validation, mode whitelist,
|
|
* response mapping (colors, walk time, wall-clock duration) and caching.
|
|
*/
|
|
import { deriveTransitStats, geocode, plan, type TransitLeg } from '../../../src/services/transitService';
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
vi.mock('../../../src/services/notifications', () => ({ getAppUrl: () => 'https://trek.example.com' }));
|
|
vi.mock('../../../src/services/mapsService', () => ({ buildUserAgent: () => 'TREK-Test-UA' }));
|
|
|
|
const fetchMock = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
fetchMock.mockReset();
|
|
});
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
function okJson(data: unknown) {
|
|
return { ok: true, json: async () => data };
|
|
}
|
|
|
|
describe('geocode', () => {
|
|
it('TRANSIT-SVC-001: returns [] without calling upstream for short queries', async () => {
|
|
const r = await geocode('a');
|
|
expect(r.results).toEqual([]);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TRANSIT-SVC-002: maps matches to compact places and sends the UA', async () => {
|
|
fetchMock.mockResolvedValueOnce(
|
|
okJson([
|
|
{ name: 'Alexanderplatz', lat: 52.52, lon: 13.41, type: 'STOP', areas: [{ name: 'Berlin', default: true }] },
|
|
{ name: 'no-coords' },
|
|
]),
|
|
);
|
|
const r = await geocode('alexanderplatz-u1');
|
|
expect(r.results).toEqual([{ name: 'Alexanderplatz', lat: 52.52, lng: 13.41, type: 'STOP', area: 'Berlin' }]);
|
|
const [url, init] = fetchMock.mock.calls[0];
|
|
expect(String(url)).toContain('/api/v1/geocode?');
|
|
expect(init.headers['User-Agent']).toBe('TREK-Test-UA');
|
|
});
|
|
|
|
it('TRANSIT-SVC-003: ignores an invalid near bias instead of forwarding it', async () => {
|
|
fetchMock.mockResolvedValueOnce(okJson([]));
|
|
await geocode('hauptbahnhof-x1', undefined, 'not,coords');
|
|
expect(String(fetchMock.mock.calls[0][0])).not.toContain('place=');
|
|
});
|
|
});
|
|
|
|
describe('plan validation', () => {
|
|
it('TRANSIT-SVC-004: rejects malformed coordinates with 400', async () => {
|
|
await expect(plan({ from: 'x', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 });
|
|
await expect(plan({ from: '95,13.4', to: '52.5,13.4' })).rejects.toMatchObject({ status: 400 });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TRANSIT-SVC-005: rejects modes outside the whitelist', async () => {
|
|
await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', modes: 'BUS,CAR' })).rejects.toMatchObject({
|
|
status: 400,
|
|
});
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TRANSIT-SVC-006: rejects out-of-range maxTransfers and bad time', async () => {
|
|
await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', maxTransfers: 99 })).rejects.toMatchObject({
|
|
status: 400,
|
|
});
|
|
await expect(plan({ from: '52.50,13.40', to: '52.51,13.41', time: 'not-a-date' })).rejects.toMatchObject({
|
|
status: 400,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('itinerary statistics', () => {
|
|
it('derives wall-clock duration, walking time, and transfers from canonical legs', () => {
|
|
const leg = (mode: string, duration: number) => ({ mode, duration }) as TransitLeg;
|
|
const stats = deriveTransitStats('2026-07-13T08:00:00Z', '2026-07-13T08:30:00Z', [
|
|
leg('WALK', 300),
|
|
leg('BUS', 600),
|
|
leg('RAIL', 600),
|
|
]);
|
|
|
|
expect(stats).toEqual({ duration: 1800, transfers: 1, walkSeconds: 300 });
|
|
expect(deriveTransitStats('2026-07-13T08:00:00Z', '2026-07-13T08:30:00Z', [leg('BUS', 1800)], 2).transfers).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('plan mapping', () => {
|
|
const motisResponse = {
|
|
itineraries: [
|
|
{
|
|
duration: 999, // deliberately wrong — mapping must use wall-clock instead
|
|
startTime: '2026-07-13T08:00:00Z',
|
|
endTime: '2026-07-13T08:30:00Z',
|
|
transfers: 1,
|
|
legs: [
|
|
{
|
|
mode: 'WALK',
|
|
duration: 300,
|
|
distance: 250.7,
|
|
from: { name: 'A', lat: 1, lon: 2, departure: '2026-07-13T08:00:00Z' },
|
|
to: { name: 'Stop 1', lat: 1.1, lon: 2.1, arrival: '2026-07-13T08:05:00Z' },
|
|
},
|
|
{
|
|
mode: 'BUS',
|
|
duration: 1200,
|
|
routeShortName: '100',
|
|
routeColor: 'FF0000',
|
|
routeTextColor: '#ffffff',
|
|
headsign: 'Zoo',
|
|
agencyName: 'BVG',
|
|
intermediateStops: [{}, {}],
|
|
from: { name: 'Stop 1', lat: 1.1, lon: 2.1, departure: '2026-07-13T08:07:00Z', track: '2' },
|
|
to: { name: 'Stop 2', lat: 1.2, lon: 2.2, arrival: '2026-07-13T08:27:00Z' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
it('TRANSIT-SVC-007: maps legs compactly, normalises GTFS colors and counts walk seconds', async () => {
|
|
fetchMock.mockResolvedValueOnce(okJson(motisResponse));
|
|
const r = await plan({ from: '52.5000,13.4000', to: '52.5100,13.4100' });
|
|
expect(r.itineraries).toHaveLength(1);
|
|
const it = r.itineraries[0];
|
|
// Wall-clock 08:00→08:30, not the reported 999s.
|
|
expect(it.duration).toBe(1800);
|
|
expect(it.walkSeconds).toBe(300);
|
|
expect(it.transfers).toBe(1);
|
|
const bus = it.legs[1];
|
|
expect(bus.line).toBe('100');
|
|
expect(bus.lineColor).toBe('#FF0000');
|
|
expect(bus.lineTextColor).toBe('#ffffff');
|
|
expect(bus.intermediateStops).toBe(2);
|
|
expect(bus.from.track).toBe('2');
|
|
expect(it.legs[0].distance).toBe(251);
|
|
});
|
|
|
|
it('TRANSIT-SVC-008: forwards only whitelisted params and pins directModes=WALK', async () => {
|
|
fetchMock.mockResolvedValueOnce(okJson({ itineraries: [] }));
|
|
await plan({
|
|
from: '48.1000,11.5000',
|
|
to: '48.2000,11.6000',
|
|
modes: 'BUS,TRAM',
|
|
maxTransfers: 2,
|
|
arriveBy: true,
|
|
time: '2026-07-13T09:00:00Z',
|
|
});
|
|
const url = String(fetchMock.mock.calls[0][0]);
|
|
expect(url).toContain('/api/v6/plan?');
|
|
expect(url).toContain('transitModes=BUS%2CTRAM');
|
|
expect(url).toContain('maxTransfers=2');
|
|
expect(url).toContain('arriveBy=true');
|
|
expect(url).toContain('directModes=WALK');
|
|
});
|
|
|
|
it('TRANSIT-SVC-009: identical plans hit the cache (single upstream call)', async () => {
|
|
fetchMock.mockResolvedValue(okJson({ itineraries: [] }));
|
|
await plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' });
|
|
await plan({ from: '40.0000,-3.0000', to: '40.1000,-3.1000' });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('TRANSIT-SVC-010: upstream failure surfaces as a 502-style error', async () => {
|
|
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 });
|
|
await expect(plan({ from: '41.0000,2.0000', to: '41.1000,2.1000' })).rejects.toMatchObject({ status: 502 });
|
|
});
|
|
});
|