← back to Butlr

scripts/validate-twiml.js

64 lines

#!/usr/bin/env node
// Validate the TwiML emitted by /twilio/twiml/:call_id against the
// inline-Gather + inbound_track contract.
//
// Asserts:
//   • response is well-formed XML wrapped in <Response>…</Response>
//   • contains exactly one <Start><Stream … track="inbound_track" …/></Start>
//   • Stream url uses wss:// and matches the wsBase host pattern
//   • contains exactly one <Gather> with action=…/twilio/gather/<id>
//   • does NOT contain <Redirect> (would tear down <Stream>)
//   • Gather has actionOnEmptyResult="true"
//
// Usage:
//   node scripts/validate-twiml.js <call_id> [--base https://butlr.agentabrams.com]
//
// Exits non-zero on first assertion failure with a clear message.

const callId = process.argv[2];
const baseIdx = process.argv.indexOf('--base');
const base = baseIdx > 0 ? process.argv[baseIdx + 1] : 'https://butlr.agentabrams.com';

if (!callId) {
  console.error('usage: node scripts/validate-twiml.js <call_id> [--base URL]');
  process.exit(64);
}
if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) {
  console.error('bad call_id format'); process.exit(64);
}

const url = `${base}/twilio/twiml/${callId}`;
console.log(`→ POST ${url}`);

async function main() {
  const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: '' });
  const xml = await r.text();
  console.log(`  status: ${r.status}  content-type: ${r.headers.get('content-type')}`);
  console.log('  body:'); console.log(xml.split('\n').map(l => '    ' + l).join('\n'));

  const assertions = [
    { name: 'http 200',                       ok: r.status === 200 },
    { name: 'xml prolog',                     ok: /^<\?xml/.test(xml.trim()) },
    { name: '<Response> wrapper',             ok: /<Response>[\s\S]*<\/Response>/.test(xml) },
    { name: '<Start><Stream> present',        ok: /<Start>\s*<Stream\b/.test(xml) },
    { name: 'track="inbound_track"',          ok: /<Stream[^>]*\btrack="inbound_track"/.test(xml) },
    { name: 'stream url is wss://',           ok: /<Stream[^>]*\burl="wss:\/\//.test(xml) },
    { name: 'one <Gather> only',              ok: (xml.match(/<Gather\b/g) || []).length === 1 },
    { name: 'gather action → /twilio/gather', ok: new RegExp(`action="[^"]*\\/twilio\\/gather\\/${callId}"`).test(xml) },
    { name: 'actionOnEmptyResult="true"',     ok: /actionOnEmptyResult="true"/.test(xml) },
    { name: 'NO <Redirect>',                  ok: !/<Redirect\b/.test(xml) },
    { name: 'announce present (Play or Say)', ok: /<Play>|<Say\b/.test(xml) },
  ];

  let pass = 0, fail = 0;
  console.log('\nassertions:');
  for (const a of assertions) {
    console.log(`  ${a.ok ? '✓' : '✗'}  ${a.name}`);
    if (a.ok) pass++; else fail++;
  }
  console.log(`\n${pass}/${pass + fail} passed${fail ? ` — ${fail} FAILED` : ''}`);
  process.exit(fail ? 1 : 0);
}

main().catch(e => { console.error('error:', e.message); process.exit(2); });