Introduces a new diagnostic command that walks a registry of check
modules against a store user's Webex Calling configuration and
surfaces per-issue adaptive-card remediation for the fixable ones.
Checks (services/voiceDiag/checks/): dnd, callForwarding, callWaiting,
callIntercept, voicemail, hoteling, executiveAssistant,
outgoingPermission, phoneOnline. Remediations offered for DND,
forwarding, waiting, and intercept.
Uses the /v1/people/{id}/features/* admin surface (spark-admin:people_read
+ spark-admin:people_write scopes we already hold) — the earlier
telephony/config/people/*/callSettings/* path scheme returns 404 from
the Webex gateway and is not a live surface. Runner distinguishes
routing-404s ("URL moved") from "not applicable" 404s ("no calling
license") via the response body.
Arg parser accepts detail/detailed/--detail/--detailed and normalises
macOS smart-dashes so --detailed doesn't die when auto-correct
turns it into an em-dash.
Wires a VOICEDIAG_ACTIONS dispatcher in index.js mirroring the IGMP
branch, and registers /voicediag in commands/registry.js. 170 tests
pass (52 new: 39 check + 12 renderer + 5 arg-normalization).
Docs updated in .env.example, services/phoneService.js:467, and a new
services/voiceDiag/README.md that includes a "how to add a check"
recipe plus a note on the earlier wrong URL scheme.
Co-authored-by: Cursor <cursoragent@cursor.com>
160 lines
5.7 KiB
JavaScript
160 lines
5.7 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 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/);
|
|
});
|