mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
45f1ff7799
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
97 lines
4.1 KiB
TypeScript
97 lines
4.1 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|