Files
ridebus-status/test/next-stage.test.mjs
2026-10-13 02:24:39 +03:00

227 lines
14 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadConfig } from '../server/config.mjs';
import { openStore } from '../server/store.mjs';
import { createMonitor } from '../server/monitor.mjs';
import { nextState } from '../server/probe.mjs';
import { makeSnapshot } from '../server/snapshot.mjs';
import { affectsService, maintenancePhase, parseInstant, validateMaintenance } from '../server/maintenance.mjs';
import { createScenarioRunner, validDepartures, validEta, validBoard } from '../server/scenarios.mjs';
import { createNotifier, sendTelegram, notificationText } from '../server/notifications.mjs';
const config = loadConfig({});
const service = config.services[0];
const good = { ok: true, latencyMs: 120, httpCode: 200, reason: null };
const bad = { ok: false, latencyMs: null, httpCode: 503, reason: 'http' };
const windowInput = { titleRu: 'Работы', titleEn: 'Maintenance', messageRu: 'Обновление', messageEn: 'Update', serviceIds: ['api'], startsAt: 1000, endsAt: 5000 };
test('maintenance validates times, service IDs and explicit timezones', () => {
assert.equal(validateMaintenance(windowInput, ['api']).endsAt, 5000);
assert.throws(() => validateMaintenance({ ...windowInput, endsAt: 1000 }, ['api']));
assert.throws(() => validateMaintenance({ ...windowInput, serviceIds: ['missing'] }, ['api']));
assert.throws(() => parseInstant('2026-10-13T02:00:00'));
assert.throws(() => parseInstant('2026-02-31T02:00:00+03:00'));
assert.equal(parseInstant('2026-10-13T02:00:00+03:00'), Date.UTC(2026, 9, 12, 23));
});
test('maintenance transitions at exact boundaries and early completion truncates its effect', () => {
const item = { ...windowInput, cancelledAt: null, completedAt: null };
assert.equal(maintenancePhase(item, 999), 'scheduled'); assert.equal(maintenancePhase(item, 1000), 'active');
assert.equal(maintenancePhase(item, 5000), 'ended');
assert.equal(affectsService(item, 'api', 4999), true); assert.equal(affectsService(item, 'website', 4999), false);
const completed = { ...item, completedAt: 3000 };
assert.equal(affectsService(completed, 'api', 2999), true); assert.equal(affectsService(completed, 'api', 3000), false);
assert.equal(maintenancePhase(completed, 3000), 'completed');
});
test('planned outages are stored honestly but open no incident or notification', () => {
const store = openStore(':memory:');
try {
store.createMaintenance(windowInput, 500);
for (let time = 1000; time <= 3000; time += 1000) store.record(service, bad, nextState(store.state(service), bad, config), time, { notify: true });
assert.equal(store.state(service).status, 'down'); assert.equal(store.incidents().length, 0); assert.equal(store.dueNotifications(3000).length, 0);
const snapshot = makeSnapshot(store, config, 30, 3000);
assert.equal(snapshot.services[0].status, 'maintenance'); assert.equal(snapshot.services[0].availabilityStatus, 'down');
assert.equal(snapshot.services[0].uptime, 0); assert.equal(snapshot.maintenance[0].phase, 'active');
} finally { store.close(); }
});
test('after maintenance ends, outage confirmation starts over', async () => {
const store = openStore(':memory:');
let time = 1000;
const one = { ...config, services: [service] };
const monitor = createMonitor(store, one, { check: async () => bad, now: () => time });
try {
store.createMaintenance(windowInput, 500);
for (time of [1000, 2000, 3000]) await monitor.cycle();
assert.equal(store.state(service).status, 'down');
time = 5000; await monitor.cycle();
assert.equal(store.state(service).status, 'degraded'); assert.equal(store.incidents().length, 0);
time = 6000; await monitor.cycle(); time = 7000; await monitor.cycle();
assert.equal(store.incidents().length, 1); assert.equal(store.incidents()[0].startedAt, 7000);
} finally { await monitor.stop(); store.close(); }
});
test('maintenance is scoped and cancelling a future window does not suppress real outages', () => {
const store = openStore(':memory:');
try {
const id = store.createMaintenance(windowInput, 500);
assert.equal(store.inMaintenance(config.services[1], 2000), false);
assert.equal(store.endMaintenance(id, 'cancel', 900), true);
assert.equal(store.endMaintenance(id, 'finish', 950), false);
assert.equal(store.inMaintenance(service, 2000), false);
} finally { store.close(); }
});
test('a pre-existing incident is resolved by real recovery during maintenance', () => {
const store = openStore(':memory:');
try {
for (const time of [100, 200, 300]) store.record(service, bad, nextState(store.state(service), bad, config), time);
store.createMaintenance(windowInput, 400);
for (const time of [2000, 3000]) store.record(service, good, nextState(store.state(service), good, config), time);
assert.equal(store.incidents()[0].resolvedAt, 3000);
} finally { store.close(); }
});
test('legacy MVP SQLite database is upgraded without dropping incidents', () => {
const folder = mkdtempSync(join(tmpdir(), 'ridebus-status-migration-'));
const path = join(folder, 'status.sqlite');
const db = new DatabaseSync(path);
db.exec("CREATE TABLE incidents(id INTEGER PRIMARY KEY, source TEXT NOT NULL, service_id TEXT, signature TEXT, started_at INTEGER NOT NULL, resolved_at INTEGER, title_ru TEXT NOT NULL, title_en TEXT NOT NULL, message_ru TEXT NOT NULL, message_en TEXT NOT NULL); INSERT INTO incidents VALUES (1, 'manual', NULL, NULL, 1000, NULL, 'Сообщение', 'Message', 'Текст', 'Text');");
db.close();
const store = openStore(path);
try { assert.equal(store.incidents()[0].title.en, 'Message'); assert.equal(store.incidents()[0].scope, 'availability'); assert.equal(store.maintenances().length, 0); }
finally { store.close(); rmSync(folder, { recursive: true, force: true }); }
});
test('night-time empty departures are healthy; malformed and partial responses are failures', () => {
assert.equal(Boolean(validDepartures({ routes: [], departures: [], partial: false })), true);
assert.equal(Boolean(validEta({ routes: [{ id: 1, arrivals: [] }] })), true);
assert.equal(Boolean(validBoard({ mode: 'live', stop: { id: 1 }, departures: [], partial: false })), true);
assert.equal(Boolean(validDepartures({ routes: [], departures: [], partial: true })), false);
assert.equal(Boolean(validEta({ routes: [{ id: 1, error: 'unavailable', arrivals: [] }] })), false);
assert.equal(Boolean(validBoard({ mode: 'demo', stop: { id: 1 }, departures: [] })), false);
});
test('PWA journey obtains real data and requires a successful render check', async () => {
const pwa = config.services.find(service => service.id === 'pwa');
const calls = [];
const runner = createScenarioRunner(config, {
fetchImpl: async url => { const resource = new URL(url).searchParams.get('resource'); calls.push(resource); return Response.json(resource === 'cities' ? [{ id: 1, name: 'Город', slug: 'polotsk' }] : resource === 'stops' ? [{ id: 2, name: 'Остановка', routesCount: 1 }] : { routes: [], departures: [], partial: false }); },
browserChecks: { run: async (_spec, target) => { assert.equal(target.stopId, 2); return { ok: true, latencyMs: 200, httpCode: 200 }; }, close: async () => {} },
});
try { const result = await runner.run(pwa); assert.equal(result.ok, true); assert.equal(result.mode, 'browser'); assert.deepEqual(calls, ['cities', 'stops', 'departures']); assert.equal(result.steps.at(-1).status, 'passed'); }
finally { await runner.close(); }
});
test('HTML success cannot hide a broken journey, and missing access is unknown', async () => {
const pwa = config.services.find(service => service.id === 'pwa');
const runner = createScenarioRunner(config, { fetchImpl: async () => new Response('<html>error</html>'), browserChecks: { run: async () => { throw new Error('must not render'); }, close: async () => {} } });
try { assert.equal((await runner.run(pwa)).ok, false); assert.equal((await runner.run(service)).reason, 'unconfigured'); }
finally { await runner.close(); }
});
test('API journey uses city headers and publishes no key or transport payload', async () => {
const keyed = loadConfig({ RIDEBUS_API_KEY: 'private-key', STATUS_BROWSER_CHECKS: 'off' });
const api = keyed.services[0];
const requests = [];
const runner = createScenarioRunner(keyed, { fetchImpl: async (url, options) => { requests.push({ url, headers: options.headers }); return Response.json(url.endsWith('/cities') ? [{ id: 3, name: 'City' }] : url.endsWith('/stops') ? [{ id: 7, name: 'Private test stop', routes_count: 1 }] : { routes: [{ id: 1, arrivals: [] }] }); } });
try {
const result = await runner.run(api); assert.equal(result.ok, true);
assert.equal(requests[0].headers['X-City-Id'], undefined); assert.equal(requests[1].headers['X-City-Id'], '3');
assert.equal(requests[2].headers['X-API-Key'], 'private-key'); assert.doesNotMatch(JSON.stringify(result), /private-key|Private test stop/);
} finally { await runner.close(); }
});
test('scenario polling is independent and respects its saved cadence after restart', async () => {
const store = openStore(':memory:');
let time = 1000000, calls = 0;
const one = { ...config, services: [service] };
const runScenario = async () => { calls++; return { ...good, mode: 'http', steps: [] }; };
const monitor = createMonitor(store, one, { check: async () => good, runScenario, now: () => time });
try {
await monitor.cycle(); await monitor.scenarioCycle(); await monitor.scenarioCycle(); assert.equal(calls, 1);
time += config.scenarioIntervalSeconds * 1000; await monitor.scenarioCycle(); assert.equal(calls, 2);
const next = createMonitor(store, one, { runScenario, now: () => time }); await next.scenarioCycle(); assert.equal(calls, 2); await next.stop();
time += config.scenarioIntervalSeconds * 3000 + 1;
assert.equal(makeSnapshot(store, one, 30, time).services[0].scenario.status, 'unknown');
} finally { await monitor.stop(); store.close(); }
});
test('scenario failures open a separate incident after confirmation and recover after repeat success', () => {
const store = openStore(':memory:');
try {
for (const time of [1000, 2000, 3000]) store.recordScenario(service, { ...bad, steps: [], mode: 'http' }, nextState(store.scenario(service), bad, config), time);
assert.equal(store.incidents()[0].scope, 'scenario');
for (const time of [4000, 5000]) store.recordScenario(service, { ...good, steps: [], mode: 'http' }, nextState(store.scenario(service), good, config), time);
assert.equal(store.incidents()[0].resolvedAt, 5000);
} finally { store.close(); }
});
test('restart schedules the next journey at its saved deadline without an extra full interval', async () => {
const store = openStore(':memory:');
const delays = [];
let calls = 0;
const recordedAt = 1000000;
store.recordScenario(service, { ...good, mode: 'http', steps: [] }, nextState(null, good, config), recordedAt);
const monitor = createMonitor(store, { ...config, services: [service] }, {
now: () => recordedAt + config.scenarioIntervalSeconds * 1000 - 10000,
check: async () => good,
runScenario: async () => { calls++; return { ...good, mode: 'http', steps: [] }; },
setTimer: (callback, delay) => { delays.push(delay); return delays.length; },
clearTimer: () => {},
});
try {
monitor.start();
await new Promise(resolve => setImmediate(resolve));
assert.equal(calls, 0);
assert.ok(delays.includes(10000));
assert.ok(delays.includes(config.intervalSeconds * 1000));
} finally { await monitor.stop(); store.close(); }
});
test('notification outbox queues one outage and one recovery and holds ordering across retries', async () => {
const store = openStore(':memory:');
const enabled = { ...config, telegram: { ...config.telegram, enabled: true } };
let time = 100000, sent = 0;
try {
for (let index = 0; index < 4; index++) store.record(service, bad, nextState(store.state(service), bad, config), time + index, { notify: true });
for (let index = 0; index < 2; index++) store.record(service, good, nextState(store.state(service), good, config), time + 10 + index, { notify: true });
time += 20;
assert.equal(store.dueNotifications(time).length, 1);
const notifier = createNotifier(store, enabled, { now: () => time, send: async () => { sent++; return sent === 1 ? { ok: false, retryable: true, reason: 'network' } : { ok: true }; } });
await notifier.flush(); assert.equal(sent, 1);
time += 15000; await notifier.flush(); assert.equal(sent, 1);
time += 15000; await notifier.flush(); assert.equal(sent, 2);
await notifier.flush(); assert.equal(sent, 3); await notifier.flush(); assert.equal(sent, 3);
assert.deepEqual(store.notificationStats().map(row => ({ ...row })), [{ status: 'sent', count: 2 }]);
await notifier.stop();
} finally { store.close(); }
});
test('Telegram transport uses POST and sanitizes rate limits and credential errors', async () => {
const telegram = { ...config.telegram, token: '123:private-token', chatId: '1234' };
const success = await sendTelegram(telegram, 'Test', { fetchImpl: async (url, options) => { assert.match(url, /^https:\/\/api.telegram.org/); assert.equal(options.method, 'POST'); assert.equal(JSON.parse(options.body).chat_id, '1234'); assert.equal(options.redirect, 'error'); return Response.json({ ok: true }); } });
assert.equal(success.ok, true);
const limited = await sendTelegram(telegram, 'Test', { fetchImpl: async () => Response.json({ ok: false, error_code: 429, parameters: { retry_after: 45 } }, { status: 429 }) });
assert.equal(limited.retryAfterSeconds, 45); assert.equal(limited.retryable, true);
const failed = await sendTelegram(telegram, 'Test', { fetchImpl: async () => { throw new Error('123:private-token'); } });
assert.doesNotMatch(JSON.stringify(failed), /private-token/);
const text = notificationText({ kind: 'outage', scope: 'scenario', created_at: 100000 }, service, telegram);
assert.match(text, /Пользовательский сценарий/); assert.doesNotMatch(text, /private-token|1234/);
});
test('notification delivery stays disabled until both Telegram settings are present', async () => {
const store = openStore(':memory:');
let called = false;
try {
const notifier = createNotifier(store, config, { send: async () => { called = true; return { ok: true }; } });
await notifier.flush(); assert.equal(called, false); await notifier.stop();
assert.equal(loadConfig({ TELEGRAM_BOT_TOKEN: 'private' }).telegram.enabled, false);
} finally { store.close(); }
});