127 lines
8.1 KiB
JavaScript
127 lines
8.1 KiB
JavaScript
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');
|
||
});
|