initial commit

This commit is contained in:
2026-10-13 02:24:39 +03:00
commit 191b80b6b1
80 changed files with 6863 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { loadConfig } from '../server/config.mjs';
import { createScenarioRunner } from '../server/scenarios.mjs';
const cookie = 'ridebus_board_device=test-device-secret';
const publicSettings = { cityId: 1, defaultStopId: 1, configured: true };
const managedSettings = { cityId: 2, defaultStopId: 28, configured: true };
function fixture({ device = { enabled: true, state: 'paired', config: managedSettings }, deviceStatus = 200, env = { STATUS_BOARD_TEST_COOKIE: cookie } } = {}) {
const config = loadConfig(env);
const service = config.services.find(item => item.id === 'board');
const requests = [], renders = [];
const runner = createScenarioRunner(config, {
fetchImpl: async (url, options) => {
assert.equal(options.method || 'GET', 'GET');
const { pathname, search } = new URL(url);
requests.push({ path: pathname + search, cookie: options.headers.Cookie });
if (pathname === '/api/config') return Response.json(publicSettings);
if (pathname === '/api/device/status') return Response.json(device, { status: deviceStatus });
if (pathname === '/api/stops') return Response.json([{ id: 1, name: 'Default stop' }, { id: 28, name: 'Assigned stop' }, { id: 99, name: 'Override stop' }]);
if (pathname === '/api/board') {
const stopId = Number(new URL(url).searchParams.get('stopId'));
if (device.enabled && env.STATUS_BOARD_TEST_COOKIE && stopId !== managedSettings.defaultStopId) return Response.json({ error: 'settings_changed' }, { status: 409 });
return Response.json({ mode: 'live', stop: { id: stopId }, departures: [], partial: false });
}
throw new Error('Unexpected request');
},
browserChecks: { run: async (_spec, target) => { renders.push({ ...target }); return { ok: true, latencyMs: 20, httpCode: 200 }; }, close: async () => {} },
});
return { runner, service, requests, renders };
}
test('paired board uses its assigned city and stop instead of public defaults or env override', async () => {
const item = fixture({ env: { STATUS_BOARD_TEST_COOKIE: cookie, STATUS_TEST_CITY_ID: '1', STATUS_BOARD_TEST_STOP_ID: '99' } });
try {
const result = await item.runner.run(item.service);
assert.equal(result.ok, true);
assert.deepEqual(item.renders, [{ cityId: 2, stopId: 28 }]);
assert.deepEqual(item.requests.map(value => value.path), ['/api/config', '/api/device/status', '/api/stops', '/api/board?stopId=28']);
assert.ok(item.requests.every(value => value.cookie === cookie));
assert.deepEqual(result.steps.map(value => value.id), ['config', 'device', 'stops', 'departures', 'render']);
assert.doesNotMatch(JSON.stringify(result), /test-device-secret|Assigned stop/);
} finally { await item.runner.close(); }
});
test('standalone board keeps its explicit test stop and needs no device endpoint', async () => {
const item = fixture({ env: { STATUS_BOARD_TEST_STOP_ID: '99' } });
try {
assert.equal((await item.runner.run(item.service)).ok, true);
assert.deepEqual(item.renders, [{ cityId: 1, stopId: 99 }]);
assert.ok(item.requests.every(value => value.path !== '/api/device/status' && value.cookie === undefined));
} finally { await item.runner.close(); }
});
test('cookie on a board with remote settings disabled retains standalone configuration', async () => {
const item = fixture({ device: { enabled: false } });
try {
assert.equal((await item.runner.run(item.service)).ok, true);
assert.deepEqual(item.renders, [{ cityId: 1, stopId: 1 }]);
} finally { await item.runner.close(); }
});
test('unpaired, pending and expired test sessions are missing access rather than outages', async () => {
for (const state of ['unpaired', 'pending', 'expired']) {
const item = fixture({ device: { enabled: true, state } });
try {
const result = await item.runner.run(item.service);
assert.equal(result.ok, null);
assert.equal(result.reason, 'auth_required');
assert.equal(result.steps.at(-1).id, 'device');
assert.equal(result.steps.at(-1).status, 'failed');
assert.equal(item.renders.length, 0);
assert.equal(item.requests.length, 2);
} finally { await item.runner.close(); }
}
});
test('revoked device access is unknown while malformed paired configuration remains a failure', async () => {
for (const options of [{ device: { error: 'device_revoked' }, deviceStatus: 410 }, { device: { enabled: true, state: 'paired', config: { ...managedSettings, defaultStopId: null } } }]) {
const item = fixture(options);
try {
const result = await item.runner.run(item.service);
assert.equal(result.ok, options.deviceStatus === 410 ? null : false);
assert.equal(result.reason, options.deviceStatus === 410 ? 'auth_required' : 'content');
assert.equal(item.renders.length, 0);
} finally { await item.runner.close(); }
}
});
test('only board scenario history is separated from the old public-default probe', () => {
for (const service of loadConfig({ STATUS_BOARD_TEST_COOKIE: cookie }).services.filter(item => item.scenario)) {
const { signature: _signature, ...scenario } = service.scenario;
const previousSignature = createHash('sha256').update(JSON.stringify({ ...scenario, apiKey: Boolean(scenario.apiKey), cookie: Boolean(scenario.cookie) })).digest('hex').slice(0, 24);
if (service.id === 'board') assert.notEqual(service.scenario.signature, previousSignature);
else assert.equal(service.scenario.signature, previousSignature);
}
});

