199 lines
11 KiB
JavaScript
199 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 { 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 }); }
|
|
});
|