feat: add Open Graph image generation and SEO metadata
- Introduced `PageSeo` component for managing page metadata for social sharing. - Created Open Graph image templates using SVG and static fonts for better visual representation. - Implemented service alert management with functions to check active alerts and format route numbers. - Added utility functions for generating and watching alert validity. - Developed a script to generate Open Graph images dynamically based on page content. - Included tests for service alert functionalities to ensure correctness and reliability.
This commit is contained in:
@@ -102,6 +102,7 @@ test('loads active alerts for the requested city without exposing notification f
|
||||
id: 2,
|
||||
alert_type: 'detour',
|
||||
route_number: ' 21тк ',
|
||||
route_id: 19,
|
||||
route_name: ' Route ',
|
||||
start_time: '2026-10-01T12:00:00Z',
|
||||
created_at: '2026-09-30T12:00:00Z',
|
||||
@@ -118,9 +119,14 @@ test('loads active alerts for the requested city without exposing notification f
|
||||
alerts: [
|
||||
{
|
||||
id: 2,
|
||||
cityId: 2,
|
||||
type: 'detour',
|
||||
title: 'Notice',
|
||||
message: 'Public message',
|
||||
routeNumbers: ['21тк'],
|
||||
routeIds: [19],
|
||||
isCityWide: false,
|
||||
routeId: 19,
|
||||
routeNumber: '21тк',
|
||||
routeName: 'Route',
|
||||
startTime: '2026-10-01T12:00:00.000Z',
|
||||
@@ -129,9 +135,14 @@ test('loads active alerts for the requested city without exposing notification f
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
cityId: 2,
|
||||
type: 'info',
|
||||
title: 'Notice',
|
||||
message: 'Public message',
|
||||
routeNumbers: [],
|
||||
routeIds: [],
|
||||
isCityWide: true,
|
||||
routeId: null,
|
||||
routeNumber: null,
|
||||
routeName: null,
|
||||
startTime: '2026-09-25T12:00:00.000Z',
|
||||
@@ -170,7 +181,9 @@ test('expires alerts within a cached response and keeps different cities separat
|
||||
const initial = await Promise.all([client.get(query), client.get(query)]);
|
||||
assert.equal(initial[0].alerts.length, 1);
|
||||
assert.equal(calls, 1);
|
||||
clock += 11000;
|
||||
clock += 9999;
|
||||
assert.equal((await client.get(query)).alerts.length, 1);
|
||||
clock += 1;
|
||||
assert.equal((await client.get(query)).alerts.length, 0);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(
|
||||
@@ -268,7 +281,7 @@ test('shows schedule times, removes historical delay, sorts and limits departure
|
||||
route_number: '1',
|
||||
name: 'Centre',
|
||||
arrivals: [
|
||||
{ scheduledTime: '9:05:00', minutesUntil: 7, delay: 2 },
|
||||
{ scheduledTime: '9:05:00', minutesUntil: 7, delay: 7 },
|
||||
{ scheduledTime: '09:15', minutesUntil: 15 },
|
||||
{ scheduledTime: '09:25', minutesUntil: 25 },
|
||||
],
|
||||
@@ -292,12 +305,12 @@ test('shows schedule times, removes historical delay, sorts and limits departure
|
||||
cityId: 1,
|
||||
stopId: 5,
|
||||
});
|
||||
assert.equal(data.departures.length, 6);
|
||||
assert.equal(data.departures.length, 4);
|
||||
assert.deepEqual(
|
||||
data.departures.map((item) => item.minutesUntil),
|
||||
[1, 2, 3, 4, 5, 15]
|
||||
[0, 1, 2, 3]
|
||||
);
|
||||
assert.equal(data.departures[4].scheduledTime, '09:05');
|
||||
assert.equal(data.departures[0].scheduledTime, '09:05');
|
||||
assert.equal(data.updatedAt, '1970-01-01T00:00:00.000Z');
|
||||
assert.equal(data.partial, false);
|
||||
});
|
||||
|
||||
305
tests/service-alerts.test.mjs
Normal file
305
tests/service-alerts.test.mjs
Normal file
@@ -0,0 +1,305 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { test } from 'node:test';
|
||||
import ts from 'typescript';
|
||||
import { createRideBusClient, RideBusError } from '../lib/ridebus.ts';
|
||||
import {
|
||||
formatAlertRouteNumbers,
|
||||
getActiveAlerts,
|
||||
isAlertActive,
|
||||
watchAlertValidity,
|
||||
} from '../lib/serviceAlerts.ts';
|
||||
|
||||
const now = Date.parse('2026-10-05T09:00:00Z');
|
||||
const notice = {
|
||||
id: 42,
|
||||
city_id: 1,
|
||||
alert_type: 'cancellation',
|
||||
title: 'Изменение рейсов',
|
||||
message: 'Отменены отправления:\n№2: Вокзал — 12:10.\n№27: Больница — 13:25.',
|
||||
start_time: '2026-10-05T08:00:00Z',
|
||||
end_time: '2026-10-05T10:00:00Z',
|
||||
route_numbers: ['2', '27'],
|
||||
route_ids: [1, 2, 3, 4],
|
||||
route_id: null,
|
||||
route_number: null,
|
||||
};
|
||||
const json = (data) => new Response(JSON.stringify(data));
|
||||
const query = { resource: 'alerts', cityId: 1 };
|
||||
function clientFor(alerts) {
|
||||
return createRideBusClient({
|
||||
apiKey: 'test-only-key',
|
||||
now: () => now,
|
||||
fetch: async () => json(alerts),
|
||||
});
|
||||
}
|
||||
|
||||
test('keeps several numbers and all direction IDs in one notice, preserving cancellation details', async () => {
|
||||
const { alerts } = await clientFor([notice]).get(query);
|
||||
assert.equal(alerts.length, 1);
|
||||
assert.deepEqual(alerts[0].routeNumbers, ['2', '27']);
|
||||
assert.deepEqual(alerts[0].routeIds, [1, 2, 3, 4]);
|
||||
assert.equal(alerts[0].cityId, 1);
|
||||
assert.equal(alerts[0].isCityWide, false);
|
||||
assert.equal(alerts[0].routeId, null);
|
||||
assert.equal(alerts[0].routeNumber, null);
|
||||
assert.equal(alerts[0].message, notice.message);
|
||||
assert.equal(formatAlertRouteNumbers(alerts[0]), '№2 · №27');
|
||||
});
|
||||
|
||||
test('normalizes old single fields and distinguishes general notices from ID-only and number-only notices', async () => {
|
||||
const { route_numbers, route_ids, ...old } = notice;
|
||||
const { alerts } = await clientFor([
|
||||
{
|
||||
...old,
|
||||
id: 1,
|
||||
route_id: 5,
|
||||
route_number: ' 21тк ',
|
||||
route_name: 'Centre',
|
||||
},
|
||||
{ ...notice, id: 2, route_numbers: [], route_ids: [] },
|
||||
{ ...old, id: 3, route_id: 6 },
|
||||
{ ...notice, id: 4, route_numbers: [], route_ids: [7, 8] },
|
||||
{ ...notice, id: 5, route_numbers: ['27'], route_ids: [] },
|
||||
{ ...old, id: 6 },
|
||||
// Empty new fields cannot erase a still-present old association.
|
||||
{
|
||||
...notice,
|
||||
id: 7,
|
||||
route_numbers: [],
|
||||
route_ids: [],
|
||||
route_id: 9,
|
||||
route_number: '2',
|
||||
},
|
||||
]).get(query);
|
||||
assert.deepEqual(
|
||||
alerts.map(({ routeNumbers, routeIds, isCityWide }) => ({
|
||||
routeNumbers,
|
||||
routeIds,
|
||||
isCityWide,
|
||||
})),
|
||||
[
|
||||
{ routeNumbers: ['21тк'], routeIds: [5], isCityWide: false },
|
||||
{ routeNumbers: [], routeIds: [], isCityWide: true },
|
||||
{ routeNumbers: [], routeIds: [6], isCityWide: false },
|
||||
{ routeNumbers: [], routeIds: [7, 8], isCityWide: false },
|
||||
{ routeNumbers: ['27'], routeIds: [], isCityWide: false },
|
||||
{ routeNumbers: [], routeIds: [], isCityWide: true },
|
||||
{ routeNumbers: ['2'], routeIds: [9], isCityWide: false },
|
||||
]
|
||||
);
|
||||
assert.equal(alerts[0].routeName, 'Centre');
|
||||
});
|
||||
|
||||
test('deduplicates repeated numbers, IDs and notice rows without multiplying by direction', async () => {
|
||||
const duplicate = {
|
||||
...notice,
|
||||
route_numbers: ['2', ' 2 ', '27', '27'],
|
||||
route_ids: [1, 2, 2, 3, 4],
|
||||
};
|
||||
const { alerts } = await clientFor([
|
||||
duplicate,
|
||||
duplicate,
|
||||
{ ...notice, id: 43 },
|
||||
]).get(query);
|
||||
assert.equal(alerts.length, 2);
|
||||
assert.deepEqual(
|
||||
alerts.map((alert) => alert.id),
|
||||
[42, 43]
|
||||
);
|
||||
assert.deepEqual(alerts[0].routeNumbers, ['2', '27']);
|
||||
assert.deepEqual(alerts[0].routeIds, [1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test('isolates city caches and ignores another city even when route numbers and notice IDs coincide', async () => {
|
||||
const calls = [];
|
||||
const client = createRideBusClient({
|
||||
apiKey: 'test-only-key',
|
||||
now: () => now,
|
||||
fetch: async (url, options) => {
|
||||
const cityId = Number(options.headers['X-City-Id']);
|
||||
calls.push(cityId);
|
||||
return json([
|
||||
{
|
||||
...notice,
|
||||
city_id: cityId,
|
||||
route_ids: [cityId * 10],
|
||||
message: `City ${cityId}`,
|
||||
},
|
||||
{ ...notice, id: 999, city_id: cityId === 1 ? 2 : 1 },
|
||||
]);
|
||||
},
|
||||
});
|
||||
const first = await client.get(query);
|
||||
const second = await client.get({ ...query, cityId: 2 });
|
||||
const back = await client.get(query);
|
||||
assert.deepEqual(calls, [1, 2]);
|
||||
assert.equal(first.alerts.length, 1);
|
||||
assert.equal(second.alerts.length, 1);
|
||||
assert.equal(first.alerts[0].message, 'City 1');
|
||||
assert.equal(second.alerts[0].message, 'City 2');
|
||||
assert.deepEqual(back, first);
|
||||
assert.deepEqual(getActiveAlerts(first.alerts, 2, now), []);
|
||||
assert.deepEqual(getActiveAlerts(second.alerts, 1, now), []);
|
||||
});
|
||||
|
||||
test('uses inclusive start and exclusive end, including future notices inside the server cache', async () => {
|
||||
let clock = now - 1;
|
||||
let calls = 0;
|
||||
const client = createRideBusClient({
|
||||
apiKey: 'test-only-key',
|
||||
now: () => clock,
|
||||
fetch: async () => {
|
||||
calls++;
|
||||
return json([
|
||||
{
|
||||
...notice,
|
||||
start_time: new Date(now).toISOString(),
|
||||
end_time: new Date(now + 1000).toISOString(),
|
||||
},
|
||||
]);
|
||||
},
|
||||
});
|
||||
assert.equal((await client.get(query)).alerts.length, 0);
|
||||
clock = now;
|
||||
const { alerts } = await client.get(query);
|
||||
assert.equal(alerts.length, 1);
|
||||
assert.equal(isAlertActive(alerts[0], now - 1), false);
|
||||
assert.equal(isAlertActive(alerts[0], now), true);
|
||||
assert.equal(isAlertActive(alerts[0], now + 999), true);
|
||||
assert.equal(isAlertActive(alerts[0], now + 1000), false);
|
||||
clock += 999;
|
||||
assert.equal((await client.get(query)).alerts.length, 1);
|
||||
clock++;
|
||||
assert.equal((await client.get(query)).alerts.length, 0);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('removes expired notices on the open page without another request and cancels timers on city change', async (t) => {
|
||||
const { alerts } = await clientFor([notice]).get(query);
|
||||
const alert = {
|
||||
...alerts[0],
|
||||
startTime: new Date(now + 100).toISOString(),
|
||||
endTime: new Date(now + 1000).toISOString(),
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now });
|
||||
const visible = [];
|
||||
const stop = watchAlertValidity([alert], () => {
|
||||
visible.push(getActiveAlerts([alert], 1, Date.now()).length);
|
||||
});
|
||||
t.mock.timers.tick(0);
|
||||
assert.deepEqual(visible, [0]);
|
||||
t.mock.timers.tick(100);
|
||||
assert.deepEqual(visible, [0, 1]);
|
||||
t.mock.timers.tick(899);
|
||||
assert.deepEqual(visible, [0, 1]);
|
||||
t.mock.timers.tick(1);
|
||||
assert.deepEqual(visible, [0, 1, 0]);
|
||||
stop();
|
||||
const changed = t.mock.fn();
|
||||
const cancel = watchAlertValidity(
|
||||
[{ ...alert, endTime: new Date(now + 5000).toISOString() }],
|
||||
changed
|
||||
);
|
||||
t.mock.timers.tick(0);
|
||||
assert.equal(changed.mock.callCount(), 1);
|
||||
cancel();
|
||||
t.mock.timers.tick(30000);
|
||||
assert.equal(changed.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
test('reschedules capped long timers instead of expiring distant notices immediately', async (t) => {
|
||||
const { alerts } = await clientFor([notice]).get(query);
|
||||
const duration = 2147483647 + 1000;
|
||||
const alert = {
|
||||
...alerts[0],
|
||||
endTime: new Date(now + duration).toISOString(),
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now });
|
||||
const visible = [];
|
||||
const stop = watchAlertValidity([alert], () =>
|
||||
visible.push(isAlertActive(alert, Date.now()))
|
||||
);
|
||||
t.mock.timers.tick(0);
|
||||
t.mock.timers.tick(2147483647);
|
||||
assert.deepEqual(visible, [true, true]);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.deepEqual(visible, [true, true, false]);
|
||||
stop();
|
||||
});
|
||||
|
||||
// Exercise the real Next handler with Node's test runner; erase Next's type-only
|
||||
// imports and resolve the project alias without adding another test framework.
|
||||
const source = await readFile(
|
||||
new URL('../pages/api/departures.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const compiled = ts
|
||||
.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
})
|
||||
.outputText.replace(
|
||||
'@/lib/ridebus',
|
||||
new URL('../lib/ridebus.ts', import.meta.url).href
|
||||
);
|
||||
const { createDeparturesHandler } = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`
|
||||
);
|
||||
|
||||
function apiResponse() {
|
||||
return {
|
||||
headers: {},
|
||||
statusCode: 0,
|
||||
body: null,
|
||||
setHeader(name, value) {
|
||||
this.headers[name] = value;
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body) {
|
||||
this.body = body;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('API proxy passes normalized lists and uses no-store without leaking server credentials', async () => {
|
||||
const res = apiResponse();
|
||||
await createDeparturesHandler(clientFor([notice]))(
|
||||
{ method: 'GET', query: { resource: 'alerts', cityId: '1' } },
|
||||
res
|
||||
);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Cache-Control'], 'no-store');
|
||||
assert.deepEqual(res.body.alerts[0].routeNumbers, ['2', '27']);
|
||||
assert.deepEqual(res.body.alerts[0].routeIds, [1, 2, 3, 4]);
|
||||
assert.equal(res.body.alerts.length, 1);
|
||||
assert.equal(JSON.stringify(res.body).includes('test-only-key'), false);
|
||||
});
|
||||
|
||||
test('API proxy rejects missing cities and non-GET methods and sanitizes upstream errors', async () => {
|
||||
const handler = createDeparturesHandler({
|
||||
get: async () => {
|
||||
throw new RideBusError(502);
|
||||
},
|
||||
});
|
||||
const missingCity = apiResponse();
|
||||
await handler({ method: 'GET', query: { resource: 'alerts' } }, missingCity);
|
||||
assert.equal(missingCity.statusCode, 400);
|
||||
const wrongMethod = apiResponse();
|
||||
await handler({ method: 'POST', query: {} }, wrongMethod);
|
||||
assert.equal(wrongMethod.statusCode, 405);
|
||||
assert.equal(wrongMethod.headers.Allow, 'GET');
|
||||
const failed = apiResponse();
|
||||
await handler(
|
||||
{ method: 'GET', query: { resource: 'alerts', cityId: '1' } },
|
||||
failed
|
||||
);
|
||||
assert.equal(failed.statusCode, 502);
|
||||
assert.deepEqual(failed.body, { error: 'departures_unavailable' });
|
||||
});
|
||||
Reference in New Issue
Block a user