Docker hosts default to UTC, so bare toLocaleTimeString() showed wrong "Last checked" times in avstatus and other commands. Add formatDisplayTime() (default America/New_York, overridable via DISPLAY_TIMEZONE) and use it across renderers and command footers.
455 lines
18 KiB
JavaScript
455 lines
18 KiB
JavaScript
// 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 a display-timezone timestamp line', () => {
|
||
const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], {
|
||
storeNum: '99',
|
||
detailed: true,
|
||
});
|
||
assert.match(md, /_Last checked: .+ (AM|PM) [A-Z]{2,5}_/);
|
||
});
|
||
|
||
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\)/);
|
||
});
|