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

157 lines
11 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 { 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(); }
});