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 & ', 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 & 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(//g) || []).length, 2); assert.match(first, /Cause &lt;error&gt; &amp; fix/); assert.doesNotMatch(first, /|signature|apiKey|cookie/); assert.deepEqual(first.match(//g), second.match(//g)); assert.match(first, /#incident-1/); assert.equal(xmlEscape('🙂\u0000\ud800 <'), '🙂 <'); } 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(//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(); } });