Files
ridebus-website/tests/ridebus.test.mjs
2026-10-02 20:31:49 +03:00

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));
});