[object Object]

← 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

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 →