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>
851 lines
34 KiB
JavaScript
851 lines
34 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 → error + compound remediation', async () => {
|
|
// Severity upgraded from warn → error per store-line standard
|
|
// (any forwarding active on a store line = missed customer calls).
|
|
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, 'error');
|
|
assert.equal(r.remediation.action, 'clear_call_forwarding');
|
|
assert.deepEqual(r.remediation.payload.variantsToClear, ['always']);
|
|
assert.match(r.message, /\+18005551212/);
|
|
assert.match(r.message, /Store phone standard/);
|
|
});
|
|
|
|
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, 'error');
|
|
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 → warn (callers can\'t leave messages)', async () => {
|
|
// Standard is enabled=true; disabled surfaces as warn, no
|
|
// auto-remediation (some sites intentionally disable it).
|
|
const ctx = mkCtx({ response: { enabled: false } });
|
|
const r = await voicemailCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.remediation, null);
|
|
assert.match(r.message, /Voicemail is disabled/);
|
|
});
|
|
|
|
test('voicemail: sendAllCalls on → error + stop_sending_to_voicemail remediation', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
enabled: true,
|
|
sendAllCalls: { enabled: true },
|
|
sendBusyCalls: { enabled: false },
|
|
sendUnansweredCalls: { enabled: false },
|
|
messageStorage: { mwiEnabled: true },
|
|
},
|
|
});
|
|
const r = await voicemailCheck.run(ctx);
|
|
assert.equal(r.status, 'error');
|
|
assert.equal(r.remediation.action, 'stop_sending_to_voicemail');
|
|
assert.deepEqual(r.remediation.payload.activeSendTriggers, ['sendAllCalls']);
|
|
assert.equal(r.remediation.payload.before.sendAllCallsEnabled, true);
|
|
assert.match(r.message, /Voicemail is receiving calls/);
|
|
});
|
|
|
|
test('voicemail: sendBusyCalls + sendUnansweredCalls both on → error, all triggers listed', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
enabled: true,
|
|
sendAllCalls: { enabled: false },
|
|
sendBusyCalls: { enabled: true },
|
|
sendUnansweredCalls: { enabled: true },
|
|
messageStorage: { mwiEnabled: true },
|
|
},
|
|
});
|
|
const r = await voicemailCheck.run(ctx);
|
|
assert.equal(r.status, 'error');
|
|
assert.deepEqual(
|
|
r.remediation.payload.activeSendTriggers.sort(),
|
|
['sendBusyCalls', 'sendUnansweredCalls'],
|
|
);
|
|
assert.match(r.message, /2 triggers are active|triggers are active/);
|
|
});
|
|
|
|
test('voicemail: enabled but MWI off → warn (no remediation, still soft signal)', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
enabled: true,
|
|
sendAllCalls: { enabled: false },
|
|
sendBusyCalls: { enabled: false },
|
|
sendUnansweredCalls: { enabled: false },
|
|
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,
|
|
sendAllCalls: { enabled: false },
|
|
sendBusyCalls: { enabled: false },
|
|
sendUnansweredCalls: { enabled: false },
|
|
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 send-to-VM + no unusual forwarding → ok', async () => {
|
|
const ctx = mkCtx({
|
|
response: {
|
|
enabled: true,
|
|
sendAllCalls: { enabled: false },
|
|
sendBusyCalls: { enabled: false },
|
|
sendUnansweredCalls: { enabled: false },
|
|
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 + disable_hoteling remediation', async () => {
|
|
const ctx = mkCtx({ response: { enabled: true } });
|
|
const r = await hotelingCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.remediation.action, 'disable_hoteling');
|
|
assert.equal(r.remediation.payload.personId, 'PID_TEST');
|
|
assert.equal(r.remediation.payload.before.enabled, true);
|
|
});
|
|
|
|
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('parseWindowMinutes: unit-suffixed values (m/h/d) resolve to minutes', async () => {
|
|
const { parseWindowMinutes } = await import('../commands/voiceDiag.js');
|
|
assert.equal(parseWindowMinutes('15m'), 15);
|
|
assert.equal(parseWindowMinutes('15min'), 15);
|
|
assert.equal(parseWindowMinutes('1h'), 60);
|
|
assert.equal(parseWindowMinutes('6h'), 360);
|
|
assert.equal(parseWindowMinutes('24h'), 1440);
|
|
assert.equal(parseWindowMinutes('1d'), 1440);
|
|
assert.equal(parseWindowMinutes('1day'), 1440);
|
|
assert.equal(parseWindowMinutes('2 hours'), 120);
|
|
});
|
|
|
|
test('parseWindowMinutes: bare integer treated as minutes', async () => {
|
|
const { parseWindowMinutes } = await import('../commands/voiceDiag.js');
|
|
assert.equal(parseWindowMinutes('30'), 30);
|
|
assert.equal(parseWindowMinutes('1440'), 1440);
|
|
});
|
|
|
|
test('parseWindowMinutes: empty / null → undefined (falls to env default)', async () => {
|
|
const { parseWindowMinutes } = await import('../commands/voiceDiag.js');
|
|
assert.equal(parseWindowMinutes(null), undefined);
|
|
assert.equal(parseWindowMinutes(undefined), undefined);
|
|
assert.equal(parseWindowMinutes(''), undefined);
|
|
});
|
|
|
|
test('parseWindowMinutes: unrecognised token → null (caller shows friendly error)', async () => {
|
|
const { parseWindowMinutes } = await import('../commands/voiceDiag.js');
|
|
assert.equal(parseWindowMinutes('bogus'), null);
|
|
assert.equal(parseWindowMinutes('15x'), null);
|
|
assert.equal(parseWindowMinutes('h1'), null);
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
// ─── standards regression guards ───────────────────────────────────
|
|
// The "voice standards" reference table in README.md is generated
|
|
// off the `standards` field on each check descriptor. If a check
|
|
// forgets to declare it, we lose visibility of what the desired
|
|
// state actually is. Fail loud instead of silently dropping the row.
|
|
|
|
test('every registered check exposes a standards object', () => {
|
|
const missing = CHECKS.filter((c) => !c.standards || typeof c.standards !== 'object');
|
|
assert.deepEqual(
|
|
missing.map((c) => c.id),
|
|
[],
|
|
`checks without .standards: ${missing.map((c) => c.id).join(', ')}`,
|
|
);
|
|
});
|
|
|
|
test('standards for DND / callWaiting / callIntercept encode the expected boolean shape', () => {
|
|
const byId = new Map(CHECKS.map((c) => [c.id, c]));
|
|
assert.equal(byId.get('dnd').standards.enabled, false);
|
|
assert.equal(byId.get('callWaiting').standards.enabled, true);
|
|
assert.equal(byId.get('callIntercept').standards.enabled, false);
|
|
});
|
|
|
|
test('standards for callForwarding declare all three variants off', () => {
|
|
const byId = new Map(CHECKS.map((c) => [c.id, c]));
|
|
const cf = byId.get('callForwarding').standards;
|
|
assert.equal(cf.always.enabled, false);
|
|
assert.equal(cf.busy.enabled, false);
|
|
assert.equal(cf.noAnswer.enabled, false);
|
|
});
|
|
|
|
test('standards for voicemail encode enabled + no send-to-VM triggers + MWI on', () => {
|
|
const byId = new Map(CHECKS.map((c) => [c.id, c]));
|
|
const vm = byId.get('voicemail').standards;
|
|
assert.equal(vm.enabled, true);
|
|
assert.equal(vm.sendAllCalls.enabled, false);
|
|
assert.equal(vm.sendBusyCalls.enabled, false);
|
|
assert.equal(vm.sendUnansweredCalls.enabled, false);
|
|
assert.equal(vm.messageStorage.mwiEnabled, true);
|
|
});
|
|
|
|
// ─── port-hygiene bucket ───────────────────────────────────────────
|
|
// Each of these tests injects a phoneStatus snapshot shaped like
|
|
// collectPhoneStatus() output, focussed on the fields the port check
|
|
// consumes.
|
|
|
|
async function loadPortChecks() {
|
|
const [
|
|
{ portTypeCheck, PORT_TYPE_STANDARDS },
|
|
{ portVlanCheck, PORT_VLAN_STANDARDS },
|
|
{ portPoeCheck, PORT_POE_STANDARDS },
|
|
{ portEnabledCheck, PORT_ENABLED_STANDARDS },
|
|
] = await Promise.all([
|
|
import('../services/voiceDiag/checks/port/portType.js'),
|
|
import('../services/voiceDiag/checks/port/portVlan.js'),
|
|
import('../services/voiceDiag/checks/port/portPoe.js'),
|
|
import('../services/voiceDiag/checks/port/portEnabled.js'),
|
|
]);
|
|
return {
|
|
portTypeCheck, PORT_TYPE_STANDARDS,
|
|
portVlanCheck, PORT_VLAN_STANDARDS,
|
|
portPoeCheck, PORT_POE_STANDARDS,
|
|
portEnabledCheck, PORT_ENABLED_STANDARDS,
|
|
};
|
|
}
|
|
|
|
function mkPortCtx(phones = [], dectBasestations = []) {
|
|
return {
|
|
...mkCtx(),
|
|
phoneStatus: {
|
|
phones: { data: phones },
|
|
dectBasestations,
|
|
},
|
|
};
|
|
}
|
|
|
|
test('portType: all access ports → ok', async () => {
|
|
const { portTypeCheck } = await loadPortChecks();
|
|
const ctx = mkPortCtx(
|
|
[{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', switchName: 'sw1', portName: 'p1', connectionType: 'Wired' } }],
|
|
[{ mac: 'bb:bb', name: 'B1', meraki: { portType: 'access', switchName: 'sw1', portName: 'p3', connectionType: 'Wired' } }],
|
|
);
|
|
const r = await portTypeCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
assert.equal(r.details.compliant, 2);
|
|
assert.equal(r.details.trunks, 0);
|
|
});
|
|
|
|
test('portType: trunk uplink → warn, offender named', async () => {
|
|
const { portTypeCheck } = await loadPortChecks();
|
|
const ctx = mkPortCtx([
|
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', switchName: 'sw1', portName: 'p1', connectionType: 'Wired' } },
|
|
{ mac: 'cc:cc', name: 'P2', meraki: { portType: 'trunk', switchName: 'sw1', portName: 'p2', connectionType: 'Wired' } },
|
|
]);
|
|
const r = await portTypeCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.details.trunks, 1);
|
|
assert.match(r.message, /TRUNK/);
|
|
assert.equal(r.details.offenders[0].deviceLabel, 'P2');
|
|
});
|
|
|
|
test('portType: wireless phone → skipped from compliance count (no switchport)', async () => {
|
|
const { portTypeCheck } = await loadPortChecks();
|
|
const ctx = mkPortCtx([
|
|
{ mac: 'aa:aa', name: 'P1', meraki: { connectionType: 'Wireless' } },
|
|
]);
|
|
const r = await portTypeCheck.run(ctx);
|
|
// With one device that's wireless, no compliant / trunks / unknown
|
|
// → we land in the "all compliant" branch with compliant=0.
|
|
assert.equal(r.status, 'ok');
|
|
assert.equal(r.details.wireless, 1);
|
|
});
|
|
|
|
test('portType: no devices at all → skipped', async () => {
|
|
const { portTypeCheck } = await loadPortChecks();
|
|
const ctx = mkPortCtx([], []);
|
|
const r = await portTypeCheck.run(ctx);
|
|
assert.equal(r.status, 'skipped');
|
|
});
|
|
|
|
test('portVlan: expected VLAN from env, mismatch → warn', async () => {
|
|
const prev = process.env.VOICE_STANDARD_PHONE_VLAN;
|
|
process.env.VOICE_STANDARD_PHONE_VLAN = '102';
|
|
try {
|
|
const { portVlanCheck } = await loadPortChecks();
|
|
const ctx = mkPortCtx([
|
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', vlan: 102, switchName: 'sw1', portName: 'p1', connectionType: 'Wired' } },
|
|
{ mac: 'bb:bb', name: 'P2', meraki: { portType: 'access', vlan: 1, switchName: 'sw1', portName: 'p2', connectionType: 'Wired' } },
|
|
]);
|
|
const r = await portVlanCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.details.expectedVlan, 102);
|
|
assert.equal(r.details.wrong, 1);
|
|
assert.match(r.message, /expected 102/);
|
|
assert.match(r.message, /P2.*VLAN 1/);
|
|
} finally {
|
|
if (prev === undefined) delete process.env.VOICE_STANDARD_PHONE_VLAN;
|
|
else process.env.VOICE_STANDARD_PHONE_VLAN = prev;
|
|
}
|
|
});
|
|
|
|
test('portVlan: env override — VOICE_STANDARD_PHONE_VLAN=200 makes 200 compliant', async () => {
|
|
const prev = process.env.VOICE_STANDARD_PHONE_VLAN;
|
|
process.env.VOICE_STANDARD_PHONE_VLAN = '200';
|
|
try {
|
|
const { portVlanCheck } = await loadPortChecks();
|
|
const ctx = mkPortCtx([
|
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', vlan: 200, switchName: 'sw', portName: 'p1', connectionType: 'Wired' } },
|
|
]);
|
|
const r = await portVlanCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
assert.equal(r.details.expectedVlan, 200);
|
|
assert.equal(r.details.compliant, 1);
|
|
} finally {
|
|
if (prev === undefined) delete process.env.VOICE_STANDARD_PHONE_VLAN;
|
|
else process.env.VOICE_STANDARD_PHONE_VLAN = prev;
|
|
}
|
|
});
|
|
|
|
test('portVlan: trunk ports get skipped (owned by portType check)', async () => {
|
|
const { portVlanCheck } = await loadPortChecks();
|
|
const ctx = mkPortCtx([
|
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', vlan: 102, switchName: 'sw', portName: 'p1', connectionType: 'Wired' } },
|
|
{ mac: 'bb:bb', name: 'P2', meraki: { portType: 'trunk', vlan: 999, switchName: 'sw', portName: 'p2', connectionType: 'Wired' } },
|
|
]);
|
|
const r = await portVlanCheck.run(ctx);
|
|
assert.equal(r.status, 'ok');
|
|
assert.equal(r.details.trunkSkipped, 1);
|
|
});
|
|
|
|
test('portPoe: PoE off on an access port → warn', async () => {
|
|
const { portPoeCheck } = await loadPortChecks();
|
|
const ctx = mkPortCtx([
|
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', poeEnabled: true, switchName: 'sw', portName: 'p1', connectionType: 'Wired' } },
|
|
{ mac: 'bb:bb', name: 'P2', meraki: { portType: 'access', poeEnabled: false, switchName: 'sw', portName: 'p2', connectionType: 'Wired' } },
|
|
]);
|
|
const r = await portPoeCheck.run(ctx);
|
|
assert.equal(r.status, 'warn');
|
|
assert.equal(r.details.off, 1);
|
|
assert.match(r.message, /PoE DISABLED/);
|
|
});
|
|
|
|
test('portEnabled: admin-disabled port → error (phone won\'t work)', async () => {
|
|
const { portEnabledCheck } = await loadPortChecks();
|
|
const ctx = mkPortCtx([
|
|
{ mac: 'aa:aa', name: 'P1', meraki: { portType: 'access', portEnabled: true, switchName: 'sw', portName: 'p1', connectionType: 'Wired' } },
|
|
{ mac: 'bb:bb', name: 'P2', meraki: { portType: 'access', portEnabled: false, switchName: 'sw', portName: 'p2', connectionType: 'Wired' } },
|
|
]);
|
|
const r = await portEnabledCheck.run(ctx);
|
|
assert.equal(r.status, 'error');
|
|
assert.equal(r.details.disabled, 1);
|
|
});
|
|
|
|
test('port kill-switch: VOICE_STANDARD_ENABLED=false → all four port checks return skipped', async () => {
|
|
const prev = process.env.VOICE_STANDARD_ENABLED;
|
|
process.env.VOICE_STANDARD_ENABLED = 'false';
|
|
try {
|
|
const { portTypeCheck, portVlanCheck, portPoeCheck, portEnabledCheck } = await loadPortChecks();
|
|
const ctx = mkPortCtx([
|
|
{ mac: 'aa:aa', meraki: { portType: 'access', vlan: 102, poeEnabled: true, portEnabled: true, switchName: 'sw', portName: 'p1', connectionType: 'Wired' } },
|
|
]);
|
|
for (const chk of [portTypeCheck, portVlanCheck, portPoeCheck, portEnabledCheck]) {
|
|
const r = await chk.run(ctx);
|
|
assert.equal(r.status, 'skipped', `${chk.id} should be skipped by kill switch`);
|
|
assert.match(r.message, /VOICE_STANDARD_ENABLED/);
|
|
}
|
|
} finally {
|
|
if (prev === undefined) delete process.env.VOICE_STANDARD_ENABLED;
|
|
else process.env.VOICE_STANDARD_ENABLED = prev;
|
|
}
|
|
});
|
|
|
|
// ─── combined "apply all N fixes" card ─────────────────────────────
|
|
|
|
// bot.say has two supported signatures — `bot.say('markdown', md)`
|
|
// (used by simple text posts) and `bot.say({ markdown, attachments })`
|
|
// (used by adaptive-card posts). The mock captures the *message
|
|
// body* from either shape so per-test assertions can match on
|
|
// content rather than mimicking the framework calling convention.
|
|
function mkBotMock() {
|
|
return {
|
|
messages: [],
|
|
async say(a, b) {
|
|
if (typeof a === 'string' && typeof b === 'string') this.messages.push(b);
|
|
else if (a && typeof a === 'object') this.messages.push(a.markdown || JSON.stringify(a));
|
|
else this.messages.push(String(a ?? ''));
|
|
},
|
|
};
|
|
}
|
|
|
|
test('applyAllVoiceDiagRemediations: iterates entries in sequence, tolerates individual failure', async () => {
|
|
const { applyAllVoiceDiagRemediations } = await import('../commands/voiceDiag.js');
|
|
const bot = mkBotMock();
|
|
const requester = { displayName: 'Op Test', email: 'op@example.com' };
|
|
|
|
// Two entries: one whose handler is real (disable_dnd via the
|
|
// registry — but that would talk to WebexClient which we can't
|
|
// in a test). We rely on the fact that the registered handler is
|
|
// called with a payload we can inspect via a mock — but the actual
|
|
// handler will throw on WebexClient import. So we test the
|
|
// "unknown handler" path (safe) and the audit summary.
|
|
|
|
const data = {
|
|
combined: true,
|
|
storeNum: '782',
|
|
personLabel: 'Test Store',
|
|
entries: [
|
|
{ remediationId: 'nonexistent_handler_1', remediationPayload: {} },
|
|
{ remediationId: 'nonexistent_handler_2', remediationPayload: {} },
|
|
],
|
|
};
|
|
|
|
await applyAllVoiceDiagRemediations(bot, data, null, requester);
|
|
|
|
// Final summary posted regardless
|
|
assert.ok(bot.messages.length >= 1, 'should post final summary');
|
|
const summary = bot.messages[bot.messages.length - 1];
|
|
assert.match(summary, /Apply-all complete/);
|
|
assert.match(summary, /Applied: 0 \/ 2/);
|
|
assert.match(summary, /Failed: 2/);
|
|
assert.match(summary, /nonexistent_handler_1/);
|
|
assert.match(summary, /no handler registered/);
|
|
});
|
|
|
|
test('applyAllVoiceDiagRemediations: no entries → posts "no pending" hint', async () => {
|
|
const { applyAllVoiceDiagRemediations } = await import('../commands/voiceDiag.js');
|
|
const bot = mkBotMock();
|
|
await applyAllVoiceDiagRemediations(bot, { combined: true, storeNum: '782', entries: [] }, null, {});
|
|
assert.equal(bot.messages.length, 1);
|
|
assert.match(bot.messages[0], /No pending remediations/);
|
|
});
|
|
|
|
test('cancelAllVoiceDiagRemediations: acknowledges the batch was cancelled', async () => {
|
|
const { cancelAllVoiceDiagRemediations } = await import('../commands/voiceDiag.js');
|
|
const bot = mkBotMock();
|
|
await cancelAllVoiceDiagRemediations(bot, {
|
|
combined: true,
|
|
storeNum: '782',
|
|
personLabel: 'Store 782',
|
|
entries: [{ remediationId: 'a' }, { remediationId: 'b' }, { remediationId: 'c' }],
|
|
}, null, { displayName: 'Op' });
|
|
assert.equal(bot.messages.length, 1);
|
|
assert.match(bot.messages[0], /Cancelled all pending remediations/);
|
|
assert.match(bot.messages[0], /3 fixes not applied/);
|
|
});
|