Files
ridebus-pwa/test/features.test.mjs
2026-10-07 01:48:32 +03:00

127 lines
8.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});