/ tests / utils / compliance.test.js
compliance.test.js
  1  /**
  2   * Tests for src/utils/compliance.js
  3   *
  4   * Covers: isBusinessHours, addOptOut, isOptedOut, removeOptOut,
  5   * shouldBlockSMS, shouldBlockEmail, processStopKeyword, processStartKeyword.
  6   */
  7  
  8  import { test, describe, before, mock } from 'node:test';
  9  import assert from 'node:assert/strict';
 10  import Database from 'better-sqlite3';
 11  import { createPgMock } from '../helpers/pg-mock.js';
 12  
 13  const db = new Database(':memory:');
 14  db.exec(`
 15    CREATE TABLE IF NOT EXISTS opt_outs (
 16      id INTEGER PRIMARY KEY AUTOINCREMENT,
 17      phone TEXT,
 18      email TEXT,
 19      method TEXT NOT NULL,
 20      opted_out_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 21      UNIQUE(phone, method),
 22      UNIQUE(email, method)
 23    );
 24  
 25    CREATE TABLE IF NOT EXISTS unsubscribed_emails (
 26      id INTEGER PRIMARY KEY AUTOINCREMENT,
 27      email TEXT NOT NULL UNIQUE,
 28      unsubscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP
 29    );
 30  
 31    CREATE TABLE IF NOT EXISTS sites (
 32      id INTEGER PRIMARY KEY AUTOINCREMENT,
 33      domain TEXT NOT NULL,
 34      country_code TEXT DEFAULT 'AU',
 35      state TEXT,
 36      city TEXT,
 37      rescored_at DATETIME
 38    );
 39  `);
 40  
 41  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
 42  
 43  const {
 44    isBusinessHours,
 45    addOptOut,
 46    isOptedOut,
 47    removeOptOut,
 48    shouldBlockSMS,
 49    shouldBlockEmail,
 50    processStopKeyword,
 51    processStartKeyword,
 52  } = await import('../../src/utils/compliance.js');
 53  
 54  function clearOptOuts() {
 55    db.exec('DELETE FROM opt_outs');
 56  }
 57  
 58  // ─── isBusinessHours ──────────────────────────────────────────────────────────
 59  
 60  describe('isBusinessHours', () => {
 61    test('returns boolean', () => {
 62      const result = isBusinessHours('America/New_York');
 63      assert.ok(typeof result === 'boolean');
 64    });
 65  
 66    test('returns false for invalid timezone', () => {
 67      const result = isBusinessHours('Not/A/Timezone');
 68      assert.equal(result, false);
 69    });
 70  
 71    test('uses America/New_York as default', () => {
 72      const result = isBusinessHours();
 73      assert.ok(typeof result === 'boolean');
 74    });
 75  
 76    test('accepts IANA timezone strings', () => {
 77      for (const tz of ['Australia/Sydney', 'Europe/London', 'America/Los_Angeles']) {
 78        assert.ok(typeof isBusinessHours(tz) === 'boolean', `Failed for ${tz}`);
 79      }
 80    });
 81  });
 82  
 83  // ─── addOptOut ────────────────────────────────────────────────────────────────
 84  
 85  describe('addOptOut', () => {
 86    test('throws if neither phone nor email provided', async () => {
 87      await assert.rejects(() => addOptOut(null, null, 'sms'), /Must provide phone or email/);
 88    });
 89  
 90    test('throws for invalid method', async () => {
 91      await assert.rejects(() => addOptOut('+61412345678', null, 'fax'), /Invalid method/);
 92    });
 93  
 94    test('inserts phone opt-out and returns numeric id', async () => {
 95      clearOptOuts();
 96      const id = await addOptOut('+61412345678', null, 'sms');
 97      assert.ok(typeof id === 'number');
 98      assert.ok(id > 0);
 99    });
100  
101    test('inserts email opt-out', async () => {
102      clearOptOuts();
103      const id = await addOptOut(null, 'test@example.com', 'email');
104      assert.ok(id > 0);
105    });
106  
107    test('upserts on duplicate phone + method (no error)', async () => {
108      clearOptOuts();
109      const id1 = await addOptOut('+61412345678', null, 'sms');
110      const id2 = await addOptOut('+61412345678', null, 'sms');
111      assert.ok(typeof id1 === 'number');
112      assert.ok(typeof id2 === 'number');
113    });
114  
115    test('accepts both phone and email', async () => {
116      clearOptOuts();
117      const id = await addOptOut('+61412345678', 'test@example.com', 'sms');
118      assert.ok(id > 0);
119    });
120  });
121  
122  // ─── isOptedOut ───────────────────────────────────────────────────────────────
123  
124  describe('isOptedOut', () => {
125    test('returns false when neither phone nor email provided', async () => {
126      assert.equal(await isOptedOut(null, null, 'sms'), false);
127    });
128  
129    test('returns false when phone not opted out', async () => {
130      clearOptOuts();
131      assert.equal(await isOptedOut('+61400000000', null, 'sms'), false);
132    });
133  
134    test('returns true when phone is opted out', async () => {
135      clearOptOuts();
136      await addOptOut('+61412345678', null, 'sms');
137      assert.equal(await isOptedOut('+61412345678', null, 'sms'), true);
138    });
139  
140    test('returns false when opted out for different method', async () => {
141      clearOptOuts();
142      await addOptOut('+61412345678', null, 'sms');
143      assert.equal(await isOptedOut('+61412345678', null, 'email'), false);
144    });
145  
146    test('returns true when email is opted out', async () => {
147      clearOptOuts();
148      await addOptOut(null, 'blocked@example.com', 'email');
149      assert.equal(await isOptedOut(null, 'blocked@example.com', 'email'), true);
150    });
151  
152    test('returns false for different email', async () => {
153      clearOptOuts();
154      await addOptOut(null, 'blocked@example.com', 'email');
155      assert.equal(await isOptedOut(null, 'other@example.com', 'email'), false);
156    });
157  });
158  
159  // ─── removeOptOut ─────────────────────────────────────────────────────────────
160  
161  describe('removeOptOut', () => {
162    test('throws if neither phone nor email provided', async () => {
163      await assert.rejects(() => removeOptOut(null, null, 'sms'), /Must provide phone or email/);
164    });
165  
166    test('returns false when opt-out does not exist', async () => {
167      clearOptOuts();
168      const removed = await removeOptOut('+61499999999', null, 'sms');
169      assert.equal(removed, false);
170    });
171  
172    test('returns true and removes existing opt-out', async () => {
173      clearOptOuts();
174      await addOptOut('+61412345678', null, 'sms');
175      const removed = await removeOptOut('+61412345678', null, 'sms');
176      assert.equal(removed, true);
177      assert.equal(await isOptedOut('+61412345678', null, 'sms'), false);
178    });
179  
180    test('removes email opt-out', async () => {
181      clearOptOuts();
182      await addOptOut(null, 'test@example.com', 'email');
183      const removed = await removeOptOut(null, 'test@example.com', 'email');
184      assert.equal(removed, true);
185    });
186  });
187  
188  // ─── shouldBlockSMS ───────────────────────────────────────────────────────────
189  
190  describe('shouldBlockSMS', () => {
191    test('blocks opted-out phone', async () => {
192      clearOptOuts();
193      await addOptOut('+61412345678', null, 'sms');
194      // Set E2E flag so business hours check is skipped
195      const origPath = process.env.DATABASE_PATH;
196      process.env.DATABASE_PATH = '/tmp/test-e2e/test.db';
197      const result = await shouldBlockSMS('+61412345678', 1);
198      process.env.DATABASE_PATH = origPath;
199      assert.equal(result.blocked, true);
200      assert.equal(result.reason, 'opted_out');
201    });
202  
203    test('does not block fresh phone', async () => {
204      clearOptOuts();
205      // Set E2E flag so business hours check is skipped (test must be time-independent)
206      const origPath = process.env.DATABASE_PATH;
207      process.env.DATABASE_PATH = '/tmp/test-e2e/test.db';
208      const result = await shouldBlockSMS('+61499000099', 1);
209      process.env.DATABASE_PATH = origPath;
210      assert.equal(result.blocked, false);
211    });
212  });
213  
214  // ─── shouldBlockEmail ─────────────────────────────────────────────────────────
215  
216  describe('shouldBlockEmail', () => {
217    test('returns not-blocked for fresh email', async () => {
218      clearOptOuts();
219      db.exec('DELETE FROM unsubscribed_emails');
220      const result = await shouldBlockEmail('fresh@example.com');
221      assert.equal(result.blocked, false);
222    });
223  
224    test('blocks opted-out email', async () => {
225      clearOptOuts();
226      await addOptOut(null, 'blocked@example.com', 'email');
227      const result = await shouldBlockEmail('blocked@example.com');
228      assert.equal(result.blocked, true);
229      assert.equal(result.reason, 'opted_out');
230    });
231  
232    test('blocks unsubscribed email', async () => {
233      clearOptOuts();
234      db.exec('DELETE FROM unsubscribed_emails');
235      db.prepare('INSERT INTO unsubscribed_emails (email) VALUES (?)').run('unsub@example.com');
236      const result = await shouldBlockEmail('unsub@example.com');
237      assert.equal(result.blocked, true);
238      assert.equal(result.reason, 'unsubscribed');
239    });
240  
241    test('unsubscribed check is case-insensitive', async () => {
242      db.exec('DELETE FROM unsubscribed_emails');
243      db.prepare('INSERT INTO unsubscribed_emails (email) VALUES (?)').run('CaseTest@Example.COM');
244      const result = await shouldBlockEmail('casetest@example.com');
245      assert.equal(result.blocked, true);
246      assert.equal(result.reason, 'unsubscribed');
247    });
248  });
249  
250  // ─── processStopKeyword ───────────────────────────────────────────────────────
251  
252  describe('processStopKeyword', () => {
253    test('detects STOP keyword', async () => {
254      clearOptOuts();
255      const result = await processStopKeyword('STOP', '+61412345678');
256      assert.equal(result.isOptOutRequest, true);
257      assert.equal(result.optedOut, true);
258    });
259  
260    test('detects STOPALL keyword', async () => {
261      clearOptOuts();
262      const result = await processStopKeyword('STOPALL', '+61412345678');
263      assert.equal(result.isOptOutRequest, true);
264    });
265  
266    test('detects UNSUBSCRIBE keyword', async () => {
267      clearOptOuts();
268      const result = await processStopKeyword('UNSUBSCRIBE', '+61412345678');
269      assert.equal(result.isOptOutRequest, true);
270    });
271  
272    test('detects CANCEL keyword', async () => {
273      clearOptOuts();
274      const result = await processStopKeyword('CANCEL', '+61412345678');
275      assert.equal(result.isOptOutRequest, true);
276    });
277  
278    test('detects END keyword', async () => {
279      clearOptOuts();
280      const result = await processStopKeyword('END', '+61412345678');
281      assert.equal(result.isOptOutRequest, true);
282    });
283  
284    test('detects QUIT keyword', async () => {
285      clearOptOuts();
286      const result = await processStopKeyword('QUIT', '+61412345678');
287      assert.equal(result.isOptOutRequest, true);
288    });
289  
290    test('is case-insensitive (trims and uppercases)', async () => {
291      clearOptOuts();
292      const result = await processStopKeyword('  stop  ', '+61412345678');
293      assert.equal(result.isOptOutRequest, true);
294    });
295  
296    test('does not trigger for non-stop message', async () => {
297      const result = await processStopKeyword('Hello there', '+61412345678');
298      assert.equal(result.isOptOutRequest, false);
299      assert.equal(result.optedOut, false);
300    });
301  
302    test('does not trigger for partial STOP match', async () => {
303      const result = await processStopKeyword('please STOP sending', '+61412345678');
304      assert.equal(result.isOptOutRequest, false);
305    });
306  
307    test('adds phone to opt-out list on STOP', async () => {
308      clearOptOuts();
309      await processStopKeyword('STOP', '+61499000001');
310      assert.equal(await isOptedOut('+61499000001', null, 'sms'), true);
311    });
312  });
313  
314  // ─── processStartKeyword ──────────────────────────────────────────────────────
315  
316  describe('processStartKeyword', () => {
317    test('detects START keyword and re-subscribes', async () => {
318      clearOptOuts();
319      await addOptOut('+61412345678', null, 'sms');
320      const result = await processStartKeyword('START', '+61412345678');
321      assert.equal(result.isResubscribeRequest, true);
322      assert.equal(result.resubscribed, true);
323      assert.equal(await isOptedOut('+61412345678', null, 'sms'), false);
324    });
325  
326    test('detects UNSTOP keyword', async () => {
327      clearOptOuts();
328      await addOptOut('+61412345678', null, 'sms');
329      const result = await processStartKeyword('UNSTOP', '+61412345678');
330      assert.equal(result.isResubscribeRequest, true);
331      assert.equal(result.resubscribed, true);
332    });
333  
334    test('returns resubscribed=false when not opted out', async () => {
335      clearOptOuts();
336      const result = await processStartKeyword('START', '+61412345678');
337      assert.equal(result.isResubscribeRequest, true);
338      assert.equal(result.resubscribed, false);
339    });
340  
341    test('does not trigger for non-start message', async () => {
342      const result = await processStartKeyword('Yes please', '+61412345678');
343      assert.equal(result.isResubscribeRequest, false);
344      assert.equal(result.resubscribed, false);
345    });
346  
347    test('is case-insensitive', async () => {
348      clearOptOuts();
349      await addOptOut('+61412345678', null, 'sms');
350      const result = await processStartKeyword('  start  ', '+61412345678');
351      assert.equal(result.isResubscribeRequest, true);
352    });
353  });