Show overall tunnel status with downs only, list the last five alarms with times, and note when overlay/physical alarms may explain bad voice DPI. Co-authored-by: Cursor <cursoragent@cursor.com>
374 lines
15 KiB
JavaScript
374 lines
15 KiB
JavaScript
// tests/renderers.wan.test.js
|
||
//
|
||
// Pure-function coverage for services/renderers/wanDiagnosticsRenderer.js.
|
||
// Same style as tests/renderers.test.js — asserts on the returned
|
||
// markdown string. No mocking, no HTTP.
|
||
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js';
|
||
|
||
function baseData(overrides = {}) {
|
||
return {
|
||
storeNum: '782',
|
||
site: { id: 'site-1', name: 'CG00782', storeNum: '782', description: '' },
|
||
elements: [{ id: 'el-1', name: 'ION-A', model: 'ION1000', connected: true }],
|
||
healthscore: { value: 90, breakdown: {} },
|
||
links: [
|
||
{
|
||
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,
|
||
},
|
||
],
|
||
alarms: { last1h: { critical: 0, major: 0, minor: 0 }, samples: [] },
|
||
errors: [],
|
||
fetchedAt: new Date().toISOString(),
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
test('renderer: no site → empty string (caller no-ops)', () => {
|
||
const md = renderWanDiagnosticsMarkdown({ site: null, storeNum: '782' });
|
||
assert.equal(md, '');
|
||
});
|
||
|
||
test('renderer: null / non-object → empty string', () => {
|
||
assert.equal(renderWanDiagnosticsMarkdown(null), '');
|
||
assert.equal(renderWanDiagnosticsMarkdown('nope'), '');
|
||
});
|
||
|
||
test('renderer: healthy site — header + site + one path bullet', () => {
|
||
const md = renderWanDiagnosticsMarkdown(baseData(), { storeNum: '782' });
|
||
assert.match(md, /WAN Diagnostics.*Store 782/);
|
||
assert.match(md, /Site.*CG00782/);
|
||
assert.match(md, /Healthscore.*90/);
|
||
assert.match(md, /wan1/);
|
||
assert.match(md, /MPLS/);
|
||
assert.match(md, /latency 40ms/);
|
||
assert.match(md, /MOS 4\.4/);
|
||
});
|
||
|
||
test('renderer: degraded path — worst path floats to top', () => {
|
||
const data = baseData({
|
||
links: [
|
||
{ interfaceId: 'a', interfaceName: 'good', up: true, latencyMs: 30, jitterMs: 3, lossPct: 0, mos: 4.5 },
|
||
{ interfaceId: 'b', interfaceName: 'bad', up: true, latencyMs: 500, jitterMs: 60, lossPct: 5, mos: 3.0 },
|
||
],
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '782' });
|
||
const goodIdx = md.indexOf('good');
|
||
const badIdx = md.indexOf('bad');
|
||
assert.ok(badIdx >= 0 && goodIdx >= 0);
|
||
assert.ok(badIdx < goodIdx, 'bad link should render before good link');
|
||
});
|
||
|
||
test('renderer: all links down — down status labelled per link', () => {
|
||
const data = baseData({
|
||
links: [
|
||
{ interfaceId: 'a', interfaceName: 'mpls', up: false, latencyMs: null, jitterMs: null, lossPct: null, mos: null },
|
||
{ interfaceId: 'b', interfaceName: 'broadband', up: false, latencyMs: null, jitterMs: null, lossPct: null, mos: null },
|
||
],
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '782' });
|
||
assert.match(md, /mpls.*DOWN/);
|
||
assert.match(md, /broadband.*DOWN/);
|
||
});
|
||
|
||
test('renderer: partial-fetch errors surfaced under "Partial fetch"', () => {
|
||
const data = baseData({
|
||
errors: [
|
||
{ scope: 'lqm.latency', message: 'timeout' },
|
||
{ scope: 'alarms', message: 'unauthorized' },
|
||
],
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '782' });
|
||
assert.match(md, /Partial fetch/);
|
||
assert.match(md, /lqm\.latency.*timeout/);
|
||
assert.match(md, /alarms.*unauthorized/);
|
||
});
|
||
|
||
test('renderer: alarms summary emitted when non-zero — sample lines show code, not raw JSON', () => {
|
||
const data = baseData({
|
||
alarms: {
|
||
last1h: { critical: 1, major: 2, minor: 3 },
|
||
samples: [
|
||
{ code: 'NETWORK_ANYNETLINK_DOWN', message: 'link flapping', severity: 'critical', ts: new Date().toISOString() },
|
||
],
|
||
},
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '782' });
|
||
assert.match(md, /Alarms.*1 critical, 2 major, 3 minor/);
|
||
assert.match(md, /NETWORK_ANYNETLINK_DOWN/);
|
||
// Rendered rollup lines must NEVER include raw JSON blobs. If we
|
||
// regress and paste a stringified `info` dict into chat, this catches it.
|
||
assert.equal(md.includes('{"vpn_reasons'), false, 'no raw JSON in rendered alarm samples');
|
||
assert.equal(md.includes('[object Object]'), false, 'no ugly [object Object] in rendered alarm samples');
|
||
});
|
||
|
||
test('renderer: alarm rollup collapses N identical (code, severity) pairs to one line with ×N', () => {
|
||
// Regression: previously each of the 20 NETWORK_ANYNETLINK_DOWN
|
||
// events was pasted into chat as its own JSON blob line — noisy
|
||
// and unreadable. Rollup by (code, severity) prints one line.
|
||
const now = Date.now();
|
||
const dupes = Array.from({ length: 20 }, (_, i) => ({
|
||
code: 'NETWORK_ANYNETLINK_DOWN',
|
||
severity: 'major',
|
||
message: 'noise',
|
||
ts: new Date(now - i * 1000).toISOString(),
|
||
}));
|
||
const data = baseData({
|
||
alarms: {
|
||
last1h: { critical: 0, major: 20, minor: 0 },
|
||
samples: dupes,
|
||
},
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '782' });
|
||
// Exactly one line containing the code with the ×20 count marker.
|
||
const matches = md.match(/NETWORK_ANYNETLINK_DOWN/g) || [];
|
||
assert.equal(matches.length, 1, 'code should appear exactly once after rollup');
|
||
assert.match(md, /×20/, 'should print a count marker for the rollup');
|
||
});
|
||
|
||
test('renderer: alarm rollup orders by severity (critical → major → minor)', () => {
|
||
const data = baseData({
|
||
alarms: {
|
||
last1h: { critical: 1, major: 1, minor: 1 },
|
||
samples: [
|
||
{ code: 'MINOR_CODE', severity: 'minor', ts: 't1' },
|
||
{ code: 'MAJOR_CODE', severity: 'major', ts: 't2' },
|
||
{ code: 'CRITICAL_CODE', severity: 'critical', ts: 't3' },
|
||
],
|
||
},
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '782' });
|
||
const iCrit = md.indexOf('CRITICAL_CODE');
|
||
const iMaj = md.indexOf('MAJOR_CODE');
|
||
const iMinor = md.indexOf('MINOR_CODE');
|
||
assert.ok(iCrit > 0 && iMaj > 0 && iMinor > 0);
|
||
assert.ok(iCrit < iMaj, 'critical printed before major');
|
||
assert.ok(iMaj < iMinor, 'major printed before minor');
|
||
});
|
||
|
||
test('renderer: no alarms — no alarm section rendered', () => {
|
||
const md = renderWanDiagnosticsMarkdown(baseData(), { storeNum: '782' });
|
||
// The section header would be "Alarms (last 1h):"; the footer
|
||
// mentions wanAlarms as a --only check name which is fine.
|
||
assert.equal(md.includes('Alarms (last 1h)'), false);
|
||
});
|
||
|
||
test('renderer: no links — placeholder line', () => {
|
||
const md = renderWanDiagnosticsMarkdown(baseData({ links: [] }), { storeNum: '782' });
|
||
assert.match(md, /No WAN path metrics available/);
|
||
});
|
||
|
||
test('renderer: healthscore missing → "n/a" with unknown icon', () => {
|
||
const md = renderWanDiagnosticsMarkdown(baseData({ healthscore: null }), { storeNum: '782' });
|
||
assert.match(md, /Healthscore.*n\/a/);
|
||
});
|
||
|
||
// ─── Per-app "Voice Traffic Quality" section ────────────────────────
|
||
|
||
function appAudioSummary({ values, unit = '', interval = '5min' } = {}) {
|
||
const nums = values.filter((v) => Number.isFinite(v));
|
||
const avg = nums.length ? Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 100) / 100 : null;
|
||
return {
|
||
unit, interval,
|
||
samples: values.length, validSamples: nums.length,
|
||
avg,
|
||
min: nums.length ? Math.min(...nums) : null,
|
||
max: nums.length ? Math.max(...nums) : null,
|
||
p95: nums.length ? nums.sort((a, b) => a - b)[Math.floor(0.95 * nums.length)] || nums[nums.length - 1] : null,
|
||
values,
|
||
};
|
||
}
|
||
|
||
test('renderer: renders Voice Traffic Quality section when appAudio present with samples', () => {
|
||
// Uses Webex_Calling_RTP as the configured app — the renderer must
|
||
// pick up appName from the fixture, not hardcode "rtp-base".
|
||
const data = baseData({
|
||
appAudio: {
|
||
appId: '1708539371717015196', appName: 'Webex_Calling_RTP',
|
||
mos: appAudioSummary({ values: [3.55, 3.55, 1.85, 4.41], unit: 'count' }),
|
||
loss: appAudioSummary({ values: [22, 0, 69, 0], unit: '%' }),
|
||
jitter: appAudioSummary({ values: [0, 0, 0], unit: 'ms' }),
|
||
bandwidth: appAudioSummary({ values: [0.5, 0.6, 0.4], unit: 'Mbps' }),
|
||
},
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '127' });
|
||
assert.match(md, /Voice Traffic Quality \(Webex_Calling_RTP/,
|
||
'section header must reflect the tenant-configured app name');
|
||
assert.match(md, /DPI/, 'callout for "real DPI measurement" surfaces');
|
||
// Worst-window numbers must appear (that's the whole point).
|
||
assert.match(md, /1\.85/, 'worst MOS surfaced');
|
||
assert.match(md, /69%/, 'worst loss surfaced');
|
||
});
|
||
|
||
test('renderer: still renders correctly with legacy "rtp-base" appName (backwards-compat)', () => {
|
||
// Tenants that stayed on the legacy PRISMA_APP_ID_RTP_BASE env get
|
||
// appName='rtp-base' from resolveVoiceAppConfig() — must render
|
||
// identically to any other app.
|
||
const data = baseData({
|
||
appAudio: {
|
||
appId: '15932000365560116', appName: 'rtp-base',
|
||
mos: appAudioSummary({ values: [4.35], unit: 'count' }),
|
||
},
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '127' });
|
||
assert.match(md, /Voice Traffic Quality \(rtp-base/);
|
||
});
|
||
|
||
test('renderer: omits Voice Traffic Quality section entirely when appAudio is null', () => {
|
||
const md = renderWanDiagnosticsMarkdown(baseData(), { storeNum: '782' });
|
||
assert.doesNotMatch(md, /Voice Traffic Quality/);
|
||
});
|
||
|
||
test('renderer: omits Voice Traffic Quality section when appAudio has no valid samples', () => {
|
||
const empty = appAudioSummary({ values: [null, null] });
|
||
const data = baseData({
|
||
appAudio: { appId: 'x', appName: 'Webex_Calling_RTP', mos: empty, loss: empty, jitter: empty, bandwidth: empty },
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '782' });
|
||
assert.doesNotMatch(md, /Voice Traffic Quality/,
|
||
'no data across every metric → skip the header entirely rather than show empty rows');
|
||
});
|
||
|
||
test('renderer: MOS row uses worst-lowest label (not worst-highest)', () => {
|
||
const data = baseData({
|
||
appAudio: {
|
||
appId: 'x', appName: 'Webex_Calling_RTP',
|
||
mos: appAudioSummary({ values: [4.4, 4.3, 3.9], unit: '' }),
|
||
},
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '782' });
|
||
assert.match(md, /worst \(lowest\)/, 'MOS is lowIsBad — must say "lowest" not "highest"');
|
||
});
|
||
|
||
test('renderer: "View in Prisma UI" deep link renders when appAudio.detailsUrl is present', () => {
|
||
const data = baseData({
|
||
appAudio: {
|
||
appId: '1708539371717015196', appName: 'Webex_Calling_RTP',
|
||
detailsUrl: 'https://stratacloudmanager.paloaltonetworks.com/insights/operational/sdwan-applications/1708539371717015196/site/16190109915660160/details',
|
||
mos: appAudioSummary({ values: [4.35] }),
|
||
},
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '127' });
|
||
assert.match(
|
||
md,
|
||
/\[View in Prisma UI\]\(https:\/\/stratacloudmanager\.paloaltonetworks\.com\/insights\/operational\/sdwan-applications\/1708539371717015196\/site\/16190109915660160\/details\)/,
|
||
'deep link must be rendered as a markdown link inside the section header',
|
||
);
|
||
});
|
||
|
||
test('renderer: no deep link when appAudio.detailsUrl is missing (defensive)', () => {
|
||
const data = baseData({
|
||
appAudio: {
|
||
appId: 'x', appName: 'Webex_Calling_RTP',
|
||
mos: appAudioSummary({ values: [4.35] }),
|
||
},
|
||
});
|
||
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '127' });
|
||
assert.doesNotMatch(md, /View in Prisma UI/,
|
||
'link line should be omitted entirely when the URL is null');
|
||
});
|
||
|
||
test('renderer: footer mentions the per-app --only shortcut', () => {
|
||
const md = renderWanDiagnosticsMarkdown(baseData(), { storeNum: '782', footer: true });
|
||
assert.match(md, /wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter/,
|
||
'footer should point operators at the per-app checks by id');
|
||
});
|
||
|
||
test('renderer: overlay tunnels show overall status and only list downs', () => {
|
||
const md = renderWanDiagnosticsMarkdown(baseData({
|
||
tunnels: [
|
||
{ id: 't1', peerLabel: 'CG00001-HUB', up: false, state: 'down', recentAlarm: true },
|
||
{ id: 't2', peerLabel: 'CG00002-HUB', up: true, state: 'up' },
|
||
{ id: 't3', peerLabel: 'Cologix', up: true, state: 'up' },
|
||
],
|
||
}), { storeNum: '782', footer: false });
|
||
assert.match(md, /\*\*Overlay tunnels\*\* — ❌ 1 down \/ 3/);
|
||
assert.match(md, /CG00001-HUB/);
|
||
assert.doesNotMatch(md, /CG00002-HUB/);
|
||
assert.doesNotMatch(md, /Cologix/);
|
||
assert.match(md, /overlay down while physical/);
|
||
});
|
||
|
||
test('renderer: all-up tunnels collapse to summary only', () => {
|
||
const md = renderWanDiagnosticsMarkdown(baseData({
|
||
tunnels: [
|
||
{ id: 't1', peerLabel: 'Warrendale', up: true, state: 'up' },
|
||
{ id: 't2', peerLabel: 'Cologix', up: true, state: 'up' },
|
||
],
|
||
}), { storeNum: '782', footer: false });
|
||
assert.match(md, /\*\*Overlay tunnels\*\* — ✅ all 2 up/);
|
||
assert.doesNotMatch(md, /Warrendale/);
|
||
assert.doesNotMatch(md, /Cologix/);
|
||
});
|
||
|
||
test('renderer: all-unknown tunnels collapse to one inventory note', () => {
|
||
const md = renderWanDiagnosticsMarkdown(baseData({
|
||
tunnels: Array.from({ length: 5 }, (_, i) => ({
|
||
id: `t${i}`, peerLabel: 'peer', up: null, state: 'unknown',
|
||
})),
|
||
}), { storeNum: '782', footer: false });
|
||
assert.match(md, /status unknown/);
|
||
assert.match(md, /Tunnel inventory returned 5/);
|
||
assert.doesNotMatch(md, /\? peer — unknown/);
|
||
});
|
||
|
||
test('renderer: alarms include most-recent list with times and voice correlation', () => {
|
||
const now = Date.now();
|
||
const md = renderWanDiagnosticsMarkdown(baseData({
|
||
appAudio: {
|
||
appName: 'Webex_Calling_RTP',
|
||
mos: { avg: 4.0, min: 1.0, max: 4.4, samples: 10, validSamples: 10, interval: '5min' },
|
||
loss: { avg: 4, min: 0, max: 68, samples: 10, validSamples: 10, interval: '5min' },
|
||
jitter: { avg: 1, min: 0, max: 2, samples: 10, validSamples: 10, interval: '5min' },
|
||
bandwidth: { avg: 0.5, min: 0.1, max: 1, samples: 10, validSamples: 10, interval: '5min' },
|
||
},
|
||
alarms: {
|
||
last1h: { critical: 0, major: 2, minor: 0 },
|
||
samples: [
|
||
{
|
||
code: 'SITE_CONNECTIVITY_DEGRADED',
|
||
severity: 'major',
|
||
message: 'degraded',
|
||
ts: new Date(now - 9 * 3600 * 1000).toISOString(),
|
||
},
|
||
{
|
||
code: 'NETWORK_VPNLINK_DOWN',
|
||
severity: 'major',
|
||
message: 'down',
|
||
ts: new Date(now - 10 * 3600 * 1000).toISOString(),
|
||
},
|
||
],
|
||
},
|
||
}), { storeNum: '782', footer: false });
|
||
assert.match(md, /Most recent:/);
|
||
assert.match(md, /Site connectivity degraded/i);
|
||
assert.match(md, /may affect voice/);
|
||
assert.match(md, /Voice DPI looks degraded/);
|
||
assert.match(md, /may help explain it/);
|
||
});
|
||
|
||
test('renderer: voice DPI byPathType lines', () => {
|
||
const md = renderWanDiagnosticsMarkdown(baseData({
|
||
appAudio: {
|
||
appName: 'Webex_Calling_RTP',
|
||
mos: { avg: 4.2, min: 3.8, max: 4.5, samples: 10, validSamples: 10, interval: '5min' },
|
||
loss: { avg: 1, min: 0, max: 3, samples: 10, validSamples: 10, interval: '5min' },
|
||
jitter: { avg: 10, min: 5, max: 20, samples: 10, validSamples: 10, interval: '5min' },
|
||
bandwidth: { avg: 0.5, min: 0.1, max: 1, samples: 10, validSamples: 10, interval: '5min' },
|
||
byPathType: {
|
||
VPN: {
|
||
loss: { avg: 5, min: 1, max: 12, samples: 5, validSamples: 5 },
|
||
jitter: { avg: 30, min: 10, max: 45, samples: 5, validSamples: 5 },
|
||
},
|
||
},
|
||
},
|
||
}), { storeNum: '782', footer: false });
|
||
assert.match(md, /By path type/);
|
||
assert.match(md, /\*\*VPN:\*\*/);
|
||
});
|