Add Twilio /calltest for store and direct-dial voice path testing.

Enables outbound PSTN probes via TwiML webhooks with Webex result cards, status polling, and optional store CDR enrichment.
This commit is contained in:
Joseph McQueen 2026-07-23 17:59:10 -04:00
parent a807f22b43
commit 7aa8c37d1d
20 changed files with 1684 additions and 2 deletions

View file

@ -23,7 +23,7 @@ LOG_LEVEL=info
# HTTP API authentication
# -----------------------------------------------------------------------------
# Shared secret required to call destructive /:command HTTP endpoints such as
# /offboarduser, /provision-dect, /provision-vc, /vcmonitor, /bulkavstatuscsv,
# /offboarduser, /provision-dect, /provision-vc, /vcmonitor, /calltest, /bulkavstatuscsv,
# /bulkavswitchcsv, /devicesbymodel. Without it, those endpoints fail-closed
# with HTTP 503 — set this to any high-entropy string (e.g. `openssl rand -hex 32`).
# Callers send the token as `Authorization: Bearer <token>` or `X-API-Token: <token>`.
@ -467,6 +467,38 @@ DECT_RELAY_AGENT_TOKEN=
# Optional. Default: /dect-relay/ws. Change only if you also change
# DECT_RELAY_BOT_URL on the agent side to match.
# DECT_RELAY_PATH=/dect-relay/ws
# -----------------------------------------------------------------------------
# Twilio /calltest — outbound voice path testing
# -----------------------------------------------------------------------------
# Places real PSTN calls via Twilio. Requires a public HTTPS base URL Twilio
# can reach for TwiML + status webhooks. Must match your reverse-proxy path
# exactly (no trailing slash). Example for NGINX Proxy Manager location
# /CollabSupport/ → http://bot-host:1800:
#
# TWILIO_ACCOUNT_SID=
# TWILIO_AUTH_TOKEN=
# TWILIO_FROM_NUMBER=+1...
# TWILIO_WEBHOOK_BASE_URL=https://your-public-host.example.com/CollabSupport
# CALLTEST_ENABLED=true
#
# Store entry (AA path only):
# CALLTEST_DTMF=1
# CALLTEST_GREETING_PAUSE_SEC=5
# CALLTEST_ANSWER_WAIT_SEC=45
# CALLTEST_DIAL_TIMEOUT_SEC=30
#
# Shared 60s test core (both store + dial modes):
# CALLTEST_LISTEN_SEC=60
# CALLTEST_INTRO_TTS=Hello. This is an automated connectivity test...
# CALLTEST_OUTRO_TTS=Thank you. The connectivity test is complete. Goodbye.
#
# Store mode CDR follow-up (uses spark-admin:calling_cdr_read):
# CALLTEST_CDR_ENRICH=true
# CALLTEST_CDR_DELAY_MS=360000
#
# Per-store entry overrides: config/calltest-stores.json
# -----------------------------------------------------------------------------
# Optional. Per-base collect() RPC timeout. Corporate proxies can make
# DBS-210 reads slow; 15s is comfortable, 30s is generous.
# DECT_COLLECT_TIMEOUT_MS=15000

93
commands/callTest.js Normal file
View file

@ -0,0 +1,93 @@
// commands/callTest.js
// /calltest — Twilio voice path testing (store AA entry or direct dial).
import {
getConfigurationMessage,
getSessionForStatus,
isCallTestConfigured,
startDialTest,
startStoreTest,
} from '../services/callTest/callTestService.js';
import { parseCallTestArgs } from '../services/callTest/parseArgs.js';
import { renderCallTestResultMarkdown } from '../services/renderers/callTestRenderer.js';
import { extractRequester } from '../utils/requester.js';
import { logger } from '../utils/logger.js';
function usageMarkdown() {
return (
'**Usage:** `/calltest <store|store subcommand|dial subcommand>`\n\n' +
'**Store path** (auto-attendant → DTMF → 60s test):\n' +
'• `/calltest 782` or `/calltest store 782`\n' +
'• `/calltest store 782 +12125550100` — override main number\n\n' +
'**Direct dial** (answer → 60s test, no AA/DTMF):\n' +
'• `/calltest dial +12125550100`\n' +
'• `/calltest +12125550100` — shorthand when arg looks like a phone number\n\n' +
'**Status:**\n' +
'• `/calltest status <testId>`\n\n' +
'Each test places a **real outbound call** from the configured Twilio number. ' +
'Results post to this room when the call completes (~23 min).'
);
}
export async function handleCallTest(bot, trigger) {
logger('calltest', 'Handler entered');
const args = trigger.args || [];
const query = trigger.query || {};
const parsed = parseCallTestArgs(
args.length
? args
: [
query.mode,
query.store || query.storeNum,
query.dial || query.number,
query.testId,
].filter(Boolean),
);
if (parsed.kind === 'usage') {
await bot.say('markdown', usageMarkdown());
return;
}
if (!isCallTestConfigured()) {
await bot.say('markdown', getConfigurationMessage() || 'Call test is not available.');
return;
}
const roomId = trigger.roomId || bot?.room?.id || null;
const requester = extractRequester(trigger);
try {
if (parsed.kind === 'status') {
const session = getSessionForStatus(parsed.testId);
if (!session) {
await bot.say('markdown', `No in-memory session for testId \`${parsed.testId}\` (expired or unknown).`);
return;
}
await bot.say('markdown', renderCallTestResultMarkdown(session));
return;
}
if (parsed.kind === 'store') {
await startStoreTest({
storeNum: parsed.storeNum,
dialOverride: parsed.dialOverride,
roomId,
requester,
});
return;
}
if (parsed.kind === 'dial') {
await startDialTest({
dialNumber: parsed.dial,
roomId,
requester,
});
}
} catch (err) {
logger('calltest', `Handler error: ${err.message}`, 'error');
await bot.say('markdown', `❌ **Call test failed**\n\n${err.message}`);
}
}

View file

@ -21,6 +21,7 @@ const SHORT_HELP = {
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
// Jira
jirahistory: 'Recent Jira tickets for a store (optionally filtered by component)',
@ -149,6 +150,29 @@ const LONG_HELP = {
'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.',
],
},
calltest: {
title: '/calltest',
usage: [
'/calltest <store>',
'/calltest store <store> [+1...]',
'/calltest dial <e164>',
'/calltest status <testId>',
],
examples: [
'/calltest 782',
'/calltest store 782',
'/calltest dial +12125550100',
'/calltest status a1b2c3d4-...',
],
notes: [
'Places a **real outbound call** via Twilio from the configured `TWILIO_FROM_NUMBER`.',
'**Store path:** dials the store main number (Webex `locationMainNumber`), pauses for the AA greeting, sends DTMF `1`, waits for the store leg, then runs the shared **60-second** listen test (intro → pause → thank you).',
'**Direct dial:** calls any E.164 number; on answer, runs the same 60s test with no AA/DTMF.',
'Requires `TWILIO_*` env vars, `TWILIO_WEBHOOK_BASE_URL` (public HTTPS), and `CALLTEST_ENABLED=true`.',
'Store tests optionally post a **CDR follow-up** ~6 minutes later when `CALLTEST_CDR_ENRICH=true`.',
'Per-store entry tuning: `config/calltest-stores.json` (`dtmf`, `greetingPauseSec`, `answerWaitSec`).',
],
},
wohistory: {
title: '/wohistory',
usage: ['/wohistory <store>'],
@ -253,7 +277,7 @@ const LONG_HELP = {
const GROUPS = [
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag'] },
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'calltest'] },
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },

