initial commit

This commit is contained in:
2026-10-07 01:48:32 +03:00
commit 664421662c
70 changed files with 11333 additions and 0 deletions

73
test/api.test.mjs Normal file
View File

@@ -0,0 +1,73 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ApiError, createRideBusClient, normalizeDepartures, parseRequest } from '../server/ridebus.mjs';
import { apiHandler } from '../server/index.mjs';
test('only supported resources and positive city/stop identifiers are accepted', () => {
assert.deepEqual(parseRequest(new URLSearchParams('resource=cities')), { resource: 'cities' });
assert.deepEqual(parseRequest(new URLSearchParams('resource=departures&cityId=2&stopId=15')), { resource: 'departures', cityId: 2, stopId: 15 });
for (const input of ['resource=https://evil.example', 'resource=stops', 'resource=stops&cityId=-1', 'resource=departures&cityId=1&stopId=NaN', 'resource=stops&cityId=9007199254740992']) {
assert.throws(() => parseRequest(new URLSearchParams(input)), error => error.status === 400);
}
});
test('scheduled departures exclude historical delay and retain absolute expiration times', () => {
const now = Date.parse('2026-10-06T20:55:00Z');
const data = normalizeDepartures({ routes: [{ id: 5, route_number: '10', name: 'Вокзал → Центр', arrivals: [
{ scheduledTime: '0:05:00', minutesUntil: 14, delay: 4 },
{ scheduledTime: '23:56', minutesUntil: 1, delay: 0 },
{ scheduledTime: '25:12', minutesUntil: 12, delay: 0 },
] }, { id: 6, route_number: '4', name: 'Больница', error: 'unavailable' }] }, now);
assert.equal(data.departures.length, 2);
assert.equal(data.departures[0].scheduledTime, '23:56');
assert.equal(data.departures[1].scheduledTime, '00:05');
assert.equal(data.departures[1].scheduledAt, '2026-10-06T21:05:00.000Z');
assert.equal(data.partial, true);
assert.equal(data.routes.length, 2);
});
test('a negative scheduled wait is skipped rather than shown as a future bus', () => {
const data = normalizeDepartures({ routes: [{ id: 1, route_number: '1', name: 'Центр', arrivals: [
{ scheduledTime: '10:00', minutesUntil: 3, delay: 5 },
] }] });
assert.equal(data.departures.length, 0);
});
test('server cache isolates cities, coalesces concurrent requests, and sends credentials only upstream', async () => {
const calls = [];
const client = createRideBusClient({ apiKey: 'private-test-key', baseUrl: 'https://api.example/', fetchData: async (url, options) => {
calls.push({ url, options });
return { ok: true, json: async () => [{ id: 1, name: `City ${options.headers['X-City-Id']}`, routes_count: '2' }] };
} });
const city1 = { resource: 'stops', cityId: 1 };
const city2 = { resource: 'stops', cityId: 2 };
const [a, b] = await Promise.all([client.get(city1), client.get(city1)]);
const c = await client.get(city2);
assert.equal(calls.length, 2);
assert.deepEqual(a, b);
assert.notEqual(a[0].name, c[0].name);
assert.equal(calls[0].options.headers['X-API-Key'], 'private-test-key');
assert.equal(calls[1].options.headers['X-City-Id'], '2');
assert.equal(JSON.stringify(a).includes('private-test-key'), false);
});
test('missing configuration and upstream failures produce safe API errors', async () => {
await assert.rejects(createRideBusClient({ apiKey: '' }).get({ resource: 'cities' }), error => error.status === 503);
await assert.rejects(createRideBusClient({ apiKey: 'key', fetchData: async () => { throw new Error('private-key=secret'); } }).get({ resource: 'cities' }), error => error.status === 502 && !error.message.includes('secret'));
});
function response() {
return { headers: {}, status: 200, body: '', setHeader(k, v) { this.headers[k] = v; }, writeHead(code) { this.status = code; return this; }, end(body = '') { this.body = body; } };
}
test('HTTP API refuses writes and hides internal exception details', async () => {
const handler = apiHandler({ get: async () => { throw new Error('secret details'); } });
const write = response();
assert.equal(await handler({ method: 'POST', url: '/api/data?resource=cities' }, write), true);
assert.equal(write.status, 405);
const failure = response();
await handler({ method: 'GET', url: '/api/data?resource=cities' }, failure);
assert.equal(failure.status, 502);
assert.deepEqual(JSON.parse(failure.body), { error: 'data_unavailable' });
assert.equal(failure.headers['Cache-Control'], 'no-store');
assert.equal(await handler({ method: 'GET', url: '/' }, response()), false);
});

