collabSupport/tests/voiceDiagRenderer.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

455 lines
18 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/renderers/voiceDiagRenderer.js. Renderer
// is pure — no I/O — so the tests just assert on the produced
// markdown string. The interesting cases are:
//
// - severity buckets appear in the right order
// - the OK bucket is hidden by default and shown under `detailed`
// - the fixable-issues footer counts and lists only remediable
// results (an OK check with a remediation still doesn't count,
// since we filter status !== 'ok')
// - the details block is only rendered under `detailed`
// - empty result list yields a benign single-line message
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderVoiceDiagMarkdown } from '../services/renderers/voiceDiagRenderer.js';
const R = (id, status, message, remediation = null, details = null, label = null) => ({
id,
label: label || `Check ${id}`,
status,
message,
details,
remediation,
});
const REMEDIATION = {
action: 'disable_dnd',
title: 'Disable DND',
summary: 'Turn DND off.',
payload: {},
};
test('renderer: empty results → benign message', () => {
const md = renderVoiceDiagMarkdown([], { storeNum: '12345' });
assert.match(md, /Voice Diagnostic - Store 12345/);
assert.match(md, /No checks were executed/);
});
test('renderer: header includes personLabel + email when provided', () => {
const md = renderVoiceDiagMarkdown([], {
storeNum: '12345',
personLabel: 'Store 12345',
email: 'ae12345@ae.com',
});
assert.match(md, /user: Store 12345 — ae12345@ae\.com/);
});
test('renderer: mixed severities render in error → warn → skipped order (default hides ok)', () => {
const results = [
R('c1', 'ok', 'all good'),
R('c2', 'warn', 'watch this', REMEDIATION),
R('c3', 'error', 'boom'),
R('c4', 'skipped', '403 missing scope'),
R('c5', 'ok', 'also good'),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
const orderIdx = ['**ERRORS**', '**WARNINGS**', '**SKIPPED**'].map((h) => md.indexOf(h));
assert.ok(orderIdx.every((i) => i > -1), `all severity headings present, got ${orderIdx}`);
assert.ok(orderIdx[0] < orderIdx[1], 'ERRORS before WARNINGS');
assert.ok(orderIdx[1] < orderIdx[2], 'WARNINGS before SKIPPED');
assert.equal(md.includes('**OK**'), false, 'OK bucket hidden by default');
assert.match(md, /OK check\(s\) hidden — pass `detailed`/);
});
test('renderer: summary line reports every bucket count', () => {
const results = [
R('c1', 'ok', 'a'),
R('c2', 'warn', 'b'),
R('c3', 'warn', 'c'),
R('c4', 'error', 'd'),
R('c5', 'skipped', 'e'),
R('c6', 'ok', 'f'),
R('c7', 'ok', 'g'),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
assert.match(md, /Errors \(1\) · Warnings \(2\) · Skipped \(1\) · OK \(3\)/);
});
test('renderer: detailed mode surfaces OK bucket + details block', () => {
const results = [
R('c1', 'ok', 'all good', null, { enabled: false, mwiEnabled: true }),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '99', detailed: true });
assert.match(md, /\*\*OK\*\*/);
assert.match(md, /enabled: false, mwiEnabled: true/);
});
test('renderer: fixable footer counts non-ok results with a remediation only', () => {
const results = [
R('c1', 'warn', 'w1', REMEDIATION),
R('c2', 'error', 'e1', REMEDIATION),
R('c3', 'ok', 'o1', REMEDIATION), // OK w/ remediation should NOT count
R('c4', 'warn', 'w2'), // warn w/o remediation should NOT count
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
assert.match(md, /Fixable issues \(2\)/);
const footerLineCount = (md.match(/^- Check c[12]: Disable DND/gm) || []).length;
assert.equal(footerLineCount, 2, 'footer lists exactly the fixable ones');
});
test('renderer: no fixable-footer emitted when nothing is fixable', () => {
const results = [
R('c1', 'warn', 'no fix'),
R('c2', 'error', 'no fix'),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
assert.equal(md.includes('Fixable issues'), false);
});
test('renderer: emitFooter=false suppresses trailing timestamp', () => {
const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], {
storeNum: '99',
detailed: true,
emitFooter: false,
});
assert.equal(md.includes('Last checked'), false);
});
test('renderer: emitFooter=true (default) adds an ISO timestamp line', () => {
const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], {
storeNum: '99',
detailed: true,
});
assert.match(md, /_Last checked: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
test('renderer: details values — arrays truncated past 3 items, nested objects JSON-ified', () => {
const results = [
R('c1', 'warn', 'x', null, {
long: ['a', 'b', 'c', 'd', 'e'],
short: ['a', 'b'],
nested: { foo: 'bar', n: 1 },
nada: null,
}),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '99', detailed: true });
assert.match(md, /long: \[a, b, c, …\+2\]/);
assert.match(md, /short: \[a, b\]/);
assert.match(md, /nested: \{"foo":"bar","n":1\}/);
assert.match(md, /nada: —/);
});
test('renderer: --detail off does not include details even when present', () => {
const results = [
R('c1', 'warn', 'x', null, { foo: 'bar' }),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
assert.equal(md.includes('foo: bar'), false);
});
test('renderer: skipped bucket shown even without detail', () => {
const results = [
R('c1', 'skipped', '403 missing scope'),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
assert.match(md, /\*\*SKIPPED\*\*/);
assert.match(md, /403 missing scope/);
});
// ─── Shape-aware detail formatters ──────────────────────────────────
test('renderer: perLink shape → clean per-link list with verdict icons + threshold + roll-up', () => {
const results = [
R('wanLatency', 'ok', 'All 3 paths ok', null, {
total: 3, ok: 3, warn: 0, error: 0,
warnThresh: 150, errorThresh: 400,
standardLabel: 'warn > 150ms, error > 400ms',
perLink: [
{ link: 'Inet1-00782', value: 22.2, interfaceId: '1', verdict: 'ok' },
{ link: 'Inet2-00782', value: 13.5, interfaceId: '2', verdict: 'ok' },
{ link: '5G-LTE-00782', value: 52.4, interfaceId: '3', verdict: 'ok' },
],
}),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '782', detailed: true });
// Clean per-link list — one line per link with verdict icon
assert.match(md, /Threshold: warn > 150ms, error > 400ms/);
assert.match(md, /Per link:/);
assert.match(md, /✅ Inet1-00782: 22\.2/);
assert.match(md, /✅ Inet2-00782: 13\.5/);
assert.match(md, /✅ 5G-LTE-00782: 52\.4/);
assert.match(md, /Roll-up: 3 total · 3 ok · 0 warn · 0 error/);
// Must NOT dump the raw perLink JSON in a stringified form.
assert.equal(md.includes('"link":'), false);
assert.equal(md.includes('interfaceId'), false);
});
test('renderer: perLink shape — surfaces per-link warn/error icons', () => {
const results = [
R('wanLatency', 'warn', '1 path in warning', null, {
total: 3, ok: 2, warn: 1, error: 0,
standardLabel: 'warn > 150ms, error > 400ms',
perLink: [
{ link: 'Inet1', value: 22, verdict: 'ok' },
{ link: 'Inet2', value: 175, verdict: 'warn' },
{ link: '5G-LTE', value: 450, verdict: 'error' },
],
}),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '782', detailed: true });
assert.match(md, /✅ Inet1: 22/);
assert.match(md, /⚠️ Inet2: 175/);
assert.match(md, /❌ 5G-LTE: 450/);
});
test('renderer: link-state shape → up/down/unknown roll-up + offender list', () => {
const results = [
R('wanLinkState', 'error', '1 WAN path down', null, {
total: 3, up: 2, down: 1, unknown: 0,
offenders: ['5G-LTE-00782'],
unknownLabels: [],
}),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '782', detailed: true });
assert.match(md, /Roll-up: 3 total · 2 up · 1 down · 0 unknown/);
assert.match(md, /Down: 5G-LTE-00782/);
// No stringified offenders array
assert.equal(md.includes('"offenders"'), false);
});
test('renderer: alarm shape → counts one-liner + recent alarm list (raw code fallback)', () => {
const results = [
R('wanAlarms', 'warn', '2 major alarms in last hour', null, {
critical: 0, major: 2, minor: 1,
recentSamples: [
{ type: 'NETWORK_ANYNETLINK_DOWN', severity: 'major' },
{ code: 'DEVICE_HB_MISSED', severity: 'minor' },
],
}),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '782', detailed: true });
assert.match(md, /Counts: 🔴 0 critical · 🟠 2 major · 🟡 1 minor/);
assert.match(md, /Recent:/);
assert.match(md, /NETWORK_ANYNETLINK_DOWN \(major\)/);
assert.match(md, /DEVICE_HB_MISSED \(minor\)/);
});
test('renderer: alarm shape → new byCategory + humanized rollup format', () => {
const results = [
R('wanAlarms', 'warn', '20 major alarms in the last 1d — most common: SD-WAN overlay tunnel down (×20, latest 3h ago). These affect SD-WAN overlay/VPN tunnels between sites; physical WAN paths are all up per the Link State check.', null, {
critical: 0, major: 20, minor: 0,
window: '1d',
byCategory: { overlay: 20, physical: 0, device: 0, other: 0, total: 20 },
recentSamples: [
{
code: 'NETWORK_ANYNETLINK_DOWN',
humanized: 'SD-WAN overlay tunnel down',
category: 'overlay',
severity: 'major',
count: 20,
age: '3h ago',
},
],
}),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '782', detailed: true });
// Counts line still present.
assert.match(md, /Counts: 🔴 0 critical · 🟠 20 major · 🟡 0 minor/);
// Category breakdown appears when byCategory is populated.
assert.match(md, /Category: 20 overlay\/VPN/);
// Humanized label first, raw code in backticks, count, and age.
assert.match(md, /SD-WAN overlay tunnel down/);
assert.match(md, /`NETWORK_ANYNETLINK_DOWN`/);
assert.match(md, /×20/);
assert.match(md, /3h ago/);
});
test('renderer: healthscore shape → breakdown only (value already in message)', () => {
const results = [
R('wanHealthscore', 'ok', 'Healthscore 100/100 (>=80).', null, {
value: 100, warnThresh: 80, errorThresh: 60, breakdown: {},
}),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '782', detailed: true });
// Breakdown is empty → no useless "- value: 100, warnThresh: 80..." line
assert.equal(md.includes('warnThresh'), false, 'threshold constants already in the message');
assert.equal(md.includes('errorThresh'), false);
});
test('renderer: healthscore shape → surfaces breakdown when populated', () => {
const results = [
R('wanHealthscore', 'warn', 'Score 75', null, {
value: 75, warnThresh: 80, errorThresh: 60,
breakdown: { link_health: 60, device_health: 85 },
}),
];
const md = renderVoiceDiagMarkdown(results, { storeNum: '782', detailed: true });
assert.match(md, /Breakdown: link_health: 60 · device_health: 85/);
});
test('renderer: WAN window banner shown when wanWindowMinutes provided AND a wan* check is present', () => {
const results = [
R('wanHealthscore', 'ok', 'ok', null, { value: 100, warnThresh: 80, errorThresh: 60 }),
];
const md = renderVoiceDiagMarkdown(results, {
storeNum: '782', detailed: false, wanWindowMinutes: 1440,
});
assert.match(md, /WAN window: 1d/);
});
test('renderer: WAN window banner hidden when no wan* check runs', () => {
const results = [R('dnd', 'ok', 'ok')];
const md = renderVoiceDiagMarkdown(results, {
storeNum: '782', wanWindowMinutes: 60,
});
assert.equal(md.includes('WAN window'), false,
'no need to advertise a WAN window when no WAN check ran');
});
test('renderer: WAN window banner formats: 15m / 1h / 6h / 1d / 7d', () => {
const wanResult = [
R('wanLatency', 'ok', 'ok', null, {
total: 1, ok: 1, warn: 0, error: 0,
perLink: [{ link: 'A', value: 20, verdict: 'ok' }],
}),
];
const cases = [
{ min: 15, expect: 'WAN window: 15m' },
{ min: 60, expect: 'WAN window: 1h' },
{ min: 360, expect: 'WAN window: 6h' },
{ min: 1440, expect: 'WAN window: 1d' },
{ min: 10080, expect: 'WAN window: 7d' }, // new default
{ min: 45, expect: 'WAN window: 45m' },
];
for (const c of cases) {
const md = renderVoiceDiagMarkdown(wanResult, { storeNum: '1', wanWindowMinutes: c.min });
assert.ok(md.includes(c.expect), `${c.min} minutes → "${c.expect}", got:\n${md}`);
}
});
// ─── Per-app audio details renderer ─────────────────────────────────
//
// renderAppAudioDetails is triggered by shape detection on
// {appName, worst, validSamples} — the fixture below carries all
// three so the shape-aware branch fires (rather than the JSON dump
// fallback which is what we're guarding against). appName is asserted
// verbatim from the fixture (not hardcoded to a specific app) — see
// wanAppRtpMos.js for the tenant-configurable contract.
test('renderer: per-app audio MOS details render worst-window + threshold + range (Webex_Calling_RTP)', () => {
const wanResult = R(
'wanAppRtpMos',
'error',
'Actual voice traffic (Webex_Calling_RTP) worst-window MOS: 1.60 < 3.5 (avg 4.27 over 33 valid samples)…',
null,
{
appName: 'Webex_Calling_RTP',
worst: 1.60, avg: 4.27, min: 1.60, max: 4.41, p95: 4.28,
samples: 55, validSamples: 33, interval: '5min',
warnThresh: 4.0, errorThresh: 3.5,
standardLabel: 'warn < 4, error < 3.5',
unit: '',
badSampleCount: 4, badSamplePct: 12,
},
'SD-WAN Voice Traffic MOS (worst window)',
);
const md = renderVoiceDiagMarkdown([wanResult], { storeNum: '127', detailed: true });
// The app name is threaded from the fixture (appAudio.appName) — the
// renderer must not hardcode "rtp-base" so tenants using
// Webex_Calling_RTP, MS_Teams_RTP, etc. render correctly.
assert.match(md, /App: Webex_Calling_RTP \(voice traffic, DPI\)/);
assert.match(md, /Threshold: warn < 4, error < 3\.5/);
assert.match(md, /Worst window: \*\*1\.6\*\*/);
assert.match(md, /Avg: 4\.27/);
assert.match(md, /p95: 4\.28/);
assert.match(md, /Range: 1\.6 4\.41 across 33\/55 samples @ 5min/);
assert.match(md, /Time in warn\/error: 4 samples/);
// Absence check — the fallback JSON key:value dump must NOT appear.
assert.doesNotMatch(md, /"values":/, 'shape-aware formatter must not dump raw JSON');
});
test('renderer: per-app audio details fall back to "voice" when appName absent (defensive)', () => {
// If a fixture / integration test forgets to set appName (or a
// future refactor drops it), the renderer must not crash or render
// "undefined" — fall back to the generic "voice" label.
const wanResult = R(
'wanAppRtpMos', 'ok', 'ok',
null,
{
appName: null,
worst: 4.4, avg: 4.5, min: 4.4, max: 4.6, p95: 4.5,
samples: 12, validSamples: 12, interval: '5min',
warnThresh: 4.0, errorThresh: 3.5,
unit: '', badSampleCount: 0, badSamplePct: 0,
},
'SD-WAN Voice Traffic MOS (worst window)',
);
const md = renderVoiceDiagMarkdown([wanResult], { storeNum: '127', detailed: true });
assert.match(md, /App: voice \(voice traffic, DPI\)/,
'null appName must fall back to generic "voice" label');
});
test('renderer: per-app audio details renders "View in Prisma UI" deep link when detailsUrl is set', () => {
const wanResult = R(
'wanAppRtpMos', 'error', 'msg',
null,
{
appName: 'Webex_Calling_RTP',
worst: 1.85, avg: 3.55, min: 1.85, max: 4.41, p95: 4.28,
samples: 288, validSamples: 288, interval: '5min',
warnThresh: 4.0, errorThresh: 3.5,
standardLabel: 'warn < 4, error < 3.5',
unit: '',
badSampleCount: 42, badSamplePct: 15,
detailsUrl: 'https://stratacloudmanager.paloaltonetworks.com/insights/operational/sdwan-applications/1708539371717015196/site/16190109915660160/details',
},
'SD-WAN Voice Traffic MOS (worst window)',
);
const md = renderVoiceDiagMarkdown([wanResult], { storeNum: '127', detailed: true });
assert.match(
md,
/\[View in Prisma UI\]\(https:\/\/stratacloudmanager\.paloaltonetworks\.com\/insights\/operational\/sdwan-applications\/1708539371717015196\/site\/16190109915660160\/details\)/,
'deep link must render as a markdown link so Webex chats it as a clickable URL',
);
});
test('renderer: per-app audio details omits Prisma link when detailsUrl is not set', () => {
const wanResult = R(
'wanAppRtpMos', 'error', 'msg',
null,
{
appName: 'Webex_Calling_RTP',
worst: 1.85, avg: 3.55, min: 1.85, max: 4.41, p95: 4.28,
samples: 288, validSamples: 288, interval: '5min',
warnThresh: 4.0, errorThresh: 3.5,
unit: '',
badSampleCount: 42, badSamplePct: 15,
},
'SD-WAN Voice Traffic MOS (worst window)',
);
const md = renderVoiceDiagMarkdown([wanResult], { storeNum: '127', detailed: true });
assert.doesNotMatch(md, /View in Prisma UI/);
});
test('renderer: per-app audio details — clean window says "0 samples (clean throughout window)"', () => {
const wanResult = R(
'wanAppRtpLoss', 'ok', 'ok',
null,
{
appName: 'Webex_Calling_RTP',
worst: 0, avg: 0, min: 0, max: 0, p95: 0,
samples: 288, validSamples: 288, interval: '5min',
warnThresh: 5, errorThresh: 15,
standardLabel: 'warn > 5%, error > 15%',
unit: '%',
badSampleCount: 0, badSamplePct: 0,
},
'SD-WAN Voice Traffic Packet Loss (worst window)',
);
const md = renderVoiceDiagMarkdown([wanResult], { storeNum: '127', detailed: true });
assert.match(md, /Time in warn\/error: 0 samples \(clean throughout window\)/);
});