View file

@ -30,6 +30,7 @@ import { handleVoiceDiag } from './voiceDiag.js';
import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
import { handleTestDevicesByModel } from './testDevicesByModel.js';
import { handleCallTest } from './callTest.js';
/**
* Each entry:
@ -69,6 +70,8 @@ export const commands = [
{ name: 'provision-dect', aliases: ['provisiondect'], handler: handleProvisionDect, mutating: true },
{ name: 'provision-vc', aliases: ['vcprovision'], handler: handleProvisionVc, mutating: true },
{ name: 'vcmonitor', handler: handleVcMonitor, mutating: true },
// /calltest places real Twilio PSTN calls (store AA path or direct dial).
{ name: 'calltest', handler: handleCallTest, mutating: true },
{ name: 'offboarduser', handler: handleOffboardUser, mutating: true },
{ name: 'webexhost', handler: handleWebexHost, mutating: true },
// bulkavstatuscsv delivers a CSV attachment via BotClient.sendWithAttachment,

View file

@ -0,0 +1 @@
{}

View file

@ -52,6 +52,8 @@ import {
ALL_HTTP_COMMAND_KEYS,
MUTATING_COMMAND_KEYS,
} from './commands/registry.js';
import twilioCallTestRouter from './routes/twilioCallTest.js';
import { isCallTestConfigured } from './services/callTest/callTestService.js';
// ──────────────────────────────────────────────
// Express setup
@ -83,6 +85,9 @@ app.use((req, res, next) => {
next();
});
// Twilio /calltest webhooks — after path-prefix strip, before /:command catch-all.
app.use(twilioCallTestRouter);
// Health check
app.get('/health', (req, res) => {
res.json({
@ -772,6 +777,16 @@ if (process.env.DECT_RELAY_AGENT_TOKEN) {
);
}
if (isCallTestConfigured()) {
logger('startup', '/calltest enabled (Twilio voice webhooks active)');
} else {
logger(
'startup',
'/calltest disabled — set TWILIO_* env vars and CALLTEST_ENABLED=true to enable',
'warn',
);
}
// ──────────────────────────────────────────────
// Cron Jobs
// ──────────────────────────────────────────────

View file

@ -0,0 +1,90 @@
// integrations/twilio/client.js
// Thin Twilio Voice client for /calltest outbound calls + webhook validation.
// Uses axios (already a project dep) instead of the twilio SDK to keep the
// install surface small.
import axios from 'axios';
import twilio from 'twilio';
import { logger } from '../../utils/logger.js';
const LOG_SCOPE = 'twilio:client';
function requireEnv(name) {
const v = process.env[name];
if (!v || !String(v).trim()) {
throw new Error(`Missing required env: ${name}`);
}
return String(v).trim();
}
export function isTwilioConfigured() {
return !!(
process.env.TWILIO_ACCOUNT_SID
&& process.env.TWILIO_AUTH_TOKEN
&& process.env.TWILIO_FROM_NUMBER
&& process.env.TWILIO_WEBHOOK_BASE_URL
);
}
export function isCallTestEnabled() {
const flag = String(process.env.CALLTEST_ENABLED || '').toLowerCase();
return flag === 'true' || flag === '1' || flag === 'yes';
}
function twilioAuth() {
const accountSid = requireEnv('TWILIO_ACCOUNT_SID');
const authToken = requireEnv('TWILIO_AUTH_TOKEN');
return { accountSid, authToken };
}
/**
* Twilio request signature validation (delegates to the official SDK, which
* handles array params, port variants, and legacy query-string encoding).
* @see https://www.twilio.com/docs/usage/security#validating-requests
*/
export function validateTwilioSignature(signature, url, params) {
const { authToken } = twilioAuth();
if (!signature || !url) return false;
return twilio.validateRequest(authToken, signature, url, params || {});
}
/**
* Place an outbound voice call. Twilio fetches `voiceUrl` when the callee answers.
*/
export async function createOutboundCall({ to, voiceUrl, statusCallback, timeoutSec = 30 }) {
const from = requireEnv('TWILIO_FROM_NUMBER');
const { accountSid, authToken } = twilioAuth();
logger(LOG_SCOPE, `Creating outbound call to ${to} from ${from}`, 'debug');
const body = new URLSearchParams({
To: to,
From: from,
Url: voiceUrl,
Method: 'POST',
StatusCallback: statusCallback,
StatusCallbackMethod: 'POST',
Timeout: String(timeoutSec),
});
for (const event of ['initiated', 'ringing', 'answered', 'completed']) {
body.append('StatusCallbackEvent', event);
}
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls.json`;
const resp = await axios.post(url, body.toString(), {
auth: { username: accountSid, password: authToken },
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 30_000,
});
const call = resp.data;
logger(LOG_SCOPE, `Call created sid=${call.sid} status=${call.status}`, 'debug');
return call;
}
export default {
isTwilioConfigured,
isCallTestEnabled,
createOutboundCall,
validateTwilioSignature,
};

41
package-lock.json generated
View file

@ -15,6 +15,7 @@
"form-data": "^4.0.5",
"graphql-request": "^7.4.0",
"node-cron": "^4.2.1",
"twilio": "^6.0.2",
"webex-node-bot-framework": "^2.5.1",
"ws": "^8.21.0"
},
@ -6020,6 +6021,12 @@
"node": ">=0.10"
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -11123,6 +11130,13 @@
"optional": true,
"peer": true
},
"node_modules/scmp": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz",
"integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==",
"deprecated": "Just use Node.js's crypto.timingSafeEqual()",
"license": "BSD-3-Clause"
},
"node_modules/sdp": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
@ -11976,6 +11990,33 @@
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/twilio": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/twilio/-/twilio-6.0.2.tgz",
"integrity": "sha512-RN3TZxUtxLz2HBZVt62+LdZxQbrMVgYKtuzLgwmO7nqKvR+gQS5mCackD9hf4Y7MmoK/bX7tCm7kaJC8kC8zFA==",
"license": "MIT",
"dependencies": {
"axios": "^1.13.5",
"dayjs": "^1.11.9",
"https-proxy-agent": "^5.0.0",
"jsonwebtoken": "^9.0.3",
"qs": "^6.14.1",
"scmp": "^2.1.0",
"xmlbuilder": "^13.0.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/twilio/node_modules/xmlbuilder": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz",
"integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",

View file

@ -26,6 +26,7 @@
"form-data": "^4.0.5",
"graphql-request": "^7.4.0",
"node-cron": "^4.2.1",
"twilio": "^6.0.2",
"webex-node-bot-framework": "^2.5.1",
"ws": "^8.21.0"
},

89
routes/twilioCallTest.js Normal file
View file

@ -0,0 +1,89 @@
// routes/twilioCallTest.js
// Twilio voice + status webhooks for /calltest.
import express from 'express';
import { validateTwilioSignature } from '../integrations/twilio/client.js';
import { logger } from '../utils/logger.js';
import {
getWebhookBaseUrl,
resolveCallTestWebhookUrl,
} from '../services/callTest/config.js';
import {
handleStatusCallback,
handleVoiceWebhook,
isCallTestConfigured,
} from '../services/callTest/callTestService.js';
const router = express.Router();
function verifyTwilio(req, res, next) {
if (!isCallTestConfigured()) {
return res.status(503).send('Call test not configured');
}
const signature = req.get('X-Twilio-Signature') || req.get('x-twilio-signature');
const url = resolveCallTestWebhookUrl(req);
const params = req.body || {};
if (!url) {
return next();
}
try {
if (!validateTwilioSignature(signature, url, params)) {
logger(
'calltest:webhook',
`Invalid Twilio signature for ${req.originalUrl} ` +
`(validated as ${url}, base=${getWebhookBaseUrl()}, bodyKeys=${Object.keys(params).length})`,
'warn',
);
return res.status(403).send('Forbidden');
}
} catch (err) {
logger('calltest:webhook', `Signature check error: ${err.message}`, 'error');
return res.status(503).send('Not configured');
}
return next();
}
router.post('/twilio/calltest/voice', verifyTwilio, (req, res) => {
const testId = req.query.testId;
const mode = req.query.mode === 'store' ? 'store' : 'dial';
const step = String(req.query.step || '').trim();
if (!testId || !step) {
return res.status(400).type('text/xml').send(
'<?xml version="1.0" encoding="UTF-8"?><Response><Say>Missing test parameters.</Say><Hangup/></Response>',
);
}
logger('calltest:webhook', `Voice testId=${testId} mode=${mode} step=${step}`, 'info');
const twiml = handleVoiceWebhook(testId, mode, step);
res.type('text/xml').send(twiml);
});
router.post('/twilio/calltest/status', verifyTwilio, async (req, res) => {
const testId = req.query.testId;
if (!testId) {
return res.status(400).send('Missing testId');
}
logger(
'calltest:webhook',
`Status testId=${testId} CallStatus=${req.body?.CallStatus} CallSid=${req.body?.CallSid}`,
'info',
);
try {
await handleStatusCallback(testId, req.body || {});
res.status(200).send('OK');
} catch (err) {
logger('calltest:webhook', `Status handler error: ${err.message}`, 'error');
res.status(500).send('Error');
}
});
export default router;

View file

@ -0,0 +1,403 @@
// services/callTest/callTestService.js
// Orchestrates /calltest Twilio outbound calls, webhooks, and Webex notifications.
import { randomUUID } from 'node:crypto';
import {
createOutboundCall,
isCallTestEnabled,
isTwilioConfigured,
} from '../../integrations/twilio/client.js';
import { logger } from '../../utils/logger.js';
import {
buildStatusWebhookUrl,
buildVoiceWebhookUrl,
getCallTestConfig,
} from './config.js';
import {
createSession,
getSession,
listActiveByDestination,
updateSession,
} from './sessionStore.js';
import {
buildTwiml,
initialStepForMode,
isCoreStep,
} from './twiml.js';
import {
renderCallTestCdrMarkdown,
renderCallTestResultMarkdown,
renderCallTestStartedMarkdown,
} from '../renderers/callTestRenderer.js';
const LOG_SCOPE = 'calltest:service';
const STORE_ENTRY_FAIL_STEPS = ['greeting', 'dtmf', 'wait'];
const TERMINAL_CALL_STATUSES = new Set(['completed', 'busy', 'failed', 'no-answer', 'canceled']);
const FINALIZE_AFTER_DONE_MS = Number(process.env.CALLTEST_FINALIZE_DELAY_MS) || 5000;
const _pendingFinalize = new Map();
export function isCallTestConfigured() {
return isTwilioConfigured() && isCallTestEnabled();
}
function wasCallAnswered(session) {
const events = session.events || {};
return !!(events.answered || events['in-progress'] || session.reachedCore);
}
export function getConfigurationMessage() {
if (!isTwilioConfigured()) {
return (
'Call test is not configured. Set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, ' +
'`TWILIO_FROM_NUMBER`, and `TWILIO_WEBHOOK_BASE_URL` in `.env`.'
);
}
if (!isCallTestEnabled()) {
return 'Call test is disabled. Set `CALLTEST_ENABLED=true` in `.env`.';
}
return null;
}
async function notifyRoom(roomId, markdown) {
if (!roomId) return;
try {
const { default: botClient } = await import('../../integrations/webex/BotClient.js');
await botClient.sendMarkdown(roomId, markdown);
} catch (err) {
logger(LOG_SCOPE, `Failed to notify room ${roomId}: ${err.message}`, 'error');
}
}
function computeResult(session) {
const status = session.twilioCallStatus;
const answered = wasCallAnswered(session);
const reachedDone = session.lastStep === 'done' || (session.reachedCore && session.lastStep === 'outro');
if (!answered && TERMINAL_CALL_STATUSES.has(status)) {
return { result: 'fail', resultReason: `Call ended: ${status || 'no-answer'}` };
}
if (!answered) {
return { result: 'fail', resultReason: 'Call was never answered' };
}
if (session.failedAt) {
return { result: 'fail', resultReason: `Disconnected during entry (${session.failedAt})` };
}
if (status === 'completed' && (reachedDone || session.reachedCore)) {
const dur = session.durationSec;
if (dur != null && dur < 10) {
return { result: 'warn', resultReason: 'Completed but very short duration (<10s)' };
}
return { result: 'pass', resultReason: 'Call completed through shared test core' };
}
if (status === 'completed') {
return { result: 'fail', resultReason: 'Call completed before test core finished' };
}
if (session.lastStep === 'done' && session.reachedCore) {
return { result: 'pass', resultReason: 'Call completed through shared test core' };
}
if (!session.result && (session.status === 'in_progress' || session.status === 'pending')) {
return { result: null, resultReason: 'Call test still in progress' };
}
return { result: 'fail', resultReason: status ? `Unexpected status: ${status}` : 'Unknown outcome' };
}
async function scheduleCdrEnrichment(session) {
const cfg = getCallTestConfig(session.storeNum);
if (!cfg.cdrEnrich || session.mode !== 'store' || !session.personId) return;
const delay = cfg.cdrDelayMs;
logger(LOG_SCOPE, `Scheduling CDR enrich for ${session.testId} in ${delay}ms`, 'debug');
setTimeout(async () => {
try {
const { getHistoricalCallActivity } = await import('../phoneService.js');
const cdr = await getHistoricalCallActivity(session.personId, 12, {
locationName: session.locationName,
});
const md = renderCallTestCdrMarkdown(session, cdr);
await notifyRoom(session.roomId, md);
} catch (err) {
logger(LOG_SCOPE, `CDR enrich failed for ${session.testId}: ${err.message}`, 'warn');
await notifyRoom(
session.roomId,
`**Call test CDR** (\`${session.testId}\`)\n\n_CDR fetch failed:_ ${err.message}`,
);
}
}, delay).unref?.();
}
async function finalizeSession(testId) {
const session = getSession(testId);
if (!session || session.result || session.status === 'completed' || session.status === 'failed') {
return session;
}
const { result, resultReason } = computeResult(session);
if (result == null) return session;
const next = updateSession(testId, {
status: result === 'pass' || result === 'warn' ? 'completed' : 'failed',
result,
resultReason,
});
if (next?.roomId) {
await notifyRoom(next.roomId, renderCallTestResultMarkdown(next));
}
if (next?.mode === 'store') {
scheduleCdrEnrichment(next);
}
return next;
}
async function tryFinalizeFromVoiceDone(testId) {
const session = getSession(testId);
if (!session || session.result || session.status === 'completed' || session.status === 'failed') {
return session;
}
if (session.lastStep !== 'done' || !session.reachedCore) return session;
const now = new Date().toISOString();
const events = { ...session.events };
if (!events.answered) {
events.answered = events['in-progress'] || events.coreStartedAt || now;
}
if (!events.completed) {
events.completed = now;
}
const patch = { events };
if (!TERMINAL_CALL_STATUSES.has(session.twilioCallStatus)) {
patch.twilioCallStatus = 'completed';
}
updateSession(testId, patch);
logger(LOG_SCOPE, `Finalizing testId=${testId} from voice done step`, 'info');
return finalizeSession(testId);
}
function scheduleFinalizeFromVoiceDone(testId) {
if (_pendingFinalize.has(testId)) return;
const timer = setTimeout(() => {
_pendingFinalize.delete(testId);
void tryFinalizeFromVoiceDone(testId);
}, FINALIZE_AFTER_DONE_MS);
if (typeof timer.unref === 'function') timer.unref();
_pendingFinalize.set(testId, timer);
}
/**
* @param {'store'|'dial'} mode
* @param {object} opts
*/
async function startTest(mode, opts) {
const cfgMsg = getConfigurationMessage();
if (cfgMsg) throw new Error(cfgMsg);
const testId = randomUUID();
const config = getCallTestConfig(opts.storeNum || null);
const initialStep = initialStepForMode(mode);
const active = listActiveByDestination(opts.dialNumber);
if (active.length) {
const existing = active[0];
throw new Error(
`A call test is already in progress for ${opts.dialNumber} (testId \`${existing.testId}\`).`,
);
}
const session = createSession(testId, {
mode,
storeNum: opts.storeNum || null,
dialNumber: opts.dialNumber,
roomId: opts.roomId || null,
requester: opts.requester || null,
personId: opts.personId || null,
locationId: opts.locationId || null,
locationName: opts.locationName || null,
config,
status: 'in_progress',
});
const voiceUrl = buildVoiceWebhookUrl(testId, mode, initialStep);
const statusUrl = buildStatusWebhookUrl(testId);
try {
const call = await createOutboundCall({
to: opts.dialNumber,
voiceUrl,
statusCallback: statusUrl,
timeoutSec: config.dialTimeoutSec,
});
updateSession(testId, { callSid: call.sid, twilioCallStatus: call.status });
} catch (err) {
updateSession(testId, { status: 'failed', result: 'fail', resultReason: err.message });
throw err;
}
return getSession(testId);
}
export async function startStoreTest({ storeNum, dialOverride, roomId, requester }) {
const { resolveStoreMainNumber } = await import('./storeResolver.js');
const resolved = await resolveStoreMainNumber(storeNum);
const dialNumber = dialOverride || resolved.dialNumber;
const session = await startTest('store', {
storeNum: resolved.storeNum,
dialNumber,
roomId,
requester,
personId: resolved.personId,
locationId: resolved.locationId,
locationName: resolved.locationName,
});
if (roomId) {
await notifyRoom(roomId, renderCallTestStartedMarkdown(session));
}
return session;
}
export async function startDialTest({ dialNumber, roomId, requester }) {
const session = await startTest('dial', {
dialNumber,
roomId,
requester,
});
if (roomId) {
await notifyRoom(roomId, renderCallTestStartedMarkdown(session));
}
return session;
}
/**
* @param {string} testId
* @param {'store'|'dial'} mode
* @param {string} step
*/
export function handleVoiceWebhook(testId, mode, step) {
const session = getSession(testId);
if (!session) {
logger(LOG_SCOPE, `Voice webhook for unknown testId ${testId}`, 'warn');
return buildTwiml({
mode: 'dial',
step: 'done',
testId,
config: getCallTestConfig(),
});
}
const config = session.config || getCallTestConfig(session.storeNum);
const patch = {
lastStep: step,
status: 'in_progress',
};
if (isCoreStep(step)) {
patch.reachedCore = true;
if (!session.events?.coreStartedAt) {
patch.events = { ...session.events, coreStartedAt: new Date().toISOString() };
}
}
if (mode === 'store' && step === 'dtmf') {
patch.events = { ...(patch.events || session.events), dtmfStepAt: new Date().toISOString() };
}
updateSession(testId, patch);
if (step === 'done') {
scheduleFinalizeFromVoiceDone(testId);
}
return buildTwiml({ mode, step, testId, config });
}
/**
* @param {string} testId
* @param {Record<string, string>} body Twilio status callback body
*/
export async function handleStatusCallback(testId, body) {
const session = getSession(testId);
if (!session) {
logger(LOG_SCOPE, `Status callback for unknown testId ${testId}`, 'warn');
return;
}
const callStatus = body.CallStatus || body.callStatus;
const callSid = body.CallSid || body.callSid;
const now = new Date().toISOString();
logger(
LOG_SCOPE,
`Status callback testId=${testId} CallStatus=${callStatus} CallSid=${callSid || 'n/a'}`,
'info',
);
const events = { ...session.events };
if (callStatus && !events[callStatus]) {
events[callStatus] = now;
}
if ((callStatus === 'answered' || callStatus === 'in-progress') && !events.answered) {
events.answered = now;
}
const patch = {
twilioCallStatus: callStatus,
callSid: callSid || session.callSid,
events,
};
if (callStatus === 'answered' || callStatus === 'in-progress') {
patch.status = 'in_progress';
}
if (callStatus === 'completed') {
const dur = body.CallDuration || body.Duration;
if (dur != null) patch.durationSec = Number(dur);
}
// If call ends before core and we were still in store entry
if (['completed', 'busy', 'failed', 'no-answer', 'canceled'].includes(callStatus)) {
if (!session.reachedCore && session.mode === 'store' && session.lastStep) {
if (STORE_ENTRY_FAIL_STEPS.includes(session.lastStep)) {
patch.failedAt = session.lastStep;
}
}
}
updateSession(testId, patch);
if (TERMINAL_CALL_STATUSES.has(callStatus)) {
await finalizeSession(testId);
}
}
export function getSessionForStatus(testId) {
const session = getSession(testId);
if (!session) return null;
if (session.result) return session;
const { result, resultReason } = computeResult(session);
return { ...session, result, resultReason };
}
export async function tryFinalizeFromVoiceDoneForTests(testId) {
return tryFinalizeFromVoiceDone(testId);
}
export function _clearPendingFinalizeTimersForTests() {
for (const timer of _pendingFinalize.values()) {
clearTimeout(timer);
}
_pendingFinalize.clear();
}