126
test/features.test.mjs Normal file
View File

@@ -0,0 +1,126 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { normalizeAlerts, normalizeNearby } from '../server/features.mjs';
import { createRideBusClient, normalizeDepartures, parseRequest } from '../server/ridebus.mjs';
import { journeyStops, routeDepartures, tripIdentity } from '../server/timetable.mjs';
import { relevantAlerts } from '../src/lib/alerts.ts';
import { groupByHour } from '../src/lib/timetable.ts';
import { journeyLink } from '../src/lib/links.ts';
const at = '2026-10-07T09:00:00.000Z';
const baseAlert = { id: 1, city_id: 1, is_active: true, title: 'Отмена', message: 'Вокзал 12:00 — отменён\nЦентр 12:15 — отменён', start_time: at, end_time: '2026-10-07T10:00:00.000Z' };
test('alerts normalize arrays before legacy fields, deduplicate and preserve cancellation details', () => {
const alerts = normalizeAlerts([
{ ...baseAlert, route_id: null, route_number: null, route_numbers: ['№27', '2', '27'], route_ids: [5, 6], alert_type: 'cancellation' },
{ ...baseAlert },
{ ...baseAlert, id: 2, route_id: 7, route_number: ' 112с ', route_numbers: [], route_ids: [] },
{ ...baseAlert, id: 3, city_id: 2 },
{ ...baseAlert, id: 4, is_active: false },
{ ...baseAlert, id: 5, route_numbers: null, route_ids: null },
], 1);
assert.deepEqual(alerts.map(a => a.id), [1, 2, 5]);
assert.deepEqual(alerts[0].routeNumbers, ['2', '27']);
assert.equal(alerts[0].message, baseAlert.message);
assert.equal(alerts[0].isCityWide, false);
assert.deepEqual(alerts[1].routeIds, [7]);
assert.deepEqual(alerts[1].routeNumbers, ['112С']);
assert.equal(alerts[2].isCityWide, true);
for (const invalid of [{ route_numbers: '2,27' }, { route_ids: [null] }, { route_numbers: [''] }]) {
assert.throws(() => normalizeAlerts([{ ...baseAlert, ...invalid }], 1), e => e.status === 502);
}
});
test('cached alerts expire at exact boundaries and target either route id or number', () => {
const alerts = normalizeAlerts([{ ...baseAlert, route_id: 7, route_number: '112с' }], 1);
const now = Date.parse(at);
assert.equal(relevantAlerts(alerts, 1, now - 1).length, 0);
assert.equal(relevantAlerts([...alerts, ...alerts], 1, now, [{ id: 99, number: '№112С' }]).length, 1);
assert.equal(relevantAlerts(alerts, 1, now, [{ id: 7, number: '5' }]).length, 1);
assert.equal(relevantAlerts(alerts, 1, now, [{ id: 8, number: '5' }]).length, 0);
assert.equal(relevantAlerts(alerts, 2, now).length, 0);
assert.equal(relevantAlerts(alerts, 1, now + 3600000).length, 0);
assert.equal(relevantAlerts([{ ...alerts[0], startTime: 'bad date' }], 1, now).length, 0);
});
test('location and journey requests accept only bounded coordinates, identifiers and times', () => {
assert.deepEqual(parseRequest(new URLSearchParams('resource=nearby&cityId=1&lat=0&lon=0')), { resource: 'nearby', cityId: 1, lat: 0, lon: 0, radius: 1000 });
const journey = { resource: 'journey', cityId: '1', stopId: '2', routeId: '3', at };
assert.equal(parseRequest(new URLSearchParams(journey)).at, at);
assert.equal(parseRequest(new URLSearchParams({ ...journey, at: '2100-12-31T09:00:00.000Z' })).at, '2100-12-31T09:00:00.000Z');
assert.equal(parseRequest(new URLSearchParams({ ...journey, at: '2000-01-01T21:00:00.000Z' })).at, '2000-01-01T21:00:00.000Z');
for (const params of [
{ resource: 'nearby', cityId: '1', lat: '91', lon: '28' },
{ resource: 'nearby', cityId: '1', lat: '55', lon: 'https://evil/' },
{ resource: 'nearby', cityId: '1', lat: '55', lon: '28', radius: '2001' },
{ ...journey, routeId: '-1' }, { ...journey, at: '2026-02-30' }, { ...journey, tripKey: '../../x' },
]) assert.throws(() => parseRequest(new URLSearchParams(params)), e => e.status === 400);
});
test('nearby search does not cache coordinates/results and exposes only normalized stops', async () => {
let calls = 0;
const client = createRideBusClient({ apiKey: 'key', fetchData: async url => {
calls++; assert.equal(url.pathname, '/api/v1/stops/nearby');
return { ok: true, json: async () => [{ id: 1, name: 'Вокзал', distance_meters: '80.3', lat: 55 }, { id: 2, name: 'Центр', distance_meters: 12 }] };
} });
const input = { resource: 'nearby', cityId: 1, lat: 55, lon: 28, radius: 1000 };
const a = await client.get(input); await client.get(input);
assert.equal(calls, 2);
assert.deepEqual(a.map(stop => stop.distance), [12, 80]);
assert.equal(JSON.stringify(a).includes('lat'), false);
assert.throws(() => normalizeNearby([null]), e => e.status === 502);
assert.throws(() => normalizeNearby([{ id: 1, name: 'Вокзал', distance_meters: -1 }]), e => e.status === 502);
});
const route = { id: 3, number: '112С', name: 'Вокзал → Центр' };
const stops = [
{ stop_id: 1, sequence: 1, name: 'Вокзал', time_offset_minutes: 0 },
{ stop_id: 2, sequence: 2, name: 'Площадь', time_offset_minutes: 10 },
{ stop_id: 3, sequence: 3, name: 'Центр', time_offset_minutes: 25 },
];
test('journey times follow the selected full, shortened and reverse trip across midnight', () => {
for (const [trip, ids, times] of [
[{ departureTime: '23:50' }, [1, 2, 3], ['23:50', '00:00', '00:15']],
[{ departureTime: '08:00', startStopId: 2, endStopId: 3 }, [2, 3], ['08:00', '08:15']],
[{ departureTime: '08:00', startStopId: 3, endStopId: 1 }, [3, 2, 1], ['08:00', '08:15', '08:25']],
]) {
const result = journeyStops(stops, { trip, serviceDate: '2026-10-06' });
assert.deepEqual(result.map(stop => stop.id), ids);
assert.deepEqual(result.map(stop => stop.scheduledTime), times);
if (trip.departureTime === '23:50') assert.equal(result[2].scheduledAt, '2026-10-06T21:15:00.000Z');
}
});
test('journey adapter distinguishes trips sharing an arrival time and caches by city', async () => {
const trips = [{ departureTime: '08:00' }, { departureTime: '08:10', startStopId: 2 }];
const calls = [];
const client = createRideBusClient({ apiKey: 'key', fetchData: async (url, options) => {
calls.push(options.headers['X-City-Id']);
return { ok: true, json: async () => url.pathname.endsWith('/schedule/today')
? { schedule: { trips } } : { id: 3, route_number: '112С', name: route.name, stops: stops.map(stop => ({ ...stop, id: stop.stop_id })) } };
} });
const input = { resource: 'journey', cityId: 1, stopId: 2, routeId: 3, at: '2026-10-07T05:10:00.000Z' };
await assert.rejects(client.get(input), e => e.status === 409 && e.code === 'journey_ambiguous');
const data = await client.get({ ...input, tripKey: tripIdentity(trips[1], '2026-10-07') });
assert.deepEqual(data.stops.map(stop => stop.id), [2, 3]);
assert.equal(data.stops[1].scheduledTime, '08:25');
await client.get({ ...input, cityId: 2, tripKey: data.tripKey });
assert.ok(calls.includes('2'));
await assert.rejects(client.get({ ...input, at: '2026-10-07T12:00:00.000Z' }), e => e.code === 'journey_not_found');
});
test('ETA trip identity is the same as full timetable for an overnight arrival', () => {
const trip = { departureTime: '23:50:00', startStopId: 1, endStopId: 3, note: 'Рейс' };
const full = routeDepartures(route, stops, { schedule: { trips: [trip] } }, 3, '2026-10-06').departures[0];
const eta = normalizeDepartures({ routes: [{ id: 3, route_number: '112С', name: route.name, arrivals: [{ trip, scheduledTime: '00:15:00', minutesUntil: 20, delay: 0 }] }] }, Date.parse('2026-10-06T20:55:00Z')).departures[0];
assert.equal(eta.tripKey, full.tripKey);
});
test('hour grid groups sorted Belarus time and trip links retain date/filter identity', () => {
const departures = ['2026-10-06T22:15:00.000Z', '2026-10-06T21:30:00.000Z', '2026-10-06T21:00:00.000Z'].map(scheduledAt => ({ routeId: 3, routeNumber: '112С', direction: 'Центр', scheduledAt, tripKey: 'unique' }));
const groups = groupByHour(departures);
assert.deepEqual(groups.map(group => [group.hour, group.departures.length]), [['00', 2], ['01', 1]]);
const params = new URL(journeyLink('demo', 1, 2, departures[0], 'day', '2026-10-07', 3), 'http://localhost').searchParams;
assert.equal(params.get('date'), '2026-10-07');
assert.equal(params.get('filter'), '3');
assert.equal(params.get('tripKey'), 'unique');
});