View File

@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { existsSync } from 'node:fs';
import { chromium } from 'playwright';
import { compile } from 'sass';
import { loadConfig } from '../server/config.mjs';
import { createBrowserChecks } from '../server/browser-checks.mjs';
const available = existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome') || existsSync(chromium.executablePath());
test('real browser journey blocks writes, applies reduced motion and handles night-time empty data', { skip: !available }, async () => {
let writes = 0, observedMotion = null;
const css = compile('src/styles.scss').css;
const fixture = createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (req.method !== 'GET') { writes++; res.end('write'); return; }
if (url.pathname === '/api/board') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ departures: [] })); return; }
if (url.pathname === '/motion-report') { observedMotion = url.searchParams.get('motion'); res.end('ok'); return; }
res.setHeader('Content-Type', 'text/html');
res.end(`<!doctype html><html><head><style>${css}</style></head><body><div class="board"><header class="board-header"><h1>Test stop</h1></header></div><section class="servicesCard">Test</section><script>
(async()=>{ await fetch('/api/device/pair',{method:'POST'}).catch(()=>{}); await fetch('/api/board?stopId=1').then(r=>r.json()); await fetch('/motion-report?motion='+getComputedStyle(document.querySelector('.servicesCard')).animationName); const main=document.createElement('main');main.className='scene-container';main.textContent='No departures tonight';document.querySelector('.board').append(main); })();
</script></body></html>`);
});
await new Promise((resolve, reject) => { fixture.once('error', reject); fixture.listen(0, '127.0.0.1', resolve); });
const config = loadConfig({ STATUS_SCENARIO_TIMEOUT_MS: '2000' });
const browser = createBrowserChecks(config);
try {
const result = await browser.run({ type: 'board', baseUrl: `http://127.0.0.1:${fixture.address().port}`, cookie: null }, { cityId: 1, stopId: 1 });
assert.equal(result.ok, true); assert.equal(writes, 0); assert.equal(observedMotion, 'none');
} finally { await browser.close(); fixture.closeIdleConnections(); await new Promise(resolve => fixture.close(resolve)); }
});
test('paired board browser uses real cookies while writes and requests to another origin are blocked', { skip: !available }, async () => {
const cookie = 'ridebus_board_device=test-session-value';
let authenticatedReads = 0, unauthenticatedReads = 0, writes = 0, otherOriginRequests = 0;
const otherOrigin = createServer((_req, res) => { otherOriginRequests++; res.end('unexpected'); });
await new Promise(resolve => otherOrigin.listen(0, '127.0.0.1', resolve));
const fixture = createServer((req, res) => {
if (req.method !== 'GET') { writes++; res.end('write'); return; }
if (req.url.startsWith('/api/')) {
if (req.headers.cookie !== cookie) { unauthenticatedReads++; res.writeHead(401); res.end('{}'); return; }
authenticatedReads++; res.setHeader('Content-Type', 'application/json'); res.end('{}'); return;
}
res.setHeader('Content-Type', 'text/html');
res.end(`<!doctype html><html><body><div class="device-setup">Connecting</div><script>
(async()=>{
await fetch('/api/device/heartbeat',{method:'POST'}).catch(()=>{});
await fetch('http://127.0.0.1:${otherOrigin.address().port}/api/private').catch(()=>{});
if(!(await fetch('/api/device/status')).ok)return;
if(!(await fetch('/api/board?stopId=28')).ok)return;
document.body.innerHTML='<div class="board"><header class="board-header"><h1>Assigned stop</h1></header><main class="scene-container">Live departures</main></div>';
})();
</script></body></html>`);
});
await new Promise(resolve => fixture.listen(0, '127.0.0.1', resolve));
const browser = createBrowserChecks(loadConfig({ STATUS_SCENARIO_TIMEOUT_MS: '3000' }));
try {
const result = await browser.run({ type: 'board', baseUrl: `http://127.0.0.1:${fixture.address().port}`, cookie }, { cityId: 1, stopId: 28 });
assert.equal(result.ok, true);
assert.equal(authenticatedReads, 2);
assert.equal(unauthenticatedReads, 0);
assert.equal(writes, 0);
assert.equal(otherOriginRequests, 0);
} finally {
await browser.close();
fixture.closeAllConnections(); otherOrigin.closeAllConnections();
await Promise.all([new Promise(resolve => fixture.close(resolve)), new Promise(resolve => otherOrigin.close(resolve))]);
}
});

178
test/monitor.test.mjs Normal file
View File