131
services/callTest/config.js Normal file
View file

@ -0,0 +1,131 @@
// services/callTest/config.js
// Env defaults + optional per-store entry overrides from config/calltest-stores.json.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STORES_CONFIG_PATH = path.resolve(__dirname, '../../config/calltest-stores.json');
let _storeOverridesCache = null;
function envInt(name, fallback) {
const raw = process.env[name];
if (raw == null || raw === '') return fallback;
const n = Number(raw);
return Number.isFinite(n) ? n : fallback;
}
function envStr(name, fallback) {
const raw = process.env[name];
return raw != null && String(raw).trim() ? String(raw).trim() : fallback;
}
export function getWebhookBaseUrl() {
const base = envStr('TWILIO_WEBHOOK_BASE_URL', '');
return base.replace(/\/+$/, '');
}
export function loadStoreOverrides() {
if (_storeOverridesCache) return _storeOverridesCache;
try {
if (!fs.existsSync(STORES_CONFIG_PATH)) {
_storeOverridesCache = {};
return _storeOverridesCache;
}
const raw = fs.readFileSync(STORES_CONFIG_PATH, 'utf8');
const parsed = JSON.parse(raw);
_storeOverridesCache = parsed && typeof parsed === 'object' ? parsed : {};
return _storeOverridesCache;
} catch {
_storeOverridesCache = {};
return _storeOverridesCache;
}
}
/** @param {string|null} storeNum */
export function getCallTestConfig(storeNum = null) {
const overrides = storeNum ? (loadStoreOverrides()[String(storeNum)] || {}) : {};
return {
dtmf: String(overrides.dtmf ?? envStr('CALLTEST_DTMF', '1')),
greetingPauseSec: Number(overrides.greetingPauseSec ?? envInt('CALLTEST_GREETING_PAUSE_SEC', 5)),
answerWaitSec: Number(overrides.answerWaitSec ?? envInt('CALLTEST_ANSWER_WAIT_SEC', 45)),
dialTimeoutSec: envInt('CALLTEST_DIAL_TIMEOUT_SEC', 30),
listenSec: envInt('CALLTEST_LISTEN_SEC', 60),
introTts: envStr(
'CALLTEST_INTRO_TTS',
'Hello. This is an automated connectivity test from Collab Support. We will listen on this line for sixty seconds. Please stay on the line.',
),
outroTts: envStr(
'CALLTEST_OUTRO_TTS',
'Thank you. The connectivity test is complete. Goodbye.',
),
cdrEnrich: (() => {
const v = String(process.env.CALLTEST_CDR_ENRICH ?? 'true').toLowerCase();
return v === 'true' || v === '1' || v === 'yes';
})(),
cdrDelayMs: envInt('CALLTEST_CDR_DELAY_MS', 360_000),
};
}
export function buildVoiceWebhookUrl(testId, mode, step) {
const base = getWebhookBaseUrl();
const q = new URLSearchParams({ testId, mode, step });
return `${base}/twilio/calltest/voice?${q.toString()}`;
}
export function buildStatusWebhookUrl(testId) {
const base = getWebhookBaseUrl();
const q = new URLSearchParams({ testId });
return `${base}/twilio/calltest/status?${q.toString()}`;
}
/**
* Exact webhook URL Twilio was given for this inbound request. Uses the same
* builders as outbound call setup / TwiML redirects so proxy path stripping
* (e.g. NGINX /CollabSupport/ backend) cannot desync validation.
*/
export function resolveCallTestWebhookUrl(req) {
const routePath = req.path || req.originalUrl?.split('?')[0] || '';
if (routePath.endsWith('/twilio/calltest/voice')) {
const testId = String(req.query?.testId || '').trim();
const mode = req.query?.mode === 'store' ? 'store' : 'dial';
const step = String(req.query?.step || '').trim();
if (!testId || !step) return null;
return buildVoiceWebhookUrl(testId, mode, step);
}
if (routePath.endsWith('/twilio/calltest/status')) {
const testId = String(req.query?.testId || '').trim();
if (!testId) return null;
return buildStatusWebhookUrl(testId);
}
return null;
}
/** Normalize user input to E.164 (+1...) */
export function normalizeE164(input) {
if (!input) return null;
let s = String(input).trim().replace(/[^\d+]/g, '');
if (s.startsWith('+')) {
const digits = s.slice(1);
if (!/^\d{10,15}$/.test(digits)) return null;
return `+${digits}`;
}
const digits = s.replace(/\D/g, '');
if (digits.length === 10) return `+1${digits}`;
if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
return null;
}
export function isStoreNumberToken(token) {
return /^\d{2,4}$/.test(String(token || '').trim());
}
export function _resetStoreOverridesCacheForTests() {
_storeOverridesCache = null;
}

