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('error'), 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(); } });