import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import ts from 'typescript'; import { createRideBusClient, RideBusError } from '../lib/ridebus.ts'; import { formatAlertRouteNumbers, getActiveAlerts, isAlertActive, watchAlertValidity, } from '../lib/serviceAlerts.ts'; const now = Date.parse('2026-10-05T09:00:00Z'); const notice = { id: 42, city_id: 1, alert_type: 'cancellation', title: 'Изменение рейсов', message: 'Отменены отправления:\n№2: Вокзал — 12:10.\n№27: Больница — 13:25.', start_time: '2026-10-05T08:00:00Z', end_time: '2026-10-05T10:00:00Z', route_numbers: ['2', '27'], route_ids: [1, 2, 3, 4], route_id: null, route_number: null, }; const json = (data) => new Response(JSON.stringify(data)); const query = { resource: 'alerts', cityId: 1 }; function clientFor(alerts) { return createRideBusClient({ apiKey: 'test-only-key', now: () => now, fetch: async () => json(alerts), }); } test('keeps several numbers and all direction IDs in one notice, preserving cancellation details', async () => { const { alerts } = await clientFor([notice]).get(query); assert.equal(alerts.length, 1); assert.deepEqual(alerts[0].routeNumbers, ['2', '27']); assert.deepEqual(alerts[0].routeIds, [1, 2, 3, 4]); assert.equal(alerts[0].cityId, 1); assert.equal(alerts[0].isCityWide, false); assert.equal(alerts[0].routeId, null); assert.equal(alerts[0].routeNumber, null); assert.equal(alerts[0].message, notice.message); assert.equal(formatAlertRouteNumbers(alerts[0]), '№2 · №27'); }); test('normalizes old single fields and distinguishes general notices from ID-only and number-only notices', async () => { const { route_numbers, route_ids, ...old } = notice; const { alerts } = await clientFor([ { ...old, id: 1, route_id: 5, route_number: ' 21тк ', route_name: 'Centre', }, { ...notice, id: 2, route_numbers: [], route_ids: [] }, { ...old, id: 3, route_id: 6 }, { ...notice, id: 4, route_numbers: [], route_ids: [7, 8] }, { ...notice, id: 5, route_numbers: ['27'], route_ids: [] }, { ...old, id: 6 }, // Empty new fields cannot erase a still-present old association. { ...notice, id: 7, route_numbers: [], route_ids: [], route_id: 9, route_number: '2', }, ]).get(query); assert.deepEqual( alerts.map(({ routeNumbers, routeIds, isCityWide }) => ({ routeNumbers, routeIds, isCityWide, })), [ { routeNumbers: ['21тк'], routeIds: [5], isCityWide: false }, { routeNumbers: [], routeIds: [], isCityWide: true }, { routeNumbers: [], routeIds: [6], isCityWide: false }, { routeNumbers: [], routeIds: [7, 8], isCityWide: false }, { routeNumbers: ['27'], routeIds: [], isCityWide: false }, { routeNumbers: [], routeIds: [], isCityWide: true }, { routeNumbers: ['2'], routeIds: [9], isCityWide: false }, ] ); assert.equal(alerts[0].routeName, 'Centre'); }); test('deduplicates repeated numbers, IDs and notice rows without multiplying by direction', async () => { const duplicate = { ...notice, route_numbers: ['2', ' 2 ', '27', '27'], route_ids: [1, 2, 2, 3, 4], }; const { alerts } = await clientFor([ duplicate, duplicate, { ...notice, id: 43 }, ]).get(query); assert.equal(alerts.length, 2); assert.deepEqual( alerts.map((alert) => alert.id), [42, 43] ); assert.deepEqual(alerts[0].routeNumbers, ['2', '27']); assert.deepEqual(alerts[0].routeIds, [1, 2, 3, 4]); }); test('isolates city caches and ignores another city even when route numbers and notice IDs coincide', async () => { const calls = []; const client = createRideBusClient({ apiKey: 'test-only-key', now: () => now, fetch: async (url, options) => { const cityId = Number(options.headers['X-City-Id']); calls.push(cityId); return json([ { ...notice, city_id: cityId, route_ids: [cityId * 10], message: `City ${cityId}`, }, { ...notice, id: 999, city_id: cityId === 1 ? 2 : 1 }, ]); }, }); const first = await client.get(query); const second = await client.get({ ...query, cityId: 2 }); const back = await client.get(query); assert.deepEqual(calls, [1, 2]); assert.equal(first.alerts.length, 1); assert.equal(second.alerts.length, 1); assert.equal(first.alerts[0].message, 'City 1'); assert.equal(second.alerts[0].message, 'City 2'); assert.deepEqual(back, first); assert.deepEqual(getActiveAlerts(first.alerts, 2, now), []); assert.deepEqual(getActiveAlerts(second.alerts, 1, now), []); }); test('uses inclusive start and exclusive end, including future notices inside the server cache', async () => { let clock = now - 1; let calls = 0; const client = createRideBusClient({ apiKey: 'test-only-key', now: () => clock, fetch: async () => { calls++; return json([ { ...notice, start_time: new Date(now).toISOString(), end_time: new Date(now + 1000).toISOString(), }, ]); }, }); assert.equal((await client.get(query)).alerts.length, 0); clock = now; const { alerts } = await client.get(query); assert.equal(alerts.length, 1); assert.equal(isAlertActive(alerts[0], now - 1), false); assert.equal(isAlertActive(alerts[0], now), true); assert.equal(isAlertActive(alerts[0], now + 999), true); assert.equal(isAlertActive(alerts[0], now + 1000), false); clock += 999; assert.equal((await client.get(query)).alerts.length, 1); clock++; assert.equal((await client.get(query)).alerts.length, 0); assert.equal(calls, 1); }); test('removes expired notices on the open page without another request and cancels timers on city change', async (t) => { const { alerts } = await clientFor([notice]).get(query); const alert = { ...alerts[0], startTime: new Date(now + 100).toISOString(), endTime: new Date(now + 1000).toISOString(), }; t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now }); const visible = []; const stop = watchAlertValidity([alert], () => { visible.push(getActiveAlerts([alert], 1, Date.now()).length); }); t.mock.timers.tick(0); assert.deepEqual(visible, [0]); t.mock.timers.tick(100); assert.deepEqual(visible, [0, 1]); t.mock.timers.tick(899); assert.deepEqual(visible, [0, 1]); t.mock.timers.tick(1); assert.deepEqual(visible, [0, 1, 0]); stop(); const changed = t.mock.fn(); const cancel = watchAlertValidity( [{ ...alert, endTime: new Date(now + 5000).toISOString() }], changed ); t.mock.timers.tick(0); assert.equal(changed.mock.callCount(), 1); cancel(); t.mock.timers.tick(30000); assert.equal(changed.mock.callCount(), 1); }); test('reschedules capped long timers instead of expiring distant notices immediately', async (t) => { const { alerts } = await clientFor([notice]).get(query); const duration = 2147483647 + 1000; const alert = { ...alerts[0], endTime: new Date(now + duration).toISOString(), }; t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now }); const visible = []; const stop = watchAlertValidity([alert], () => visible.push(isAlertActive(alert, Date.now())) ); t.mock.timers.tick(0); t.mock.timers.tick(2147483647); assert.deepEqual(visible, [true, true]); t.mock.timers.tick(1000); assert.deepEqual(visible, [true, true, false]); stop(); }); // Exercise the real Next handler with Node's test runner; erase Next's type-only // imports and resolve the project alias without adding another test framework. const source = await readFile( new URL('../pages/api/departures.ts', import.meta.url), 'utf8' ); const compiled = ts .transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022, }, }) .outputText.replace( '@/lib/ridebus', new URL('../lib/ridebus.ts', import.meta.url).href ); const { createDeparturesHandler } = await import( `data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}` ); function apiResponse() { return { headers: {}, statusCode: 0, body: null, setHeader(name, value) { this.headers[name] = value; }, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, }; } test('API proxy passes normalized lists and uses no-store without leaking server credentials', async () => { const res = apiResponse(); await createDeparturesHandler(clientFor([notice]))( { method: 'GET', query: { resource: 'alerts', cityId: '1' } }, res ); assert.equal(res.statusCode, 200); assert.equal(res.headers['Cache-Control'], 'no-store'); assert.deepEqual(res.body.alerts[0].routeNumbers, ['2', '27']); assert.deepEqual(res.body.alerts[0].routeIds, [1, 2, 3, 4]); assert.equal(res.body.alerts.length, 1); assert.equal(JSON.stringify(res.body).includes('test-only-key'), false); }); test('API proxy rejects missing cities and non-GET methods and sanitizes upstream errors', async () => { const handler = createDeparturesHandler({ get: async () => { throw new RideBusError(502); }, }); const missingCity = apiResponse(); await handler({ method: 'GET', query: { resource: 'alerts' } }, missingCity); assert.equal(missingCity.statusCode, 400); const wrongMethod = apiResponse(); await handler({ method: 'POST', query: {} }, wrongMethod); assert.equal(wrongMethod.statusCode, 405); assert.equal(wrongMethod.headers.Allow, 'GET'); const failed = apiResponse(); await handler( { method: 'GET', query: { resource: 'alerts', cityId: '1' } }, failed ); assert.equal(failed.statusCode, 502); assert.deepEqual(failed.body, { error: 'departures_unavailable' }); });