View file

@ -0,0 +1,43 @@
// services/callTest/parseArgs.js
import { isStoreNumberToken, normalizeE164 } from './config.js';
/**
* Parse /calltest args into a command descriptor.
* @returns {{ kind: 'usage'|'status'|'store'|'dial', storeNum?: string, dial?: string, dialOverride?: string|null, testId?: string }}
*/
export function parseCallTestArgs(args = []) {
const tokens = (args || []).map((a) => String(a).trim()).filter(Boolean);
if (!tokens.length) return { kind: 'usage' };
const first = tokens[0].toLowerCase();
if (first === 'status') {
const testId = tokens[1];
return testId ? { kind: 'status', testId } : { kind: 'usage' };
}
if (first === 'store') {
const storeNum = tokens[1];
if (!storeNum || !isStoreNumberToken(storeNum)) return { kind: 'usage' };
const override = tokens[2] ? normalizeE164(tokens[2]) : null;
if (tokens[2] && !override) return { kind: 'usage' };
return { kind: 'store', storeNum, dialOverride: override };
}
if (first === 'dial') {
const dial = normalizeE164(tokens[1]);
return dial ? { kind: 'dial', dial } : { kind: 'usage' };
}
if (isStoreNumberToken(first)) {
const override = tokens[1] ? normalizeE164(tokens[1]) : null;
if (tokens[1] && !override) return { kind: 'usage' };
return { kind: 'store', storeNum: first, dialOverride: override };
}
const dial = normalizeE164(first);
if (dial) return { kind: 'dial', dial };
return { kind: 'usage' };
}

