← back to Butlr

test/sms-stop.test.js

155 lines

#!/usr/bin/env node
// Unit test for /twilio/sms-inbound STOP-keyword auto-suppression.
//
// Spins up a tiny express app with just the twilio-webhooks router,
// POSTs canonical Twilio inbound-SMS payloads for STOP / START / random,
// asserts internal suppression list is updated correctly.

process.env.HFM_NO_WORKER = '1';
process.env.HFM_NO_WATCHER = '1';
process.env.TWILIO_DRY_RUN = '0';

const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const assert = require('assert');

// Isolated suppression file
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'butlr-sms-stop-'));
const tmpSuppress = path.join(tmpDir, 'dnc-suppression.json');
const tmpDncFile = path.join(tmpDir, 'national-dnc.txt');
fs.writeFileSync(tmpSuppress, '[]');
fs.writeFileSync(tmpDncFile, '');  // empty federal DNC so checkPhone won't throw
process.env.DNC_FILE = tmpDncFile;

// Redirect dnc-suppression reads/writes to our tmp
const realWrite = fs.writeFileSync, realRead = fs.readFileSync, realRename = fs.renameSync, realExists = fs.existsSync;
fs.writeFileSync = (p, ...rest) => {
  if (String(p).includes('dnc-suppression')) return realWrite(tmpSuppress + (String(p).endsWith('.tmp') ? '.tmp' : ''), ...rest);
  return realWrite(p, ...rest);
};
fs.readFileSync = (p, ...rest) => {
  if (String(p).includes('dnc-suppression')) return realRead(tmpSuppress, ...rest);
  return realRead(p, ...rest);
};
fs.renameSync = (a, b) => {
  const rewrite = (p) => String(p).includes('dnc-suppression') ? tmpSuppress + (String(p).endsWith('.tmp') ? '.tmp' : '') : p;
  return realRename(rewrite(a), rewrite(b));
};
fs.existsSync = (p) => {
  if (String(p).includes('dnc-suppression')) return realExists(tmpSuppress);
  return realExists(p);
};

const express = require('express');
const router = require('../routes/twilio-webhooks');
const app = express();
app.use('/twilio', 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(from, body) {
  return new Promise((resolve) => {
    const server = app.listen(0, () => {
      const port = server.address().port;
      const bodyStr = `From=${encodeURIComponent(from)}&Body=${encodeURIComponent(body)}`;
      const req = http.request({
        hostname: '127.0.0.1', port, path: '/twilio/sms-inbound', method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(bodyStr) },
      }, (res) => {
        let chunks = '';
        res.on('data', c => { chunks += c; });
        res.on('end', () => { server.close(); resolve({ status: res.statusCode, body: chunks }); });
      });
      req.write(bodyStr);
      req.end();
    });
  });
}

function readList() {
  try { return JSON.parse(fs.readFileSync(tmpSuppress, 'utf8')); }
  catch { return []; }
}

(async () => {
  let pass = 0, total = 0;

  // ── 1. STOP adds to suppression
  total++; pass += ok('STOP adds to internal suppression', async () => {
    const r = await post('+13105551001', 'STOP');
    assert.strictEqual(r.status, 200);
    assert.ok(r.body.includes('unsubscribed'), 'reply should confirm unsub');
    const list = readList();
    assert.strictEqual(list.length, 1);
    assert.strictEqual(list[0].phone, '+13105551001');
    assert.strictEqual(list[0].reason, 'sms_stop_keyword');
  });

  // ── 2. UNSUBSCRIBE also adds
  total++; pass += ok('UNSUBSCRIBE adds another number', async () => {
    const r = await post('+13105551002', 'unsubscribe please');
    assert.strictEqual(r.status, 200);
    const list = readList();
    assert.strictEqual(list.length, 2);
  });

  // ── 3. CANCEL added
  total++; pass += ok('CANCEL adds', async () => {
    await post('+13105551003', 'CANCEL');
    const list = readList();
    assert.strictEqual(list.length, 3);
  });

  // ── 4. Re-stopping same number doesn't duplicate
  total++; pass += ok('repeat STOP from same number is idempotent', async () => {
    await post('+13105551001', 'STOP');
    const list = readList();
    assert.strictEqual(list.length, 3);  // still 3, no duplicate
  });

  // ── 5. START removes from suppression (re-opt-in)
  total++; pass += ok('START removes from suppression', async () => {
    const r = await post('+13105551001', 'START');
    assert.strictEqual(r.status, 200);
    assert.ok(r.body.includes('re-subscribed'), 'reply should confirm re-sub');
    const list = readList();
    assert.strictEqual(list.length, 2);
    assert.ok(!list.some(e => e.phone === '+13105551001'));
  });

  // ── 6. Random message replies with generic + does NOT add to list
  total++; pass += ok('random body does not change suppression', async () => {
    const before = readList().length;
    const r = await post('+13105559999', 'hi there');
    assert.strictEqual(r.status, 200);
    assert.ok(r.body.includes('Reply STOP'), 'should mention STOP');
    const after = readList().length;
    assert.strictEqual(after, before);
  });

  // ── 7. No From field → 200 with empty <Response/> (graceful)
  total++; pass += ok('missing From returns 200 no-op', async () => {
    const r = await post('', 'STOP');
    assert.strictEqual(r.status, 200);
    assert.ok(r.body.includes('Response'));
  });

  // ── 8. Case-insensitive
  total++; pass += ok('case-insensitive: "stop" lowercase', async () => {
    await post('+13105551010', 'stop');
    const list = readList();
    assert.ok(list.some(e => e.phone === '+13105551010'));
  });

  fs.writeFileSync = realWrite; fs.readFileSync = realRead; fs.renameSync = realRename; fs.existsSync = realExists;
  fs.rmSync(tmpDir, { recursive: true, force: true });

  console.log(`\n${pass}/${total} passed`);
  process.exit(pass === total ? 0 : 1);
})();