@@ -0,0 +1,178 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadConfig } from '../server/config.mjs';
import { runProbe, checkService, nextState } from '../server/probe.mjs';
import { openStore } from '../server/store.mjs';
import { makeSnapshot, summarize, overallStatus } from '../server/snapshot.mjs';
import { createMonitor } from '../server/monitor.mjs';
import { demoSnapshot } from '../server/demo.mjs';
const config = loadConfig({});
const service = config.services[0];
const healthy = { ok: true, latencyMs: 120, httpCode: 200, reason: null };
const failed = { ok: false, latencyMs: null, httpCode: 503, reason: 'http' };
const fetchHtml = async () => new Response('<!doctype html><html><title>RideBus</title></html>', { headers: { 'content-type': 'text/html' } });
test('configured services match the five RideBus domains and remain server-side', () => {
assert.equal(config.services.length, 5);
assert.deepEqual(config.services.map(item => new URL(item.publicUrl).hostname).sort(), ['admin.ridebus.by', 'api.ridebus.by', 'board.ridebus.by', 'pwa.ridebus.by', 'ridebus.by']);
const keyed = loadConfig({ RIDEBUS_API_KEY: 'test-secret' });
assert.equal(keyed.services[0].probes.length, 2);
assert.equal(keyed.services[0].probes[1].headers['X-API-Key'], 'test-secret');
assert.equal(loadConfig({ STATUS_API_URL: 'off' }).services[0].probes.length, 0);
assert.throws(() => loadConfig({ STATUS_INTERVAL_SECONDS: '0' }));
assert.throws(() => loadConfig({ STATUS_API_URL: 'https://user:secret@example.com' }));
});
test('HTML probes check content as well as HTTP status', async () => {
const probe = { url: 'https://example.com', kind: 'html', headers: {} };
assert.equal((await runProbe(probe, { timeoutMs: 100, fetchImpl: fetchHtml })).ok, true);
const invalid = await runProbe(probe, { timeoutMs: 100, fetchImpl: async () => new Response('{}', { headers: { 'content-type': 'application/json' } }) });
assert.equal(invalid.reason, 'content');
const failure = await runProbe(probe, { timeoutMs: 100, fetchImpl: async () => new Response('gateway', { status: 503 }) });
assert.equal(failure.reason, 'http'); assert.equal(failure.httpCode, 503);
});
test('API probes reject invalid health JSON and require data in the optional functional probe', async () => {
assert.equal((await runProbe(service.probes[0], { timeoutMs: 100, fetchImpl: async () => Response.json({ status: 'healthy' }) })).ok, true);
assert.equal((await runProbe(service.probes[0], { timeoutMs: 100, fetchImpl: async () => Response.json({ status: 'unhealthy' }) })).reason, 'content');
const keyed = loadConfig({ RIDEBUS_API_KEY: 'test-secret' }).services[0];
const result = await checkService(keyed, { timeoutMs: 100, fetchImpl: async url => url.endsWith('/health') ? Response.json({ status: 'healthy' }) : Response.json([]) });
assert.equal(result.ok, false); assert.equal(result.reason, 'content');
});
test('timeouts and network errors are sanitized without exposing URLs or secrets', async () => {
const probe = { url: 'https://example.com?secret=value', kind: 'http', headers: {} };
const timeout = await runProbe(probe, { timeoutMs: 15, fetchImpl: (_url, { signal }) => new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(new Error('private timeout detail')))) });
assert.equal(timeout.reason, 'timeout');
const network = await runProbe(probe, { timeoutMs: 100, fetchImpl: async () => { throw new Error('secret credentials and URL'); } });
assert.equal(network.reason, 'network'); assert.doesNotMatch(JSON.stringify(network), /secret|credentials/);
});
test('custom credentials are removed on cross-origin redirects', async () => {
const calls = [];
const result = await runProbe({ url: 'https://api.example.com/health', kind: 'json', headers: { 'X-API-Key': 'secret' } }, {
timeoutMs: 100,
fetchImpl: async (url, options) => { calls.push({ url, headers: options.headers }); return calls.length === 1 ? new Response(null, { status: 302, headers: { location: 'https://other.example.com/health' } }) : Response.json({ status: 'healthy' }); },
});
assert.equal(result.ok, true); assert.equal(calls[0].headers['X-API-Key'], 'secret'); assert.equal(calls[1].headers['X-API-Key'], undefined);
});
test('a very large response is rejected rather than kept in memory', async () => {
const result = await runProbe(service.probes[0], { timeoutMs: 100, fetchImpl: async () => new Response('x'.repeat(1024 * 1024 + 1)) });
assert.equal(result.reason, 'content');
});
test('outage confirmation and recovery survive across checks without claiming a failed probe is healthy', () => {
let state = nextState(null, healthy, config);
assert.equal(state.status, 'operational');
state = nextState(state, failed, config); assert.equal(state.status, 'degraded');
state = nextState(state, failed, config); assert.equal(state.status, 'degraded');
state = nextState(state, failed, config); assert.equal(state.status, 'down');
state = nextState(state, healthy, config); assert.equal(state.status, 'down');
state = nextState(state, healthy, config); assert.equal(state.status, 'operational');
assert.equal(nextState(state, { ...healthy, latencyMs: 2000 }, config).status, 'degraded');
});
test('SQLite persists history and creates one incident only after confirmation, then resolves it', () => {
const folder = mkdtempSync(join(tmpdir(), 'ridebus-status-store-'));
const file = join(folder, 'status.sqlite');
let store = openStore(file);
try {
for (let index = 1; index <= 3; index++) store.record(service, failed, nextState(store.state(service), failed, config), 1000 * index);
assert.equal(store.incidents().length, 1); assert.equal(store.incidents()[0].resolvedAt, null);
store.close(); store = openStore(file);
assert.equal(store.state(service).status, 'down');
store.record(service, healthy, nextState(store.state(service), healthy, config), 4000);
assert.equal(store.incidents()[0].resolvedAt, null);
store.record(service, healthy, nextState(store.state(service), healthy, config), 5000);
assert.equal(store.incidents()[0].resolvedAt, 5000);
assert.equal(store.checks(service, 0).length, 5);
} finally { store.close(); rmSync(folder, { recursive: true, force: true }); }
});
test('stale results become unknown while historical gaps remain visible', () => {
const store = openStore(':memory:');
const now = Date.UTC(2026, 9, 12, 12);
try {
store.record(service, healthy, nextState(null, healthy, config), now - 181000);
const snapshot = makeSnapshot(store, config, 30, now);
assert.equal(snapshot.services[0].status, 'unknown'); assert.equal(snapshot.services[0].stale, true);
assert.equal(snapshot.overall, 'unknown');
assert.equal(snapshot.services[0].history[0].status, 'unknown'); assert.equal(snapshot.services[0].history[0].uptime, null);
assert.equal(snapshot.services[0].history.at(-1).uptime, 100); assert.ok(snapshot.services[0].history.at(-1).coverage < 95);
} finally { store.close(); }
});
test('sample availability excludes unconfigured observations and does not fill missing hours', () => {
assert.equal(summarize([]).uptime, null);
const result = summarize([{ ok: 1, latencyMs: 120, reason: null }, { ok: 0, latencyMs: null, reason: 'http' }, { ok: 0, latencyMs: null, reason: 'unconfigured' }]);
assert.equal(result.uptime, 50); assert.equal(result.checks, 2);
const store = openStore(':memory:');
try {
const now = Date.UTC(2026, 9, 12, 12);
store.record(service, healthy, nextState(null, healthy, config), now);
const item = makeSnapshot(store, config, 30, now).services[0];
assert.equal(item.responseHistory[0].latencyMs, null); assert.equal(item.history[0].checks, 0);
} finally { store.close(); }
});
test('changing a probe target starts separate history and no sensitive config is published', () => {
const keyedConfig = loadConfig({ RIDEBUS_API_KEY: 'not-public', STATUS_API_URL: 'https://api.ridebus.by/private-check?credential=hidden' });
const store = openStore(':memory:');
try {
store.record(service, healthy, nextState(null, healthy, config));
const snapshot = makeSnapshot(store, keyedConfig);
assert.equal(snapshot.services[0].checks, 0);
assert.doesNotMatch(JSON.stringify(snapshot), /not-public|private-check|credential|hidden|X-API-Key/);
} finally { store.close(); }
});
test('database aggregation preserves sample availability, daily severity, latency and hour gaps', () => {
const store = openStore(':memory:');
const now = Date.UTC(2026, 9, 12, 12);
try {
store.record(service, healthy, { status: 'operational', failures: 0, successes: 1 }, now - 300000);
store.record(service, failed, { status: 'degraded', failures: 1, successes: 0 }, now - 240000);
const item = makeSnapshot(store, config, 30, now).services[0];
assert.equal(item.uptime, 50); assert.equal(item.avgLatencyMs, 120);
assert.equal(item.latencyMs, null); assert.equal(item.history.at(-1).status, 'degraded');
assert.equal(item.history.at(-1).uptime, 50); assert.equal(item.responseHistory.at(-1).checks, 2);
assert.equal(item.responseHistory[0].checks, 0); assert.equal(item.recentChecks.length, 2);
} finally { store.close(); }
});
test('overlapping monitoring cycles coalesce and perform only one check per service', async () => {
const store = openStore(':memory:');
let calls = 0, resolve;
const result = new Promise(done => { resolve = done; });
const monitor = createMonitor(store, config, { check: async () => { calls++; return result; } });
try {
const first = monitor.cycle(), second = monitor.cycle();
assert.equal(calls, 5);
resolve(healthy); await Promise.all([first, second]);
for (const item of config.services) assert.equal(store.checks(item, 0).length, 1);
} finally { await monitor.stop(); store.close(); }
});
test('overall operational status requires fresh successful data for every service', () => {
assert.equal(overallStatus([{ status: 'operational' }, { status: 'unknown' }]), 'unknown');
assert.equal(overallStatus([{ status: 'operational' }, { status: 'down' }]), 'degraded');
assert.equal(overallStatus([{ status: 'down' }, { status: 'down' }]), 'down');
});
test('manual incidents have an explicit lifecycle and demo data is marked', () => {
const store = openStore(':memory:');
try {
const id = store.createIncident({ titleRu: 'Проверяем', titleEn: 'Investigating', messageRu: 'Сообщение', messageEn: 'Update' }, 1000);
assert.equal(store.incidents()[0].source, 'manual');
assert.equal(store.resolveIncident(id, 2000), true); assert.equal(store.resolveIncident(id, 3000), false);
assert.equal(store.incidents()[0].resolvedAt, 2000);
const demo = demoSnapshot(30);
assert.equal(demo.demo, true); assert.equal(demo.services.length, 5);
assert.equal(makeSnapshot(store, config).demo, false); assert.equal(makeSnapshot(store, config).services[0].checks, 0);
} finally { store.close(); }
});

