/ tests / compliance / compliance.test.js
compliance.test.js
  1  /**
  2   * Tests for TCPA Compliance Utilities
  3   *
  4   * Functions in compliance.js are async and use db.js internally (no db param).
  5   * We mock db.js via mock.module() before importing compliance.js, routing all
  6   * SQL through an in-memory SQLite database.
  7   */
  8  
  9  import { test, mock } from 'node:test';
 10  import assert from 'node:assert';
 11  import Database from 'better-sqlite3';
 12  import { createPgMock } from '../helpers/pg-mock.js';
 13  
 14  // ─── Create in-memory test DB ─────────────────────────────────────────────────
 15  
 16  const db = new Database(':memory:');
 17  
 18  db.exec(`
 19    CREATE TABLE opt_outs (
 20      id INTEGER PRIMARY KEY AUTOINCREMENT,
 21      phone TEXT,
 22      email TEXT,
 23      method TEXT NOT NULL CHECK(method IN ('sms', 'email')),
 24      opted_out_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 25      source TEXT DEFAULT 'inbound',
 26      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 27      UNIQUE(phone, method),
 28      UNIQUE(email, method)
 29    );
 30  
 31    CREATE TABLE unsubscribed_emails (
 32      id INTEGER PRIMARY KEY AUTOINCREMENT,
 33      email TEXT NOT NULL UNIQUE COLLATE NOCASE,
 34      unsubscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP
 35    );
 36  
 37    CREATE TABLE sites (
 38      id INTEGER PRIMARY KEY,
 39      city TEXT,
 40      country_code TEXT,
 41      rescored_at DATETIME
 42    );
 43  `);
 44  
 45  // ─── Mock db.js BEFORE importing compliance.js ───────────────────────────────
 46  
 47  mock.module('../../src/utils/db.js', {
 48    namedExports: createPgMock(db),
 49  });
 50  
 51  // Mock suppression.js (cross-project, not needed for unit tests)
 52  mock.module('../../../mmo-platform/src/suppression.js', {
 53    namedExports: {
 54      openDb: () => ({ close: () => {} }),
 55      addSuppression: () => {},
 56    },
 57  });
 58  
 59  // Import AFTER mock.module
 60  const {
 61    isBusinessHours,
 62    addOptOut,
 63    removeOptOut,
 64    isOptedOut,
 65    shouldBlockSMS,
 66    shouldBlockEmail,
 67    processStopKeyword,
 68    processStartKeyword,
 69  } = await import('../../src/utils/compliance.js');
 70  
 71  // ─── Helper: reset the DB between tests ──────────────────────────────────────
 72  
 73  function clearOptOuts() {
 74    db.prepare('DELETE FROM opt_outs').run();
 75    db.prepare('DELETE FROM unsubscribed_emails').run();
 76    db.prepare('DELETE FROM sites').run();
 77  }
 78  
 79  // ─── isBusinessHours ─────────────────────────────────────────────────────────
 80  
 81  test('isBusinessHours - within business hours', () => {
 82    const OriginalDate = global.Date;
 83    global.Date = class extends OriginalDate {
 84      constructor(...args) {
 85        if (args.length === 0) {
 86          super('2026-01-27T10:00:00-05:00'); // 10am EST
 87        } else {
 88          super(...args);
 89        }
 90      }
 91    };
 92  
 93    const result = isBusinessHours('America/New_York');
 94    assert.strictEqual(result, true);
 95  
 96    global.Date = OriginalDate;
 97  });
 98  
 99  test('isBusinessHours - outside business hours (too early)', () => {
100    const OriginalDate = global.Date;
101    global.Date = class extends OriginalDate {
102      constructor(...args) {
103        if (args.length === 0) {
104          super('2026-01-27T06:00:00-05:00'); // 6am EST
105        } else {
106          super(...args);
107        }
108      }
109    };
110  
111    const result = isBusinessHours('America/New_York');
112    assert.strictEqual(result, false);
113  
114    global.Date = OriginalDate;
115  });
116  
117  test('isBusinessHours - outside business hours (too late)', () => {
118    const OriginalDate = global.Date;
119    global.Date = class extends OriginalDate {
120      constructor(...args) {
121        if (args.length === 0) {
122          super('2026-01-27T22:00:00-05:00'); // 10pm EST
123        } else {
124          super(...args);
125        }
126      }
127    };
128  
129    const result = isBusinessHours('America/New_York');
130    assert.strictEqual(result, false);
131  
132    global.Date = OriginalDate;
133  });
134  
135  // ─── addOptOut ────────────────────────────────────────────────────────────────
136  
137  test('addOptOut - SMS opt-out', async () => {
138    clearOptOuts();
139  
140    const id = await addOptOut('+61412345678', null, 'sms');
141    assert.ok(id > 0);
142  
143    const optOut = db.prepare('SELECT * FROM opt_outs WHERE phone = ?').get('+61412345678');
144    assert.strictEqual(optOut.phone, '+61412345678');
145    assert.strictEqual(optOut.method, 'sms');
146  });
147  
148  test('addOptOut - Email opt-out', async () => {
149    clearOptOuts();
150  
151    const id = await addOptOut(null, 'test@example.com', 'email');
152    assert.ok(id > 0);
153  
154    const optOut = db.prepare('SELECT * FROM opt_outs WHERE email = ?').get('test@example.com');
155    assert.strictEqual(optOut.email, 'test@example.com');
156    assert.strictEqual(optOut.method, 'email');
157  });
158  
159  // ─── isOptedOut ───────────────────────────────────────────────────────────────
160  
161  test('isOptedOut - SMS opted out', async () => {
162    clearOptOuts();
163  
164    await addOptOut('+61412345678', null, 'sms');
165  
166    const result = await isOptedOut('+61412345678', null, 'sms');
167    assert.strictEqual(result, true);
168  });
169  
170  test('isOptedOut - SMS not opted out', async () => {
171    clearOptOuts();
172  
173    const result = await isOptedOut('+61412345678', null, 'sms');
174    assert.strictEqual(result, false);
175  });
176  
177  // ─── removeOptOut ─────────────────────────────────────────────────────────────
178  
179  test('removeOptOut - SMS re-subscription', async () => {
180    clearOptOuts();
181  
182    await addOptOut('+61412345678', null, 'sms');
183    assert.strictEqual(await isOptedOut('+61412345678', null, 'sms'), true);
184  
185    const removed = await removeOptOut('+61412345678', null, 'sms');
186    assert.strictEqual(removed, true);
187    assert.strictEqual(await isOptedOut('+61412345678', null, 'sms'), false);
188  });
189  
190  // ─── processStopKeyword ───────────────────────────────────────────────────────
191  
192  test('processStopKeyword - STOP keyword', async () => {
193    clearOptOuts();
194  
195    const result = await processStopKeyword('STOP', '+61412345678');
196  
197    assert.strictEqual(result.isOptOutRequest, true);
198    assert.strictEqual(result.optedOut, true);
199  
200    assert.strictEqual(await isOptedOut('+61412345678', null, 'sms'), true);
201  });
202  
203  test('processStopKeyword - STOPALL keyword', async () => {
204    clearOptOuts();
205  
206    const result = await processStopKeyword('STOPALL', '+61412345678');
207  
208    assert.strictEqual(result.isOptOutRequest, true);
209    assert.strictEqual(result.optedOut, true);
210  });
211  
212  test('processStopKeyword - UNSUBSCRIBE keyword', async () => {
213    clearOptOuts();
214  
215    const result = await processStopKeyword('UNSUBSCRIBE', '+61412345678');
216  
217    assert.strictEqual(result.isOptOutRequest, true);
218    assert.strictEqual(result.optedOut, true);
219  });
220  
221  test('processStopKeyword - case insensitive', async () => {
222    clearOptOuts();
223  
224    const result = await processStopKeyword('stop', '+61412345678');
225  
226    assert.strictEqual(result.isOptOutRequest, true);
227    assert.strictEqual(result.optedOut, true);
228  });
229  
230  test('processStopKeyword - not a STOP keyword', async () => {
231    clearOptOuts();
232  
233    const result = await processStopKeyword('Hello, I am interested', '+61412345678');
234  
235    assert.strictEqual(result.isOptOutRequest, false);
236    assert.strictEqual(result.optedOut, false);
237  });
238  
239  // ─── processStartKeyword ──────────────────────────────────────────────────────
240  
241  test('processStartKeyword - START keyword', async () => {
242    clearOptOuts();
243  
244    await addOptOut('+61412345678', null, 'sms');
245  
246    const result = await processStartKeyword('START', '+61412345678');
247  
248    assert.strictEqual(result.isResubscribeRequest, true);
249    assert.strictEqual(result.resubscribed, true);
250  
251    assert.strictEqual(await isOptedOut('+61412345678', null, 'sms'), false);
252  });
253  
254  test('processStartKeyword - UNSTOP keyword', async () => {
255    clearOptOuts();
256  
257    await addOptOut('+61412345678', null, 'sms');
258  
259    const result = await processStartKeyword('UNSTOP', '+61412345678');
260  
261    assert.strictEqual(result.isResubscribeRequest, true);
262    assert.strictEqual(result.resubscribed, true);
263  });
264  
265  // ─── shouldBlockEmail ────────────────────────────────────────────────────────
266  
267  test('shouldBlockEmail - opted out', async () => {
268    clearOptOuts();
269  
270    await addOptOut(null, 'test@example.com', 'email');
271  
272    const result = await shouldBlockEmail('test@example.com');
273  
274    assert.strictEqual(result.blocked, true);
275    assert.strictEqual(result.reason, 'opted_out');
276  });
277  
278  test('shouldBlockEmail - unsubscribed (existing table)', async () => {
279    clearOptOuts();
280  
281    db.prepare('INSERT INTO unsubscribed_emails (email) VALUES (?)').run('test@example.com');
282  
283    const result = await shouldBlockEmail('test@example.com');
284  
285    assert.strictEqual(result.blocked, true);
286    assert.strictEqual(result.reason, 'unsubscribed');
287  });
288  
289  test('shouldBlockEmail - not blocked', async () => {
290    clearOptOuts();
291  
292    const result = await shouldBlockEmail('test@example.com');
293  
294    assert.strictEqual(result.blocked, false);
295  });
296  
297  // ─── shouldBlockSMS ──────────────────────────────────────────────────────────
298  
299  test('shouldBlockSMS - with location-based timezone (Sydney)', async () => {
300    clearOptOuts();
301  
302    db.prepare('INSERT INTO sites (id, city, country_code) VALUES (?, ?, ?)').run(
303      1, 'Sydney', 'AU'
304    );
305  
306    // Use E2E mode to skip business hours check and just test opt-out logic
307    const savedDbPath = process.env.DATABASE_PATH;
308    process.env.DATABASE_PATH = '/tmp/test-e2e-sites.db';
309  
310    const result = await shouldBlockSMS('+61412345678', 1);
311    assert.strictEqual(result.blocked, false);
312  
313    if (savedDbPath !== undefined) {
314      process.env.DATABASE_PATH = savedDbPath;
315    } else {
316      delete process.env.DATABASE_PATH;
317    }
318  });
319  
320  test('shouldBlockSMS - blocked by timezone (Sydney outside hours)', async () => {
321    clearOptOuts();
322  
323    db.prepare('INSERT INTO sites (id, city, country_code) VALUES (?, ?, ?)').run(
324      1, 'Sydney', 'AU'
325    );
326  
327    // Mock time to 10pm AEDT (Sydney time) = outside business hours
328    const OriginalDate = global.Date;
329    global.Date = class extends OriginalDate {
330      constructor(...args) {
331        if (args.length === 0) {
332          super('2026-01-27T22:00:00+11:00'); // 10pm Sydney time
333        } else {
334          super(...args);
335        }
336      }
337    };
338  
339    const result = await shouldBlockSMS('+61412345678', 1);
340    assert.strictEqual(result.blocked, true);
341    assert.strictEqual(result.reason, 'outside_business_hours');
342  
343    global.Date = OriginalDate;
344  });