import assert from "node:assert/strict" import { getStatistics, getStatisticsPivot, normalizeFactService, parseStatisticsPeriod, pivotDimsConflict } from "./statistics-aggregate.js" import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js" import { setRefreshIfacesForTests } from "./traffic-flow-ifaces.js" import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js" import { withPgOrSkip } from "../test/pg.js" import { dbQuery } from "../db/index.js" import { ensurePartitionFor } from "../db/partitions.js" import { pool } from "../db/index.js" import { STATISTICS_UNBOUND_USER_ID } from "@mmapp/contracts/statistics" { const sameDay = parseStatisticsPeriod("2026-09-10", "2026-09-10") assert.ok(sameDay) assert.equal(sameDay.fromDay, "2026-09-10") assert.equal(sameDay.toDayExclusive, "2026-09-11") assert.equal(sameDay.grain, "hour") const month = parseStatisticsPeriod("2026-08-01", "2026-08-31") assert.ok(month) assert.equal(month.grain, "day") assert.equal(month.toDayExclusive, "2026-09-01") assert.equal(parseStatisticsPeriod("2026-09-10", "2026-09-09"), null) assert.equal(pivotDimsConflict("country", "country"), true) assert.equal(pivotDimsConflict("country", "service"), false) } { const nowMs = Date.parse("2026-09-12T12:00:00Z") // «Сегодня»: окно до сейчас, не до конца суток — иначе avgBps размывается будущими часами. const today = parseStatisticsPeriod("2026-09-12", "2026-09-12", nowMs) assert.ok(today) assert.equal(today.windowSec, 12 * 3600) assert.equal(today.grain, "hour") assert.equal(today.toDayExclusive, "2026-09-13", "дневные факты текущего дня не теряем") // Прошлые периоды не клампятся. const past = parseStatisticsPeriod("2026-09-10", "2026-09-10", nowMs) assert.ok(past) assert.equal(past.windowSec, 86_400) // ISO-диапазон ровно 24 часа. const iso24 = parseStatisticsPeriod("2026-09-11T12:00:00Z", "2026-09-12T12:00:00Z", nowMs) assert.ok(iso24) assert.equal(iso24.windowSec, 86_400) assert.equal(iso24.grain, "hour") // «to» далеко в будущем клампится к сейчас. const futureTo = parseStatisticsPeriod("2026-09-11", "2026-09-20", nowMs) assert.ok(futureTo) assert.equal(futureTo.windowSec, 86_400 + 12 * 3600) // Полностью будущий диапазон невалиден. assert.equal(parseStatisticsPeriod("2026-09-13", "2026-09-14", nowMs), null) } { // Таксономия сервисов как на карте: skip-список сворачивается в «Прочее». assert.equal(normalizeFactService("Google"), "Google") assert.equal(normalizeFactService("DNS"), "Прочее") assert.equal(normalizeFactService("SSH"), "Прочее") assert.equal(normalizeFactService("BGP"), "Прочее") assert.equal(normalizeFactService("WireGuard"), "Прочее") assert.equal(normalizeFactService("GRE"), "Прочее") assert.equal(normalizeFactService("Прочее"), "Прочее") assert.equal(normalizeFactService(""), "Прочее") } if (!(await withPgOrSkip())) { console.log("statistics-aggregate.test.ts: skip") process.exit(0) } const inserted = await dbQuery<{ id: number }>(` INSERT INTO servers (name, host, type, wan_uplinks) VALUES ('stats-cube', '127.0.0.1', 'jump-host', '[{"iface":"wan1"}]'::jsonb) RETURNING id `) const serverId = inserted.rows[0]?.id if (serverId == null) throw new Error("no server") const enInserted = await dbQuery<{ id: number }>(` INSERT INTO servers (name, host, type) VALUES ('stats-en', '198.51.100.1', 'exit-node') RETURNING id `) const enId = enInserted.rows[0]?.id if (enId == null) throw new Error("no en server") await ensurePartitionFor(pool, "flow_daily_facts", "month", new Date("2026-09-01T00:00:00Z")) await ensurePartitionFor(pool, "flow_hour_facts", "day", new Date("2026-09-10T00:00:00Z")) await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id IN ($1, $2)`, [serverId, enId]) await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id IN ($1, $2)`, [serverId, enId]) await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = $1`, [serverId]) await dbQuery(`DELETE FROM server_snapshots WHERE server_id IN ($1, $2)`, [serverId, enId]) await dbQuery(`DELETE FROM app_users WHERE id = 'u-stats-1'`) await dbQuery(` INSERT INTO app_users (id, name, login, role, active) VALUES ('u-stats-1', 'Клиент', 'stats-user', 'viewer', TRUE) ON CONFLICT (id) DO NOTHING `) await dbQuery(` INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type) VALUES ('bind-stats-1', 'u-stats-1', $1, 'gre-client', 'gre') `, [serverId]) await dbQuery(` INSERT INTO server_snapshots (server_id, polled_at, status, raw_interfaces) VALUES ($1, '2026-09-10T12:00:00Z', 'online', $3::jsonb), ($2, '2026-09-10T12:00:00Z', 'online', $4::jsonb) `, [ serverId, enId, JSON.stringify([ { name: "gre-client", type: "gre-tunnel" }, { name: "wan1", type: "ether" }, { name: "gre-en", type: "gre-tunnel" }, { name: "NSK-SERVHOST-RTK", type: "gre-tunnel" }, { name: "wg-mesh", type: "wg" }, { name: "wg-server", type: "wg" }, { name: "wg-flow", type: "wg" }, ]), JSON.stringify([ { name: "ether1", type: "ether" }, { name: "gre-jh", type: "gre-tunnel" }, ]), ]) resetIfaceCacheForTests() rememberServerIfaces(serverId, [ { name: "gre-client", ifindex: "2" }, { name: "wan1", ifindex: "8" }, { name: "gre-en", ifindex: "9" }, { name: "NSK-SERVHOST-RTK" }, { name: "wg-mesh" }, { name: "wg-server" }, { name: "wg-flow" }, ]) rememberServerIfaces(enId, [ { name: "ether1", ifindex: "2" }, { name: "gre-jh", ifindex: "5" }, ]) setRefreshIfacesForTests(async () => {}) invalidateFlowCatalogCache() await dbQuery(` INSERT INTO flow_daily_facts (server_id, day, iface, country, service, asn, bytes, packets) VALUES ($1, '2026-09-10', '2', 'US', 'https', 15169, 800, 10), ($1, '2026-09-10', '2', 'DE', 'dns', 15133, 200, 4), ($1, '2026-09-10', 'wan1', 'NL', 'other', 0, 70, 1), ($1, '2026-09-10', '0', 'US', 'https', 0, 999, 3), ($1, '2026-09-10', 'gre-en', 'US', 'https', 15169, 400, 2), ($1, '2026-09-10', 'NSK-SERVHOST-RTK', 'US', 'https', 15169, 300, 2), ($1, '2026-09-10', 'wg-mesh', 'US', 'https', 0, 250, 2), ($1, '2026-09-10', 'wg-flow', 'US', 'https', 0, 80, 1), ($2, '2026-09-10', 'gre-jh', 'US', 'https', 15169, 500, 5), ($2, '2026-09-10', 'ether1', 'US', 'https', 15169, 200, 2) `, [serverId, enId]) try { const unique = await getStatistics({ from: "2026-09-01", to: "2026-09-30", planes: "unique" }) assert.equal(unique.grain, "day") assert.equal(unique.kpis.bytes, 1000) const uniqueAsnSum = unique.asns.reduce((s, r) => s + r.bytes, 0) assert.equal(uniqueAsnSum, unique.kpis.bytes, "unique KPI = SUM dest ASN") assert.equal(unique.kpis.users, 1) assert.ok(unique.countries.some((r) => r.id === "US")) assert.ok(unique.users.some((r) => r.id === "u-stats-1")) assert.equal(unique.users.find((r) => r.id === STATISTICS_UNBOUND_USER_ID), undefined) assert.ok(unique.servers.some((r) => r.id === String(serverId))) assert.ok(!unique.servers.some((r) => r.id === String(enId)), "EN-транзит не в сетевом KPI") const greIface = unique.interfaces.find((r) => r.label.includes("gre-client")) assert.ok(greIface) assert.equal(greIface.bytes, 1000) assert.equal(greIface.id, `${serverId}:gre-client`) assert.ok(!unique.interfaces.some((r) => /· (?:#)?\d+$/.test(r.label))) assert.ok(!unique.interfaces.some((r) => r.label.includes(" · —") || r.label.endsWith("· —"))) assert.ok(!unique.interfaces.some((r) => r.label.includes("gre-en"))) assert.ok(!unique.interfaces.some((r) => r.label.includes("NSK-SERVHOST-RTK"))) assert.ok(!unique.interfaces.some((r) => r.label.includes("wg-mesh"))) assert.ok(!unique.interfaces.some((r) => r.label.includes("wg-flow"))) assert.equal(unique.interfaces.find((r) => r.id === `${serverId}:wan1`), undefined, "unique без WAN") const allPlanes = await getStatistics({ from: "2026-09-01", to: "2026-09-30", planes: "all" }) assert.equal(allPlanes.kpis.bytes, 1000, "KPI unique и all одинаковый") const wanRow = allPlanes.interfaces.find((r) => r.id === `${serverId}:wan1`) assert.ok(wanRow) assert.ok(wanRow.label.includes("WAN · интернет")) assert.equal(wanRow.bytes, 70) assert.equal(wanRow.percent, 0) const overlayGre = allPlanes.interfaces.find((r) => r.id === `${serverId}:gre-en`) assert.ok(overlayGre) assert.ok(overlayGre.label.includes("дубль")) assert.equal(overlayGre.percent, 0) const overlayCustom = allPlanes.interfaces.find((r) => r.label.includes("NSK-SERVHOST-RTK")) assert.ok(overlayCustom) assert.ok(overlayCustom.label.includes("дубль")) const overlayWg = allPlanes.interfaces.find((r) => r.label.includes("wg-mesh")) assert.ok(overlayWg) assert.ok(overlayWg.label.includes("дубль")) assert.ok(!allPlanes.interfaces.some((r) => r.label.includes("wg-flow"))) const wanSlice = await getStatistics({ from: "2026-09-01", to: "2026-09-30", serverId, iface: "wan1", }) assert.equal(wanSlice.kpis.bytes, 70) const nodeSlice = await getStatistics({ from: "2026-09-01", to: "2026-09-30", serverId, planes: "unique", }) assert.equal(nodeSlice.kpis.bytes, 1000) assert.equal(nodeSlice.interfaces.find((r) => r.id === `${serverId}:wan1`), undefined) assert.ok(!nodeSlice.users.some((r) => r.id === STATISTICS_UNBOUND_USER_ID)) const nodeAll = await getStatistics({ from: "2026-09-01", to: "2026-09-30", serverId, planes: "all", }) assert.equal(nodeAll.kpis.bytes, 1000) const nodeWan = nodeAll.interfaces.find((r) => r.id === `${serverId}:wan1`) assert.ok(nodeWan) assert.equal(nodeWan.percent, 0) assert.ok(nodeWan.label.includes("WAN · интернет")) const enSlice = await getStatistics({ from: "2026-09-01", to: "2026-09-30", serverId: enId, planes: "unique", }) assert.equal(enSlice.kpis.bytes, 0) assert.ok(!enSlice.interfaces.some((r) => r.label.includes("gre-jh"))) assert.ok(!enSlice.interfaces.some((r) => r.label.includes("WAN · интернет"))) const enAll = await getStatistics({ from: "2026-09-01", to: "2026-09-30", serverId: enId, planes: "all", }) assert.equal(enAll.kpis.bytes, 0) assert.ok(enAll.interfaces.some((r) => r.label.includes("WAN · интернет") && r.label.includes("ether1") && r.percent === 0)) assert.ok(enAll.interfaces.some((r) => r.label.includes("gre-jh") && r.label.includes("дубль"))) const sliced = await getStatistics({ from: "2026-09-01", to: "2026-09-30", country: "US", service: "https", asn: 15169, }) assert.equal(sliced.kpis.bytes, 800) assert.equal(sliced.countries.length, 1) assert.equal(sliced.countries[0]?.id, "US") assert.ok(sliced.users.some((r) => r.id === "u-stats-1")) const byUser = await getStatistics({ from: "2026-09-01", to: "2026-09-30", userId: "u-stats-1", }) assert.equal(byUser.kpis.bytes, 1000) const pivot = await getStatisticsPivot({ from: "2026-09-01", to: "2026-09-30", row: "country", col: "service", metric: "bytes", }) const us = pivot.rows.find((r) => r.id === "US") const de = pivot.rows.find((r) => r.id === "DE") assert.ok(us) assert.ok(de) assert.equal(us.cells.https, 800) assert.equal(de.cells.dns, 200) await dbQuery(` INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type) VALUES ('bind-stats-wg', 'u-stats-1', $1, 'wg-server', 'wg') `, [serverId]) await dbQuery(` INSERT INTO flow_daily_facts (server_id, day, iface, country, service, asn, bytes, packets) VALUES ($1, '2026-09-10', 'wg-server', 'US', 'https', 15169, 150, 2) `, [serverId]) invalidateFlowCatalogCache() const withWg = await getStatistics({ from: "2026-09-01", to: "2026-09-30", planes: "unique" }) assert.equal(withWg.kpis.bytes, 1150) assert.ok(withWg.interfaces.some((r) => r.label.includes("wg-server") && r.bytes === 150)) assert.ok(!withWg.interfaces.some((r) => r.label.includes("wg-flow"))) assert.ok(!withWg.interfaces.some((r) => r.label.includes("wg-mesh"))) await dbQuery(` INSERT INTO flow_hour_facts (server_id, bucket_at, iface, country, service, asn, bytes, packets) VALUES ($1, '2026-09-10T10:00:00Z', '2', 'US', 'https', 15169, 40, 2) `, [serverId]) const hourly = await getStatistics({ from: "2026-09-10T00:00:00.000Z", to: "2026-09-10T23:00:00.000Z", }) assert.equal(hourly.grain, "hour") assert.equal(hourly.kpis.bytes, 40) assert.ok(hourly.users.some((r) => r.id === "u-stats-1")) } finally { setRefreshIfacesForTests(null) resetIfaceCacheForTests() invalidateFlowCatalogCache() await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id IN ($1, $2)`, [serverId, enId]) await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id IN ($1, $2)`, [serverId, enId]) await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id IN ($1, $2)`, [serverId, enId]) await dbQuery(`DELETE FROM server_snapshots WHERE server_id IN ($1, $2)`, [serverId, enId]) await dbQuery(`DELETE FROM servers WHERE id IN ($1, $2)`, [serverId, enId]) } console.log("statistics-aggregate.test.ts: ok")