collabSupport/tests/voiceDiag.checks.test.js
jmcqueen d12723d010 Voicediag: store voice standards + port-hygiene checks + apply-all card
Refactor every /voicediag check to declare a top-level `standards`
object so the desired state is legible without reading run() logic
and can drive a documented reference table. Upgrade callForwarding
to error severity, tighten voicemail with three send-to-VM error
paths + a `stop_sending_to_voicemail` remediation, and add a
`disable_hoteling` remediation.

Add a port-hygiene check bucket under services/voiceDiag/checks/port
(portType, portVlan, portPoe, portEnabled) that reuses the phone-
status snapshot to enforce switchport standards. Configurable via
VOICE_STANDARD_PHONE_VLAN (default 102) and VOICE_STANDARD_ENABLED
(kill-switch). Preserve Meraki `portType`/`voiceVlan`/`dataVlan`
through the enrichment chain so the checks have clean data to read.

Add an "apply all N fixes" combined card that shows up when 2+
remediations are available. New confirm_voicediag_all /
cancel_voicediag_all actions run each fix in sequence (readable
audit trail, no per-person write-throttle stacking), accumulate
individual failures into a summary rather than aborting.

Adds regression tests asserting every check exposes .standards,
plus coverage for port checks, kill-switch, and combined-card
iteration. 63 tests in the checks file, 188 total, all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:14:38 -04:00

819 lines
33 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('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/);
});