initial commit
This commit is contained in:
44
test/api.test.mjs
Normal file
44
test/api.test.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp } from '../server/index.mjs';
|
||||
import { ApiError } from '../server/board.mjs';
|
||||
|
||||
test('board HTTP API validates inputs, rejects writes, and hides upstream errors', async t => {
|
||||
const client = { config: () => ({ configured: true, cityId: 1 }), stops: async () => [{ id: 1, name: 'Швейная фабрика' }],
|
||||
timetable: async id => ({ stop: { id }, routes: [] }), coordinates: async () => ({ latitude: 55, longitude: 28 }),
|
||||
board: async id => { if (id === 2) throw new Error('secret-api-key and upstream response'); if (id === 3) throw new ApiError(404, 'stop_not_found'); return { stop: { id }, departures: [] }; } };
|
||||
const weatherClient = { weather: async (cityId, stopId) => ({ cityId, stopId, temperature: 12 }) };
|
||||
const server = createApp(client, weatherClient).listen(0, '127.0.0.1');
|
||||
await new Promise((resolve, reject) => { server.once('listening', resolve); server.once('error', reject); });
|
||||
t.after(() => new Promise(resolve => server.close(resolve)));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await t.test('successful GET returns no-store and server time', async () => {
|
||||
const response = await fetch(base + '/api/board?stopId=1');
|
||||
assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
const body = await response.json(); assert.equal(body.stop.id, 1); assert.ok(Number.isFinite(Date.parse(body.serverTime)));
|
||||
});
|
||||
await t.test('invalid and missing IDs return 400', async () => {
|
||||
for (const endpoint of ['board', 'timetable', 'weather']) for (const query of ['', '?stopId=0', '?stopId=-1', '?stopId=abc', '?stopId=1&stopId=2']) assert.equal((await fetch(base + '/api/' + endpoint + query)).status, 400);
|
||||
});
|
||||
await t.test('full timetable and weather use only the selected platform and no-store', async () => {
|
||||
const timetable = await fetch(base + '/api/timetable?stopId=2');
|
||||
assert.equal(timetable.headers.get('cache-control'), 'no-store'); assert.equal((await timetable.json()).stop.id, 2);
|
||||
assert.deepEqual(await (await fetch(base + '/api/weather?stopId=2')).json(), { cityId: 1, stopId: 2, temperature: 12 });
|
||||
});
|
||||
await t.test('writes are never forwarded', async () => {
|
||||
const response = await fetch(base + '/api/board?stopId=1', { method: 'POST' });
|
||||
assert.equal(response.status, 405); assert.equal(response.headers.get('allow'), 'GET');
|
||||
});
|
||||
await t.test('raw upstream errors and credentials never reach the browser', async () => {
|
||||
const response = await fetch(base + '/api/board?stopId=2');
|
||||
assert.equal(response.status, 502); assert.deepEqual(await response.json(), { error: 'data_unavailable' });
|
||||
});
|
||||
await t.test('not-found and unknown endpoints keep their status', async () => {
|
||||
assert.equal((await fetch(base + '/api/board?stopId=3')).status, 404);
|
||||
assert.equal((await fetch(base + '/api/unrestricted-proxy')).status, 404);
|
||||
});
|
||||
await t.test('config contains only public settings', async () => {
|
||||
assert.deepEqual(await (await fetch(base + '/api/config')).json(), { configured: true, cityId: 1 });
|
||||
assert.equal((await fetch(base + '/healthz')).status, 200);
|
||||
});
|
||||
});
|
||||
126
test/board.test.mjs
Normal file
126
test/board.test.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ApiError, createBoardClient, normalizeAlerts, normalizeDepartures, scheduledInstant } from '../server/board.mjs';
|
||||
import { activeAlerts, isBoardData, isFresh, minutesUntil, shouldHoldScene, upcomingDepartures } from '../src/lib/board.mjs';
|
||||
|
||||
const now = Date.parse('2026-10-08T12:30:40+03:00');
|
||||
const routes = [{ id: 37, number: '5', name: 'Полоцк — Новополоцк', type: 'bus' }];
|
||||
const raw = { routes: [{ id: 37, route_number: '5', name: 'Полоцк — Новополоцк', arrivals: [
|
||||
{ scheduledTime: '12:35:00', estimatedTime: '12:40:00', delay: 5, minutesUntil: 10, trip: { note: 'Через Экимань' } },
|
||||
{ scheduledTime: '12:25:00', estimatedTime: '12:40:00', delay: 15, minutesUntil: 10 },
|
||||
] }] };
|
||||
const makeSnapshot = () => ({ cityId: 1, stop: { id: 1 }, mode: 'live', serviceDate: '2026-10-08', updatedAt: new Date(now).toISOString(),
|
||||
...normalizeDepartures(raw, now), alerts: [] });
|
||||
|
||||
test('schedule display uses exact Minsk wall-clock time, excluding historical delay', () => {
|
||||
const result = normalizeDepartures(raw, now);
|
||||
assert.equal(result.departures.length, 1);
|
||||
assert.equal(result.departures[0].scheduledAt, '2026-10-08T12:35:00+03:00');
|
||||
assert.equal(result.departures[0].destination, 'Новополоцк');
|
||||
assert.equal(result.departures[0].note, 'Через Экимань');
|
||||
assert.equal(minutesUntil(result.departures[0], now), 5);
|
||||
});
|
||||
test('invalid clock values do not create false arrivals', () => {
|
||||
assert.equal(scheduledInstant('25:01', now), null);
|
||||
assert.equal(scheduledInstant('12:99', now), null);
|
||||
assert.equal(scheduledInstant(null, now), null);
|
||||
assert.equal(scheduledInstant('9:01', now), '2026-10-08T09:01:00+03:00');
|
||||
});
|
||||
test('partial route failures preserve working routes and label incomplete data', () => {
|
||||
const result = normalizeDepartures({ routes: [...raw.routes, { id: 3, route_number: '6', name: 'А — Б', error: 'Failed' }] }, now);
|
||||
assert.equal(result.partial, true);
|
||||
assert.equal(result.departures.length, 1);
|
||||
});
|
||||
test('malformed ETA response is not reported as an empty timetable', () => {
|
||||
assert.throws(() => normalizeDepartures({}, now), ApiError);
|
||||
});
|
||||
test('partial trips use the actual terminal stop when available', () => {
|
||||
const result = normalizeDepartures({ routes: [{ ...raw.routes[0], arrivals: [{ scheduledTime: '12:35', trip: { endStopId: 99 } }] }] }, now, new Map([[99, 'Автовокзал']]));
|
||||
assert.equal(result.departures[0].destination, 'Автовокзал');
|
||||
});
|
||||
test('alerts match arrays before legacy scalar fields; unrelated and foreign-city alerts are excluded', () => {
|
||||
const alerts = [
|
||||
{ id: 1, route_id: null, route_ids: [37], route_numbers: ['5', '27'], title: 'Изменения', message: 'Полный текст', city_id: 1 },
|
||||
{ id: 2, route_id: null, route_ids: [88], route_numbers: ['88'], city_id: 1 },
|
||||
{ id: 3, route_id: 37, city_id: 2 },
|
||||
{ id: 4, route_id: 37, city_id: 1 },
|
||||
{ id: 5, route_id: null, route_numbers: [], city_id: 1 },
|
||||
];
|
||||
const result = normalizeAlerts(alerts, routes, 1, now);
|
||||
assert.deepEqual(result.map(alert => alert.id), ['1', '4', '5']);
|
||||
assert.deepEqual(result[0].routeNumbers, ['5', '27']);
|
||||
assert.equal(result[0].message, 'Полный текст');
|
||||
});
|
||||
test('expired, future and duplicate warnings never rotate onto the display', () => {
|
||||
const result = normalizeAlerts([
|
||||
{ id: 1, end_time: '2026-10-08T12:00:00+03:00' },
|
||||
{ id: 2, start_time: '2026-10-08T13:00:00+03:00' },
|
||||
{ id: 3, title: 'Актуальное' }, { id: 3, title: 'Актуальное' },
|
||||
{ id: 4, start_time: 'invalid' },
|
||||
], routes, 1, now);
|
||||
assert.deepEqual(result.map(alert => alert.id), ['3']);
|
||||
assert.equal(activeAlerts([{ ...result[0], endAt: new Date(now + 1000).toISOString() }], now + 1001).length, 0);
|
||||
});
|
||||
test('cached arrivals expire instead of counting down forever or rolling into tomorrow', () => {
|
||||
const snapshot = makeSnapshot();
|
||||
assert.equal(upcomingDepartures(snapshot, now).length, 1);
|
||||
assert.equal(upcomingDepartures(snapshot, Date.parse('2026-10-08T12:36:00+03:00')).length, 0);
|
||||
assert.equal(upcomingDepartures(snapshot, Date.parse('2026-10-09T00:01:00+03:00')).length, 0);
|
||||
});
|
||||
test('offline and old snapshots never report fresh data', () => {
|
||||
const data = makeSnapshot();
|
||||
assert.equal(isFresh(data, now, false), true);
|
||||
assert.equal(isFresh(data, now, true), false);
|
||||
assert.equal(isFresh(data, now + 90001, false), false);
|
||||
});
|
||||
test('number-only warnings respect the selected route while citywide warnings remain visible', () => {
|
||||
const common = { startAt: null, endAt: null, routeIds: [] };
|
||||
const alerts = [{ ...common, id: '1', routeNumbers: ['5'] }, { ...common, id: '2', routeNumbers: ['88'] }, { ...common, id: '3', routeNumbers: [] }];
|
||||
assert.deepEqual(activeAlerts(alerts, now, '37', routes).map(alert => alert.id), ['1', '3']);
|
||||
});
|
||||
test('city and platform identity are checked before restoring a snapshot', () => {
|
||||
const data = makeSnapshot();
|
||||
assert.equal(isBoardData(data, 1, 1), true);
|
||||
assert.equal(isBoardData(data, 2, 1), false);
|
||||
assert.equal(isBoardData(data, 1, 2), false);
|
||||
assert.equal(isBoardData({ ...data, mode: 'demo' }, 1, 1), false);
|
||||
});
|
||||
test('route filtering and transport priority use absolute arrival time', () => {
|
||||
const data = makeSnapshot();
|
||||
assert.equal(upcomingDepartures(data, now, '88').length, 0);
|
||||
assert.equal(shouldHoldScene({ holdNear: true }, data.departures[0], now), false);
|
||||
assert.equal(shouldHoldScene({ holdNear: true }, data.departures[0], now + 180000), true);
|
||||
assert.equal(shouldHoldScene({ holdNear: false }, data.departures[0], now + 180000), false);
|
||||
});
|
||||
|
||||
function fakeClient(options = {}) {
|
||||
const calls = [];
|
||||
const data = { 'api/v1/stops': [{ id: 1, name: 'Швейная фабрика', description: 'основная', city_id: 1 }],
|
||||
'api/v1/stops/1': { id: 1, name: 'Швейная фабрика', city_id: 1, routes: [{ id: 37, route_number: '5', name: 'Полоцк — Новополоцк' }] },
|
||||
'api/v1/eta/stop/1': raw, 'api/v1/alerts/active': [] };
|
||||
const client = createBoardClient({ apiKey: 'server-only-test-key', baseUrl: 'https://api.test/', now: () => now,
|
||||
fetchData: async (url, init) => {
|
||||
calls.push({ path: url.pathname, headers: init.headers });
|
||||
if (options.alertsFail && url.pathname.endsWith('/alerts/active')) return new Response('{}', { status: 500 });
|
||||
return new Response(JSON.stringify(data[url.pathname.slice(1)]), { headers: { 'Content-Type': 'application/json' } });
|
||||
} });
|
||||
return { client, calls };
|
||||
}
|
||||
test('concurrent board requests are coalesced; secrets stay server-side and city is passed upstream', async () => {
|
||||
const { client, calls } = fakeClient();
|
||||
const [first, second] = await Promise.all([client.board(1), client.board(1)]);
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(calls.filter(call => call.path === '/api/v1/eta/stop/1').length, 1);
|
||||
assert.ok(calls.every(call => call.headers['X-City-Id'] === '1'));
|
||||
assert.ok(!JSON.stringify(first).includes('server-only-test-key'));
|
||||
});
|
||||
test('alert API failure does not remove arrivals or claim no warnings', async () => {
|
||||
const { client } = fakeClient({ alertsFail: true });
|
||||
const data = await client.board(1);
|
||||
assert.equal(data.departures.length, 1);
|
||||
assert.equal(data.alertsAvailable, false);
|
||||
});
|
||||
test('missing configuration and unknown stops produce explicit errors', async () => {
|
||||
await assert.rejects(createBoardClient({ apiKey: '' }).stops(), error => error.status === 503 && error.code === 'api_not_configured');
|
||||
await assert.rejects(fakeClient().client.board(999), error => error.status === 404);
|
||||
});
|
||||
34
test/presentation.test.mjs
Normal file
34
test/presentation.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { colorScheme, colorSchemes, departurePage, numericFrame, stopDirection } from '../src/lib/presentation.mjs';
|
||||
|
||||
test('all accent schemes remain legible against black and with black route numbers', () => {
|
||||
for (const scheme of colorSchemes) {
|
||||
const rgb = scheme.color.slice(1).match(/../g).map(value => parseInt(value, 16) / 255);
|
||||
const channels = rgb.map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
|
||||
const luminance = .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2];
|
||||
assert.ok((luminance + .05) / .05 >= 7, scheme.id);
|
||||
}
|
||||
assert.equal(colorScheme('unknown').id, 'lime');
|
||||
});
|
||||
test('clock carry rolls changed positions while keeping separators and unchanged digits still', () => {
|
||||
const frame = numericFrame('09:59', '10:00', 959, 1000);
|
||||
assert.equal(frame.direction, 'up');
|
||||
assert.deepEqual(frame.glyphs.filter(glyph => glyph.changed).map(glyph => glyph.position), [4, 3, 1, 0]);
|
||||
assert.equal(frame.glyphs.find(glyph => glyph.character === ':').changed, false);
|
||||
assert.equal(numericFrame('12', '11', 12, 11).direction, 'down');
|
||||
assert.equal(numericFrame('10', '9', 10, 9).glyphs.at(-1).character, '9');
|
||||
});
|
||||
test('six departures fit in one page when space permits; short screens preserve all requested departures across balanced pages', () => {
|
||||
const items = Array.from({ length: 8 }, (_, id) => ({ id }));
|
||||
const full = departurePage(items, 6, 6, 0);
|
||||
assert.equal(full.items.length, 6); assert.equal(full.pages, 1);
|
||||
const first = departurePage(items, 6, 4, 0), second = departurePage(items, 6, 4, 1);
|
||||
assert.deepEqual([...first.items, ...second.items].map(item => item.id), [0, 1, 2, 3, 4, 5]);
|
||||
assert.equal(first.items.length, second.items.length);
|
||||
});
|
||||
test('direction comes from platform metadata without inventing one for the main platform', () => {
|
||||
assert.equal(stopDirection({ direction: 'в Новополоцк' }), 'В Новополоцк');
|
||||
assert.equal(stopDirection({ direction: 'основная' }), '');
|
||||
assert.equal(stopDirection({ direction: '' }), '');
|
||||
});
|
||||
32
test/storage.test.mjs
Normal file
32
test/storage.test.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readSnapshot, writeSnapshot, readTimetable, writeTimetable, readWeather, writeWeather } from '../src/lib/storage.ts';
|
||||
|
||||
const data = { cityId: 1, stop: { id: 1, name: 'Швейная фабрика' }, mode: 'live', serviceDate: '2026-10-08', updatedAt: '2026-10-08T12:00:00+03:00', routes: [], alerts: [],
|
||||
departures: [{ id: '1:12:30', scheduledAt: '2026-10-08T12:30:00+03:00' }] };
|
||||
|
||||
test('offline snapshot is restored when IndexedDB is unavailable in a kiosk WebView', async () => {
|
||||
const values = new Map();
|
||||
globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
|
||||
globalThis.indexedDB = { open: () => { throw new Error('IndexedDB unavailable'); } };
|
||||
await writeSnapshot(data);
|
||||
assert.deepEqual(await readSnapshot(1, 1), data);
|
||||
assert.equal(await readSnapshot(2, 1), null);
|
||||
assert.equal(await readSnapshot(1, 2), null);
|
||||
});
|
||||
test('damaged saved data is rejected, and total storage failure is reported to the UI', async () => {
|
||||
globalThis.localStorage = { getItem: () => '{invalid', setItem: () => { throw new Error('Quota exceeded'); } };
|
||||
globalThis.indexedDB = { open: () => { throw new Error('IndexedDB unavailable'); } };
|
||||
assert.equal(await readSnapshot(1, 1), null);
|
||||
await assert.rejects(writeSnapshot(data));
|
||||
});
|
||||
test('full timetable and weather survive storage fallback and stay isolated by city and stop', async () => {
|
||||
const values = new Map();
|
||||
globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
|
||||
globalThis.indexedDB = { open: () => { throw new Error('IndexedDB unavailable'); } };
|
||||
const timetable = { version: 1, cityId: 1, cityName: 'Полоцк', stop: { id: 1, name: 'Швейная фабрика', direction: '' }, routes: [], holidays: [], calendarYears: [2026], updatedAt: data.updatedAt, expiresAt: '2026-10-15T12:00:00+03:00' };
|
||||
const weather = { cityId: 1, stopId: 1, updatedAt: data.updatedAt, observedAt: data.updatedAt, temperature: 12, apparentTemperature: 10, code: 3, isDay: true, windSpeed: 3, hourly: [] };
|
||||
await writeTimetable(timetable); await writeWeather(weather); await writeSnapshot(data);
|
||||
assert.deepEqual(await readTimetable(1, 1), timetable); assert.deepEqual(await readWeather(1, 1), weather); assert.deepEqual(await readSnapshot(1, 1), data);
|
||||
assert.equal(await readTimetable(1, 2), null); assert.equal(await readWeather(2, 1), null);
|
||||
});
|
||||
64
test/timetable.test.mjs
Normal file
64
test/timetable.test.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadTimetable } from '../server/timetable.mjs';
|
||||
import { isTimetable, localDate, timetableAvailable, timetableDepartures } from '../src/lib/timetable.mjs';
|
||||
import { passengerLink } from '../src/lib/passenger.mjs';
|
||||
|
||||
const now = Date.parse('2026-10-08T12:00:00+03:00');
|
||||
const route = { id: 37, route_number: '5', name: 'А — В', type: 'bus' };
|
||||
const stops = [{ stop_id: 1, sequence: 1, time_offset_minutes: 0, name: 'А' }, { stop_id: 2, sequence: 2, time_offset_minutes: 15, name: 'Б' }, { stop_id: 3, sequence: 3, time_offset_minutes: 30, name: 'В' }];
|
||||
const schedule = (day_type, trips, extra = {}) => ({ route_id: 37, day_type, trips, ...extra });
|
||||
async function bundle(schedules, options = {}) {
|
||||
return loadTimetable({ cityId: 1, cityName: 'Полоцк', stop: { id: 2, name: 'Б', routes: [route] }, now,
|
||||
upstream: async path => path.includes('holidays') ? options.holidays || [] : path.endsWith('/stops') ? stops : schedules });
|
||||
}
|
||||
test('full cache contains all trips and regenerates Friday and Saturday without network', async () => {
|
||||
const data = await bundle([schedule('weekday', ['08:00', '12:00', '15:00', '18:00', '22:00'].map(departureTime => ({ departureTime }))), schedule('saturday', [{ departureTime: '09:00' }])]);
|
||||
assert.equal(isTimetable(data, 1, 2), true);
|
||||
assert.equal(isTimetable(data, 1, 1), false);
|
||||
assert.equal(isTimetable({ ...data, routes: [{ ...data.routes[0], schedules: [{ dayType: 'weekday', trips: [{ arrivalSeconds: NaN }] }] }] }, 1, 2), false);
|
||||
const departures = timetableDepartures(data, '2026-10-09');
|
||||
assert.equal(departures.filter(item => localDate(Date.parse(item.scheduledAt)) === '2026-10-09').length, 5);
|
||||
assert.equal(departures.find(item => localDate(Date.parse(item.scheduledAt)) === '2026-10-10').scheduledTime, '09:15');
|
||||
assert.equal(timetableAvailable(data, now + 86400000), true);
|
||||
assert.equal(timetableAvailable(data, now + 7 * 86400000), false);
|
||||
});
|
||||
test('holiday timetable wins; missing holiday variant falls back to Sunday', async () => {
|
||||
const holidays = [{ date: '2026-10-09T00:00:00.000Z' }];
|
||||
const schedules = [schedule('weekday', [{ departureTime: '08:00' }]), schedule('sunday', [{ departureTime: '09:00' }])];
|
||||
const fallback = timetableDepartures(await bundle(schedules, { holidays }), '2026-10-09');
|
||||
assert.equal(fallback[0].scheduledTime, '09:15');
|
||||
const dedicated = timetableDepartures(await bundle([...schedules, schedule('holiday', [{ departureTime: '10:00' }])], { holidays }), '2026-10-09');
|
||||
assert.equal(dedicated[0].scheduledTime, '10:15');
|
||||
});
|
||||
test('trip restrictions and schedule validity apply to the actual service date', async () => {
|
||||
const data = await bundle([schedule('weekday', [{ departureTime: '08:00', daysOfWeek: [4] }, { departureTime: '09:00', exceptDaysOfWeek: [4] }], { valid_until: '2026-10-09' })]);
|
||||
assert.equal(timetableDepartures(data, '2026-10-08')[0].scheduledTime, '08:15');
|
||||
assert.equal(timetableDepartures(data, '2026-10-09')[0].scheduledTime, '09:15');
|
||||
assert.equal(timetableDepartures(data, '2026-10-12').length, 0);
|
||||
});
|
||||
test('previous service day retains after-midnight arrivals, with no time wrapping', async () => {
|
||||
const data = await bundle([schedule('weekday', [{ departureTime: '23:55', daysOfWeek: [4] }])]);
|
||||
const departures = timetableDepartures(data, '2026-10-09');
|
||||
assert.equal(departures.length, 1);
|
||||
assert.equal(departures[0].scheduledTime, '00:10');
|
||||
assert.equal(departures[0].scheduledAt, '2026-10-08T21:10:00.000Z');
|
||||
});
|
||||
test('partial and reverse trips use the start offset and correct terminal', async () => {
|
||||
const data = await bundle([schedule('weekday', [{ departureTime: '12:00', startStopId: 3, endStopId: 1, note: 'Обратно' }, { departureTime: '13:00', startStopId: 3, endStopId: 3 }])]);
|
||||
const departures = timetableDepartures(data, '2026-10-08').filter(item => localDate(Date.parse(item.scheduledAt)) === '2026-10-08');
|
||||
assert.equal(departures.length, 1); assert.equal(departures[0].scheduledTime, '12:15'); assert.equal(departures[0].destination, 'А');
|
||||
});
|
||||
test('failed calendar, route data, or malformed trips cannot publish a misleading complete cache', async () => {
|
||||
await assert.rejects(bundle([schedule('weekday', [{ departureTime: '12:00', startStopId: 999 }])]));
|
||||
await assert.rejects(bundle([schedule('weekday', [{ departureTime: '25:00' }])]));
|
||||
await assert.rejects(bundle([], { holidays: {} }));
|
||||
await assert.rejects(loadTimetable({ cityId: 1, cityName: 'Полоцк', stop: { id: 2, name: 'Б', routes: [route] }, now, upstream: async path => {
|
||||
if (path.endsWith('/schedules')) throw new Error('Upstream unavailable'); return path.includes('holidays') ? [] : stops;
|
||||
} }));
|
||||
});
|
||||
test('QR link identifies the exact city and platform, strips credentials and old query state', () => {
|
||||
assert.equal(passengerLink('https://pwa.ridebus.by/?demo=1', 1, 107), 'https://pwa.ridebus.by/?city=1&stop=107');
|
||||
assert.equal(passengerLink('javascript:alert(1)', 1, 1), 'https://pwa.ridebus.by/?city=1&stop=1');
|
||||
assert.equal(passengerLink('https://user:password@pwa.ridebus.by/#old', 2, 88), 'https://pwa.ridebus.by/?city=2&stop=88');
|
||||
});
|
||||
39
test/weather.test.mjs
Normal file
39
test/weather.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWeatherClient } from '../server/weather.mjs';
|
||||
import { weatherAvailable, weatherCondition } from '../src/lib/weather.mjs';
|
||||
|
||||
const now = Date.parse('2026-10-08T12:00:00+03:00');
|
||||
const raw = { current: { time: now / 1000, temperature_2m: 12.2, apparent_temperature: 10.5, weather_code: 61, is_day: 1, wind_speed_10m: 3 },
|
||||
hourly: { time: [now / 1000 + 3600], temperature_2m: [13], weather_code: [3], precipitation_probability: [20] } };
|
||||
test('weather requests are coalesced and exclude transit credentials; Unix times retain Minsk instant', async () => {
|
||||
let calls = 0;
|
||||
const client = createWeatherClient({ now: () => now, fetchData: async (url, init) => {
|
||||
calls++; assert.equal(url.searchParams.get('wind_speed_unit'), 'ms'); assert.equal(url.searchParams.get('timeformat'), 'unixtime');
|
||||
assert.deepEqual(init.headers, { Accept: 'application/json' }); return Response.json(raw);
|
||||
} });
|
||||
const [first, second] = await Promise.all([client.weather(1, 1, { latitude: 55.5, longitude: 28.7 }), client.weather(1, 1, { latitude: 55.5, longitude: 28.7 })]);
|
||||
assert.deepEqual(first, second); assert.equal(calls, 1); assert.equal(first.observedAt, new Date(now).toISOString());
|
||||
assert.equal(weatherAvailable(first, now + 30 * 60000), true); assert.equal(weatherAvailable(first, now + 61 * 60000), false);
|
||||
assert.equal(weatherCondition(first.code).label, 'Дождь');
|
||||
});
|
||||
test('missing coordinates and null weather values never produce a fabricated 0 degree forecast', async () => {
|
||||
const client = createWeatherClient({ now: () => now, fetchData: async () => Response.json({ ...raw, current: { ...raw.current, temperature_2m: null } }) });
|
||||
await assert.rejects(client.weather(1, 1, { latitude: null, longitude: null }), error => error.code === 'coordinates_unavailable');
|
||||
await assert.rejects(client.weather(1, 1, { latitude: 55, longitude: 28 }), error => error.code === 'weather_unavailable');
|
||||
});
|
||||
test('commercial provider key selects its endpoint and is never exposed in response or errors', async () => {
|
||||
const client = createWeatherClient({ apiKey: 'private-weather-key', now: () => now, fetchData: async url => {
|
||||
assert.equal(url.host, 'customer-api.open-meteo.com'); assert.equal(url.searchParams.get('apikey'), 'private-weather-key'); return Response.json(raw);
|
||||
} });
|
||||
assert.ok(!JSON.stringify(await client.weather(1, 1, { latitude: 55, longitude: 28 })).includes('private-weather-key'));
|
||||
});
|
||||
test('provider outage backs off for a minute without extending the old forecast timestamp', async () => {
|
||||
let current = now, calls = 0;
|
||||
const client = createWeatherClient({ now: () => current, fetchData: async () => { calls++; return calls === 1 ? Response.json(raw) : new Response('', { status: 503 }); } });
|
||||
const coords = { latitude: 55, longitude: 28 };
|
||||
const saved = await client.weather(1, 1, coords);
|
||||
current += 11 * 60000;
|
||||
await assert.rejects(client.weather(1, 1, coords)); await assert.rejects(client.weather(1, 1, coords));
|
||||
assert.equal(calls, 2); assert.equal(saved.updatedAt, new Date(now).toISOString());
|
||||
});
|
||||
Reference in New Issue
Block a user