74 lines
4.3 KiB
JavaScript
74 lines
4.3 KiB
JavaScript
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);
|
|
});
|