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