import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createRideBusClient, parseRequest, RideBusError, } from '../lib/ridebus.ts'; const response = (data, status = 200) => new Response(JSON.stringify(data), { status }); const isStatus = (status) => (error) => error instanceof RideBusError && error.status === status; test('only accepts known resources and positive, single numeric city/stop IDs', () => { assert.deepEqual(parseRequest({}), { resource: 'cities' }); assert.deepEqual(parseRequest({ resource: 'stops', cityId: '2' }), { resource: 'stops', cityId: 2, }); assert.deepEqual(parseRequest({ resource: 'alerts', cityId: '3' }), { resource: 'alerts', cityId: 3, }); assert.deepEqual( parseRequest({ resource: 'departures', cityId: '2', stopId: '5' }), { resource: 'departures', cityId: 2, stopId: 5 } ); for (const query of [ { resource: 'https://example.com' }, { resource: ['cities'] }, { resource: 'stops', cityId: ['1', '2'] }, { resource: 'stops', cityId: '-1' }, { resource: 'stops', cityId: '1/../../auth' }, { resource: 'stops', cityId: '9007199254740992' }, { resource: 'departures', cityId: '1' }, { resource: 'alerts' }, { resource: 'alerts', cityId: ['1', '2'] }, ]) assert.throws(() => parseRequest(query), isStatus(400)); }); test('exposes public city artwork while rejecting arbitrary avatar URLs', async () => { const client = createRideBusClient({ baseUrl: 'https://api.ridebus.by/', apiKey: 'test-only-key', fetch: async () => response([ { id: 1, name: 'Полоцк', emblem: '🏛️', avatar_url: '/uploads/cities/city-1-123.webp', created_at: 'private', }, { id: 2, name: 'Other', avatar_url: 'https://other.example/image.png' }, { id: 3, name: 'Invalid', avatar_url: '/uploads/cities/../../private.png', }, { id: 4, name: 'SVG', avatar_url: '/uploads/cities/image.svg' }, ]), }); assert.deepEqual(await client.get({ resource: 'cities' }), [ { id: 1, name: 'Полоцк', emblem: '🏛️', avatarUrl: 'https://api.ridebus.by/uploads/cities/city-1-123.webp', }, { id: 2, name: 'Other' }, { id: 3, name: 'Invalid' }, { id: 4, name: 'SVG' }, ]); }); test('loads active alerts for the requested city without exposing notification fields', async () => { const clock = Date.parse('2026-10-02T12:00:00Z'); const base = { alert_type: 'info', title: ' Notice ', message: ' Public message ', start_time: '2026-09-25T12:00:00Z', end_time: null, is_active: true, route_number: null, route_name: null, notification_attempts: 3, notification_claimed_at: 'private', }; const client = createRideBusClient({ apiKey: 'test-only-key', now: () => clock, fetch: async (url, options) => { assert.equal(url.pathname, '/api/v1/alerts/active'); assert.equal(options.headers['X-City-Id'], '2'); assert.equal(options.headers['X-API-Key'], 'test-only-key'); return response([ { ...base, id: 1, alert_type: 'unknown' }, { ...base, id: 2, alert_type: 'detour', route_number: ' 21тк ', route_id: 19, route_name: ' Route ', start_time: '2026-10-01T12:00:00Z', created_at: '2026-09-30T12:00:00Z', }, { ...base, id: 3, end_time: '2026-10-01T12:00:00Z' }, { ...base, id: 4, is_active: false }, { ...base, id: 5, start_time: '2026-10-03T12:00:00Z' }, ]); }, }); const data = await client.get({ resource: 'alerts', cityId: 2 }); assert.deepEqual(data, { updatedAt: '2026-10-02T12:00:00.000Z', alerts: [ { id: 2, cityId: 2, type: 'detour', title: 'Notice', message: 'Public message', routeNumbers: ['21тк'], routeIds: [19], isCityWide: false, routeId: 19, routeNumber: '21тк', routeName: 'Route', startTime: '2026-10-01T12:00:00.000Z', endTime: null, createdAt: '2026-09-30T12:00:00.000Z', }, { id: 1, cityId: 2, type: 'info', title: 'Notice', message: 'Public message', routeNumbers: [], routeIds: [], isCityWide: true, routeId: null, routeNumber: null, routeName: null, startTime: '2026-09-25T12:00:00.000Z', endTime: null, createdAt: '2026-09-25T12:00:00.000Z', }, ], }); }); test('expires alerts within a cached response and keeps different cities separate', async () => { let clock = Date.parse('2026-10-02T12:00:00Z'); let calls = 0; const client = createRideBusClient({ apiKey: 'test-only-key', now: () => clock, fetch: async (url, options) => { calls++; return response( options.headers['X-City-Id'] === '1' ? [ { id: 1, title: 'Notice', message: 'Message', alert_type: 'delay', start_time: '2026-10-02T11:00:00Z', end_time: '2026-10-02T12:00:10Z', }, ] : [] ); }, }); const query = { resource: 'alerts', cityId: 1 }; const initial = await Promise.all([client.get(query), client.get(query)]); assert.equal(initial[0].alerts.length, 1); assert.equal(calls, 1); clock += 9999; assert.equal((await client.get(query)).alerts.length, 1); clock += 1; assert.equal((await client.get(query)).alerts.length, 0); assert.equal(calls, 1); assert.equal( (await client.get({ resource: 'alerts', cityId: 2 })).alerts.length, 0 ); assert.equal(calls, 2); clock += 60000; await client.get(query); assert.equal(calls, 3); }); test('requires a server credential without making an upstream request', async () => { const client = createRideBusClient({ apiKey: '', fetch: () => { throw new Error('Should not fetch'); }, }); await assert.rejects(client.get({ resource: 'cities' }), isStatus(503)); }); test('forwards the selected city and strips private fields from catalogues', async () => { const client = createRideBusClient({ apiKey: 'test-only-key', fetch: async (url, options) => { assert.equal(options.headers['X-API-Key'], 'test-only-key'); assert.equal(options.redirect, 'error'); if (url.pathname.endsWith('/cities')) { assert.equal(options.headers['X-City-Id'], undefined); return response([ { id: 2, name: ' City ', slug: 'city', created_at: 'private', internal: true, }, ]); } assert.equal(url.pathname, '/api/v1/stops'); assert.equal(options.headers['X-City-Id'], '2'); return response([ { id: 5, name: ' Stop ', description: ' toward Centre ', routes_count: '3', internal: true, }, { id: 6, name: 'Other stop', description: null, routes_count: 0 }, ]); }, }); assert.deepEqual(await client.get({ resource: 'cities' }), [ { id: 2, name: 'City', slug: 'city' }, ]); assert.deepEqual(await client.get({ resource: 'stops', cityId: 2 }), [ { id: 5, name: 'Stop', direction: 'toward Centre', routesCount: 3 }, { id: 6, name: 'Other stop', direction: '', routesCount: 0 }, ]); }); test('deduplicates requests, caches by city/stop, and refreshes expired departures', async () => { let calls = 0; let clock = 0; const client = createRideBusClient({ apiKey: 'test-only-key', now: () => clock, fetch: async () => { calls++; return response({ routes: [] }); }, }); const query = { resource: 'departures', cityId: 1, stopId: 5 }; await Promise.all([client.get(query), client.get(query)]); await client.get(query); assert.equal(calls, 1); await client.get({ ...query, cityId: 2 }); assert.equal(calls, 2); clock = 30001; await client.get(query); assert.equal(calls, 3); }); test('shows schedule times, removes historical delay, sorts and limits departures', async () => { const client = createRideBusClient({ apiKey: 'test-only-key', now: () => 0, fetch: async () => response({ routes: [ { id: 1, route_number: '1', name: 'Centre', arrivals: [ { scheduledTime: '9:05:00', minutesUntil: 7, delay: 7 }, { scheduledTime: '09:15', minutesUntil: 15 }, { scheduledTime: '09:25', minutesUntil: 25 }, ], }, { id: 2, route_number: '2', name: 'Station', arrivals: [ { scheduledTime: '09:01', minutesUntil: 1 }, { scheduledTime: '09:02', minutesUntil: 2 }, { scheduledTime: '09:03', minutesUntil: 3 }, { scheduledTime: '09:04', minutesUntil: 4 }, ], }, ], }), }); const data = await client.get({ resource: 'departures', cityId: 1, stopId: 5, }); assert.equal(data.departures.length, 4); assert.deepEqual( data.departures.map((item) => item.minutesUntil), [0, 1, 2, 3] ); assert.equal(data.departures[0].scheduledTime, '09:05'); assert.equal(data.updatedAt, '1970-01-01T00:00:00.000Z'); assert.equal(data.partial, false); }); test('keeps successful routes when another route fails or has invalid arrivals', async () => { const client = createRideBusClient({ apiKey: 'test-only-key', fetch: async () => response({ routes: [ { id: 1, route_number: '1', name: 'Centre', arrivals: [ { scheduledTime: '09:05', minutesUntil: 5 }, { scheduledTime: '25:05', minutesUntil: 7 }, { scheduledTime: '09:10', minutesUntil: null }, { scheduledTime: '09:11', minutesUntil: 2, delay: 5 }, ], }, { id: 2, error: 'Internal upstream failure' }, ], }), }); const data = await client.get({ resource: 'departures', cityId: 1, stopId: 5, }); assert.equal(data.departures.length, 1); assert.equal(data.partial, true); }); test('does not cache upstream errors and normalizes invalid responses', async () => { let calls = 0; const client = createRideBusClient({ apiKey: 'test-only-key', fetch: async () => { calls++; return calls === 1 ? response({ secret: 'upstream details' }, 401) : response([]); }, }); await assert.rejects(client.get({ resource: 'cities' }), isStatus(502)); assert.deepEqual(await client.get({ resource: 'cities' }), []); assert.equal(calls, 2); const invalid = createRideBusClient({ apiKey: 'test-only-key', fetch: async () => response({ invalid: true }), }); await assert.rejects(invalid.get({ resource: 'cities' }), isStatus(502)); });