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