← back to Butlr
test: 11/11 unit test for routes/vapi-webhooks.js
f05df40115848ce3f97a80193db14526fda3a6f4 · 2026-05-13 08:59:39 -0700 · SteveStudio2
Locks in the Vapi event-shape contract before Steve starts routing
real call traffic through /vapi/webhook. Tests cover:
- status-update mappings (queued/ringing/in-progress/ended/failed →
Butlr's internal statuses dialing/connected/done/failed)
- skips DB write when metadata.butlr_call_id is absent
- final transcript appends to data/agent-log/<id>.json
- partial/non-final transcript chunks do NOT log
- end-of-call-report stores recording_url + summary in DB + log
- unknown event types return 200 with no side effects
- empty body returns 200, no crash
Uses require.cache injection to stub lib/data + fs read/write
redirect to a tmp dir so the real calls.json/agent-log isn't touched.
npm test now runs 25 assertions across 3 files (orphan-recordings 8/8
+ admin-gate 6/6 + vapi-webhook 11/11).
Files touched
M package.jsonA test/vapi-webhook.test.js
Diff
commit f05df40115848ce3f97a80193db14526fda3a6f4
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 08:59:39 2026 -0700
test: 11/11 unit test for routes/vapi-webhooks.js
Locks in the Vapi event-shape contract before Steve starts routing
real call traffic through /vapi/webhook. Tests cover:
- status-update mappings (queued/ringing/in-progress/ended/failed →
Butlr's internal statuses dialing/connected/done/failed)
- skips DB write when metadata.butlr_call_id is absent
- final transcript appends to data/agent-log/<id>.json
- partial/non-final transcript chunks do NOT log
- end-of-call-report stores recording_url + summary in DB + log
- unknown event types return 200 with no side effects
- empty body returns 200, no crash
Uses require.cache injection to stub lib/data + fs read/write
redirect to a tmp dir so the real calls.json/agent-log isn't touched.
npm test now runs 25 assertions across 3 files (orphan-recordings 8/8
+ admin-gate 6/6 + vapi-webhook 11/11).
---
package.json | 2 +-
test/vapi-webhook.test.js | 182 ++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 183 insertions(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 35c262d..e73934d 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"scripts": {
"start": "node server.js",
"dev": "node server.js",
- "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js",
+ "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js && node test/vapi-webhook.test.js",
"report": "node scripts/call-quality-report.js --remote"
},
"dependencies": {
diff --git a/test/vapi-webhook.test.js b/test/vapi-webhook.test.js
new file mode 100644
index 0000000..e32eba9
--- /dev/null
+++ b/test/vapi-webhook.test.js
@@ -0,0 +1,182 @@
+#!/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);
+})();
← fde778e snapshot: 5 file(s) changed, +2 new, ~3 modified
·
back to Butlr
·
call-quality-report: auto-detect Vapi vs Twilio provider ed991e5 →