mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(planner): public transit routing via Transitous (#1065)
Each day header gets a transit button (replacing the rename pencil, which moved next to the day name in the day detail panel). It opens a route search backed by Transitous/MOTIS — free, open data, no paid provider: from/to stop search with the day's own places as quick picks, depart/arrive time, mode filters (train, subway, tram, bus, ferry, cable car) and ranking by best route, fewer transfers or less walking. Results show local times, duration, transfers, walking time and line badges in their official colors, with a stop-by-stop breakdown per connection. Adding a connection saves it as a regular transport reservation — typed by its dominant leg, timed from the itinerary's wall-clock departure/arrival converted to station-local time (tz-lookup), with the origin, transfer stops and destination as endpoints and the compact legs in metadata.transit. It slots into the day timeline by time and inherits editing, deletion and drag-reordering from the existing transport machinery; the transport detail view renders the full itinerary. Re-saving a transit transport through the edit modal preserves the stored itinerary while the route is unchanged. The server proxies the Transitous API (JWT-guarded, rate-limited, identifying User-Agent, short response cache, strict mode whitelist); TRANSIT_API_URL lets self-hosters use their own MOTIS instance. New i18n keys in every locale, wiki page updated. Discussion #1065
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Transit proxy e2e — /api/transit/geocode + /api/transit/plan through the real
|
||||
* JwtAuthGuard against a temp SQLite db. The transit service is mocked; this
|
||||
* focuses on auth (401), param pass-through and error propagation (#1065).
|
||||
*/
|
||||
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);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { transitSvc } = vi.hoisted(() => ({ transitSvc: { geocode: vi.fn(), plan: vi.fn() } }));
|
||||
vi.mock('../../src/services/transitService', () => transitSvc);
|
||||
|
||||
import { TransitModule } from '../../src/nest/transit/transit.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Transit proxy e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [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: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
transitSvc.geocode.mockReset();
|
||||
transitSvc.plan.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => { await app.close(); });
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
expect((await request(server).get('/api/transit/geocode?q=alexanderplatz')).status).toBe(401);
|
||||
expect((await request(server).get('/api/transit/plan?from=1,2&to=3,4')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('geocode passes q/lang/near through and returns the service result', async () => {
|
||||
transitSvc.geocode.mockResolvedValueOnce({ results: [{ name: 'Alexanderplatz' }] });
|
||||
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(transitSvc.geocode).toHaveBeenCalledWith('alex', 'de', '52.5,13.4');
|
||||
});
|
||||
|
||||
it('plan passes all params through (arriveBy + maxTransfers coerced)', async () => {
|
||||
transitSvc.plan.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')
|
||||
.set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(transitSvc.plan).toHaveBeenCalledWith({
|
||||
from: '52.5,13.4', to: '52.6,13.5', time: '2026-07-13T09:00:00Z', arriveBy: true, modes: 'BUS', maxTransfers: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('service validation errors propagate with their status', async () => {
|
||||
const err = new Error('from must be "lat,lng"') as Error & { status: number };
|
||||
err.status = 400;
|
||||
transitSvc.plan.mockRejectedValueOnce(err);
|
||||
const res = await request(server).get('/api/transit/plan?from=bad&to=1,2').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('lat,lng');
|
||||
});
|
||||
|
||||
it('upstream failures surface as 502', async () => {
|
||||
const err = new Error('Transit provider error (HTTP 500)') as Error & { status: number };
|
||||
err.status = 502;
|
||||
transitSvc.plan.mockRejectedValueOnce(err);
|
||||
const res = await request(server).get('/api/transit/plan?from=1,2&to=3,4').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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 { 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' }));
|
||||
|
||||
import { geocode, plan } from '../../../src/services/transitService';
|
||||
|
||||
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('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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user