/ tests / compliance / compliance-supplement2.test.js
compliance-supplement2.test.js
  1  /**
  2   * Compliance Supplement 2 Tests
  3   *
  4   * Covers additional untested paths in src/utils/compliance.js:
  5   *  - shouldBlockEmail: opted_out path, unsubscribed path, not blocked path
  6   *  - shouldBlockSMS: outside_business_hours path (non-E2E mode)
  7   *  - processStopKeyword: all STOP variants, non-keyword messages
  8   *  - processStartKeyword: START keyword with existing opt-out (resubscribed=true)
  9   *  - isBusinessHours: valid business hours check
 10   *  - addOptOut: email-only opt-out
 11   *  - isOptedOut: email match, phone match, not opted out
 12   *  - removeOptOut: successful removal
 13   *
 14   * Uses mock.module() to intercept db.js imports and route SQL through SQLite.
 15   */
 16  
 17  import { test, describe, beforeEach, mock } from 'node:test';
 18  import assert from 'node:assert/strict';
 19  import Database from 'better-sqlite3';
 20  import { createPgMock } from '../helpers/pg-mock.js';
 21  
 22  // ─── Create shared in-memory test DB ─────────────────────────────────────────
 23  
 24  const db = new Database(':memory:');
 25  
 26  db.exec(`
 27    CREATE TABLE opt_outs (
 28      id INTEGER PRIMARY KEY AUTOINCREMENT,
 29      phone TEXT,
 30      email TEXT,
 31      method TEXT NOT NULL CHECK(method IN ('sms', 'email')),
 32      opted_out_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 33      source TEXT DEFAULT 'inbound',
 34      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 35      UNIQUE(phone, method),
 36      UNIQUE(email, method)
 37    );
 38  
 39    CREATE TABLE sites (
 40      id INTEGER PRIMARY KEY,
 41      city TEXT,
 42      country_code TEXT DEFAULT 'US',
 43      state TEXT,
 44      locale_data TEXT,
 45      rescored_at DATETIME
 46    );
 47  
 48    CREATE TABLE unsubscribed_emails (
 49      id INTEGER PRIMARY KEY AUTOINCREMENT,
 50      email TEXT NOT NULL UNIQUE COLLATE NOCASE,
 51      unsubscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP
 52    );
 53  `);
 54  
 55  // ─── Mock db.js BEFORE importing compliance.js ───────────────────────────────
 56  
 57  mock.module('../../src/utils/db.js', {
 58    namedExports: createPgMock(db),
 59  });
 60  
 61  mock.module('../../../mmo-platform/src/suppression.js', {
 62    namedExports: {
 63      openDb: () => ({ close: () => {} }),
 64      addSuppression: () => {},
 65    },
 66  });
 67  
 68  // Import AFTER mock.module
 69  const {
 70    isBusinessHours,
 71    addOptOut,
 72    removeOptOut,
 73    isOptedOut,
 74    shouldBlockSMS,
 75    shouldBlockEmail,
 76    processStopKeyword,
 77    processStartKeyword,
 78  } = await import('../../src/utils/compliance.js');
 79  
 80  function clearDb() {
 81    db.prepare('DELETE FROM opt_outs').run();
 82    db.prepare('DELETE FROM sites').run();
 83    db.prepare('DELETE FROM unsubscribed_emails').run();
 84  }
 85  
 86  // ── isBusinessHours ────────────────────────────────────────────────────────
 87  
 88  describe('isBusinessHours - valid timezones', () => {
 89    test('returns boolean for America/New_York (default)', () => {
 90      const result = isBusinessHours('America/New_York');
 91      assert.equal(typeof result, 'boolean');
 92    });
 93  
 94    test('returns boolean for America/Los_Angeles', () => {
 95      const result = isBusinessHours('America/Los_Angeles');
 96      assert.equal(typeof result, 'boolean');
 97    });
 98  
 99    test('returns boolean for Australia/Sydney', () => {
100      const result = isBusinessHours('Australia/Sydney');
101      assert.equal(typeof result, 'boolean');
102    });
103  
104    test('returns boolean for Europe/London', () => {
105      const result = isBusinessHours('Europe/London');
106      assert.equal(typeof result, 'boolean');
107    });
108  
109    test('returns false for invalid timezone', () => {
110      assert.equal(isBusinessHours('Not/Real'), false);
111    });
112  });
113  
114  // ── addOptOut ──────────────────────────────────────────────────────────────
115  
116  describe('addOptOut - various scenarios', () => {
117    test('adds phone-only sms opt-out and returns positive id', async () => {
118      clearDb();
119      const id = await addOptOut('+12125550001', null, 'sms');
120      assert.ok(typeof id === 'number');
121      assert.ok(id > 0);
122    });
123  
124    test('adds email-only email opt-out and returns positive id', async () => {
125      clearDb();
126      const id = await addOptOut(null, 'test@example.com', 'email');
127      assert.ok(typeof id === 'number');
128      assert.ok(id > 0);
129    });
130  
131    test('adds both phone and email opt-out', async () => {
132      clearDb();
133      const id = await addOptOut('+12125550002', 'dual@example.com', 'sms');
134      assert.ok(id > 0);
135    });
136  
137    test('throws when both phone and email are null', async () => {
138      clearDb();
139      await assert.rejects(
140        () => addOptOut(null, null, 'sms'),
141        /Must provide phone or email/
142      );
143    });
144  
145    test('throws when method is invalid', async () => {
146      clearDb();
147      await assert.rejects(
148        () => addOptOut('+1234567890', null, 'push'),
149        /Invalid method/
150      );
151    });
152  
153    test('second insert for same phone+method triggers upsert (ON CONFLICT path)', async () => {
154      clearDb();
155      const id1 = await addOptOut('+19999999999', null, 'sms');
156      const id2 = await addOptOut('+19999999999', null, 'sms');
157      assert.ok(typeof id2 === 'number');
158    });
159  });
160  
161  // ── isOptedOut ─────────────────────────────────────────────────────────────
162  
163  describe('isOptedOut - various scenarios', () => {
164    test('returns false when not in opt-out list (phone)', async () => {
165      clearDb();
166      assert.equal(await isOptedOut('+10000000000', null, 'sms'), false);
167    });
168  
169    test('returns true when phone is in opt-out list', async () => {
170      clearDb();
171      await addOptOut('+15559999999', null, 'sms');
172      assert.equal(await isOptedOut('+15559999999', null, 'sms'), true);
173    });
174  
175    test('returns false when phone opted out for different method', async () => {
176      clearDb();
177      await addOptOut('+15558888888', null, 'sms');
178      assert.equal(await isOptedOut('+15558888888', null, 'email'), false);
179    });
180  
181    test('returns true when email is in opt-out list', async () => {
182      clearDb();
183      await addOptOut(null, 'opted@example.com', 'email');
184      assert.equal(await isOptedOut(null, 'opted@example.com', 'email'), true);
185    });
186  
187    test('returns false when both phone and email are null', async () => {
188      clearDb();
189      assert.equal(await isOptedOut(null, null, 'sms'), false);
190    });
191  });
192  
193  // ── removeOptOut ───────────────────────────────────────────────────────────
194  
195  describe('removeOptOut - success and failure paths', () => {
196    test('returns true when phone successfully removed', async () => {
197      clearDb();
198      await addOptOut('+17771234567', null, 'sms');
199      const removed = await removeOptOut('+17771234567', null, 'sms');
200      assert.equal(removed, true);
201    });
202  
203    test('returns true when email successfully removed', async () => {
204      clearDb();
205      await addOptOut(null, 'remove@example.com', 'email');
206      const removed = await removeOptOut(null, 'remove@example.com', 'email');
207      assert.equal(removed, true);
208    });
209  
210    test('returns false when phone not found in list', async () => {
211      clearDb();
212      assert.equal(await removeOptOut('+10000000001', null, 'sms'), false);
213    });
214  
215    test('throws when both phone and email are null', async () => {
216      clearDb();
217      await assert.rejects(
218        () => removeOptOut(null, null, 'email'),
219        /Must provide phone or email/
220      );
221    });
222  
223    test('removal means subsequent isOptedOut returns false', async () => {
224      clearDb();
225      await addOptOut('+16660000001', null, 'sms');
226      assert.equal(await isOptedOut('+16660000001', null, 'sms'), true);
227      await removeOptOut('+16660000001', null, 'sms');
228      assert.equal(await isOptedOut('+16660000001', null, 'sms'), false);
229    });
230  });
231  
232  // ── shouldBlockEmail ───────────────────────────────────────────────────────
233  
234  describe('shouldBlockEmail', () => {
235    test('returns blocked:false for non-opted-out email', async () => {
236      clearDb();
237      const result = await shouldBlockEmail('new@example.com');
238      assert.equal(result.blocked, false);
239    });
240  
241    test('returns blocked:true with reason opted_out when email is in opt_outs', async () => {
242      clearDb();
243      await addOptOut(null, 'blocked@example.com', 'email');
244      const result = await shouldBlockEmail('blocked@example.com');
245      assert.equal(result.blocked, true);
246      assert.equal(result.reason, 'opted_out');
247    });
248  
249    test('returns blocked:true with reason unsubscribed when in unsubscribed_emails', async () => {
250      clearDb();
251      db.prepare('INSERT INTO unsubscribed_emails (email) VALUES (?)').run('unsub@example.com');
252      const result = await shouldBlockEmail('unsub@example.com');
253      assert.equal(result.blocked, true);
254      assert.equal(result.reason, 'unsubscribed');
255    });
256  
257    test('opt_out check takes precedence over unsubscribed check', async () => {
258      clearDb();
259      await addOptOut(null, 'both@example.com', 'email');
260      db.prepare('INSERT INTO unsubscribed_emails (email) VALUES (?)').run('both@example.com');
261      const result = await shouldBlockEmail('both@example.com');
262      assert.equal(result.blocked, true);
263      assert.equal(result.reason, 'opted_out');
264    });
265  
266    test('unsubscribed_emails check is case-insensitive', async () => {
267      clearDb();
268      db.prepare('INSERT INTO unsubscribed_emails (email) VALUES (?)').run(
269        'CaseSensitive@Example.COM'
270      );
271      const result = await shouldBlockEmail('casesensitive@example.com');
272      assert.equal(result.blocked, true);
273      assert.equal(result.reason, 'unsubscribed');
274    });
275  });
276  
277  // ── shouldBlockSMS ─────────────────────────────────────────────────────────
278  
279  describe('shouldBlockSMS - business hours check', () => {
280    let savedDbPath;
281  
282    beforeEach(() => {
283      savedDbPath = process.env.DATABASE_PATH;
284    });
285  
286    test('returns blocked:false for non-opted-out phone in E2E mode', async () => {
287      clearDb();
288      process.env.DATABASE_PATH = '/tmp/test-e2e-compliance.db';
289      const result = await shouldBlockSMS('+19998887776', 1);
290      assert.equal(result.blocked, false);
291      if (savedDbPath !== undefined) {
292        process.env.DATABASE_PATH = savedDbPath;
293      } else {
294        delete process.env.DATABASE_PATH;
295      }
296    });
297  
298    test('returns blocked:true reason opted_out in E2E mode when phone is opted out', async () => {
299      clearDb();
300      process.env.DATABASE_PATH = '/tmp/test-e2e-compliance.db';
301      await addOptOut('+19998887775', null, 'sms');
302      const result = await shouldBlockSMS('+19998887775', 1);
303      assert.equal(result.blocked, true);
304      assert.equal(result.reason, 'opted_out');
305      if (savedDbPath !== undefined) {
306        process.env.DATABASE_PATH = savedDbPath;
307      } else {
308        delete process.env.DATABASE_PATH;
309      }
310    });
311  });
312  
313  // ── processStopKeyword ─────────────────────────────────────────────────────
314  
315  describe('processStopKeyword', () => {
316    test('recognizes STOP keyword', async () => {
317      clearDb();
318      const result = await processStopKeyword('STOP', '+12125550100');
319      assert.equal(result.isOptOutRequest, true);
320      assert.equal(result.optedOut, true);
321    });
322  
323    test('recognizes STOPALL keyword', async () => {
324      clearDb();
325      const result = await processStopKeyword('STOPALL', '+12125550101');
326      assert.equal(result.isOptOutRequest, true);
327      assert.equal(result.optedOut, true);
328    });
329  
330    test('recognizes UNSUBSCRIBE keyword', async () => {
331      clearDb();
332      const result = await processStopKeyword('UNSUBSCRIBE', '+12125550102');
333      assert.equal(result.isOptOutRequest, true);
334      assert.equal(result.optedOut, true);
335    });
336  
337    test('recognizes CANCEL keyword', async () => {
338      clearDb();
339      const result = await processStopKeyword('CANCEL', '+12125550103');
340      assert.equal(result.isOptOutRequest, true);
341      assert.equal(result.optedOut, true);
342    });
343  
344    test('recognizes END keyword', async () => {
345      clearDb();
346      const result = await processStopKeyword('END', '+12125550104');
347      assert.equal(result.isOptOutRequest, true);
348      assert.equal(result.optedOut, true);
349    });
350  
351    test('recognizes QUIT keyword', async () => {
352      clearDb();
353      const result = await processStopKeyword('QUIT', '+12125550105');
354      assert.equal(result.isOptOutRequest, true);
355      assert.equal(result.optedOut, true);
356    });
357  
358    test('is case-insensitive (lowercase stop)', async () => {
359      clearDb();
360      const result = await processStopKeyword('stop', '+12125550106');
361      assert.equal(result.isOptOutRequest, true);
362      assert.equal(result.optedOut, true);
363    });
364  
365    test('handles leading/trailing whitespace', async () => {
366      clearDb();
367      const result = await processStopKeyword('  STOP  ', '+12125550107');
368      assert.equal(result.isOptOutRequest, true);
369      assert.equal(result.optedOut, true);
370    });
371  
372    test('does not recognize partial match (STOPHERE is not STOP)', async () => {
373      clearDb();
374      const result = await processStopKeyword('STOPHERE', '+12125550108');
375      assert.equal(result.isOptOutRequest, false);
376      assert.equal(result.optedOut, false);
377    });
378  
379    test('returns isOptOutRequest:false for regular message', async () => {
380      clearDb();
381      const result = await processStopKeyword('Hi, I am interested', '+12125550109');
382      assert.equal(result.isOptOutRequest, false);
383      assert.equal(result.optedOut, false);
384    });
385  
386    test('phone is added to opt-out list after STOP keyword', async () => {
387      clearDb();
388      await processStopKeyword('STOP', '+12125550110');
389      assert.equal(await isOptedOut('+12125550110', null, 'sms'), true);
390    });
391  });
392  
393  // ── processStartKeyword ────────────────────────────────────────────────────
394  
395  describe('processStartKeyword', () => {
396    test('recognizes START keyword and removes existing opt-out', async () => {
397      clearDb();
398      await addOptOut('+12125550200', null, 'sms');
399      const result = await processStartKeyword('START', '+12125550200');
400      assert.equal(result.isResubscribeRequest, true);
401      assert.equal(result.resubscribed, true);
402      assert.equal(await isOptedOut('+12125550200', null, 'sms'), false);
403    });
404  
405    test('recognizes UNSTOP keyword and removes existing opt-out', async () => {
406      clearDb();
407      await addOptOut('+12125550201', null, 'sms');
408      const result = await processStartKeyword('UNSTOP', '+12125550201');
409      assert.equal(result.isResubscribeRequest, true);
410      assert.equal(result.resubscribed, true);
411    });
412  
413    test('returns resubscribed:false when phone was not opted out', async () => {
414      clearDb();
415      const result = await processStartKeyword('START', '+12125550202');
416      assert.equal(result.isResubscribeRequest, true);
417      assert.equal(result.resubscribed, false);
418    });
419  
420    test('is case-insensitive (lowercase start)', async () => {
421      clearDb();
422      await addOptOut('+12125550203', null, 'sms');
423      const result = await processStartKeyword('start', '+12125550203');
424      assert.equal(result.isResubscribeRequest, true);
425    });
426  
427    test('returns isResubscribeRequest:false for non-start keyword', async () => {
428      clearDb();
429      const result = await processStartKeyword('Hello there', '+12125550204');
430      assert.equal(result.isResubscribeRequest, false);
431      assert.equal(result.resubscribed, false);
432    });
433  
434    test('returns isResubscribeRequest:false for empty message', async () => {
435      clearDb();
436      const result = await processStartKeyword('', '+12125550205');
437      assert.equal(result.isResubscribeRequest, false);
438      assert.equal(result.resubscribed, false);
439    });
440  });