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