226
test/next-stage.test.mjs Normal file
View File

@@ -0,0 +1,226 @@
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(); }
});

View File

@@ -0,0 +1,156 @@
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 { nextState } from '../server/probe.mjs';
import { makeSnapshot } from '../server/snapshot.mjs';
import { createStatusServer } from '../server/index.mjs';
import { buildFeed, feedOptions, xmlEscape } from '../server/feed.mjs';
import { serviceImpact } from '../shared/impact.mjs';
const config = loadConfig({});
const api = config.services[0];
const good = { ok: true, latencyMs: 100, httpCode: 200, reason: null };
const bad = { ok: false, latencyMs: null, httpCode: 503, reason: 'http' };
const manual = { serviceId: 'api', titleRu: 'Сбой & <расписания>', titleEn: 'Timetable & <issue>', messageRu: 'Проверяем расписание.', messageEn: 'Checking the timetable.' };
test('automatic timeline separates detection, team investigation and genuine recovery without duplicate events', () => {
const store = openStore(':memory:');
try {
for (const time of [1000, 2000, 3000, 4000]) store.record(api, bad, nextState(store.state(api), bad, config), time);
let incident = store.incidents()[0];
assert.equal(incident.updates.length, 1);
assert.deepEqual(incident.updates.map(update => [update.source, update.stage]), [['monitor', 'detected']]);
store.updateIncident(incident.id, { stage: 'investigating', messageRu: 'Команда проверяет.', messageEn: 'The team is investigating.' }, 5000);
assert.equal(store.resolveIncident(incident.id, 6000), false);
for (const time of [6000, 7000, 8000]) store.record(api, good, nextState(store.state(api), good, config), time);
incident = store.incidents()[0];
assert.deepEqual(incident.updates.map(update => [update.source, update.stage]), [['monitor', 'detected'], ['manual', 'investigating'], ['monitor', 'resolved']]);
assert.equal(incident.resolvedAt, 7000);
assert.throws(() => store.updateIncident(incident.id, { stage: 'identified', messageRu: 'Причина', messageEn: 'Cause' }, 9000), /Closed incidents/);
store.updateIncident(incident.id, { stage: 'postmortem', messageRu: 'Итоги: восстановили источник.', messageEn: 'Outcome: restored the source.' }, 9000);
const closed = store.incidents()[0];
assert.equal(closed.resolvedAt, 7000);
assert.equal(closed.updates.at(-1).stage, 'postmortem');
assert.equal(closed.updates.at(-1).source, 'manual');
} finally { store.close(); }
});
test('manual reports keep their original text and store a final explanation without accepting invalid updates', () => {
const store = openStore(':memory:');
try {
const id = store.createIncident(manual, 1000);
assert.equal(store.incidents()[0].updates[0].stage, 'reported');
assert.throws(() => store.updateIncident(id, { stage: 'postmortem', messageRu: 'Итоги', messageEn: 'Outcome' }, 2000), /Resolve/);
assert.throws(() => store.updateIncident(id, { stage: 'resolved', messageRu: 'Нет', messageEn: 'No' }, 2000), /Stage/);
assert.throws(() => store.updateIncident(id, { stage: 'identified', messageRu: 'Причина' }, 2000), /message/);
store.updateIncident(id, { stage: 'monitoring', messageRu: 'Наблюдаем.', messageEn: 'Monitoring.' }, 3000);
assert.throws(() => store.updateIncident(id, { stage: 'investigating', messageRu: 'Текст', messageEn: 'Text' }, 2000), /timeline/);
assert.equal(store.resolveIncident(id, 4000, { messageRu: 'Причина: ошибка источника. Исправлено.', messageEn: 'Cause: source error. Fixed.' }), true);
assert.equal(store.resolveIncident(id, 5000), false);
const incident = store.incidents()[0];
assert.equal(incident.message.ru, manual.messageRu);
assert.match(incident.updates.at(-1).message.en, /source error/);
assert.equal(incident.updates.length, 3);
} finally { store.close(); }
});
test('legacy incidents gain only recorded timeline facts and repeated startup is idempotent', () => {
const dir = mkdtempSync(join(tmpdir(), 'ridebus-updates-'));
const path = join(dir, 'legacy.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, 'monitor', 'api', 'old', 1000, 2000, 'Сбой', 'Outage', 'Старое сообщение', 'Original message');`);
db.close();
try {
for (let index = 0; index < 2; index++) {
const store = openStore(path);
const incident = store.incidents()[0];
assert.deepEqual(incident.updates.map(update => update.stage), ['detected', 'resolved']);
assert.equal(incident.updates[0].message.en, 'Original message');
assert.equal(incident.resolvedAt, 2000);
store.close();
}
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('impact explains the failed scope and suggests only a currently verified alternative', () => {
const board = { ...config.services.find(service => service.id === 'board'), status: 'degraded', availabilityStatus: 'operational', scenario: { status: 'down' } };
const pwa = { ...config.services.find(service => service.id === 'pwa'), status: 'operational' };
assert.equal(serviceImpact(board, [pwa]).scope, 'scenario');
assert.deepEqual(serviceImpact(board, [pwa]).alternatives.map(item => item.id), ['pwa']);
assert.equal(serviceImpact(board, [{ ...pwa, status: 'unknown' }]).alternatives.length, 0);
assert.equal(serviceImpact({ ...board, status: 'unknown' }, [pwa]), null);
assert.equal(serviceImpact({ ...board, status: 'maintenance' }, [pwa]), null);
assert.equal(serviceImpact({ ...board, status: 'down', availabilityStatus: 'down' }, [pwa]).scope, 'availability');
});
test('RSS selections are strict and deterministic, including empty, duplicate and unknown parameters', () => {
assert.deepEqual(feedOptions(new URLSearchParams('lang=en&services=pwa,api,pwa&types=maintenance'), config.services), { locale: 'en', serviceIds: ['api', 'pwa'], types: ['maintenance'] });
for (const query of ['services=', 'services=missing', 'types=', 'types=spam', 'lang=zz', 'lang=', 'lang=ru&lang=en', 'demo=1']) assert.throws(() => feedOptions(new URLSearchParams(query), config.services));
});
test('RSS contains each incident update, escapes XML and keeps event IDs stable across polling', () => {
const store = openStore(':memory:');
try {
const id = store.createIncident(manual, 1000);
store.updateIncident(id, { stage: 'identified', messageRu: 'Причина <ошибки> & исправление', messageEn: 'Cause <error> & fix' }, 2000);
const options = feedOptions(new URLSearchParams('lang=en&services=api&types=incidents'), config.services);
const first = buildFeed(makeSnapshot(store, config, 7, 3000), options, 'https://status.example');
const second = buildFeed(makeSnapshot(store, config, 7, 4000), options, 'https://status.example');
assert.equal((first.match(/<item>/g) || []).length, 2);
assert.match(first, /Cause &amp;lt;error&amp;gt; &amp;amp; fix/);
assert.doesNotMatch(first, /<error>|signature|apiKey|cookie/);
assert.deepEqual(first.match(/<guid.*?<\/guid>/g), second.match(/<guid.*?<\/guid>/g));
assert.match(first, /#incident-1/);
assert.equal(xmlEscape('🙂\u0000\ud800 <'), '🙂 &lt;');
} finally { store.close(); }
});
test('RSS filtering includes global announcements but excludes other services and event types', () => {
const store = openStore(':memory:');
try {
store.createIncident(manual, 1000);
store.createIncident({ ...manual, serviceId: 'board', titleEn: 'Board only' }, 2000);
store.createIncident({ ...manual, serviceId: null, titleEn: 'Global report' }, 3000);
store.createMaintenance({ titleRu: 'Работы', titleEn: 'Maintenance only', messageRu: 'Обновление', messageEn: 'Update', serviceIds: ['pwa'], startsAt: 5000, endsAt: 9000 }, 4000);
const snapshot = makeSnapshot(store, config, 7, 6000);
const incidentXml = buildFeed(snapshot, feedOptions(new URLSearchParams('lang=en&services=api&types=incidents'), config.services), 'https://status.example');
assert.match(incidentXml, /Global report/); assert.doesNotMatch(incidentXml, /Board only|Maintenance only/);
const maintenanceXml = buildFeed(snapshot, feedOptions(new URLSearchParams('lang=en&services=pwa&types=maintenance'), config.services), 'https://status.example');
assert.match(maintenanceXml, /Maintenance only/); assert.doesNotMatch(maintenanceXml, /Global report|Timetable/);
} finally { store.close(); }
});
test('maintenance RSS records announcement and cancellation without inventing a start or completion', () => {
const store = openStore(':memory:');
try {
const id = store.createMaintenance({ titleRu: 'Работы', titleEn: 'Cancelled window', messageRu: 'Текст', messageEn: 'Text', serviceIds: [], startsAt: 5000, endsAt: 9000 }, 1000);
store.endMaintenance(id, 'cancel', 2000);
const xml = buildFeed(makeSnapshot(store, config, 7, 10000), feedOptions(new URLSearchParams('lang=en&types=maintenance'), config.services), 'https://status.example');
assert.equal((xml.match(/<item>/g) || []).length, 2);
assert.match(xml, /Maintenance cancelled/); assert.doesNotMatch(xml, /window started|window ended|Completed by/);
} finally { store.close(); }
});
test('feed endpoint is read-only XML, uses the configured canonical URL and excludes demo events', async () => {
const app = await createStatusServer(loadConfig({ STATUS_DB_PATH: ':memory:', STATUS_PUBLIC_URL: 'https://status.example', PORT: '0', HOST: '127.0.0.1' }), { monitorEnabled: false });
try {
await new Promise(resolve => app.server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${app.server.address().port}`;
app.store.createIncident(manual, Date.now());
const response = await fetch(`${origin}/feed.xml?lang=en&services=api`);
assert.match(response.headers.get('content-type'), /^application\/rss\+xml/);
assert.equal(response.headers.get('cache-control'), 'no-store');
const xml = await response.text();
assert.match(xml, /https:\/\/status.example/); assert.doesNotMatch(xml, /example-active|demo-active/);
assert.equal((await fetch(`${origin}/feed.xml?services=missing`)).status, 400);
assert.equal((await fetch(`${origin}/feed.xml?demo=1`)).status, 400);
assert.equal((await fetch(`${origin}/feed.xml`, { method: 'POST' })).status, 405);
assert.equal(await (await fetch(`${origin}/feed.xml`, { method: 'HEAD' })).text(), '');
} finally { await app.close(); }
});

23
test/server.test.mjs Normal file
View File

@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig } from '../server/config.mjs';
import { createStatusServer } from '../server/index.mjs';
test('public HTTP API is read-only, uncached, strict on ranges and isolated from demo data', async () => {
const config = loadConfig({ STATUS_DB_PATH: ':memory:', PORT: '0', HOST: '127.0.0.1' });
const app = await createStatusServer(config, { monitorEnabled: false });
try {
await new Promise((resolve, reject) => { app.server.once('error', reject); app.server.listen(0, '127.0.0.1', resolve); });
const origin = `http://127.0.0.1:${app.server.address().port}`;
const response = await fetch(`${origin}/api/status?range=30`);
assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store');
const live = await response.json(); assert.equal(live.overall, 'unknown'); assert.equal(live.demo, false);
assert.equal((await fetch(`${origin}/api/status?range=31`)).status, 400);
assert.equal((await fetch(`${origin}/api/status`, { method: 'POST' })).status, 405);
assert.equal((await fetch(`${origin}/api/private`)).status, 404);
assert.equal((await (await fetch(`${origin}/api/status?demo=1`)).json()).demo, true);
assert.equal((await (await fetch(`${origin}/api/status`)).json()).services[0].checks, 0);
assert.equal((await fetch(`${origin}/healthz`)).status, 200);
assert.equal((await fetch(`${origin}/../.env`)).status, 404);
} finally { await app.close(); }
});

198
test/statistics.test.mjs Normal file
View File

@@ -0,0 +1,198 @@
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 { nextState } from '../server/probe.mjs';
import { makeSnapshot } from '../server/snapshot.mjs';
import { demoSnapshot } from '../server/demo.mjs';
import { createStatusServer } from '../server/index.mjs';
import { unionIntervals, intersectIntervals, subtractIntervals } from '../server/reliability.mjs';
const base = loadConfig({});
const config = { ...base, failureThreshold: 1, recoveryThreshold: 1, staleAfterSeconds: 3, scenarioIntervalSeconds: 1 };
const website = base.services.find(service => service.id === 'website');
const api = base.services.find(service => service.id === 'api');
const good = { ok: true, latencyMs: 100, httpCode: 200, reason: null };
const bad = { ok: false, latencyMs: null, httpCode: 503, reason: 'http' };
function record(store, service, result, time, settings = config) { store.record(service, result, nextState(store.state(service), result, settings), time); }
function scenario(store, result, time) { store.recordScenario(api, { ...result, steps: [], mode: 'http' }, nextState(store.scenario(api), result, config), time); }
test('interval arithmetic merges overlaps once and preserves excluded gaps', () => {
assert.deepEqual(unionIntervals([[4, 8], [1, 5], [8, 10], [15, 14], [12, 13]]), [[1, 10], [12, 13]]);
assert.deepEqual(intersectIntervals([[0, 10]], [[1, 3], [5, 12]]), [[1, 3], [5, 10]]);
assert.deepEqual(subtractIntervals([[0, 10], [20, 30]], [[-1, 2], [4, 6], [8, 22]]), [[2, 4], [6, 8], [22, 30]]);
});
test('outage time starts at confirmation and includes recovery confirmation', () => {
const store = openStore(':memory:');
const thresholds = { ...config, failureThreshold: 3, recoveryThreshold: 2 };
try {
for (const time of [1000, 2000, 3000]) record(store, website, bad, time, thresholds);
for (const time of [4000, 5000]) record(store, website, good, time, thresholds);
const stats = store.reliability(website, 0, 6000, config);
assert.equal(stats.downtimeMs, 2000); assert.equal(stats.longestDowntimeMs, 2000);
assert.equal(stats.incidentCount, 1); assert.equal(stats.lastRecoveryAt, 5000);
assert.equal(stats.downtimeIncomplete, false);
} finally { store.close(); }
});
test('availability and journey outages overlap without double counting', () => {
const store = openStore(':memory:');
try {
record(store, api, bad, 1000); record(store, api, bad, 2000); record(store, api, good, 4000);
scenario(store, good, 1000); scenario(store, bad, 2000); scenario(store, bad, 3000); scenario(store, good, 5000);
const stats = store.reliability(api, 0, 6000, config);
assert.equal(stats.downtimeMs, 4000); assert.equal(stats.longestDowntimeMs, 3000);
assert.equal(stats.incidentCount, 2); assert.equal(stats.lastRecoveryAt, 5000); assert.equal(stats.lastRecoveryScope, 'scenario');
assert.equal(stats.observedMs, 5000);
} finally { store.close(); }
});
test('monitoring gaps expire at the freshness boundary instead of becoming downtime', () => {
const store = openStore(':memory:');
try {
record(store, website, bad, 1000); record(store, website, bad, 10000); record(store, website, good, 11000);
const stats = store.reliability(website, 0, 12000, config);
assert.equal(stats.downtimeMs, 4000); assert.equal(stats.longestDowntimeMs, 4000);
assert.equal(stats.downtimeIncomplete, true); assert.equal(stats.observedMs, 5000);
const clipped = store.reliability(website, 2000, 3000, config);
assert.equal(clipped.downtimeMs, 1000);
} finally { store.close(); }
});
test('maintenance is subtracted from a genuine incident and planned-only failures do not create outage time', () => {
const store = openStore(':memory:');
const window = { titleRu: 'Работы', titleEn: 'Work', messageRu: 'Текст', messageEn: 'Text', serviceIds: ['website'], startsAt: 2000, endsAt: 4000 };
try {
store.createMaintenance(window, 0);
for (const time of [1000, 2000, 3000, 4000, 5000]) record(store, website, bad, time);
record(store, website, good, 6000);
const stats = store.reliability(website, 0, 7000, config);
assert.equal(stats.downtimeMs, 3000); assert.equal(stats.longestDowntimeMs, 3000);
assert.equal(stats.downtimeIncomplete, false);
const other = openStore(':memory:');
try {
other.createMaintenance({ ...window, startsAt: 1000, endsAt: 3000 }, 0);
record(other, website, bad, 1000); record(other, website, bad, 2000);
assert.equal(other.reliability(website, 0, 4000, config).downtimeMs, 0);
assert.equal(other.reliability(website, 0, 4000, config).incidentCount, 0);
} finally { other.close(); }
} finally { store.close(); }
});
test('an unresolved outage stops accumulating measured time when monitoring stops', () => {
const store = openStore(':memory:');
try {
record(store, website, bad, 1000);
assert.equal(store.reliability(website, 0, 50000, config).downtimeMs, 3000);
assert.equal(store.reliability(website, 0, 90000, config).downtimeMs, 3000);
assert.equal(store.reliability(website, 0, 90000, config).downtimeIncomplete, true);
} finally { store.close(); }
});
test('manual reports, unconfigured checks and a changed signature cannot fabricate measured outages', () => {
const store = openStore(':memory:');
try {
store.createIncident({ serviceId: 'website', titleRu: 'Сообщение', titleEn: 'Report', messageRu: 'Текст', messageEn: 'Text' }, 1000);
record(store, website, good, 1000);
assert.equal(store.reliability(website, 0, 2000, config).incidentCount, 0);
assert.equal(store.reliability({ ...website, signature: 'new' }, 0, 2000, config).downtimeMs, null);
const unknown = { ...bad, reason: 'unconfigured' };
const other = { ...website, signature: 'unconfigured' };
record(store, other, unknown, 1000);
const stats = store.reliability(other, 0, 2000, config);
assert.equal(stats.downtimeMs, null); assert.equal(stats.observedMs, 0);
} finally { store.close(); }
});
test('an unchecked required journey cannot be presented as zero downtime', () => {
const store = openStore(':memory:');
try {
record(store, api, good, 1000);
scenario(store, { ...bad, ok: null, reason: 'auth_required' }, 1000);
const stats = store.reliability(api, 0, 2000, config);
assert.equal(stats.downtimeMs, null); assert.equal(stats.longestDowntimeMs, null);
assert.equal(stats.incidentCount, 0); assert.equal(stats.observedMs, 0);
} finally { store.close(); }
});
test('old journey state is retained but historical scenario measurements are not invented on migration', () => {
const dir = mkdtempSync(join(tmpdir(), 'ridebus-statistics-'));
const path = join(dir, 'legacy.sqlite');
try {
const old = openStore(path);
scenario(old, bad, 1000); scenario(old, good, 2000); old.close();
const db = new DatabaseSync(path); db.exec('DROP TABLE scenario_checks'); db.close();
const store = openStore(path);
try {
record(store, api, good, 1000);
const stats = store.reliability(api, 0, 3000, config);
assert.equal(store.scenario(api).status, 'operational');
assert.equal(stats.incidentCount, 1); assert.equal(stats.downtimeMs, null);
assert.equal(stats.downtimeIncomplete, true); assert.equal(stats.lastRecoveryAt, 2000);
} finally { store.close(); }
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test('latency percentile uses successful samples and comparison needs populated windows', () => {
const store = openStore(':memory:');
try {
for (let index = 0; index < 100; index++) {
record(store, website, good, index * 100);
record(store, website, { ...good, latencyMs: index + 1 }, 10000 + index * 100);
}
record(store, website, bad, 19999);
record(store, website, { ...good, latencyMs: 9999 }, 30000);
const stats = store.latencyDetails(website, 10000, 20000);
assert.equal(stats.samples, 100); assert.equal(stats.averageMs, 51); assert.equal(stats.p95Ms, 95);
assert.equal(stats.previousAverageMs, 100); assert.equal(stats.changePercent, -49.5);
assert.equal(store.latencyDetails(website, 30000, 30001).changePercent, null);
assert.equal(store.latencyDetails({ ...website, signature: 'missing' }, 0, 30000).p95Ms, null);
for (const time of [0, 500000, 999999, 1000000, 1500000, 1999999]) record(store, website, good, time);
assert.equal(store.latencyDetails(website, 1000000, 2000000).changePercent, null);
} finally { store.close(); }
});
test('public statistics remain scoped, bounded and present for all demo ranges', () => {
const store = openStore(':memory:');
try {
const snapshot = makeSnapshot(store, base, 30, Date.UTC(2026, 9, 13));
assert.equal(snapshot.services[0].reliability.downtimeMs, null);
assert.equal(snapshot.services[0].latencyHistory.length, 30);
assert.equal(snapshot.services[0].latency24h.samples, 0);
assert.doesNotMatch(JSON.stringify(snapshot), /signature|apiKey|cookie/);
for (const range of [7, 30, 90]) {
const demo = demoSnapshot(range, Date.UTC(2026, 9, 13));
assert.equal(demo.demo, true);
assert.equal(demo.services[0].latencyHistory.length, range);
assert.equal(demo.services.find(service => service.id === 'pwa').reliability.downtimeMs, 2100000);
}
} finally { store.close(); }
});
test('internal snapshot cache expires immediately after local and CLI database writes', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ridebus-statistics-cache-'));
const path = join(dir, 'status.sqlite');
const app = await createStatusServer(loadConfig({ STATUS_DB_PATH: path, PORT: '0', HOST: '127.0.0.1' }), { monitorEnabled: false });
const text = { serviceId: 'website', titleRu: 'Сообщение', titleEn: 'Report', messageRu: 'Текст', messageEn: 'Text' };
try {
await new Promise(resolve => app.server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${app.server.address().port}/api/status?range=30`;
const initial = await (await fetch(url)).json();
assert.deepEqual(await (await fetch(url)).json(), initial);
record(app.store, website, good, Date.now(), base);
assert.equal((await (await fetch(url)).json()).services.find(service => service.id === 'website').checks, 1);
scenario(app.store, good, Date.now());
assert.equal((await (await fetch(url)).json()).services.find(service => service.id === 'api').scenario.status, 'operational');
app.store.createIncident(text);
assert.equal((await (await fetch(url)).json()).incidents.length, 1);
const cli = openStore(path); cli.createIncident(text); cli.close();
const response = await fetch(url);
assert.equal(response.headers.get('cache-control'), 'no-store');
assert.equal((await response.json()).incidents.length, 2);
} finally { await app.close(); rmSync(dir, { recursive: true, force: true }); }
});