View file

@ -0,0 +1,75 @@
// services/callTest/sessionStore.js
// In-memory call-test sessions (TTL ~30 min).
import { logger } from '../../utils/logger.js';
const TTL_MS = 30 * 60 * 1000;
const SWEEP_INTERVAL_MS = 60 * 1000;
/** @typedef {'store'|'dial'} CallTestMode */
/** @typedef {'pending'|'in_progress'|'completed'|'failed'} CallTestStatus */
const _store = new Map();
/**
* @param {string} testId
* @param {object} data
*/
export function createSession(testId, data) {
const session = {
testId,
status: 'pending',
createdAt: Date.now(),
events: {},
lastStep: null,
reachedCore: false,
failedAt: null,
callSid: null,
...data,
};
_store.set(testId, session);
return session;
}
export function getSession(testId) {
return _store.get(testId) || null;
}
export function updateSession(testId, patch) {
const cur = _store.get(testId);
if (!cur) return null;
const next = { ...cur, ...patch };
_store.set(testId, next);
return next;
}
export function listActiveByDestination(dialNumber) {
const norm = String(dialNumber || '').trim();
const out = [];
for (const [, s] of _store) {
if (s.dialNumber !== norm) continue;
if (s.status === 'pending' || s.status === 'in_progress') out.push(s);
}
return out;
}
export function allSessions() {
return [..._store.values()];
}
const sweepHandle = setInterval(() => {
const now = Date.now();
for (const [id, s] of _store.entries()) {
const ts = s.createdAt || 0;
if (now - ts > TTL_MS) {
logger('calltest:cleanup', `Expired session ${id} (store=${s.storeNum || 'n/a'})`);
_store.delete(id);
}
}
}, SWEEP_INTERVAL_MS);
if (typeof sweepHandle.unref === 'function') sweepHandle.unref();
export function _clearAllSessionsForTests() {
_store.clear();
}

View file

