import { test, before, after, mock } from "node:test"; import assert from "node:assert/strict"; import { anonymize, endpointOf, percentile } from "../server/metrics.js"; import { startApi } from "./support/nextcloud.js"; test("Метрики: адрес без последней части, адреса документов в одном счётчике", () => { assert.equal(anonymize("203.0.113.57"), "203.0.113.0"); assert.equal(anonymize("::ffff:192.168.1.20"), "192.168.1.0"); assert.equal(anonymize("2001:db8:85a3:8d3:1319:8a2e:370:7348"), "2001:db8:85a3::"); assert.equal(endpointOf("/api/original/0fcb72d4cc36c729aa"), "/api/original/:id"); assert.equal(endpointOf("/api/timetable"), "/api/timetable"); // Bounds 25, 50, 100, …: half the answers took up to 50 ms, 95% up to 1000 ms. const histogram = [3, 3, 2, 1, 0, 1, 0, 0, 0, 0]; assert.equal(percentile(histogram, 0.5), 50); assert.equal(percentile(histogram, 0.95), 1000); assert.equal(percentile(Array(10).fill(0), 0.5), null); }); // The API with its log on, a low request limit and the log lines caught. let api; const lines = []; before(async () => { mock.method(console, "log", (line) => lines.push(line)); api = await startApi({ PSU_REQUEST_LOG: "on", PSU_API_RATE_LIMIT: "6" }); }); after(() => { mock.restoreAll(); api?.stop(); }); const get = (path, headers) => fetch(api.base + path, { headers }); test("Метрики: строка журнала на запрос и сводка в /api/health", async () => { const groups = await get("/api/groups?" + new URLSearchParams({ q: "23-БА" })); const doc = (await groups.json()).results.find((g) => g.group === "23-БА").fileId; const path = "/api/timetable?" + new URLSearchParams({ doc, group: "23-БА", from: "2026-10-19", days: 7 }); const etag = (await get(path)).headers.get("etag"); assert.equal((await get(path, { "If-None-Match": etag, "Cache-Control": "max-age=0" })).status, 304); assert.equal((await get("/api/nope")).status, 404); // Health checks are neither logged nor counted. await get("/api/health"); await get("/api/ready"); const logged = lines.map((l) => JSON.parse(l)); assert.equal(logged.length, 4); assert.deepEqual(logged.map((l) => l.status), [200, 200, 304, 404]); const notFound = logged.at(-1); assert.equal(notFound.method, "GET"); assert.equal(notFound.path, "/api/nope"); assert.equal(notFound.code, "unknown_endpoint"); assert.equal(notFound.ip, "127.0.0.0"); assert.equal(typeof notFound.ms, "number"); assert.ok(!logged.some((l) => l.path.startsWith("/api/health"))); const { traffic } = await (await get("/api/health")).json(); const hour = traffic.lastHour; assert.equal(hour.requests, 4); assert.equal(hour.notModified, 1); assert.equal(hour.notModifiedShare, 0.25); assert.equal(hour.clientErrors, 1); assert.equal(hour.serverErrors, 0); assert.equal(hour.clients, 1); assert.deepEqual(hour.busiestClients, [{ client: "127.0.0.0", requests: 4 }]); assert.equal(hour.endpoints["/api/timetable"].requests, 2); assert.ok(hour.p50Ms !== null && hour.p95Ms !== null); assert.equal(traffic.total.requests, 4); }); test("Метрики: лимит запросов и сбои университета считаются отдельно", async () => { const catalog = await (await get("/api/catalog")).json(); const broken = catalog.files.find((f) => f.name === "Сломанный.docx"); assert.equal((await get(`/api/original/${broken.id}`)).status, 502); // The limit of 6 is reached: 4 earlier requests, the catalog and the original above. assert.equal((await get("/api/nope")).status, 429); const { traffic } = await (await get("/api/health")).json(); const hour = traffic.lastHour; assert.equal(hour.limited, 1); assert.equal(hour.sourceErrors, 1); assert.ok(hour.sourceFailures >= 1); assert.equal(hour.serverErrors, 1); assert.equal(hour.endpoints["/api/original/:id"].errors, 1); });