import { test } from 'node:test'; import assert from 'node:assert/strict'; import { ApiError, createRideBusClient, normalizeDepartures, parseRequest } from '../server/ridebus.mjs'; import { apiHandler } from '../server/index.mjs'; test('only supported resources and positive city/stop identifiers are accepted', () => { assert.deepEqual(parseRequest(new URLSearchParams('resource=cities')), { resource: 'cities' }); assert.deepEqual(parseRequest(new URLSearchParams('resource=departures&cityId=2&stopId=15')), { resource: 'departures', cityId: 2, stopId: 15 }); for (const input of ['resource=https://evil.example', 'resource=stops', 'resource=stops&cityId=-1', 'resource=departures&cityId=1&stopId=NaN', 'resource=stops&cityId=9007199254740992']) { assert.throws(() => parseRequest(new URLSearchParams(input)), error => error.status === 400); } }); test('scheduled departures exclude historical delay and retain absolute expiration times', () => { const now = Date.parse('2026-10-06T20:55:00Z'); const data = normalizeDepartures({ routes: [{ id: 5, route_number: '10', name: 'Вокзал → Центр', arrivals: [ { scheduledTime: '0:05:00', minutesUntil: 14, delay: 4 }, { scheduledTime: '23:56', minutesUntil: 1, delay: 0 }, { scheduledTime: '25:12', minutesUntil: 12, delay: 0 }, ] }, { id: 6, route_number: '4', name: 'Больница', error: 'unavailable' }] }, now); assert.equal(data.departures.length, 2); assert.equal(data.departures[0].scheduledTime, '23:56'); assert.equal(data.departures[1].scheduledTime, '00:05'); assert.equal(data.departures[1].scheduledAt, '2026-10-06T21:05:00.000Z'); assert.equal(data.partial, true); assert.equal(data.routes.length, 2); }); test('a negative scheduled wait is skipped rather than shown as a future bus', () => { const data = normalizeDepartures({ routes: [{ id: 1, route_number: '1', name: 'Центр', arrivals: [ { scheduledTime: '10:00', minutesUntil: 3, delay: 5 }, ] }] }); assert.equal(data.departures.length, 0); }); test('server cache isolates cities, coalesces concurrent requests, and sends credentials only upstream', async () => { const calls = []; const client = createRideBusClient({ apiKey: 'private-test-key', baseUrl: 'https://api.example/', fetchData: async (url, options) => { calls.push({ url, options }); return { ok: true, json: async () => [{ id: 1, name: `City ${options.headers['X-City-Id']}`, routes_count: '2' }] }; } }); const city1 = { resource: 'stops', cityId: 1 }; const city2 = { resource: 'stops', cityId: 2 }; const [a, b] = await Promise.all([client.get(city1), client.get(city1)]); const c = await client.get(city2); assert.equal(calls.length, 2); assert.deepEqual(a, b); assert.notEqual(a[0].name, c[0].name); assert.equal(calls[0].options.headers['X-API-Key'], 'private-test-key'); assert.equal(calls[1].options.headers['X-City-Id'], '2'); assert.equal(JSON.stringify(a).includes('private-test-key'), false); }); test('missing configuration and upstream failures produce safe API errors', async () => { await assert.rejects(createRideBusClient({ apiKey: '' }).get({ resource: 'cities' }), error => error.status === 503); await assert.rejects(createRideBusClient({ apiKey: 'key', fetchData: async () => { throw new Error('private-key=secret'); } }).get({ resource: 'cities' }), error => error.status === 502 && !error.message.includes('secret')); }); function response() { return { headers: {}, status: 200, body: '', setHeader(k, v) { this.headers[k] = v; }, writeHead(code) { this.status = code; return this; }, end(body = '') { this.body = body; } }; } test('HTTP API refuses writes and hides internal exception details', async () => { const handler = apiHandler({ get: async () => { throw new Error('secret details'); } }); const write = response(); assert.equal(await handler({ method: 'POST', url: '/api/data?resource=cities' }, write), true); assert.equal(write.status, 405); const failure = response(); await handler({ method: 'GET', url: '/api/data?resource=cities' }, failure); assert.equal(failure.status, 502); assert.deepEqual(JSON.parse(failure.body), { error: 'data_unavailable' }); assert.equal(failure.headers['Cache-Control'], 'no-store'); assert.equal(await handler({ method: 'GET', url: '/' }, response()), false); });