@ -0,0 +1,81 @@
// services/callTest/storeResolver.js
// Lightweight store main number lookup (same chain as phoneService, no Meraki fan-out).
import webex from '../../integrations/webex/WebexClient.js';
import { logger } from '../../utils/logger.js';
async function getPersonIdByEmail(email) {
if (!email) return null;
try {
const response = await webex.request('GET', 'people', null, { email });
const people = response.items || [];
return people.length ? people[0].id : null;
} catch (err) {
logger('calltest:resolver', `Person lookup failed: ${err.message}`, 'warn');
return null;
}
}
async function getDectNetworksForPerson(personId) {
if (!personId) return [];
try {
const response = await webex.request('GET', `telephony/config/people/${personId}/dectNetworks`);
const networks = response.dectNetworks || [];
return networks.map((net) => ({
id: net.id,
locationName: net.location?.name || '—',
locationId: net.location?.id || '—',
}));
} catch (err) {
logger('calltest:resolver', `DECT network lookup failed: ${err.message}`, 'warn');
return [];
}
}
/**
* Resolve the store's public main number (auto-attendant DID).
*
* @param {string} storeNum 24 digit store number
*/
export async function resolveStoreMainNumber(storeNum) {
const store = String(storeNum).trim();
const padded = store.padStart(5, '0');
const email = `ae${padded}@ae.com`;
logger('calltest:resolver', `Resolving main number for store ${store} (${email})`, 'debug');
const personId = await getPersonIdByEmail(email);
if (!personId) {
throw new Error(`No Webex person found for store ${store} (${email})`);
}
const dectNets = await getDectNetworksForPerson(personId);
if (!dectNets.length) {
throw new Error(`No DECT network / location found for store ${store}`);
}
const net = dectNets[0];
const locationId = net.locationId && net.locationId !== '—' ? net.locationId : null;
const locationName = net.locationName && net.locationName !== '—' ? net.locationName : null;
if (!locationId) {
throw new Error(`No locationId on DECT network for store ${store}`);
}
const locationDetails = await webex.request('GET', `telephony/config/locations/${locationId}`).catch(() => null);
const dialNumber = locationDetails?.callingLineId?.phoneNumber
|| locationDetails?.phoneNumber
|| null;
if (!dialNumber) {
throw new Error(`No main number (callingLineId) on location for store ${store}`);
}
return {
storeNum: store,
dialNumber,
personId,
locationId,
locationName,
};
}

View file

