Introduces a full Palo Alto Prisma SD-WAN integration (dual-mode SASE OAuth 2.0 / legacy CloudGenix auth, pagination, 429 backoff, session priming) that surfaces per-path latency/jitter/loss/MOS, site healthscore, link state, and alarm data for a store. Wired into the /phonestatus WAN follow-up and eight new /voicediag WAN checks graded against ITU-T G.114 / RFC 3550 defaults (env-overridable via WAN_STANDARD_*). Also adds a shape-aware detail renderer for /voicediag (per-link tables with verdict icons instead of a stringified JSON dump) and a --window flag (15m / 1h / 6h / 24h / 1d, env default via WAN_STANDARD_WINDOW_MINUTES) so operators can widen the look-back without redeploying. scripts/prismaProbe.js is bundled as a CLI for schema iteration against a live tenant. Co-authored-by: Cursor <cursoragent@cursor.com>
170 lines
6.7 KiB
JavaScript
170 lines
6.7 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/);
|
||
});
|