collabSupport/tests/alarmSemantics.test.js
jmcqueen 9d0dbb071e Add per-app DPI voice-quality checks + widen WAN window to 7d
Adds three new SD-WAN checks (wanAppRtpMos/Loss/Jitter) that measure
REAL voice-traffic quality on actual RTP frames via Prisma DPI, not
synthetic link probes. Graded against the WORST 5-minute window so
transient degradation the 24h link-probe averages smooth away
actually surfaces.

Voice-app selection is tenant-configurable via PRISMA_APP_ID_VOICE +
PRISMA_APP_NAME_VOICE (Webex_Calling_RTP recommended for Webex
Calling shops — the Webex-specific DPI signature excludes non-Webex
UDP noise). Legacy PRISMA_APP_ID_RTP_BASE still honored with a
one-time deprecation warning.

Widens the default WAN look-back from 24h to 7 days: per-app metrics
only get datapoints when calls actually happen, so sporadic Webex
Calling stores (3-4 calls/day) need a wider window for worst-window
statistics to be meaningful. Interval picker snaps 7d to 1hour
buckets (168 pts) to keep payloads bounded while preserving
worst-hour granularity. Hard-capped at 7d — beyond that Prisma
downsamples to 1-day buckets and the signal collapses.

Also:
- Client-side concurrency limiter (PRISMA_MAX_INFLIGHT, default 3)
  to prevent 429 cascades when /voicediag fans out 10+ parallel
  metric fetches
- "View in Prisma UI" deep links in both /phonestatus WAN follow-up
  and /voicediag details, threading through a new
  integrations/paloalto/urls.js builder
- humanizeMetricUnit maps raw API unit strings ("percentage",
  "milliseconds") to display symbols ("%", "ms") to fix
  "11.83percentage" leaking to the UI
- getAppAudio envelope distinguishes not-configured / fetch-failed /
  no-traffic states so misleading "set env var" messages don't fire
  when the real problem is a 429

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:54:15 -04:00