@ -0,0 +1,91 @@
// services/callTest/twiml.js
// Store entry steps + shared core steps for /calltest Twilio voice webhooks.
import {
buildVoiceWebhookUrl,
} from './config.js';
const CORE_STEPS = new Set(['intro', 'listen', 'outro', 'done']);
const STORE_ENTRY_STEPS = new Set(['greeting', 'dtmf', 'wait']);
function xmlEscape(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function redirect(testId, mode, step) {
const url = xmlEscape(buildVoiceWebhookUrl(testId, mode, step));
return `<Redirect method="POST">${url}</Redirect>`;
}
/**
* @param {object} opts
* @param {'store'|'dial'} opts.mode
* @param {string} opts.step
* @param {string} opts.testId
* @param {object} opts.config
*/
export function buildTwiml({ mode, step, testId, config }) {
const c = config;
let body = '';
if (mode === 'store' && STORE_ENTRY_STEPS.has(step)) {
switch (step) {
case 'greeting':
body = `<Pause length="${c.greetingPauseSec}"/>\n${redirect(testId, mode, 'dtmf')}`;
break;
case 'dtmf': {
const digits = String(c.dtmf || '1').replace(/[^0-9*#w]/gi, '');
body = `<Play digits="wwww${xmlEscape(digits)}"/>\n${redirect(testId, mode, 'wait')}`;
break;
}
case 'wait':
body = `<Pause length="${c.answerWaitSec}"/>\n${redirect(testId, mode, 'intro')}`;
break;
default:
body = `<Say>Unknown entry step.</Say>\n<Hangup/>`;
}
} else if (CORE_STEPS.has(step)) {
switch (step) {
case 'intro':
body = `<Say voice="Polly.Joanna">${xmlEscape(c.introTts)}</Say>\n${redirect(testId, mode, 'listen')}`;
break;
case 'listen':
body = `<Pause length="${c.listenSec}"/>\n${redirect(testId, mode, 'outro')}`;
break;
case 'outro':
body = `<Say voice="Polly.Joanna">${xmlEscape(c.outroTts)}</Say>\n${redirect(testId, mode, 'done')}`;
break;
case 'done':
body = '<Hangup/>';
break;
default:
body = '<Hangup/>';
}
} else if (mode === 'dial' && step === 'intro') {
body = `<Say voice="Polly.Joanna">${xmlEscape(c.introTts)}</Say>\n${redirect(testId, mode, 'listen')}`;
} else {
body = '<Say>Invalid call test step.</Say>\n<Hangup/>';
}
return `<?xml version="1.0" encoding="UTF-8"?>\n<Response>\n${body}\n</Response>`;
}
export function initialStepForMode(mode) {
return mode === 'store' ? 'greeting' : 'intro';
}
export function isCoreStep(step) {
return CORE_STEPS.has(step);
}
export function isStoreEntryStep(step) {
return STORE_ENTRY_STEPS.has(step);
}
export const STORE_ENTRY_STEP_ORDER = ['greeting', 'dtmf', 'wait'];
export const CORE_STEP_ORDER = ['intro', 'listen', 'outro', 'done'];

View file

@ -0,0 +1,119 @@
// services/renderers/callTestRenderer.js
function fmtMs(ms) {
if (ms == null || !Number.isFinite(ms)) return '—';
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function fmtTime(iso) {
if (!iso) return '—';
try {
return new Date(iso).toISOString();
} catch {
return String(iso);
}
}
/**
* @param {object} session
*/
export function renderCallTestStartedMarkdown(session) {
const modeLabel = session.mode === 'store' ? 'Store path' : 'Direct dial';
const storePart = session.storeNum ? ` for store **${session.storeNum}**` : '';
return (
`**Call test started** (${modeLabel})${storePart}\n\n` +
`- **Dialing:** ${session.dialNumber}\n` +
`- **Test ID:** \`${session.testId}\`\n` +
`- Results will post here when the call completes (~23 min).`
);
}
/**
* @param {object} session
*/
export function renderCallTestResultMarkdown(session) {
const modeLabel = session.mode === 'store' ? 'Store' : 'Direct';
const inProgress = session.result == null
&& (session.status === 'in_progress' || session.status === 'pending');
const pass = session.result === 'pass';
const warn = session.result === 'warn';
const icon = inProgress ? '⏳' : (pass || warn ? '✅' : '❌');
const headline = inProgress
? 'IN PROGRESS'
: (pass || warn ? 'PASSED' : 'FAILED');
let md = `**${icon} Call test ${headline}** (${modeLabel})\n\n`;
if (session.storeNum) md += `- **Store:** ${session.storeNum}\n`;
md += `- **Dialed:** ${session.dialNumber}\n`;
md += `- **Test ID:** \`${session.testId}\`\n`;
if (session.callSid) md += `- **Twilio CallSid:** \`${session.callSid}\`\n`;
if (session.resultReason) {
md += `- **Result:** ${session.resultReason}\n`;
}
if (session.failedAt) {
md += `- **Failed at:** ${session.failedAt}\n`;
}
const e = session.events || {};
md += '\n**Timings:**\n';
if (e.initiated) md += `- Initiated: ${fmtTime(e.initiated)}\n`;
if (e.ringing) md += `- Ringing: ${fmtTime(e.ringing)}\n`;
if (e.answered) md += `- Answered: ${fmtTime(e.answered)}\n`;
if (!e.answered && e['in-progress']) md += `- In progress: ${fmtTime(e['in-progress'])}\n`;
if (e.completed) md += `- Completed: ${fmtTime(e.completed)}\n`;
if (e.answered && e.initiated) {
md += `- Ring → answer: ${fmtMs(new Date(e.answered) - new Date(e.initiated))}\n`;
}
if (e.coreStartedAt) {
md += `- Core started: ${fmtTime(e.coreStartedAt)}\n`;
}
if (e.completed && e.answered) {
md += `- Answer → complete: ${fmtMs(new Date(e.completed) - new Date(e.answered))}\n`;
}
if (session.durationSec != null) {
md += `- Total duration: ${session.durationSec}s\n`;
}
if (session.mode === 'store' && e.dtmfStepAt) {
md += `- Entry → DTMF step: ${fmtTime(e.dtmfStepAt)}\n`;
}
return md.trim();
}
/**
* @param {object} session
* @param {object} cdr
*/
export function renderCallTestCdrMarkdown(session, cdr) {
if (!cdr?.available) {
return (
`**Call test CDR** (\`${session.testId}\`)\n\n` +
`_Detailed call history unavailable:_ ${cdr?.reason || 'not configured'}`
);
}
const summary = cdr.summary || {};
let md =
`**Call test CDR enrichment** (store **${session.storeNum}**)\n\n` +
`- **Window:** ${cdr.startTime}${cdr.endTime}\n` +
`- **Location:** ${cdr.location || '—'}\n` +
`- **Matching legs:** ${summary.totalCalls ?? cdr.rawCount ?? 0}\n`;
if (summary.inbound != null) md += `- Inbound legs: ${summary.inbound}\n`;
if (summary.missed != null) md += `- Missed / failed legs: ${summary.missed}\n`;
const samples = cdr.samples || [];
if (samples.length) {
md += '\n**Sample legs:**\n';
for (const s of samples.slice(0, 5)) {
md += `- ${s.start || '—'} | ${s.direction || '—'} | ${s.duration ?? '—'}s | ${s.otherParty || s.phoneNumber || '—'}\n`;
}
}
return md.trim();
}

View file

@ -0,0 +1,145 @@
// tests/callTest.service.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
handleStatusCallback,
handleVoiceWebhook,
tryFinalizeFromVoiceDoneForTests,
_clearPendingFinalizeTimersForTests,
} from '../services/callTest/callTestService.js';
import {
createSession,
_clearAllSessionsForTests,
getSession,
} from '../services/callTest/sessionStore.js';
import { renderCallTestResultMarkdown } from '../services/renderers/callTestRenderer.js';
const TEST_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
test('handleVoiceWebhook: store path marks core on intro', () => {
_clearAllSessionsForTests();
createSession(TEST_ID, {
mode: 'store',
dialNumber: '+12125550100',
config: { listenSec: 60, introTts: 'x', outroTts: 'y', dtmf: '1', greetingPauseSec: 5, answerWaitSec: 45 },
});
handleVoiceWebhook(TEST_ID, 'store', 'wait');
let s = getSession(TEST_ID);
assert.equal(s.lastStep, 'wait');
assert.equal(s.reachedCore, false);
handleVoiceWebhook(TEST_ID, 'store', 'intro');
s = getSession(TEST_ID);
assert.equal(s.lastStep, 'intro');
assert.equal(s.reachedCore, true);
assert.ok(s.events.coreStartedAt);
});
test('handleVoiceWebhook done step finalizes without status callback', async () => {
_clearAllSessionsForTests();
_clearPendingFinalizeTimersForTests();
createSession(TEST_ID, {
mode: 'dial',
dialNumber: '+12125550100',
roomId: 'room-1',
reachedCore: true,
config: {},
});
handleVoiceWebhook(TEST_ID, 'dial', 'outro');
handleVoiceWebhook(TEST_ID, 'dial', 'done');
const pending = getSession(TEST_ID);
assert.equal(pending.lastStep, 'done');
const finalized = await tryFinalizeFromVoiceDoneForTests(TEST_ID);
assert.equal(finalized.result, 'pass');
assert.equal(finalized.status, 'completed');
});
test('handleStatusCallback: in-progress maps to answered and passes on completed', async () => {
_clearAllSessionsForTests();
createSession(TEST_ID, {
mode: 'dial',
dialNumber: '+12125550100',
reachedCore: true,
lastStep: 'done',
config: {},
});
await handleStatusCallback(TEST_ID, {
CallStatus: 'in-progress',
CallSid: 'CA123',
});
await handleStatusCallback(TEST_ID, {
CallStatus: 'completed',
CallDuration: '75',
CallSid: 'CA123',
});
const s = getSession(TEST_ID);
assert.ok(s.events.answered);
assert.equal(s.result, 'pass');
});
test('handleStatusCallback: completed with core yields pass in renderer', async () => {
_clearAllSessionsForTests();
createSession(TEST_ID, {
mode: 'dial',
dialNumber: '+12125550100',
reachedCore: true,
lastStep: 'done',
config: {},
});
await handleStatusCallback(TEST_ID, {
CallStatus: 'answered',
CallSid: 'CA123',
});
await handleStatusCallback(TEST_ID, {
CallStatus: 'completed',
CallDuration: '75',
CallSid: 'CA123',
});
const s = getSession(TEST_ID);
assert.equal(s.result, 'pass');
assert.equal(s.durationSec, 75);
const md = renderCallTestResultMarkdown(s);
assert.match(md, /PASSED/);
assert.match(md, /CA123/);
});
test('handleStatusCallback: no-answer fails', async () => {
_clearAllSessionsForTests();
createSession(TEST_ID, {
mode: 'dial',
dialNumber: '+12125550100',
config: {},
});
await handleStatusCallback(TEST_ID, {
CallStatus: 'no-answer',
CallSid: 'CA999',
});
const s = getSession(TEST_ID);
assert.equal(s.result, 'fail');
const md = renderCallTestResultMarkdown(s);
assert.match(md, /FAILED/);
});
test('renderCallTestResultMarkdown shows in progress before final result', () => {
const md = renderCallTestResultMarkdown({
mode: 'dial',
status: 'in_progress',
dialNumber: '+12125550100',
testId: TEST_ID,
events: { coreStartedAt: new Date().toISOString() },
});
assert.match(md, /IN PROGRESS/);
});

View file

@ -0,0 +1,97 @@
// tests/callTest.signature.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import twilio from 'twilio';
import { validateTwilioSignature } from '../integrations/twilio/client.js';
import { resolveCallTestWebhookUrl } from '../services/callTest/config.js';
test('validateTwilioSignature matches Twilio HMAC algorithm', () => {
const authToken = 'test-auth-token';
const url = 'https://example.com/twilio/calltest/status?testId=abc';
const params = { CallStatus: 'completed', CallSid: 'CA123' };
let data = url;
for (const key of Object.keys(params).sort()) {
data += key + params[key];
}
const signature = crypto.createHmac('sha1', authToken).update(data, 'utf-8').digest('base64');
const prevSid = process.env.TWILIO_ACCOUNT_SID;
const prevToken = process.env.TWILIO_AUTH_TOKEN;
const prevFrom = process.env.TWILIO_FROM_NUMBER;
const prevBase = process.env.TWILIO_WEBHOOK_BASE_URL;
process.env.TWILIO_ACCOUNT_SID = 'ACtest';
process.env.TWILIO_AUTH_TOKEN = authToken;
process.env.TWILIO_FROM_NUMBER = '+15550001111';
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://example.com';
try {
assert.equal(validateTwilioSignature(signature, url, params), true);
assert.equal(validateTwilioSignature('bad', url, params), false);
} finally {
if (prevSid == null) delete process.env.TWILIO_ACCOUNT_SID;
else process.env.TWILIO_ACCOUNT_SID = prevSid;
if (prevToken == null) delete process.env.TWILIO_AUTH_TOKEN;
else process.env.TWILIO_AUTH_TOKEN = prevToken;
if (prevFrom == null) delete process.env.TWILIO_FROM_NUMBER;
else process.env.TWILIO_FROM_NUMBER = prevFrom;
if (prevBase == null) delete process.env.TWILIO_WEBHOOK_BASE_URL;
else process.env.TWILIO_WEBHOOK_BASE_URL = prevBase;
}
});
test('resolveCallTestWebhookUrl rebuilds the registered Twilio voice URL', () => {
const prevBase = process.env.TWILIO_WEBHOOK_BASE_URL;
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://proxy.example.com/CollabSupport';
try {
const req = {
path: '/twilio/calltest/voice',
originalUrl: '/twilio/calltest/voice?testId=abc&mode=dial&step=intro',
query: { testId: 'abc', mode: 'dial', step: 'intro' },
};
assert.equal(
resolveCallTestWebhookUrl(req),
'https://proxy.example.com/CollabSupport/twilio/calltest/voice?testId=abc&mode=dial&step=intro',
);
} finally {
if (prevBase == null) delete process.env.TWILIO_WEBHOOK_BASE_URL;
else process.env.TWILIO_WEBHOOK_BASE_URL = prevBase;
}
});
test('validateTwilioSignature accepts array POST params like Twilio SDK', () => {
const authToken = 'test-auth-token';
const url = 'https://example.com/twilio/calltest/status?testId=abc';
const params = { Tags: ['a', 'b'], CallStatus: 'completed' };
const signature = twilio.getExpectedTwilioSignature(authToken, url, params);
const prevSid = process.env.TWILIO_ACCOUNT_SID;
const prevToken = process.env.TWILIO_AUTH_TOKEN;
const prevFrom = process.env.TWILIO_FROM_NUMBER;
const prevBase = process.env.TWILIO_WEBHOOK_BASE_URL;
process.env.TWILIO_ACCOUNT_SID = 'ACtest';
process.env.TWILIO_AUTH_TOKEN = authToken;
process.env.TWILIO_FROM_NUMBER = '+15550001111';
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://example.com';
try {
assert.equal(validateTwilioSignature(signature, url, params), true);
} finally {
if (prevSid == null) delete process.env.TWILIO_ACCOUNT_SID;
else process.env.TWILIO_ACCOUNT_SID = prevSid;
if (prevToken == null) delete process.env.TWILIO_AUTH_TOKEN;
else process.env.TWILIO_AUTH_TOKEN = prevToken;
if (prevFrom == null) delete process.env.TWILIO_FROM_NUMBER;
else process.env.TWILIO_FROM_NUMBER = prevFrom;
if (prevBase == null) delete process.env.TWILIO_WEBHOOK_BASE_URL;
else process.env.TWILIO_WEBHOOK_BASE_URL = prevBase;
}
});

View file

@ -0,0 +1,108 @@
// tests/callTest.twiml.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildTwiml,
initialStepForMode,
CORE_STEP_ORDER,
STORE_ENTRY_STEP_ORDER,
} from '../services/callTest/twiml.js';
import {
buildVoiceWebhookUrl,
getCallTestConfig,
normalizeE164,
} from '../services/callTest/config.js';
import { parseCallTestArgs } from '../services/callTest/parseArgs.js';
const TEST_ID = '11111111-2222-3333-4444-555555555555';
const CFG = {
dtmf: '1',
greetingPauseSec: 5,
answerWaitSec: 45,
listenSec: 60,
introTts: 'Intro message',
outroTts: 'Outro message',
};
test('initialStepForMode: store starts at greeting, dial at intro', () => {
assert.equal(initialStepForMode('store'), 'greeting');
assert.equal(initialStepForMode('dial'), 'intro');
});
test('buildTwiml: store entry chain references next steps', () => {
const greeting = buildTwiml({ mode: 'store', step: 'greeting', testId: TEST_ID, config: CFG });
assert.match(greeting, /<Pause length="5"\/>/);
assert.match(greeting, /step=dtmf/);
const dtmf = buildTwiml({ mode: 'store', step: 'dtmf', testId: TEST_ID, config: CFG });
assert.match(dtmf, /<Play digits="wwww1"\/>/);
assert.match(dtmf, /step=wait/);
const wait = buildTwiml({ mode: 'store', step: 'wait', testId: TEST_ID, config: CFG });
assert.match(wait, /<Pause length="45"\/>/);
assert.match(wait, /step=intro/);
});
test('buildTwiml: shared core chain', () => {
for (const step of CORE_STEP_ORDER) {
const xml = buildTwiml({ mode: 'dial', step, testId: TEST_ID, config: CFG });
assert.match(xml, /^<\?xml/);
assert.match(xml, /<Response>/);
if (step === 'done') {
assert.match(xml, /<Hangup\/>/);
}
}
const intro = buildTwiml({ mode: 'dial', step: 'intro', testId: TEST_ID, config: CFG });
assert.match(intro, /Intro message/);
assert.match(intro, /step=listen/);
const listen = buildTwiml({ mode: 'dial', step: 'listen', testId: TEST_ID, config: CFG });
assert.match(listen, /<Pause length="60"\/>/);
const outro = buildTwiml({ mode: 'dial', step: 'outro', testId: TEST_ID, config: CFG });
assert.match(outro, /Outro message/);
});
test('buildTwiml: store entry order ends at intro then core', () => {
assert.deepEqual(STORE_ENTRY_STEP_ORDER, ['greeting', 'dtmf', 'wait']);
const last = buildTwiml({ mode: 'store', step: 'wait', testId: TEST_ID, config: CFG });
assert.match(last, /step=intro/);
});
test('normalizeE164', () => {
assert.equal(normalizeE164('2125550100'), '+12125550100');
assert.equal(normalizeE164('+12125550100'), '+12125550100');
assert.equal(normalizeE164('bad'), null);
});
test('parseCallTestArgs', () => {
assert.deepEqual(parseCallTestArgs(['782']), { kind: 'store', storeNum: '782', dialOverride: null });
assert.deepEqual(parseCallTestArgs(['store', '782']), { kind: 'store', storeNum: '782', dialOverride: null });
assert.deepEqual(parseCallTestArgs(['dial', '+12125550100']), { kind: 'dial', dial: '+12125550100' });
assert.deepEqual(parseCallTestArgs(['+12125550100']), { kind: 'dial', dial: '+12125550100' });
assert.deepEqual(parseCallTestArgs(['status', 'abc']), { kind: 'status', testId: 'abc' });
});
test('buildVoiceWebhookUrl uses TWILIO_WEBHOOK_BASE_URL', () => {
const prev = process.env.TWILIO_WEBHOOK_BASE_URL;
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://example.com';
try {
const url = buildVoiceWebhookUrl(TEST_ID, 'store', 'greeting');
assert.match(url, /^https:\/\/example\.com\/twilio\/calltest\/voice\?/);
assert.match(url, /testId=11111111/);
assert.match(url, /mode=store/);
assert.match(url, /step=greeting/);
} finally {
if (prev == null) delete process.env.TWILIO_WEBHOOK_BASE_URL;
else process.env.TWILIO_WEBHOOK_BASE_URL = prev;
}
});
test('getCallTestConfig defaults listen to 60', () => {
const cfg = getCallTestConfig();
assert.equal(cfg.listenSec, 60);
assert.equal(cfg.dtmf, '1');
});