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>
688 lines
28 KiB
JavaScript
688 lines
28 KiB
JavaScript
// tests/voiceDiag.wan.test.js
|
||
//
|
||
// Unit coverage for the 8 WAN checks under services/voiceDiag/checks/wan/.
|
||
// Same pattern as tests/voiceDiag.checks.test.js — build a stub
|
||
// ctx with a `sdwanSite` + `sdwanData` (no HTTP, no Prisma) and
|
||
// assert each check's verdict.
|
||
//
|
||
// Every check gets:
|
||
// - happy path (data present, in the compliant range)
|
||
// - threshold breach (warn and/or error where applicable)
|
||
// - missing data (skipped with a clear reason)
|
||
// - kill-switch skip (WAN_STANDARD_ENABLED=false)
|
||
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import { wanSiteCheck } from '../services/voiceDiag/checks/wan/wanSite.js';
|
||
import { wanHealthscoreCheck } from '../services/voiceDiag/checks/wan/wanHealthscore.js';
|
||
import { wanLinkStateCheck } from '../services/voiceDiag/checks/wan/wanLinkState.js';
|
||
import { wanLatencyCheck } from '../services/voiceDiag/checks/wan/wanLatency.js';
|
||
import { wanJitterCheck } from '../services/voiceDiag/checks/wan/wanJitter.js';
|
||
import { wanLossCheck } from '../services/voiceDiag/checks/wan/wanLoss.js';
|
||
import { wanMosCheck } from '../services/voiceDiag/checks/wan/wanMos.js';
|
||
import { wanAppRtpMosCheck } from '../services/voiceDiag/checks/wan/wanAppRtpMos.js';
|
||
import { wanAppRtpLossCheck } from '../services/voiceDiag/checks/wan/wanAppRtpLoss.js';
|
||
import { wanAppRtpJitterCheck } from '../services/voiceDiag/checks/wan/wanAppRtpJitter.js';
|
||
import { wanAlarmsCheck } from '../services/voiceDiag/checks/wan/wanAlarms.js';
|
||
import { humanizeMetricUnit } from '../services/voiceDiag/checks/wan/_helpers.js';
|
||
import { CHECKS } from '../services/voiceDiag/checks/index.js';
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────
|
||
|
||
function mkWanCtx({ site = { id: 'site-1', name: 'CG00782' }, links = [], healthscore = null, alarms = null, elements = [] } = {}) {
|
||
return {
|
||
storeNum: '782',
|
||
personId: null,
|
||
personLabel: 'store 782',
|
||
telephonyProfile: {},
|
||
phoneStatus: null,
|
||
sdwanSite: site,
|
||
sdwanData: site ? {
|
||
storeNum: '782',
|
||
site,
|
||
elements,
|
||
links,
|
||
healthscore,
|
||
alarms: alarms || { last1h: { critical: 0, major: 0, minor: 0 }, samples: [] },
|
||
errors: [],
|
||
} : null,
|
||
};
|
||
}
|
||
|
||
function link(overrides = {}) {
|
||
return {
|
||
interfaceId: 'if-mpls',
|
||
interfaceName: 'wan1',
|
||
elementId: 'el-1',
|
||
elementName: 'ION-A',
|
||
transportType: 'MPLS',
|
||
up: true,
|
||
latencyMs: 40,
|
||
jitterMs: 5,
|
||
lossPct: 0.1,
|
||
mos: 4.4,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
async function withKillSwitchOn(fn) {
|
||
const prev = process.env.WAN_STANDARD_ENABLED;
|
||
process.env.WAN_STANDARD_ENABLED = 'false';
|
||
try { await fn(); }
|
||
finally {
|
||
if (prev === undefined) delete process.env.WAN_STANDARD_ENABLED;
|
||
else process.env.WAN_STANDARD_ENABLED = prev;
|
||
}
|
||
}
|
||
|
||
// ─── wanSite ────────────────────────────────────────────────────────
|
||
|
||
test('wanSite: happy path → ok with site + element + link counts in message', async () => {
|
||
const ctx = mkWanCtx({
|
||
elements: [{ id: 'el-1', connected: true }, { id: 'el-2', connected: false }],
|
||
links: [link(), link({ interfaceId: 'if-bb' })],
|
||
});
|
||
const r = await wanSiteCheck.run(ctx);
|
||
assert.equal(r.status, 'ok');
|
||
assert.match(r.message, /CG00782/);
|
||
assert.equal(r.details.elementCount, 2);
|
||
assert.equal(r.details.connectedElementCount, 1);
|
||
assert.equal(r.details.linkCount, 2);
|
||
});
|
||
|
||
test('wanSite: kill-switch skips', async () => {
|
||
await withKillSwitchOn(async () => {
|
||
const r = await wanSiteCheck.run(mkWanCtx({ elements: [{ id: 'x', connected: true }] }));
|
||
assert.equal(r.status, 'skipped');
|
||
assert.match(r.message, /WAN_STANDARD_ENABLED/);
|
||
});
|
||
});
|
||
|
||
// ─── wanHealthscore ─────────────────────────────────────────────────
|
||
|
||
test('wanHealthscore: 92/100 → ok', async () => {
|
||
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: { value: 92, breakdown: {} } }));
|
||
assert.equal(r.status, 'ok');
|
||
});
|
||
|
||
test('wanHealthscore: 70/100 → warn (< default warn 80)', async () => {
|
||
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: { value: 70, breakdown: {} } }));
|
||
assert.equal(r.status, 'warn');
|
||
assert.equal(r.details.value, 70);
|
||
});
|
||
|
||
test('wanHealthscore: 40/100 → error (< default error 60)', async () => {
|
||
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: { value: 40, breakdown: {} } }));
|
||
assert.equal(r.status, 'error');
|
||
});
|
||
|
||
test('wanHealthscore: env override adjusts thresholds', async () => {
|
||
const prev = process.env.WAN_STANDARD_HEALTHSCORE_WARN;
|
||
process.env.WAN_STANDARD_HEALTHSCORE_WARN = '95';
|
||
try {
|
||
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: { value: 92 } }));
|
||
assert.equal(r.status, 'warn', '92 should warn once threshold is raised to 95');
|
||
} finally {
|
||
if (prev === undefined) delete process.env.WAN_STANDARD_HEALTHSCORE_WARN;
|
||
else process.env.WAN_STANDARD_HEALTHSCORE_WARN = prev;
|
||
}
|
||
});
|
||
|
||
test('wanHealthscore: missing → skipped', async () => {
|
||
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: null }));
|
||
assert.equal(r.status, 'skipped');
|
||
});
|
||
|
||
test('wanHealthscore: kill-switch skips', async () => {
|
||
await withKillSwitchOn(async () => {
|
||
const r = await wanHealthscoreCheck.run(mkWanCtx({ healthscore: { value: 40 } }));
|
||
assert.equal(r.status, 'skipped');
|
||
assert.match(r.message, /WAN_STANDARD_ENABLED/);
|
||
});
|
||
});
|
||
|
||
// ─── wanLinkState ───────────────────────────────────────────────────
|
||
|
||
test('wanLinkState: all up → ok', async () => {
|
||
const r = await wanLinkStateCheck.run(mkWanCtx({
|
||
links: [link({ interfaceId: 'a', up: true }), link({ interfaceId: 'b', up: true })],
|
||
}));
|
||
assert.equal(r.status, 'ok');
|
||
assert.equal(r.details.up, 2);
|
||
});
|
||
|
||
test('wanLinkState: one down → error, offender named', async () => {
|
||
const r = await wanLinkStateCheck.run(mkWanCtx({
|
||
links: [
|
||
link({ interfaceId: 'a', up: true }),
|
||
link({ interfaceId: 'b', up: false, interfaceName: 'bb1', transportType: 'BROADBAND' }),
|
||
],
|
||
}));
|
||
assert.equal(r.status, 'error');
|
||
assert.equal(r.details.down, 1);
|
||
assert.match(r.message, /bb1/);
|
||
});
|
||
|
||
test('wanLinkState: all unknown, none up → warn', async () => {
|
||
const r = await wanLinkStateCheck.run(mkWanCtx({
|
||
links: [link({ up: null }), link({ interfaceId: 'x', up: null })],
|
||
}));
|
||
assert.equal(r.status, 'warn');
|
||
assert.equal(r.details.unknown, 2);
|
||
});
|
||
|
||
test('wanLinkState: no links → skipped', async () => {
|
||
const r = await wanLinkStateCheck.run(mkWanCtx({ links: [] }));
|
||
assert.equal(r.status, 'skipped');
|
||
});
|
||
|
||
// ─── wanLatency ─────────────────────────────────────────────────────
|
||
|
||
test('wanLatency: all under 150ms → ok', async () => {
|
||
const r = await wanLatencyCheck.run(mkWanCtx({ links: [link({ latencyMs: 40 }), link({ latencyMs: 80 })] }));
|
||
assert.equal(r.status, 'ok');
|
||
});
|
||
|
||
test('wanLatency: one path 220ms → warn (> 150, < 400)', async () => {
|
||
const r = await wanLatencyCheck.run(mkWanCtx({
|
||
links: [link({ latencyMs: 40 }), link({ interfaceId: 'x', interfaceName: 'x1', latencyMs: 220 })],
|
||
}));
|
||
assert.equal(r.status, 'warn');
|
||
assert.match(r.message, /x1/);
|
||
assert.match(r.message, /220ms/);
|
||
});
|
||
|
||
test('wanLatency: one path 500ms → error (> 400)', async () => {
|
||
const r = await wanLatencyCheck.run(mkWanCtx({
|
||
links: [link({ latencyMs: 40 }), link({ interfaceId: 'x', interfaceName: 'x1', latencyMs: 500 })],
|
||
}));
|
||
assert.equal(r.status, 'error');
|
||
});
|
||
|
||
test('wanLatency: env override adjusts thresholds', async () => {
|
||
const prev = process.env.WAN_STANDARD_LATENCY_WARN_MS;
|
||
process.env.WAN_STANDARD_LATENCY_WARN_MS = '50';
|
||
try {
|
||
const r = await wanLatencyCheck.run(mkWanCtx({ links: [link({ latencyMs: 80 })] }));
|
||
assert.equal(r.status, 'warn', '80ms should warn when threshold is 50');
|
||
} finally {
|
||
if (prev === undefined) delete process.env.WAN_STANDARD_LATENCY_WARN_MS;
|
||
else process.env.WAN_STANDARD_LATENCY_WARN_MS = prev;
|
||
}
|
||
});
|
||
|
||
test('wanLatency: no data → skipped', async () => {
|
||
const r = await wanLatencyCheck.run(mkWanCtx({ links: [link({ latencyMs: null })] }));
|
||
assert.equal(r.status, 'skipped');
|
||
});
|
||
|
||
test('wanLatency: kill-switch skips', async () => {
|
||
await withKillSwitchOn(async () => {
|
||
const r = await wanLatencyCheck.run(mkWanCtx({ links: [link({ latencyMs: 500 })] }));
|
||
assert.equal(r.status, 'skipped');
|
||
});
|
||
});
|
||
|
||
// ─── wanJitter ──────────────────────────────────────────────────────
|
||
|
||
test('wanJitter: 5ms → ok', async () => {
|
||
const r = await wanJitterCheck.run(mkWanCtx({ links: [link({ jitterMs: 5 })] }));
|
||
assert.equal(r.status, 'ok');
|
||
});
|
||
|
||
test('wanJitter: 40ms → warn (> 30, < 50)', async () => {
|
||
const r = await wanJitterCheck.run(mkWanCtx({ links: [link({ jitterMs: 40 })] }));
|
||
assert.equal(r.status, 'warn');
|
||
});
|
||
|
||
test('wanJitter: 100ms → error (> 50)', async () => {
|
||
const r = await wanJitterCheck.run(mkWanCtx({ links: [link({ jitterMs: 100 })] }));
|
||
assert.equal(r.status, 'error');
|
||
});
|
||
|
||
// ─── wanLoss ────────────────────────────────────────────────────────
|
||
|
||
test('wanLoss: 0.1% → ok', async () => {
|
||
const r = await wanLossCheck.run(mkWanCtx({ links: [link({ lossPct: 0.1 })] }));
|
||
assert.equal(r.status, 'ok');
|
||
});
|
||
|
||
test('wanLoss: 2% → warn (> 1, < 3)', async () => {
|
||
const r = await wanLossCheck.run(mkWanCtx({ links: [link({ lossPct: 2 })] }));
|
||
assert.equal(r.status, 'warn');
|
||
});
|
||
|
||
test('wanLoss: 5% → error (> 3)', async () => {
|
||
const r = await wanLossCheck.run(mkWanCtx({ links: [link({ lossPct: 5 })] }));
|
||
assert.equal(r.status, 'error');
|
||
});
|
||
|
||
// ─── wanMos ─────────────────────────────────────────────────────────
|
||
// MOS is inverted — low is bad.
|
||
|
||
test('wanMos: 4.4 → ok (>= 4.0)', async () => {
|
||
const r = await wanMosCheck.run(mkWanCtx({ links: [link({ mos: 4.4 })] }));
|
||
assert.equal(r.status, 'ok');
|
||
});
|
||
|
||
test('wanMos: 3.8 → warn (< 4.0, >= 3.5)', async () => {
|
||
const r = await wanMosCheck.run(mkWanCtx({ links: [link({ mos: 3.8 })] }));
|
||
assert.equal(r.status, 'warn');
|
||
});
|
||
|
||
test('wanMos: 3.0 → error (< 3.5)', async () => {
|
||
const r = await wanMosCheck.run(mkWanCtx({ links: [link({ mos: 3.0 })] }));
|
||
assert.equal(r.status, 'error');
|
||
});
|
||
|
||
// ─── wanAlarms ──────────────────────────────────────────────────────
|
||
|
||
test('wanAlarms: none → ok', async () => {
|
||
const r = await wanAlarmsCheck.run(mkWanCtx());
|
||
assert.equal(r.status, 'ok');
|
||
});
|
||
|
||
test('wanAlarms: minor only → ok (informational)', async () => {
|
||
const r = await wanAlarmsCheck.run(mkWanCtx({
|
||
alarms: { last1h: { critical: 0, major: 0, minor: 3 }, samples: [] },
|
||
}));
|
||
assert.equal(r.status, 'ok');
|
||
assert.equal(r.details.minor, 3);
|
||
});
|
||
|
||
test('wanAlarms: major → warn (message cites humanized code, never raw info blob)', async () => {
|
||
const r = await wanAlarmsCheck.run(mkWanCtx({
|
||
alarms: {
|
||
last1h: { critical: 0, major: 1, minor: 0 },
|
||
samples: [{
|
||
code: 'NETWORK_ANYNETLINK_DOWN',
|
||
severity: 'major',
|
||
message: '{"vpn_reasons":[{"code":"NETWORK_VPNLINK_DOWN","element_id":"…"}]}',
|
||
ts: new Date().toISOString(),
|
||
}],
|
||
},
|
||
}));
|
||
assert.equal(r.status, 'warn');
|
||
// Humanized code appears — no raw JSON blob.
|
||
assert.match(r.message, /SD-WAN overlay tunnel down/);
|
||
assert.equal(r.message.includes('vpn_reasons'), false,
|
||
'raw Prisma info JSON must never leak into the chat message');
|
||
assert.equal(r.message.includes('{"'), false,
|
||
'no stringified object leakage');
|
||
// Category count captured in details.
|
||
assert.equal(r.details.byCategory.overlay, 1);
|
||
});
|
||
|
||
test('wanAlarms: critical → error, cites the highest-severity rollup', async () => {
|
||
const r = await wanAlarmsCheck.run(mkWanCtx({
|
||
alarms: {
|
||
last1h: { critical: 2, major: 0, minor: 0 },
|
||
samples: [
|
||
{ code: 'DEVICE_UNREACHABLE', severity: 'critical', ts: '2026-07-09T14:00:00Z' },
|
||
{ code: 'DEVICE_UNREACHABLE', severity: 'critical', ts: '2026-07-09T14:05:00Z' },
|
||
],
|
||
},
|
||
}));
|
||
assert.equal(r.status, 'error');
|
||
assert.match(r.message, /2 critical alarms/);
|
||
assert.match(r.message, /ION unreachable/);
|
||
assert.match(r.message, /×2/);
|
||
});
|
||
|
||
test('wanAlarms: rollup groups near-identical alarms in details.recentSamples', async () => {
|
||
const samples = Array.from({ length: 20 }).map((_, i) => ({
|
||
code: 'NETWORK_ANYNETLINK_DOWN',
|
||
severity: 'major',
|
||
ts: new Date(Date.now() - i * 60_000).toISOString(),
|
||
}));
|
||
const r = await wanAlarmsCheck.run(mkWanCtx({
|
||
alarms: { last1h: { critical: 0, major: 20, minor: 0 }, samples },
|
||
}));
|
||
assert.equal(r.status, 'warn');
|
||
assert.equal(r.details.recentSamples.length, 1,
|
||
'20 identical alarms should collapse to a single rolled-up row');
|
||
assert.equal(r.details.recentSamples[0].count, 20);
|
||
assert.equal(r.details.recentSamples[0].category, 'overlay');
|
||
assert.equal(r.details.recentSamples[0].humanized, 'SD-WAN overlay tunnel down');
|
||
});
|
||
|
||
test('wanAlarms: overlay-only + all physical links up → adds clarifying hint', async () => {
|
||
const r = await wanAlarmsCheck.run(mkWanCtx({
|
||
links: [link(), link({ interfaceId: 'if-bb', up: true })],
|
||
alarms: {
|
||
last1h: { critical: 0, major: 5, minor: 0 },
|
||
samples: Array.from({ length: 5 }).map(() => ({
|
||
code: 'NETWORK_VPNLINK_DOWN', severity: 'major',
|
||
})),
|
||
},
|
||
}));
|
||
assert.equal(r.status, 'warn');
|
||
assert.match(r.message, /physical WAN paths are all up/);
|
||
});
|
||
|
||
test('wanAlarms: physical alarms + a physical link down → different clarifier', async () => {
|
||
const r = await wanAlarmsCheck.run(mkWanCtx({
|
||
links: [link({ up: false })],
|
||
alarms: {
|
||
last1h: { critical: 1, major: 0, minor: 0 },
|
||
samples: [{ code: 'NETWORK_INTERNET_DOWN', severity: 'critical' }],
|
||
},
|
||
}));
|
||
assert.equal(r.status, 'error');
|
||
assert.match(r.message, /Physical WAN interfaces are affected/);
|
||
});
|
||
|
||
test('wanAlarms: no alarms feed → skipped', async () => {
|
||
const ctx = mkWanCtx();
|
||
ctx.sdwanData.alarms = null;
|
||
const r = await wanAlarmsCheck.run(ctx);
|
||
assert.equal(r.status, 'skipped');
|
||
});
|
||
|
||
test('wanAlarms: message reflects the effective alarm window (not hardcoded 1h)', async () => {
|
||
const ctx = mkWanCtx({
|
||
alarms: {
|
||
last1h: { critical: 0, major: 1, minor: 0 },
|
||
samples: [{ code: 'NETWORK_ANYNETLINK_DOWN', severity: 'major' }],
|
||
},
|
||
});
|
||
// Simulate a 24h alarm window (matches the shipping default)
|
||
ctx.sdwanData.window = { minutes: 1440, alarmMinutes: 1440 };
|
||
const r = await wanAlarmsCheck.run(ctx);
|
||
assert.match(r.message, /in the last 1d/);
|
||
});
|
||
|
||
// ─── standards regression + registry ordering ──────────────────────
|
||
|
||
test('every WAN check exposes a standards object', () => {
|
||
const wanChecks = CHECKS.filter((c) => c.id.startsWith('wan'));
|
||
assert.equal(wanChecks.length, 11, 'expected 11 registered WAN checks (8 link + 3 app-DPI)');
|
||
const missing = wanChecks.filter((c) => !c.standards || typeof c.standards !== 'object');
|
||
assert.deepEqual(missing.map((c) => c.id), []);
|
||
});
|
||
|
||
test('WAN checks registered in the expected order after port bucket', () => {
|
||
const ids = CHECKS.map((c) => c.id);
|
||
const wanIds = ids.filter((id) => id.startsWith('wan'));
|
||
assert.deepEqual(wanIds, [
|
||
'wanSite',
|
||
'wanHealthscore',
|
||
'wanLinkState',
|
||
'wanLatency',
|
||
'wanJitter',
|
||
'wanLoss',
|
||
'wanMos',
|
||
// Per-app DPI checks land after the link-probe checks — the
|
||
// link-probe pass/fail is the coarse signal, then the per-app
|
||
// check refines it. This ordering shows up in the /voicediag
|
||
// output as "link is up + green" followed by "but actual RTP
|
||
// saw..." which reads naturally for an operator.
|
||
'wanAppRtpMos',
|
||
'wanAppRtpLoss',
|
||
'wanAppRtpJitter',
|
||
'wanAlarms',
|
||
]);
|
||
const portEnabledIdx = ids.indexOf('portEnabled');
|
||
const wanSiteIdx = ids.indexOf('wanSite');
|
||
const phoneOnlineIdx = ids.indexOf('phoneOnline');
|
||
assert.ok(portEnabledIdx < wanSiteIdx);
|
||
assert.ok(wanSiteIdx < phoneOnlineIdx);
|
||
});
|
||
|
||
test('no WAN check declares a remediation (diagnostic-only)', () => {
|
||
const wanChecks = CHECKS.filter((c) => c.id.startsWith('wan'));
|
||
for (const c of wanChecks) {
|
||
assert.equal(c.remediations, undefined, `${c.id} should not expose remediations`);
|
||
}
|
||
});
|
||
|
||
// ─── wanAppRtp* (per-app DPI voice-quality checks) ──────────────────
|
||
//
|
||
// These grade against the WORST-window value in the series rather
|
||
// than the average — the whole point is to catch transient
|
||
// degradation the 24h link-probe average smooths away. Every case
|
||
// below spells out which value the check MUST grade against (min for
|
||
// MOS, max for loss/jitter) because if the accessor picks the wrong
|
||
// side of the summary a well-averaged store would silently pass
|
||
// while its calls sound terrible.
|
||
|
||
function mkAppCtx({ mos, loss, jitter, bandwidth } = {}) {
|
||
const ctx = mkWanCtx({});
|
||
// Uses Webex_Calling_RTP as the exemplar in fixtures since it's the
|
||
// recommended production choice — but the checks are app-agnostic
|
||
// so any app id/name should behave identically.
|
||
ctx.sdwanData.appAudio = {
|
||
appId: '1708539371717015196',
|
||
appName: 'Webex_Calling_RTP',
|
||
mos, loss, jitter, bandwidth,
|
||
};
|
||
return ctx;
|
||
}
|
||
|
||
function seriesSummary({ values, unit = '', interval = '5min' } = {}) {
|
||
// Build a shape identical to summarizeAppSeries() output so the
|
||
// check exercises the real threshold path rather than a stub.
|
||
const nums = values.filter((v) => typeof v === 'number' && Number.isFinite(v));
|
||
let min = null, max = null, avg = null, p95 = null;
|
||
if (nums.length > 0) {
|
||
min = Math.min(...nums);
|
||
max = Math.max(...nums);
|
||
avg = Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 100) / 100;
|
||
const sorted = [...nums].sort((a, b) => a - b);
|
||
p95 = sorted[Math.min(sorted.length - 1, Math.floor(0.95 * sorted.length))];
|
||
}
|
||
return {
|
||
unit, interval,
|
||
samples: values.length, validSamples: nums.length,
|
||
avg, min, max, p95, values,
|
||
};
|
||
}
|
||
|
||
test('wanAppRtpMos: skipped when appAudio missing (feature not configured)', async () => {
|
||
const ctx = mkWanCtx({}); // no appAudio
|
||
const r = await wanAppRtpMosCheck.run(ctx);
|
||
assert.equal(r.status, 'skipped');
|
||
// Message must point operators at the canonical env var name.
|
||
// Backwards-compat handling for the legacy PRISMA_APP_ID_RTP_BASE
|
||
// lives in resolveVoiceAppConfig(); the surface message points at
|
||
// the new name only.
|
||
assert.match(r.message, /PRISMA_APP_ID_VOICE/);
|
||
});
|
||
|
||
test('wanAppRtpMos: ok when worst-window MOS >= warn (default 4.0)', async () => {
|
||
const ctx = mkAppCtx({ mos: seriesSummary({ values: [4.1, 4.3, 4.5, 4.0] }) });
|
||
const r = await wanAppRtpMosCheck.run(ctx);
|
||
assert.equal(r.status, 'ok');
|
||
});
|
||
|
||
test('wanAppRtpMos: warn when worst-window MOS < 4.0 but >= 3.5 (avg irrelevant)', async () => {
|
||
// Avg = 4.05 (looks fine) but one 5-min window dipped to 3.8 →
|
||
// must warn, not pass. This is the whole reason the check exists.
|
||
const ctx = mkAppCtx({ mos: seriesSummary({ values: [4.3, 4.2, 3.8, 4.5] }) });
|
||
const r = await wanAppRtpMosCheck.run(ctx);
|
||
assert.equal(r.status, 'warn');
|
||
assert.match(r.message, /3\.8/, 'worst-window value must appear in message');
|
||
});
|
||
|
||
test('wanAppRtpMos: error when worst-window MOS < 3.5 (matches HAR failure case)', async () => {
|
||
// Real Webex_Calling_RTP numbers from CG00127
|
||
// (webex-base-metricCG00127.har, 2026-07-09): min=1.60, avg=4.27.
|
||
// Even though avg is fine, the min alone must fire error since
|
||
// those 5-min windows rendered calls unintelligible.
|
||
const ctx = mkAppCtx({ mos: seriesSummary({ values: [4.4, 4.3, 3.92, 1.60, 4.41, 4.03] }) });
|
||
const r = await wanAppRtpMosCheck.run(ctx);
|
||
assert.equal(r.status, 'error');
|
||
assert.match(r.message, /1\.6/);
|
||
assert.match(r.message, /worst-window/i);
|
||
});
|
||
|
||
test('wanAppRtpLoss: ok when worst-window loss <= 5%', async () => {
|
||
const ctx = mkAppCtx({ loss: seriesSummary({ values: [0, 0.5, 1, 2, 4] }) });
|
||
const r = await wanAppRtpLossCheck.run(ctx);
|
||
assert.equal(r.status, 'ok');
|
||
});
|
||
|
||
test('wanAppRtpLoss: warn when worst-window loss > 5% but <= 15%', async () => {
|
||
const ctx = mkAppCtx({ loss: seriesSummary({ values: [0, 0, 10, 0] }) });
|
||
const r = await wanAppRtpLossCheck.run(ctx);
|
||
assert.equal(r.status, 'warn');
|
||
assert.match(r.message, /10%/);
|
||
});
|
||
|
||
test('wanAppRtpLoss: error when worst-window loss > 15% (matches HAR failure)', async () => {
|
||
// Real Webex_Calling_RTP numbers from CG00127: max=26.88% loss.
|
||
// Must error even though avg was only ~1.37%.
|
||
const ctx = mkAppCtx({ loss: seriesSummary({ values: [0, 5, 26.88, 0, 3] }) });
|
||
const r = await wanAppRtpLossCheck.run(ctx);
|
||
assert.equal(r.status, 'error');
|
||
assert.match(r.message, /26\.88%/);
|
||
});
|
||
|
||
test('wanAppRtpJitter: ok when all-zero series (nothing to grade badly)', async () => {
|
||
// The HAR shows AppPerfUDPAudioJitter often returns all zeros for
|
||
// this tenant. Must be treated as "clean throughout", NOT skipped
|
||
// and NOT bad — the metric legitimately reports zero and that IS
|
||
// an ok result.
|
||
const ctx = mkAppCtx({ jitter: seriesSummary({ values: [0, 0, 0, 0, 0] }) });
|
||
const r = await wanAppRtpJitterCheck.run(ctx);
|
||
assert.equal(r.status, 'ok');
|
||
});
|
||
|
||
test('wanAppRtpJitter: error when worst-window > 50ms', async () => {
|
||
const ctx = mkAppCtx({ jitter: seriesSummary({ values: [1, 2, 75, 3, 0] }) });
|
||
const r = await wanAppRtpJitterCheck.run(ctx);
|
||
assert.equal(r.status, 'error');
|
||
assert.match(r.message, /75ms/);
|
||
});
|
||
|
||
test('wanAppRtp*: skipped when validSamples=0 (Prisma returned data but all-null)', async () => {
|
||
const emptyish = seriesSummary({ values: [null, null, null] });
|
||
const ctx = mkAppCtx({ mos: emptyish, loss: emptyish, jitter: emptyish });
|
||
for (const check of [wanAppRtpMosCheck, wanAppRtpLossCheck, wanAppRtpJitterCheck]) {
|
||
const r = await check.run(ctx);
|
||
assert.equal(r.status, 'skipped', `${check.id} → skipped on all-null`);
|
||
assert.match(r.message, /no voice traffic/i);
|
||
}
|
||
});
|
||
|
||
test('wanAppRtp*: kill switch (WAN_STANDARD_ENABLED=false) silences all three', async () => {
|
||
await withKillSwitchOn(async () => {
|
||
const ctx = mkAppCtx({ mos: seriesSummary({ values: [1.5] }) });
|
||
for (const check of [wanAppRtpMosCheck, wanAppRtpLossCheck, wanAppRtpJitterCheck]) {
|
||
const r = await check.run(ctx);
|
||
assert.equal(r.status, 'skipped', `${check.id} kill-switch triggered`);
|
||
assert.match(r.message, /WAN_STANDARD_ENABLED=false/);
|
||
}
|
||
});
|
||
});
|
||
|
||
test('wanAppRtp*: badSamplePct exposes how often the metric was in warn/error', async () => {
|
||
// 4 of 10 samples in error range → 40% bad.
|
||
const values = [0, 0, 20, 22, 0, 30, 0, 0, 50, 0]; // 4 > 15%
|
||
const ctx = mkAppCtx({ loss: seriesSummary({ values }) });
|
||
const r = await wanAppRtpLossCheck.run(ctx);
|
||
assert.equal(r.status, 'error');
|
||
assert.equal(r.details.badSampleCount, 4);
|
||
assert.equal(r.details.badSamplePct, 40);
|
||
});
|
||
|
||
test('wanAppRtp*: fetch-failure surfaces the underlying error message (not "set env var")', async () => {
|
||
// Regression for the 429-cascade UX bug: previously, a fetch
|
||
// failure with the env var CONFIGURED caused the check to say
|
||
// "set PRISMA_APP_ID_RTP_BASE" — actively misleading. Now the
|
||
// check tells the operator the real reason (429, timeout, etc).
|
||
//
|
||
// Error-scope names are app-agnostic ("app.voice.*") so this
|
||
// still routes correctly regardless of which voice app the tenant
|
||
// configured.
|
||
const ctx = mkWanCtx({});
|
||
ctx.sdwanData.appAudio = {
|
||
appId: '1708539371717015196',
|
||
appName: 'Webex_Calling_RTP',
|
||
mos: null, // fetch failed
|
||
loss: null, // fetch failed
|
||
jitter: seriesSummary({ values: [0, 0, 0] }), // succeeded
|
||
bandwidth: seriesSummary({ values: [0.5] }),
|
||
};
|
||
ctx.sdwanData.errors = [
|
||
{ scope: 'app.voice.mos', message: 'Request failed with status code 429 (HTTP 429)' },
|
||
{ scope: 'app.voice.loss', message: 'timeout of 20000ms exceeded' },
|
||
];
|
||
|
||
const mosResult = await wanAppRtpMosCheck.run(ctx);
|
||
assert.equal(mosResult.status, 'skipped');
|
||
assert.match(mosResult.message, /429/, 'must surface the underlying 429');
|
||
assert.doesNotMatch(mosResult.message, /PRISMA_APP_ID_VOICE/,
|
||
'must NOT say "set env var" — the env IS set, the fetch just failed');
|
||
assert.match(mosResult.message, /Retry in ~30/, 'includes actionable "try again" hint');
|
||
|
||
const lossResult = await wanAppRtpLossCheck.run(ctx);
|
||
assert.match(lossResult.message, /timeout/);
|
||
|
||
// Jitter succeeded so it should grade normally, not be dragged
|
||
// down by its siblings' failures.
|
||
const jitterResult = await wanAppRtpJitterCheck.run(ctx);
|
||
assert.equal(jitterResult.status, 'ok',
|
||
'per-metric fetch failures must not cascade into sibling metrics');
|
||
});
|
||
|
||
test('humanizeMetricUnit: maps raw Prisma unit strings to concise display suffixes', () => {
|
||
// The important cases — everything we've seen in a real Prisma
|
||
// response. These are what caused "11.83percentage" / "50milliseconds"
|
||
// to leak into the UI before this was added.
|
||
assert.equal(humanizeMetricUnit('percentage'), '%');
|
||
assert.equal(humanizeMetricUnit('percent'), '%');
|
||
assert.equal(humanizeMetricUnit('milliseconds'), 'ms');
|
||
assert.equal(humanizeMetricUnit('ms'), 'ms');
|
||
assert.equal(humanizeMetricUnit('count'), '',
|
||
'MOS unit "count" renders as empty — MOS numbers are self-explanatory');
|
||
assert.equal(humanizeMetricUnit('gauge'), '');
|
||
assert.equal(humanizeMetricUnit('Mbps'), 'Mbps');
|
||
assert.equal(humanizeMetricUnit('kbps'), 'kbps');
|
||
assert.equal(humanizeMetricUnit(''), '');
|
||
assert.equal(humanizeMetricUnit(null), '');
|
||
// Fallback path: unknown unit — return with a leading space so
|
||
// "12 widgets" reads better than "12widgets".
|
||
assert.equal(humanizeMetricUnit('widgets'), ' widgets');
|
||
});
|
||
|
||
test('wanAppRtp*: details.detailsUrl threads the SCM deep-link from appAudio through the check', async () => {
|
||
const ctx = mkWanCtx({});
|
||
ctx.sdwanData.appAudio = {
|
||
appId: '1708539371717015196', appName: 'Webex_Calling_RTP',
|
||
detailsUrl: 'https://stratacloudmanager.paloaltonetworks.com/insights/operational/sdwan-applications/1708539371717015196/site/16190109915660160/details',
|
||
mos: seriesSummary({ values: [1.85, 4.4] }),
|
||
loss: seriesSummary({ values: [0.5] }),
|
||
jitter: seriesSummary({ values: [0] }),
|
||
};
|
||
|
||
const r = await wanAppRtpMosCheck.run(ctx);
|
||
assert.equal(
|
||
r.details.detailsUrl,
|
||
'https://stratacloudmanager.paloaltonetworks.com/insights/operational/sdwan-applications/1708539371717015196/site/16190109915660160/details',
|
||
'detailsUrl must be present in the check result so the renderer can emit the link',
|
||
);
|
||
|
||
// Missing URL on the container → check details have detailsUrl:null,
|
||
// so the renderer's conditional cleanly drops the link row.
|
||
delete ctx.sdwanData.appAudio.detailsUrl;
|
||
const r2 = await wanAppRtpMosCheck.run(ctx);
|
||
assert.equal(r2.details.detailsUrl, null,
|
||
'missing container URL propagates as null (renderer no-ops on that)');
|
||
});
|
||
|
||
test('wanAppRtp*: details.unit is the humanized display unit (not raw Prisma "percentage")', async () => {
|
||
// Regression for the "11.83percentage" rendering bug — the raw
|
||
// API unit string ("percentage", "milliseconds") is unreadable
|
||
// when concatenated to a value. The check-args unit ('%', 'ms')
|
||
// MUST override the raw unit in the details payload so the
|
||
// renderer displays "11.83%" not "11.83percentage".
|
||
const ctx = mkAppCtx({
|
||
loss: seriesSummary({ values: [11.83], unit: 'percentage' }),
|
||
});
|
||
const r = await wanAppRtpLossCheck.run(ctx);
|
||
assert.equal(r.details.unit, '%', 'display unit humanized in details');
|
||
assert.equal(r.details.rawUnit, 'percentage',
|
||
'raw Prisma unit retained for debugging (as rawUnit)');
|
||
});
|