181 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Unit tests for services/enrichment/alarmSemantics.js
//
// Coverage targets:
// - Known codes map to their expected category
// - Unknown codes fall through to 'other' (never throw)
// - Humanizer returns a friendly string; unknown codes become
// Title-Cased words rather than a raw constant
// - rollupAlarms collapses (code+severity) tuples, preserves the
// newest timestamp, and orders critical → major → minor
// - countByCategory sums correctly and defaults missing fields to 0
// - humanizeAge produces the expected "just now / Nm / Nh / Nd" bands
// - humanizeWindow formats 15/60/360/1440 minutes as 15m/1h/6h/1d
import test from 'node:test';
import assert from 'node:assert/strict';
import {
categorizeAlarm,
humanizeAlarmCode,
rollupAlarms,
countByCategory,
humanizeAge,
humanizeWindow,
} from '../services/enrichment/alarmSemantics.js';
// ─── categorizeAlarm ─────────────────────────────────────────────────
test('categorizeAlarm: overlay codes bucket as overlay', () => {
for (const code of [
'NETWORK_ANYNETLINK_DOWN',
'NETWORK_VPNLINK_DOWN',
'NETWORK_VPNLINK_FLAP',
'NETWORK_SITE_UNREACHABLE',
'NETWORK_STANDBY_LINK_DOWN',
]) {
assert.equal(categorizeAlarm(code), 'overlay', `${code} → overlay`);
}
});
test('categorizeAlarm: physical WAN codes bucket as physical', () => {
for (const code of [
'NETWORK_INTERNET_DOWN',
'DEVICE_INTERFACE_DOWN',
'DEVICE_INTERFACE_STATE_CHANGED',
'NETWORK_LTE_LINK_DOWN',
]) {
assert.equal(categorizeAlarm(code), 'physical', `${code} → physical`);
}
});
test('categorizeAlarm: device / ION codes bucket as device', () => {
for (const code of [
'DEVICE_HB_MISSED',
'DEVICE_UNREACHABLE',
'DEVICE_HIGH_CPU',
'DEVICE_REBOOT',
]) {
assert.equal(categorizeAlarm(code), 'device');
}
});
test('categorizeAlarm: unknown / falsy → other (never throws)', () => {
assert.equal(categorizeAlarm('SOMETHING_BRAND_NEW'), 'other');
assert.equal(categorizeAlarm(''), 'other');
assert.equal(categorizeAlarm(null), 'other');
assert.equal(categorizeAlarm(undefined), 'other');
});
test('categorizeAlarm: case-insensitive on input', () => {
assert.equal(categorizeAlarm('network_anynetlink_down'), 'overlay');
});
// ─── humanizeAlarmCode ───────────────────────────────────────────────
test('humanizeAlarmCode: known codes → friendly labels', () => {
assert.equal(humanizeAlarmCode('NETWORK_ANYNETLINK_DOWN'), 'SD-WAN overlay tunnel down');
assert.equal(humanizeAlarmCode('DEVICE_HB_MISSED'), 'ION heartbeat missed');
assert.equal(humanizeAlarmCode('NETWORK_INTERNET_DOWN'), 'Internet WAN circuit down');
});
test('humanizeAlarmCode: unknown code → Title Cased fallback (not raw constant)', () => {
assert.equal(humanizeAlarmCode('SOMETHING_BRAND_NEW_DOWN'), 'Something Brand New Down');
});
test('humanizeAlarmCode: falsy → generic fallback', () => {
assert.equal(humanizeAlarmCode(''), 'Unknown alarm');
assert.equal(humanizeAlarmCode(null), 'Unknown alarm');
});
// ─── rollupAlarms ────────────────────────────────────────────────────
test('rollupAlarms: collapses (code + severity) tuples, keeps newest ts', () => {
const samples = [
{ code: 'X', severity: 'major', ts: '2026-07-09T10:00:00Z' },
{ code: 'X', severity: 'major', ts: '2026-07-09T12:00:00Z' },
{ code: 'X', severity: 'major', ts: '2026-07-09T09:00:00Z' },
{ code: 'Y', severity: 'critical', ts: '2026-07-09T08:00:00Z' },
];
const rollups = rollupAlarms(samples);
assert.equal(rollups.length, 2);
// Critical severity floats to the top regardless of count.
assert.equal(rollups[0].code, 'Y');
assert.equal(rollups[0].count, 1);
assert.equal(rollups[1].code, 'X');
assert.equal(rollups[1].count, 3);
// Newest ts survives.
assert.equal(rollups[1].newestTs, '2026-07-09T12:00:00Z');
});
test('rollupAlarms: sort — critical > major > minor, ties broken by count desc', () => {
const rollups = rollupAlarms([
{ code: 'A', severity: 'minor', ts: 't1' },
{ code: 'B', severity: 'major', ts: 't2' },
{ code: 'C', severity: 'critical', ts: 't3' },
{ code: 'D', severity: 'major', ts: 't4' },
{ code: 'D', severity: 'major', ts: 't5' },
]);
assert.deepEqual(
rollups.map((r) => r.code),
['C', 'D', 'B', 'A'],
'critical (C), then major sorted by count (D×2, B×1), then minor',
);
});
test('rollupAlarms: null/empty → []', () => {
assert.deepEqual(rollupAlarms(null), []);
assert.deepEqual(rollupAlarms([]), []);
assert.deepEqual(rollupAlarms(undefined), []);
});
// ─── countByCategory ─────────────────────────────────────────────────
test('countByCategory: counts overlay/physical/device/other + total', () => {
const c = countByCategory([
{ code: 'NETWORK_ANYNETLINK_DOWN' },
{ code: 'NETWORK_ANYNETLINK_DOWN' },
{ code: 'NETWORK_INTERNET_DOWN' },
{ code: 'DEVICE_HB_MISSED' },
{ code: 'UNRECOGNIZED_FOO' },
]);
assert.deepEqual(c, {
overlay: 2, physical: 1, device: 1, other: 1, total: 5,
});
});
test('countByCategory: empty → all zeros', () => {
assert.deepEqual(countByCategory([]), {
overlay: 0, physical: 0, device: 0, other: 0, total: 0,
});
});
// ─── humanizeAge ─────────────────────────────────────────────────────
test('humanizeAge: coarse buckets', () => {
const now = Date.parse('2026-07-09T14:00:00Z');
const at = (offsetSec) =>
humanizeAge(new Date(now - offsetSec * 1000).toISOString(), now);
assert.equal(at(10), 'just now');
assert.equal(at(120), '2m ago');
assert.equal(at(3600 * 2), '2h ago');
assert.equal(at(86400 * 3), '3d ago');
});
test('humanizeAge: missing / invalid ts → empty string', () => {
assert.equal(humanizeAge(null), '');
assert.equal(humanizeAge(undefined), '');
assert.equal(humanizeAge('not-a-date'), '');
});
// ─── humanizeWindow ──────────────────────────────────────────────────
test('humanizeWindow: canonical values', () => {
assert.equal(humanizeWindow(15), '15m');
assert.equal(humanizeWindow(45), '45m');
assert.equal(humanizeWindow(60), '1h');
assert.equal(humanizeWindow(360), '6h');
assert.equal(humanizeWindow(1440), '1d');
assert.equal(humanizeWindow(2880), '2d');
assert.equal(humanizeWindow(10080), '7d',
'the new default WAN window must round-trip cleanly');
});