38
test/stopGroups.test.mjs Normal file
View File

@@ -0,0 +1,38 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { countLabel, directionLabel, groupStops } from '../src/lib/stopGroups.ts';
test('names are grouped like the landing while direction IDs remain independent', () => {
const groups = groupStops([
{ id: 1, name: ' Площадь Свободы ', direction: 'В центр', routesCount: 2 },
{ id: 2, name: 'площадь свободы', direction: 'Из центра', routesCount: 3 },
{ id: 1, name: 'Площадь Свободы', direction: 'Duplicate ID', routesCount: 2 },
{ id: 3, name: 'Автовокзал', direction: '', routesCount: 1 },
]);
assert.equal(groups.length, 2);
assert.equal(groups[0].name, 'Автовокзал');
assert.equal(groups[1].name, 'Площадь Свободы');
assert.deepEqual(groups[1].stops.map(stop => stop.id), [1, 2]);
});
test('direction labels distinguish unlabeled and repeated directions', () => {
const stops = [
{ id: 1, name: 'Вокзал', direction: 'В центр', routesCount: 2 },
{ id: 2, name: 'Вокзал', direction: 'В центр', routesCount: 2 },
{ id: 3, name: 'Вокзал', direction: '', routesCount: 2 },
];
const group = groupStops(stops)[0];
assert.equal(directionLabel(group, stops[0], 0), 'В центр · 1');
assert.equal(directionLabel(group, stops[1], 1), 'В центр · 2');
assert.equal(directionLabel(group, stops[2], 2), 'Направление 3');
});
test('the list retains more than eighty stops and uses correct count labels', () => {
const stops = Array.from({ length: 120 }, (_, index) => ({ id: index + 1, name: `Остановка ${index + 1}`, direction: '', routesCount: 1 }));
assert.equal(groupStops(stops).length, 120);
const forms = ['остановка', 'остановки', 'остановок'];
assert.equal(countLabel(1, forms), '1 остановка');
assert.equal(countLabel(2, forms), '2 остановки');
assert.equal(countLabel(11, forms), '11 остановок');
assert.equal(countLabel(21, forms), '21 остановка');
});

