Files
ridebus-status/server/scenarios.mjs
2026-10-13 02:24:39 +03:00

97 lines
6.4 KiB
JavaScript

import { runProbe } from './probe.mjs';
import { createBrowserChecks } from './browser-checks.mjs';
const validId = id => Number.isSafeInteger(Number(id)) && Number(id) > 0;
const validBoardConfig = value => validId(value?.cityId) && validId(value?.defaultStopId) && value.configured === true;
const validDeviceState = value => value?.enabled === false || value?.enabled === true && (['unpaired', 'pending', 'expired'].includes(value.state) || value.state === 'paired' && validBoardConfig(value.config));
export const validCatalog = value => Array.isArray(value) && value.length > 0 && value.every(item => validId(item?.id) && typeof item.name === 'string' && Boolean(item.name.trim()));
export const validDepartures = value => value && Array.isArray(value.routes) && Array.isArray(value.departures) && value.departures.every(item => validId(item.routeId) && typeof item.scheduledTime === 'string' && Number.isFinite(Date.parse(item.scheduledAt))) && value.partial !== true;
export const validEta = value => value && Array.isArray(value.routes) && value.routes.every(item => !item.error && validId(item.id) && Array.isArray(item.arrivals));
export const validBoard = value => value && validId(value.stop?.id) && value.mode === 'live' && Array.isArray(value.departures) && value.departures.every(item => validId(item.routeId) && Number.isFinite(Date.parse(item.scheduledAt))) && value.partial !== true && value.demo !== true;
function selectCity(cities, configuredId) {
const city = configuredId ? cities.find(item => Number(item.id) === configuredId) : cities.find(item => item.slug === 'polotsk') || cities[0];
if (!city) throw { reason: 'content', httpCode: 200 };
return Number(city.id);
}
function selectStop(stops, configuredId) {
const stop = configuredId ? stops.find(item => Number(item.id) === configuredId) : stops.find(item => Number(item.routesCount ?? item.routes_count) > 0) || stops[0];
if (!stop) throw { reason: 'content', httpCode: 200 };
return Number(stop.id);
}
export function createScenarioRunner(config, { fetchImpl = fetch, browserChecks = null } = {}) {
const browser = browserChecks || createBrowserChecks(config);
async function run(service) {
const spec = service.scenario;
const steps = [];
const started = performance.now();
let mode = 'http', current = 'cities';
const target = {};
const result = (ok, reason = null) => ({ ok, reason, latencyMs: ok === true ? Math.round(performance.now() - started) : null, httpCode: steps.find(step => step.status === 'failed')?.httpCode ?? (ok === true ? 200 : null), mode, steps });
async function get(step, path, validator, headers = {}) {
current = step;
let data;
const response = await runProbe({ url: new URL(path, `${spec.baseUrl.replace(/\/+$/, '')}/`).toString(), kind: 'json', headers, validateJson: json => { data = json; return validator(json); } }, { timeoutMs: config.timeoutMs, fetchImpl });
steps.push({ id: step, status: response.ok ? 'passed' : 'failed', latencyMs: response.latencyMs, httpCode: response.httpCode });
if (!response.ok) throw { reason: [401, 403].includes(response.httpCode) || spec.type === 'board' && response.httpCode === 410 ? 'auth_required' : response.reason, httpCode: response.httpCode };
return data;
}
if (!spec || spec.type === 'api' && !spec.apiKey) return result(null, 'unconfigured');
try {
if (spec.type === 'api') {
const headers = { 'X-API-Key': spec.apiKey };
const cities = await get('cities', '/api/v1/cities', validCatalog, headers);
target.cityId = selectCity(cities, spec.cityId);
const scoped = { ...headers, 'X-City-Id': String(target.cityId) };
const stops = await get('stops', '/api/v1/stops', validCatalog, scoped);
target.stopId = selectStop(stops, spec.stopId);
await get('departures', `/api/v1/eta/stop/${target.stopId}`, validEta, scoped);
} else if (spec.type === 'pwa') {
const cities = await get('cities', '/api/data?resource=cities', validCatalog);
target.cityId = selectCity(cities, spec.cityId);
const stops = await get('stops', `/api/data?resource=stops&cityId=${target.cityId}`, validCatalog);
target.stopId = selectStop(stops, spec.stopId);
await get('departures', `/api/data?resource=departures&cityId=${target.cityId}&stopId=${target.stopId}`, validDepartures);
} else {
const headers = spec.cookie ? { Cookie: spec.cookie } : {};
let settings = await get('config', '/api/config', validBoardConfig, headers);
let managed = false;
if (spec.cookie) {
const device = await get('device', '/api/device/status', validDeviceState, headers);
if (device.enabled) {
if (device.state !== 'paired') {
steps.at(-1).status = 'failed';
throw { reason: 'auth_required', httpCode: 200 };
}
// A paired board can only request the stop assigned by the admin.
// /api/config describes the standalone defaults, not this device.
settings = device.config;
managed = true;
}
}
target.cityId = Number(settings.cityId);
const stops = await get('stops', '/api/stops', validCatalog, headers);
target.stopId = managed ? Number(settings.defaultStopId) : spec.stopId || Number(settings.defaultStopId);
if (!stops.some(stop => Number(stop.id) === target.stopId)) throw { reason: 'content' };
await get('departures', `/api/board?stopId=${target.stopId}`, value => validBoard(value) && Number(value.stop.id) === target.stopId, headers);
}
if (spec.type !== 'api') {
current = 'render';
if (!config.browser.enabled) steps.push({ id: 'render', status: 'waiting', latencyMs: null, httpCode: null });
else {
const rendered = await browser.run(spec, target);
steps.push({ id: 'render', status: rendered.ok === null ? 'waiting' : rendered.ok ? 'passed' : 'failed', latencyMs: rendered.latencyMs ?? null, httpCode: rendered.httpCode ?? null });
if (rendered.ok !== true) return result(rendered.ok, rendered.reason);
mode = 'browser';
}
}
return result(true);
} catch (error) {
if (steps.at(-1)?.status !== 'failed') steps.push({ id: current, status: 'failed', latencyMs: null, httpCode: error.httpCode ?? null });
return result(error.reason === 'auth_required' ? null : false, error.reason || 'content');
}
}
return { run, close: () => browser.close() };
}