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