← back to Butlr

test/external-place-call.test.js

191 lines

// Tests for routes/external.js — the first-party call-trigger API used by
// NationalPaperHangers.com. Runs entirely in dry-run; never dials.
//
// Boots the real app on an ephemeral port with a known shared secret and
// TWILIO_DRY_RUN forced ON, then exercises the secret gate, the 3 modes,
// input validation, and the allow_repeat / 4-call-cap interaction.

const assert = require('assert');
const http = require('http');
const fs = require('fs');
const path = require('path');

process.env.HFM_NO_WORKER = '1';
process.env.HFM_NO_WATCHER = '1';
process.env.TWILIO_DRY_RUN = '1';            // never dial for real
process.env.BUTLR_EXTERNAL_SECRET = 'test-secret-abc123';
process.env.PORT = '0';                       // ephemeral

// Isolate the call store so we don't clobber a real data/calls.json.
const CALLS_FILE = path.join(__dirname, '..', 'data', 'calls.json');
const BACKUP = CALLS_FILE + '.test-backup';
let hadFile = false;
if (fs.existsSync(CALLS_FILE)) { hadFile = true; fs.renameSync(CALLS_FILE, BACKUP); }
function restore() {
  try { if (fs.existsSync(CALLS_FILE)) fs.unlinkSync(CALLS_FILE); } catch {}
  if (hadFile && fs.existsSync(BACKUP)) fs.renameSync(BACKUP, CALLS_FILE);
}

const SECRET = 'test-secret-abc123';
let server, base;

function req(method, urlPath, { body, secret } = {}) {
  return new Promise((resolve, reject) => {
    const data = body ? JSON.stringify(body) : null;
    const headers = { 'Content-Type': 'application/json' };
    if (secret) headers['X-Butlr-External-Secret'] = secret;
    if (data) headers['Content-Length'] = Buffer.byteLength(data);
    const u = new URL(base + urlPath);
    const r = http.request(
      { hostname: u.hostname, port: u.port, path: u.pathname, method, headers },
      (res) => {
        let chunks = '';
        res.on('data', (c) => (chunks += c));
        res.on('end', () => {
          let json = null;
          try { json = JSON.parse(chunks); } catch {}
          resolve({ status: res.statusCode, json });
        });
      }
    );
    r.on('error', reject);
    if (data) r.write(data);
    r.end();
  });
}

// server.js auto-listens when run as main. Rather than wrangle that, mount the
// external router on a fresh express app — it carries its own secret gate and
// has no dependency on the rest of the server wiring.
const express = require('express');
const externalRoutes = require('../routes/external');

(async () => {
  let passed = 0, failed = 0;
  const test = async (name, fn) => {
    try { await fn(); console.log('  ✓ ' + name); passed++; }
    catch (e) { console.log('  ✗ ' + name + '\n      ' + e.message); failed++; }
  };

  const app = express();
  app.use('/', externalRoutes);
  server = http.createServer(app);
  await new Promise((res) => server.listen(0, '127.0.0.1', res));
  base = 'http://127.0.0.1:' + server.address().port;

  console.log('routes/external.js — place-call API');

  await test('health requires the shared secret', async () => {
    const r = await req('GET', '/api/external/health');
    assert.strictEqual(r.status, 401);
  });

  await test('health passes with the correct secret', async () => {
    const r = await req('GET', '/api/external/health', { secret: SECRET });
    assert.strictEqual(r.status, 200);
    assert.strictEqual(r.json.twilio_dry_run, true);
  });

  await test('place-call rejects a bad secret', async () => {
    const r = await req('POST', '/api/external/place-call', {
      secret: 'wrong',
      body: { mode: 'hold', installer_phone: '+13105550000', customer_phone: '+13105551111' },
    });
    assert.strictEqual(r.status, 401);
  });

  await test('place-call rejects an unknown mode', async () => {
    const r = await req('POST', '/api/external/place-call', {
      secret: SECRET,
      body: { mode: 'telepathy', installer_phone: '+13105550000' },
    });
    assert.strictEqual(r.status, 400);
  });

  await test('hold mode requires customer_phone', async () => {
    const r = await req('POST', '/api/external/place-call', {
      secret: SECRET,
      body: { mode: 'hold', installer_phone: '+13105550000' },
    });
    assert.strictEqual(r.status, 400);
    assert.ok(r.json.errors.some((e) => /customer_phone/.test(e)));
  });

  await test('ai_agent mode requires a brief', async () => {
    const r = await req('POST', '/api/external/place-call', {
      secret: SECRET,
      body: { mode: 'ai_agent', installer_phone: '+13105550000' },
    });
    assert.strictEqual(r.status, 400);
    assert.ok(r.json.errors.some((e) => /brief/.test(e)));
  });

  await test('hold mode places a dry-run call', async () => {
    const r = await req('POST', '/api/external/place-call', {
      secret: SECRET,
      body: { mode: 'hold', installer_phone: '+13105552001', installer_name: 'A',
        customer_phone: '+13105559001', customer_name: 'Cust' },
    });
    assert.strictEqual(r.status, 200);
    assert.strictEqual(r.json.ok, true);
    assert.strictEqual(r.json.dry_run, true);
    assert.ok(r.json.call_id);
  });

  await test('bridge mode places a dry-run call', async () => {
    const r = await req('POST', '/api/external/place-call', {
      secret: SECRET,
      body: { mode: 'bridge', installer_phone: '+13105552002', installer_name: 'B',
        customer_phone: '+13105559002', customer_name: 'Cust' },
    });
    assert.strictEqual(r.status, 200);
    assert.strictEqual(r.json.ok, true);
  });

  await test('ai_agent mode places a dry-run call with a brief', async () => {
    const r = await req('POST', '/api/external/place-call', {
      secret: SECRET,
      body: { mode: 'ai_agent', installer_phone: '+13105552003', installer_name: 'C',
        customer_phone: '+13105559003', customer_name: 'Cust',
        brief: 'Grasscloth install in a dining room next month, asking availability.' },
    });
    assert.strictEqual(r.status, 200);
    assert.strictEqual(r.json.ok, true);
  });

  await test('different customers can call the SAME installer (allow_repeat)', async () => {
    const phone = '+13105557000';
    const a = await req('POST', '/api/external/place-call', {
      secret: SECRET,
      body: { mode: 'hold', installer_phone: phone, installer_name: 'Rpt',
        customer_phone: '+13105558001', customer_name: 'A' },
    });
    const b = await req('POST', '/api/external/place-call', {
      secret: SECRET,
      body: { mode: 'hold', installer_phone: phone, installer_name: 'Rpt',
        customer_phone: '+13105558002', customer_name: 'B' },
    });
    assert.strictEqual(a.json.ok, true);
    assert.strictEqual(b.json.ok, true);
  });

  await test('the 4-call-per-number hard cap still fires (429)', async () => {
    const phone = '+13105557999';
    let last;
    for (let i = 0; i < 5; i++) {
      last = await req('POST', '/api/external/place-call', {
        secret: SECRET,
        body: { mode: 'hold', installer_phone: phone, installer_name: 'Cap',
          customer_phone: '+1310559900' + i, customer_name: 'C' + i },
      });
    }
    assert.strictEqual(last.status, 429);
    assert.ok(last.json.errors.some((e) => /max_retries_reached/.test(e)));
  });

  await new Promise((res) => server.close(res));
  restore();
  console.log(`\n${passed}/${passed + failed} passed`);
  if (failed) process.exit(1);
})().catch((e) => { restore(); console.error(e); process.exit(1); });