opt-out-race.test.js
1 /** 2 * Opt-out race condition tests 3 * 4 * Verifies that if an opt-out is processed DURING an outreach batch, 5 * the per-send compliance check catches it and prevents delivery. 6 * 7 * Uses mock.module() to intercept db.js imports and route SQL through SQLite. 8 */ 9 10 import { test, describe, beforeEach, mock } from 'node:test'; 11 import assert from 'node:assert/strict'; 12 import Database from 'better-sqlite3'; 13 import { createPgMock } from '../helpers/pg-mock.js'; 14 15 // ─── Create shared in-memory test DB ───────────────────────────────────────── 16 17 const db = new Database(':memory:'); 18 19 db.exec(` 20 CREATE TABLE opt_outs ( 21 id INTEGER PRIMARY KEY AUTOINCREMENT, 22 phone TEXT, 23 email TEXT, 24 method TEXT NOT NULL CHECK(method IN ('sms', 'email')), 25 project TEXT, 26 opted_out_at DATETIME DEFAULT CURRENT_TIMESTAMP, 27 source TEXT DEFAULT 'inbound', 28 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 29 UNIQUE(phone, method), 30 UNIQUE(email, method) 31 ); 32 33 CREATE TABLE unsubscribed_emails ( 34 id INTEGER PRIMARY KEY AUTOINCREMENT, 35 email TEXT NOT NULL UNIQUE COLLATE NOCASE, 36 message_id INTEGER, 37 project TEXT, 38 unsubscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP, 39 source TEXT DEFAULT 'web', 40 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 41 ); 42 43 CREATE TABLE countries ( 44 country_code TEXT PRIMARY KEY, 45 timezone TEXT DEFAULT 'Australia/Sydney', 46 twilio_phone_number TEXT, 47 sms_enabled INTEGER DEFAULT 1 48 ); 49 50 INSERT INTO countries (country_code, timezone) VALUES ('AU', 'Australia/Sydney'); 51 INSERT INTO countries (country_code, timezone) VALUES ('US', 'America/New_York'); 52 INSERT INTO countries (country_code, timezone) VALUES ('UK', 'Europe/London'); 53 54 CREATE TABLE sites ( 55 id INTEGER PRIMARY KEY AUTOINCREMENT, 56 domain TEXT, 57 country_code TEXT DEFAULT 'AU', 58 status TEXT DEFAULT 'outreach_sent', 59 city TEXT, 60 region TEXT, 61 landing_page_url TEXT, 62 score INTEGER, 63 last_outreach_at TEXT, 64 updated_at TEXT DEFAULT CURRENT_TIMESTAMP, 65 rescored_at DATETIME 66 ); 67 68 CREATE TABLE messages ( 69 id INTEGER PRIMARY KEY AUTOINCREMENT, 70 project TEXT NOT NULL, 71 site_id INTEGER NOT NULL, 72 direction TEXT NOT NULL DEFAULT 'outbound', 73 contact_method TEXT NOT NULL, 74 contact_uri TEXT NOT NULL, 75 message_body TEXT, 76 subject_line TEXT, 77 approval_status TEXT, 78 delivery_status TEXT, 79 error_message TEXT, 80 sent_at TEXT, 81 created_at TEXT NOT NULL DEFAULT (datetime('now')), 82 updated_at TEXT NOT NULL DEFAULT (datetime('now')), 83 message_type TEXT DEFAULT 'outreach', 84 raw_payload TEXT, 85 read_at TEXT 86 ); 87 `); 88 89 // ─── Mock db.js BEFORE importing compliance.js ─────────────────────────────── 90 91 mock.module('../../src/utils/db.js', { 92 namedExports: createPgMock(db), 93 }); 94 95 mock.module('../../../mmo-platform/src/suppression.js', { 96 namedExports: { 97 openDb: () => ({ close: () => {} }), 98 addSuppression: () => {}, 99 }, 100 }); 101 102 // Import AFTER mock.module 103 const { 104 addOptOut, 105 isOptedOut, 106 shouldBlockSMS, 107 shouldBlockEmail, 108 processStopKeyword, 109 } = await import('../../src/utils/compliance.js'); 110 111 // ─── Helpers ───────────────────────────────────────────────────────────────── 112 113 // E2E mode env so shouldBlockSMS skips business-hours check, making tests deterministic 114 const E2E_DB_PATH = '/tmp/test-e2e-race.db'; 115 116 function clearDb() { 117 db.prepare('DELETE FROM opt_outs').run(); 118 db.prepare('DELETE FROM unsubscribed_emails').run(); 119 db.prepare('DELETE FROM sites').run(); 120 db.prepare('DELETE FROM messages').run(); 121 } 122 123 function insertSite(overrides = {}) { 124 const defaults = { 125 domain: 'example.com', 126 country_code: 'AU', 127 city: 'Sydney', 128 region: 'NSW', 129 }; 130 const d = { ...defaults, ...overrides }; 131 return db 132 .prepare( 133 `INSERT INTO sites (domain, country_code, city, region) 134 VALUES (?, ?, ?, ?)` 135 ) 136 .run(d.domain, d.country_code, d.city, d.region).lastInsertRowid; 137 } 138 139 function insertApprovedOutreach({ siteId, method, uri, project = '333method', body = 'Hello' }) { 140 return db 141 .prepare( 142 `INSERT INTO messages (project, site_id, direction, contact_method, contact_uri, 143 message_body, approval_status) 144 VALUES (?, ?, 'outbound', ?, ?, ?, 'approved')` 145 ) 146 .run(project, siteId, method, uri, body).lastInsertRowid; 147 } 148 149 // Helper: set E2E mode during shouldBlockSMS calls to skip business hours check 150 async function shouldBlockSMSE2E(phone, siteId) { 151 const saved = process.env.DATABASE_PATH; 152 process.env.DATABASE_PATH = E2E_DB_PATH; 153 const result = await shouldBlockSMS(phone, siteId); 154 if (saved !== undefined) { 155 process.env.DATABASE_PATH = saved; 156 } else { 157 delete process.env.DATABASE_PATH; 158 } 159 return result; 160 } 161 162 // ── Tests ─────────────────────────────────────────────────────────────────── 163 164 describe('Opt-out race condition: 333Method compliance.js', () => { 165 beforeEach(() => { 166 clearDb(); 167 }); 168 169 test('isOptedOut returns false before opt-out, true after', async () => { 170 const phone = '+61400000001'; 171 172 assert.equal(await isOptedOut(phone, null, 'sms'), false, 'should be clean before opt-out'); 173 174 await addOptOut(phone, null, 'sms'); 175 176 assert.equal(await isOptedOut(phone, null, 'sms'), true, 'should be blocked after opt-out'); 177 }); 178 179 test('shouldBlockSMS catches opt-out inserted between batch query and send', async () => { 180 const phone = '+61400000001'; 181 const siteId = insertSite(); 182 insertApprovedOutreach({ siteId, method: 'sms', uri: phone }); 183 184 // Step 1: Simulate batch query time — no opt-out yet 185 const batchCheck = await isOptedOut(phone, null, 'sms'); 186 assert.equal(batchCheck, false, 'batch query sees no opt-out'); 187 188 // Step 2: Opt-out arrives (STOP keyword processed by webhook) 189 await addOptOut(phone, null, 'sms'); 190 191 // Step 3: Per-send compliance check — must catch the opt-out 192 const sendCheck = await shouldBlockSMSE2E(phone, siteId); 193 assert.equal(sendCheck.blocked, true, 'per-send check must block after opt-out'); 194 assert.equal(sendCheck.reason, 'opted_out'); 195 }); 196 197 test('shouldBlockEmail catches opt-out inserted between batch query and send', async () => { 198 const email = 'owner@example.com'; 199 const siteId = insertSite(); 200 insertApprovedOutreach({ siteId, method: 'email', uri: email }); 201 202 assert.equal(await isOptedOut(null, email, 'email'), false); 203 204 await addOptOut(null, email, 'email'); 205 206 const sendCheck = await shouldBlockEmail(email); 207 assert.equal(sendCheck.blocked, true); 208 assert.equal(sendCheck.reason, 'opted_out'); 209 }); 210 211 test('processStopKeyword immediately makes isOptedOut return true', async () => { 212 const phone = '+61400000001'; 213 214 assert.equal(await isOptedOut(phone, null, 'sms'), false); 215 216 const result = await processStopKeyword('STOP', phone); 217 assert.equal(result.isOptOutRequest, true); 218 assert.equal(result.optedOut, true); 219 220 assert.equal(await isOptedOut(phone, null, 'sms'), true); 221 }); 222 223 test('opt-out for one method does not block the other method', async () => { 224 const phone = '+61400000001'; 225 const email = 'owner@example.com'; 226 227 await addOptOut(phone, null, 'sms'); 228 229 assert.equal(await isOptedOut(phone, null, 'sms'), true, 'SMS should be blocked'); 230 assert.equal(await isOptedOut(null, email, 'email'), false, 'email should NOT be blocked'); 231 }); 232 233 test('unsubscribed_emails table also blocks email via shouldBlockEmail', async () => { 234 const email = 'owner@example.com'; 235 236 db.prepare('INSERT INTO unsubscribed_emails (email) VALUES (?)').run(email); 237 238 const check = await shouldBlockEmail(email); 239 assert.equal(check.blocked, true); 240 assert.equal(check.reason, 'unsubscribed'); 241 }); 242 243 test('concurrent STOP processing for same phone is idempotent', async () => { 244 const phone = '+61400000001'; 245 246 const r1 = await processStopKeyword('STOP', phone); 247 const r2 = await processStopKeyword('STOP', phone); 248 249 assert.equal(r1.optedOut, true); 250 assert.equal(r2.optedOut, true); 251 252 const count = db.prepare('SELECT COUNT(*) as c FROM opt_outs WHERE phone = ?').get(phone).c; 253 assert.equal(count, 1, 'duplicate STOP should not create duplicate rows'); 254 }); 255 256 test('batch of N outreaches: opt-out mid-batch blocks remaining sends', async () => { 257 const phone = '+61400000001'; 258 const siteIds = []; 259 for (let i = 0; i < 5; i++) { 260 siteIds.push(insertSite({ domain: `site${i}.com` })); 261 } 262 263 const outreachIds = siteIds.map(siteId => 264 insertApprovedOutreach({ siteId, method: 'sms', uri: phone }) 265 ); 266 267 const results = []; 268 for (let i = 0; i < outreachIds.length; i++) { 269 if (i === 2) { 270 await addOptOut(phone, null, 'sms'); 271 } 272 273 const check = await shouldBlockSMSE2E(phone, siteIds[i]); 274 results.push({ outreachId: outreachIds[i], blocked: check.blocked, reason: check.reason }); 275 } 276 277 assert.equal(results[0].blocked, false); 278 assert.equal(results[1].blocked, false); 279 280 assert.equal(results[2].blocked, true); 281 assert.equal(results[2].reason, 'opted_out'); 282 assert.equal(results[3].blocked, true); 283 assert.equal(results[4].blocked, true); 284 }); 285 286 test('email opt-out mid-batch blocks remaining email sends', async () => { 287 const email = 'owner@example.com'; 288 const siteIds = []; 289 for (let i = 0; i < 4; i++) { 290 siteIds.push(insertSite({ domain: `site${i}.com` })); 291 } 292 293 const results = []; 294 for (let i = 0; i < siteIds.length; i++) { 295 if (i === 1) { 296 await addOptOut(null, email, 'email'); 297 } 298 299 const check = await shouldBlockEmail(email); 300 results.push({ blocked: check.blocked }); 301 } 302 303 assert.equal(results[0].blocked, false, 'first email should send'); 304 assert.equal(results[1].blocked, true, 'second email should be blocked'); 305 assert.equal(results[2].blocked, true); 306 assert.equal(results[3].blocked, true); 307 }); 308 }); 309 310 describe('Opt-out race condition: 2Step isOptedOut pattern', () => { 311 beforeEach(() => { 312 clearDb(); 313 }); 314 315 /** 316 * Mirrors the 2Step isOptedOut function from src/stages/outreach.js 317 * but operates on the main db (simulating the ATTACHed msgs schema). 318 */ 319 function isOptedOut2Step(phone, email, method) { 320 if (!phone && !email) return false; 321 const row = db 322 .prepare( 323 `SELECT 1 FROM opt_outs 324 WHERE method = ? 325 AND (phone = ? OR email = ?) 326 LIMIT 1` 327 ) 328 .get(method, phone ?? null, email ?? null); 329 return Boolean(row); 330 } 331 332 test('2Step isOptedOut catches opt-out inserted between batch query and send', () => { 333 const email = 'owner@example.com'; 334 335 assert.equal(isOptedOut2Step(null, email, 'email'), false); 336 337 db.prepare( 338 `INSERT INTO opt_outs (email, method) VALUES (?, 'email')` 339 ).run(email); 340 341 assert.equal(isOptedOut2Step(null, email, 'email'), true); 342 }); 343 344 test('2Step SMS opt-out blocks send mid-batch', () => { 345 const phone = '+61400000001'; 346 347 assert.equal(isOptedOut2Step(phone, null, 'sms'), false); 348 349 db.prepare( 350 `INSERT INTO opt_outs (phone, method) VALUES (?, 'sms')` 351 ).run(phone); 352 353 assert.equal(isOptedOut2Step(phone, null, 'sms'), true); 354 }); 355 356 test('2Step batch simulation: 3 emails, opt-out after 1st', () => { 357 const email = 'owner@example.com'; 358 const siteId = insertSite(); 359 360 for (let i = 0; i < 3; i++) { 361 insertApprovedOutreach({ 362 siteId, 363 method: 'email', 364 uri: email, 365 project: '2step', 366 }); 367 } 368 369 const results = []; 370 for (let i = 0; i < 3; i++) { 371 if (i === 1) { 372 db.prepare( 373 `INSERT INTO opt_outs (email, method) VALUES (?, 'email')` 374 ).run(email); 375 } 376 results.push(isOptedOut2Step(null, email, 'email')); 377 } 378 379 assert.equal(results[0], false, '1st email should send'); 380 assert.equal(results[1], true, '2nd email should be blocked'); 381 assert.equal(results[2], true, '3rd email should be blocked'); 382 }); 383 384 test('null phone and null email returns false (no false positive)', () => { 385 assert.equal(isOptedOut2Step(null, null, 'sms'), false); 386 assert.equal(isOptedOut2Step(null, null, 'email'), false); 387 }); 388 389 test('opt-out for different phone does not block unrelated recipient', () => { 390 const phone1 = '+61400000001'; 391 const phone2 = '+61400000002'; 392 393 db.prepare( 394 `INSERT INTO opt_outs (phone, method) VALUES (?, 'sms')` 395 ).run(phone1); 396 397 assert.equal(isOptedOut2Step(phone1, null, 'sms'), true); 398 assert.equal(isOptedOut2Step(phone2, null, 'sms'), false, 'different phone should not be blocked'); 399 }); 400 });