/ tests / compliance / opt-out-race.test.js
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  });