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>
492 lines
18 KiB
JavaScript
492 lines
18 KiB
JavaScript
// Unit tests for services/voiceDiag/checks/*. Every check gets a
|
|
// happy-path, an actionable-finding path, and a scope-missing path
|
|
// (403 or 404 surfaced by the runner as `status: 'skipped'`).
|
|
//
|
|
// Remediation functions are exercised in tests/voiceDiag.remediations.test.js
|
|
// where we can stub the WebexClient import. This file only covers the
|
|
// `run(ctx)` shape — ctx.webex.request is trivially stubbed.
|
|
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { dndCheck } from '../services/voiceDiag/checks/dnd.js';
|
|
import { callForwardingCheck } from '../services/voiceDiag/checks/callForwarding.js';
|
|
import { callWaitingCheck } from '../services/voiceDiag/checks/callWaiting.js';
|
|
import { voicemailCheck } from '../services/voiceDiag/checks/voicemail.js';
|
|
import { callInterceptCheck } from '../services/voiceDiag/checks/callIntercept.js';
|
|
import { hotelingCheck } from '../services/voiceDiag/checks/hoteling.js';
|
|
import { executiveAssistantCheck } from '../services/voiceDiag/checks/executiveAssistant.js';
|
|
import { outgoingPermissionCheck } from '../services/voiceDiag/checks/outgoingPermission.js';
|
|
import { phoneOnlineCheck } from '../services/voiceDiag/checks/phoneOnline.js';
|
|
import {
|
|
runVoiceDiag,
|
|
buildRemediationRegistry,
|
|
} from '../services/voiceDiag/voiceDiagService.js';
|
|
import { CHECKS } from '../services/voiceDiag/checks/index.js';
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
|
|
/** Build a stub context with a webex.request that returns whatever
|
|
* callback returns for its (method, endpoint) signature. */
|
|
function mkCtx({ response, error } = {}) {
|
|
return {
|
|
storeNum: '12345',
|
|
email: 'ae12345@ae.com',
|
|
personId: 'PID_TEST',
|
|
personLabel: 'Store 12345',
|
|
telephonyProfile: {},
|
|
phoneStatus: null,
|
|
person: null,
|
|
webex: {
|
|
async request(_method, _endpoint) {
|
|
if (error) throw error;
|
|
return response;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Build an http error that mirrors what axios throws. */
|
|
function httpError(status, message = 'Boom') {
|
|
const err = new Error(message);
|
|
err.response = { status, data: { message } };
|
|
return err;
|
|
}
|
|
|
|
// ─── dnd ────────────────────────────────────────────────────────────
|
|
|
|
test('dnd: disabled → ok', async () => {
|
|
const ctx = mkCtx({ response: { enabled: false, ringSplashEnabled: false } });
|
|
const r = await dndCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
assert.equal(r.remediation, null);
|
|
});
|
|
|
|
test('dnd: enabled → warn + disable_dnd remediation', async () => {
|
|
const ctx = mkCtx({ response: { enabled: true, ringSplashEnabled: true } });
|
|
const r = await dndCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.remediation.action, 'disable_dnd');
|
|
assert.equal(r.remediation.payload.personId, 'PID_TEST');
|
|
assert.equal(r.remediation.payload.before.enabled, true);
|
|
});
|
|
|
|
test('dnd: 403 flows through runner as skipped with scope hint', async () => {
|
|
const ctx = mkCtx({ error: httpError(403) });
|
|
const results = await runVoiceDiag(ctx, { only: ['dnd'] });
|
|
assert.equal(results.length, 1);
|
|
assert.equal(results[0].status, 'skipped');
|
|
assert.match(results[0].message, /missing/);
|
|
assert.match(results[0].message, /spark-admin:people_read/);
|
|
});
|
|
|
|
test('dnd: 404 flows through runner as skipped ("not applicable" wording)', async () => {
|
|
const ctx = mkCtx({ error: httpError(404) });
|
|
const results = await runVoiceDiag(ctx, { only: ['dnd'] });
|
|
assert.equal(results[0].status, 'skipped');
|
|
assert.match(results[0].message, /Webex Calling license|feature isn't applicable/);
|
|
});
|
|
|
|
test('dnd: 404 with "no static resource" body → skipped with routing-miss diagnostic', async () => {
|
|
const ctx = mkCtx({
|
|
error: httpError(404, 'No static resource hydra/v1/people/.../features/doNotDisturb.'),
|
|
});
|
|
const results = await runVoiceDiag(ctx, { only: ['dnd'] });
|
|
assert.equal(results[0].status, 'skipped');
|
|
assert.match(results[0].message, /URL for this feature has moved/);
|
|
assert.equal(results[0].details.routingMiss, true);
|
|
});
|
|
|
|
// ─── callForwarding ─────────────────────────────────────────────────
|
|
|
|
test('callForwarding: nothing enabled → ok', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
callForwarding: {
|
|
always: { enabled: false },
|
|
busy: { enabled: false },
|
|
noAnswer: { enabled: false },
|
|
},
|
|
},
|
|
});
|
|
const r = await callForwardingCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
assert.equal(r.remediation, null);
|
|
});
|
|
|
|
test('callForwarding: always-forwarding enabled → warn + compound remediation', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
callForwarding: {
|
|
always: { enabled: true, destination: '+18005551212' },
|
|
busy: { enabled: false },
|
|
noAnswer: { enabled: false },
|
|
},
|
|
},
|
|
});
|
|
const r = await callForwardingCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.remediation.action, 'clear_call_forwarding');
|
|
assert.deepEqual(r.remediation.payload.variantsToClear, ['always']);
|
|
assert.match(r.message, /\+18005551212/);
|
|
});
|
|
|
|
test('callForwarding: multiple variants active → single compound remediation', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
callForwarding: {
|
|
always: { enabled: true, destination: '+15551111111' },
|
|
busy: { enabled: true, destination: '+15552222222' },
|
|
noAnswer: { enabled: false },
|
|
},
|
|
},
|
|
});
|
|
const r = await callForwardingCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.deepEqual(r.remediation.payload.variantsToClear, ['always', 'busy']);
|
|
assert.match(r.remediation.title, /2 forwarding variants/);
|
|
});
|
|
|
|
test('callForwarding: 403 → skipped via runner', async () => {
|
|
const ctx = mkCtx({ error: httpError(403) });
|
|
const results = await runVoiceDiag(ctx, { only: ['callForwarding'] });
|
|
assert.equal(results[0].status, 'skipped');
|
|
});
|
|
|
|
// ─── callWaiting ────────────────────────────────────────────────────
|
|
|
|
test('callWaiting: enabled → ok', async () => {
|
|
const ctx = mkCtx({ response: { enabled: true } });
|
|
const r = await callWaitingCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('callWaiting: disabled → warn + enable_call_waiting remediation', async () => {
|
|
const ctx = mkCtx({ response: { enabled: false } });
|
|
const r = await callWaitingCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.remediation.action, 'enable_call_waiting');
|
|
});
|
|
|
|
test('callWaiting: 404 → skipped via runner', async () => {
|
|
const ctx = mkCtx({ error: httpError(404) });
|
|
const results = await runVoiceDiag(ctx, { only: ['callWaiting'] });
|
|
assert.equal(results[0].status, 'skipped');
|
|
});
|
|
|
|
// ─── voicemail ──────────────────────────────────────────────────────
|
|
|
|
test('voicemail: disabled → ok', async () => {
|
|
const ctx = mkCtx({ response: { enabled: false } });
|
|
const r = await voicemailCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('voicemail: sendAllCalls on → error', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
enabled: true,
|
|
sendAllCalls: { enabled: true },
|
|
messageStorage: { mwiEnabled: true },
|
|
},
|
|
});
|
|
const r = await voicemailCheck.run(ctx);
|
|
assert.equal(r.status, 'error');
|
|
assert.match(r.message, /never ring/);
|
|
});
|
|
|
|
test('voicemail: enabled but MWI off → warn', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
enabled: true,
|
|
messageStorage: { mwiEnabled: false },
|
|
},
|
|
});
|
|
const r = await voicemailCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.match(r.message, /MWI/);
|
|
});
|
|
|
|
test('voicemail: enabled, email forward to off-org → warn', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
enabled: true,
|
|
messageStorage: { mwiEnabled: true },
|
|
emailCopyOfMessage: { enabled: true, emailId: 'random@gmail.com' },
|
|
},
|
|
});
|
|
const r = await voicemailCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.match(r.message, /off-org/);
|
|
});
|
|
|
|
test('voicemail: enabled + MWI + no unusual forwarding → ok', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
enabled: true,
|
|
messageStorage: { mwiEnabled: true },
|
|
emailCopyOfMessage: { enabled: false, emailId: '' },
|
|
},
|
|
});
|
|
const r = await voicemailCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('voicemail: 401 → skipped via runner', async () => {
|
|
const ctx = mkCtx({ error: httpError(401) });
|
|
const results = await runVoiceDiag(ctx, { only: ['voicemail'] });
|
|
assert.equal(results[0].status, 'skipped');
|
|
});
|
|
|
|
// ─── callIntercept ──────────────────────────────────────────────────
|
|
|
|
test('callIntercept: disabled → ok', async () => {
|
|
const ctx = mkCtx({ response: { enabled: false } });
|
|
const r = await callInterceptCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('callIntercept: enabled → error + disable_call_intercept remediation', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
enabled: true,
|
|
incoming: { type: 'INTERCEPT_ALL' },
|
|
outgoing: { type: 'ALLOW_ALL' },
|
|
},
|
|
});
|
|
const r = await callInterceptCheck.run(ctx);
|
|
assert.equal(r.status, 'error');
|
|
assert.equal(r.remediation.action, 'disable_call_intercept');
|
|
});
|
|
|
|
test('callIntercept: 403 → skipped via runner', async () => {
|
|
const ctx = mkCtx({ error: httpError(403) });
|
|
const results = await runVoiceDiag(ctx, { only: ['callIntercept'] });
|
|
assert.equal(results[0].status, 'skipped');
|
|
});
|
|
|
|
// ─── hoteling (info-only) ───────────────────────────────────────────
|
|
|
|
test('hoteling: disabled → ok', async () => {
|
|
const ctx = mkCtx({ response: { enabled: false } });
|
|
const r = await hotelingCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
assert.equal(r.remediation, null);
|
|
});
|
|
|
|
test('hoteling: enabled → warn (info only, no remediation)', async () => {
|
|
const ctx = mkCtx({ response: { enabled: true } });
|
|
const r = await hotelingCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.remediation, null);
|
|
});
|
|
|
|
test('hoteling: 404 → skipped via runner', async () => {
|
|
const ctx = mkCtx({ error: httpError(404) });
|
|
const results = await runVoiceDiag(ctx, { only: ['hoteling'] });
|
|
assert.equal(results[0].status, 'skipped');
|
|
});
|
|
|
|
// ─── executiveAssistant (info-only) ─────────────────────────────────
|
|
|
|
test('executiveAssistant: UNASSIGNED → ok', async () => {
|
|
const ctx = mkCtx({ response: { type: 'UNASSIGNED' } });
|
|
const r = await executiveAssistantCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('executiveAssistant: EXECUTIVE_ASSISTANT → warn (no remediation)', async () => {
|
|
const ctx = mkCtx({ response: { type: 'EXECUTIVE_ASSISTANT' } });
|
|
const r = await executiveAssistantCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.remediation, null);
|
|
assert.match(r.message, /EXECUTIVE_ASSISTANT/);
|
|
});
|
|
|
|
test('executiveAssistant: 403 → skipped via runner', async () => {
|
|
const ctx = mkCtx({ error: httpError(403) });
|
|
const results = await runVoiceDiag(ctx, { only: ['executiveAssistant'] });
|
|
assert.equal(results[0].status, 'skipped');
|
|
});
|
|
|
|
// ─── outgoingPermission (info-only) ─────────────────────────────────
|
|
|
|
test('outgoingPermission: useCustomEnabled=false → ok (defers to location)', async () => {
|
|
const ctx = mkCtx({
|
|
response: { useCustomEnabled: false, callingPermissions: [] },
|
|
});
|
|
const r = await outgoingPermissionCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
assert.match(r.message, /location default/);
|
|
});
|
|
|
|
test('outgoingPermission: NATIONAL BLOCK → warn', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
useCustomEnabled: true,
|
|
callingPermissions: [
|
|
{ callType: 'NATIONAL', action: 'BLOCK' },
|
|
{ callType: 'INTERNAL_CALL', action: 'ALLOW' },
|
|
],
|
|
},
|
|
});
|
|
const r = await outgoingPermissionCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.deepEqual(r.details.highImpactBlocked, ['NATIONAL']);
|
|
});
|
|
|
|
test('outgoingPermission: only premium blocked → ok', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
useCustomEnabled: true,
|
|
callingPermissions: [
|
|
{ callType: 'PREMIUM_SERVICES_I', action: 'BLOCK' },
|
|
],
|
|
},
|
|
});
|
|
const r = await outgoingPermissionCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
assert.deepEqual(r.details.highImpactBlocked, []);
|
|
});
|
|
|
|
test('outgoingPermission: 404 → skipped via runner', async () => {
|
|
const ctx = mkCtx({ error: httpError(404) });
|
|
const results = await runVoiceDiag(ctx, { only: ['outgoingPermission'] });
|
|
assert.equal(results[0].status, 'skipped');
|
|
});
|
|
|
|
// ─── phoneOnline (no API — reads phoneStatus snapshot) ──────────────
|
|
|
|
test('phoneOnline: no phoneStatus → skipped by runner (requires guard)', async () => {
|
|
const ctx = { ...mkCtx(), phoneStatus: null };
|
|
const results = await runVoiceDiag(ctx, { only: ['phoneOnline'] });
|
|
assert.equal(results[0].status, 'skipped');
|
|
assert.match(results[0].message, /missing phoneStatus/);
|
|
});
|
|
|
|
test('phoneOnline: no devices → warn', async () => {
|
|
const ctx = {
|
|
...mkCtx(),
|
|
phoneStatus: { phones: { data: [] }, dectBasestations: [] },
|
|
};
|
|
const r = await phoneOnlineCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.match(r.message, /No desk phones/);
|
|
});
|
|
|
|
test('phoneOnline: all connected → ok', async () => {
|
|
const ctx = {
|
|
...mkCtx(),
|
|
phoneStatus: {
|
|
phones: { data: [{ mac: 'aa:bb', status: 'connected', name: 'A' }] },
|
|
dectBasestations: [{ mac: 'cc:dd', status: 'connected', name: 'DB' }],
|
|
},
|
|
};
|
|
const r = await phoneOnlineCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
});
|
|
|
|
test('phoneOnline: one offline phone → error, offlineDevices populated', async () => {
|
|
const ctx = {
|
|
...mkCtx(),
|
|
phoneStatus: {
|
|
phones: { data: [
|
|
{ mac: 'aa:bb', status: 'connected', name: 'A' },
|
|
{ mac: 'ee:ff', status: 'disconnected', name: 'B', lastSeen: 'now' },
|
|
] },
|
|
dectBasestations: [],
|
|
},
|
|
};
|
|
const r = await phoneOnlineCheck.run(ctx);
|
|
assert.equal(r.status, 'error');
|
|
assert.equal(r.details.offlineDevices.length, 1);
|
|
assert.equal(r.details.offlineDevices[0].name, 'B');
|
|
});
|
|
|
|
// ─── runner-level integration ───────────────────────────────────────
|
|
|
|
test('runVoiceDiag: missing personId → every personId-requiring check is skipped', async () => {
|
|
const ctx = mkCtx();
|
|
ctx.personId = null;
|
|
const results = await runVoiceDiag(ctx);
|
|
const personRequired = results.filter((r) =>
|
|
['dnd', 'callForwarding', 'callWaiting', 'voicemail', 'callIntercept', 'hoteling', 'executiveAssistant', 'outgoingPermission'].includes(r.id),
|
|
);
|
|
for (const r of personRequired) {
|
|
assert.equal(r.status, 'skipped', `expected ${r.id} skipped, got ${r.status}`);
|
|
}
|
|
});
|
|
|
|
test('runVoiceDiag: --only filter honours case-insensitive names', async () => {
|
|
const ctx = mkCtx({ response: { enabled: false } });
|
|
const results = await runVoiceDiag(ctx, { only: ['DND'] });
|
|
assert.equal(results.length, 1);
|
|
assert.equal(results[0].id, 'dnd');
|
|
});
|
|
|
|
test('runVoiceDiag: check runner isolates thrown errors as error status', async () => {
|
|
const ctx = mkCtx({ error: new Error('random blowup') });
|
|
const results = await runVoiceDiag(ctx, { only: ['dnd'] });
|
|
assert.equal(results[0].status, 'error');
|
|
assert.match(results[0].message, /random blowup/);
|
|
});
|
|
|
|
test('runVoiceDiag: registry order preserved in results', async () => {
|
|
const ctx = mkCtx({ response: { enabled: false } });
|
|
ctx.phoneStatus = { phones: { data: [] }, dectBasestations: [] };
|
|
const results = await runVoiceDiag(ctx);
|
|
const orderedIds = CHECKS.map((c) => c.id);
|
|
assert.deepEqual(results.map((r) => r.id), orderedIds);
|
|
});
|
|
|
|
// ─── command-arg normalization ─────────────────────────────────────
|
|
|
|
test('normalizeArg: em-dash converted to double-hyphen', async () => {
|
|
const { normalizeArg } = await import('../commands/voiceDiag.js');
|
|
assert.equal(normalizeArg('\u2014detailed'), '--detailed');
|
|
assert.equal(normalizeArg('\u2014only'), '--only');
|
|
});
|
|
|
|
test('normalizeArg: en-dash and hyphen-like Unicode range all collapse to --', async () => {
|
|
const { normalizeArg } = await import('../commands/voiceDiag.js');
|
|
assert.equal(normalizeArg('\u2013detailed'), '--detailed'); // en dash
|
|
assert.equal(normalizeArg('\u2015detailed'), '--detailed'); // horizontal bar
|
|
assert.equal(normalizeArg('\u2212detailed'), '--detailed'); // minus sign
|
|
});
|
|
|
|
test('normalizeArg: run of 3+ ASCII hyphens collapses to --', async () => {
|
|
const { normalizeArg } = await import('../commands/voiceDiag.js');
|
|
assert.equal(normalizeArg('---detailed'), '--detailed');
|
|
assert.equal(normalizeArg('----only'), '--only');
|
|
});
|
|
|
|
test('normalizeArg: leaves plain --flag and bare word untouched', async () => {
|
|
const { normalizeArg } = await import('../commands/voiceDiag.js');
|
|
assert.equal(normalizeArg('--detailed'), '--detailed');
|
|
assert.equal(normalizeArg('detailed'), 'detailed');
|
|
assert.equal(normalizeArg('782'), '782');
|
|
});
|
|
|
|
test('normalizeArg: nullish input passed through', async () => {
|
|
const { normalizeArg } = await import('../commands/voiceDiag.js');
|
|
assert.equal(normalizeArg(null), null);
|
|
assert.equal(normalizeArg(undefined), undefined);
|
|
});
|
|
|
|
test('buildRemediationRegistry: contains every declared remediation exactly once', () => {
|
|
const registry = buildRemediationRegistry();
|
|
const declared = new Map();
|
|
for (const c of CHECKS) {
|
|
for (const rid of Object.keys(c.remediations || {})) {
|
|
if (declared.has(rid)) {
|
|
throw new Error(`duplicate remediation id in fixture: ${rid}`);
|
|
}
|
|
declared.set(rid, c.id);
|
|
}
|
|
}
|
|
assert.equal(registry.size, declared.size);
|
|
for (const [rid, checkId] of declared.entries()) {
|
|
assert.ok(registry.has(rid), `missing remediation ${rid}`);
|
|
assert.equal(registry.get(rid).check.id, checkId);
|
|
}
|
|
});
|