← 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); });