87
test/timetable.test.mjs Normal file
View File

@@ -0,0 +1,87 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRideBusClient, parseRequest } from '../server/ridebus.mjs';
import { routeDepartures } from '../server/timetable.mjs';
import { formatWait, isTravelDate, localDate } from '../src/lib/time.ts';
const route = { id: 1, number: '112С', name: 'Вокзал → Центр' };
const stops = [
{ stop_id: 1, sequence: 1, time_offset_minutes: 0 },
{ stop_id: 2, sequence: 2, time_offset_minutes: 10 },
{ stop_id: 3, sequence: 3, time_offset_minutes: 25 },
];
test('waiting time uses hours and minutes and dates respect Belarus midnight', () => {
for (const [minutes, label] of [[0, 'сейчас'], [-1, 'сейчас'], [1, 'через 1 мин'], [59, 'через 59 мин'], [60, 'через 1 ч'], [61, 'через 1 ч 1 мин'], [121, 'через 2 ч 1 мин'], [180, 'через 3 ч']]) {
assert.equal(formatWait(minutes), label);
}
assert.equal(localDate(Date.parse('2026-10-06T21:01:00Z')), '2026-10-07');
assert.equal(isTravelDate('2026-02-30'), false);
});
test('timetable requests require actual calendar dates and safe IDs', () => {
assert.deepEqual(parseRequest(new URLSearchParams('resource=timetable&cityId=1&stopId=2&date=2026-10-07')), { resource: 'timetable', cityId: 1, stopId: 2, date: '2026-10-07' });
for (const date of ['', '2026-02-30', '2026-13-01', '2026-10-07&url=evil', '0000-01-01']) {
assert.throws(() => parseRequest(new URLSearchParams({ resource: 'timetable', cityId: '1', stopId: '2', date })), error => error.status === 400);
}
});
test('full timetable respects weekday exceptions and shortened trips, and offsets from the trip start', () => {
const result = routeDepartures(route, stops, { schedule: { trips: [
{ departureTime: '08:00', startStopId: 2, endStopId: 3, daysOfWeek: [3], note: 'Укороченный рейс' },
{ departureTime: '09:00', endStopId: 1 },
{ departureTime: '10:00', exceptDaysOfWeek: [3] },
{ departureTime: '11:00', daysOfWeek: [4] },
{ departureTime: '12:00', startStopId: 999 },
] } }, 3, '2026-10-07');
assert.equal(result.departures.length, 1);
assert.equal(result.departures[0].scheduledTime, '08:15');
assert.equal(result.departures[0].scheduledAt, '2026-10-07T05:15:00.000Z');
assert.equal(result.departures[0].note, 'Укороченный рейс');
assert.equal(result.partial, true);
});
test('reverse trips and legacy schedules produce correct times across midnight', () => {
const reverse = routeDepartures(route, stops, { schedule: { trips: [{ departureTime: '08:00', startStopId: 3, endStopId: 1 }] } }, 2, '2026-10-07');
assert.equal(reverse.departures[0].scheduledTime, '08:15');
const legacy = routeDepartures(route, stops, { schedule: { departure_times: ['23:50:00'] } }, 3, '2026-10-06');
assert.equal(legacy.departures[0].scheduledTime, '00:15');
assert.equal(legacy.departures[0].scheduledAt, '2026-10-06T21:15:00.000Z');
});
test('adapter includes previous-day overnight trips, caches by date/city, and reports failed routes', async () => {
const calls = [];
const client = createRideBusClient({ apiKey: 'secret', fetchData: async (url, options) => {
calls.push(`${options.headers['X-City-Id']}:${url.pathname}${url.search}`);
if (url.pathname.includes('/routes/2/')) return { ok: false, status: 500 };
const data = url.pathname.includes('/schedule/today')
? { schedule: { departure_times: ['08:00', '23:50'] }, isHoliday: true, holidayName: 'Праздник' }
: url.pathname.endsWith('/stops') ? stops
: { routes: [{ id: 1, route_number: '112С', name: 'Центр' }, { id: 2, route_number: '2', name: 'Больница' }] };
return { ok: true, status: 200, json: async () => data };
} });
const input = { resource: 'timetable', cityId: 1, stopId: 3, date: '2026-10-07' };
const data = await client.get(input);
assert.deepEqual(data.departures.map(item => item.scheduledTime), ['00:15', '08:25']);
assert.equal(data.partial, true);
assert.equal(data.isHoliday, true);
assert.equal(JSON.stringify(data).includes('secret'), false);
const count = calls.length;
await client.get(input);
assert.equal(calls.length, count);
await client.get({ ...input, date: '2026-10-08' });
assert.ok(calls.some(call => call.includes('date=2026-10-08')));
await client.get({ ...input, cityId: 2 });
assert.ok(calls.some(call => call.startsWith('2:')));
});
test('missing route schedule is empty while total upstream failure remains an error', async () => {
const client = createRideBusClient({ apiKey: 'key', fetchData: async url => {
if (url.pathname.includes('/schedule/today')) return { ok: false, status: 404 };
return { ok: true, json: async () => url.pathname.endsWith('/stops') ? stops : { routes: [{ id: 1, route_number: '1', name: 'Центр' }] } };
} });
const data = await client.get({ resource: 'timetable', cityId: 1, stopId: 3, date: '2026-10-07' });
assert.deepEqual(data.departures, []);
assert.equal(data.partial, false);
await assert.rejects(createRideBusClient({ apiKey: 'key', fetchData: async () => ({ ok: false, status: 500 }) }).get({ resource: 'timetable', cityId: 1, stopId: 3, date: '2026-10-07' }), error => error.status === 502);
});