collabSupport/tests/voiceDiag.checks.test.js
jmcqueen 2eb31a2ddc Add /voicediag rules-engine command with 9 per-user calling checks
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>
2026-07-07 18:51:31 -04:00

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);
}
});