355 lines
11 KiB
JavaScript
355 lines
11 KiB
JavaScript
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_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,
|
|
type: 'detour',
|
|
title: 'Notice',
|
|
message: 'Public message',
|
|
routeNumber: '21тк',
|
|
routeName: 'Route',
|
|
startTime: '2026-10-01T12:00:00.000Z',
|
|
endTime: null,
|
|
createdAt: '2026-09-30T12:00:00.000Z',
|
|
},
|
|
{
|
|
id: 1,
|
|
type: 'info',
|
|
title: 'Notice',
|
|
message: 'Public message',
|
|
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 += 11000;
|
|
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: 2 },
|
|
{ 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, 6);
|
|
assert.deepEqual(
|
|
data.departures.map((item) => item.minutesUntil),
|
|
[1, 2, 3, 4, 5, 15]
|
|
);
|
|
assert.equal(data.departures[4].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));
|
|
});
|