← back to Butlr
test/vapi-webhook.test.js
183 lines
#!/usr/bin/env node
// Unit test for routes/vapi-webhooks.js.
//
// Spins up a tiny express app, mounts the router, hits POST /webhook with
// each Vapi event shape (status-update / transcript / end-of-call-report)
// and asserts the right side-effects. Uses a stub data module so the
// real calls.json isn't touched.
//
// HFM_NO_WORKER=1 + tmpdir for agent-log isolation.
process.env.HFM_NO_WORKER = '1';
const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const assert = require('assert');
// Set up isolated agent-log + data dirs BEFORE requiring the router.
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'butlr-vapi-test-'));
fs.mkdirSync(path.join(tmpRoot, 'data', 'agent-log'), { recursive: true });
// Stub lib/data so updateStatus is observable and no real fs writes happen.
const statusCalls = [];
const stubData = {
updateStatus: (id, status) => { statusCalls.push({ id, status }); },
setRecordingMeta: (id, meta) => { statusCalls.push({ id, recording: meta }); },
setTranscriptMeta: (id, meta) => { statusCalls.push({ id, transcript: meta }); },
};
// Inject stub via require.cache before loading the router.
const dataPath = require.resolve('../lib/data');
require.cache[dataPath] = { id: dataPath, filename: dataPath, loaded: true, exports: stubData };
// Patch agent-log dir to our tmp by hot-swapping __dirname resolution —
// the router does `path.join(__dirname, '..', 'data', 'agent-log')`. We
// can't change __dirname inside the require, but the router resolves
// against its own location. Workaround: monkey-patch fs.writeFileSync
// to redirect the agent-log writes to our tmp.
const realWrite = fs.writeFileSync;
const writes = [];
fs.writeFileSync = (p, content, ...rest) => {
if (String(p).includes('agent-log')) {
const rebased = path.join(tmpRoot, 'data', 'agent-log', path.basename(String(p)));
writes.push({ rebased, content });
return realWrite(rebased, content, ...rest);
}
return realWrite(p, content, ...rest);
};
const realRead = fs.readFileSync;
fs.readFileSync = (p, ...rest) => {
if (String(p).includes('agent-log')) {
const rebased = path.join(tmpRoot, 'data', 'agent-log', path.basename(String(p)));
return realRead(rebased, ...rest);
}
return realRead(p, ...rest);
};
const express = require('express');
const router = require('../routes/vapi-webhooks');
const app = express();
app.use('/vapi', router);
function ok(name, fn) {
try { fn(); console.log(` ✓ ${name}`); return 1; }
catch (e) { console.error(` ✗ ${name}`); console.error(' ', e.message); return 0; }
}
async function post(body) {
return new Promise((resolve) => {
const server = app.listen(0, () => {
const port = server.address().port;
const data = JSON.stringify(body);
const req = http.request({ hostname: '127.0.0.1', port, path: '/vapi/webhook', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } }, (res) => {
let chunks = '';
res.on('data', c => { chunks += c; });
res.on('end', () => { server.close(); resolve({ status: res.statusCode, body: chunks }); });
});
req.write(data);
req.end();
});
});
}
function reset() { statusCalls.length = 0; writes.length = 0; }
(async () => {
let pass = 0, total = 0;
// ── status-update: queued → dialing
reset();
let r = await post({ message: { type: 'status-update', status: 'queued', call: { id: 'vc1', metadata: { butlr_call_id: 'BUTLR01' } } } });
total++; pass += ok('queued → dialing', () => {
assert.strictEqual(r.status, 200);
assert.deepStrictEqual(statusCalls, [{ id: 'BUTLR01', status: 'dialing' }]);
});
reset();
r = await post({ message: { type: 'status-update', status: 'ringing', call: { id: 'vc2', metadata: { butlr_call_id: 'BUTLR02' } } } });
total++; pass += ok('ringing → dialing', () => {
assert.deepStrictEqual(statusCalls, [{ id: 'BUTLR02', status: 'dialing' }]);
});
reset();
r = await post({ message: { type: 'status-update', status: 'in-progress', call: { id: 'vc3', metadata: { butlr_call_id: 'BUTLR03' } } } });
total++; pass += ok('in-progress → connected', () => {
assert.deepStrictEqual(statusCalls, [{ id: 'BUTLR03', status: 'connected' }]);
});
reset();
r = await post({ message: { type: 'status-update', status: 'ended', call: { id: 'vc4', metadata: { butlr_call_id: 'BUTLR04' } } } });
total++; pass += ok('ended → done', () => {
assert.deepStrictEqual(statusCalls, [{ id: 'BUTLR04', status: 'done' }]);
});
reset();
r = await post({ message: { type: 'status-update', status: 'failed', call: { id: 'vc5', metadata: { butlr_call_id: 'BUTLR05' } } } });
total++; pass += ok('failed → failed', () => {
assert.deepStrictEqual(statusCalls, [{ id: 'BUTLR05', status: 'failed' }]);
});
// ── no butlr_call_id → ignored
reset();
r = await post({ message: { type: 'status-update', status: 'queued', call: { id: 'vc-anon' } } });
total++; pass += ok('no butlr_call_id → no DB write', () => {
assert.strictEqual(r.status, 200);
assert.deepStrictEqual(statusCalls, []);
});
// ── transcript: final assistant turn appends to agent-log
reset();
r = await post({ message: { type: 'transcript', role: 'assistant', transcript: 'Hi this is Butlr', transcriptType: 'final', call: { id: 'vc6', metadata: { butlr_call_id: 'BUTLR06' } } } });
total++; pass += ok('final transcript → agent-log write', () => {
assert.strictEqual(r.status, 200);
assert.strictEqual(writes.length, 1);
const arr = JSON.parse(writes[0].content);
assert.strictEqual(arr.length, 1);
assert.strictEqual(arr[0].kind, 'said');
assert.strictEqual(arr[0].text, 'Hi this is Butlr');
});
// ── transcript: partial (non-final) → no write
reset();
r = await post({ message: { type: 'transcript', role: 'user', transcript: 'partial...', transcriptType: 'partial', call: { id: 'vc7', metadata: { butlr_call_id: 'BUTLR07' } } } });
total++; pass += ok('partial transcript → no log write', () => {
assert.strictEqual(writes.length, 0);
});
// ── end-of-call-report: recording URL + summary captured
reset();
r = await post({ message: { type: 'end-of-call-report', recordingUrl: 'https://vapi.example/rec.mp3', summary: 'caller asked about hours', call: { id: 'vc8', metadata: { butlr_call_id: 'BUTLR08' } } } });
total++; pass += ok('end-of-call → recording meta + agent-log', () => {
assert.strictEqual(r.status, 200);
const recCall = statusCalls.find(c => c.recording);
assert.ok(recCall, 'setRecordingMeta should fire');
assert.strictEqual(recCall.recording.recording_url, 'https://vapi.example/rec.mp3');
assert.ok(writes.length > 0, 'should write agent log');
});
// ── unknown event type → 200 noop
reset();
r = await post({ message: { type: 'something-else', call: { id: 'vc9' } } });
total++; pass += ok('unknown event → 200, no side effects', () => {
assert.strictEqual(r.status, 200);
assert.deepStrictEqual(statusCalls, []);
});
// ── empty body → 200 noop (graceful)
reset();
r = await post({});
total++; pass += ok('empty body → 200, no crash', () => {
assert.strictEqual(r.status, 200);
});
// Cleanup
fs.writeFileSync = realWrite;
fs.readFileSync = realRead;
fs.rmSync(tmpRoot, { recursive: true, force: true });
console.log(`\n${pass}/${total} passed`);
process.exit(pass === total ? 0 : 1);
})();