/ tests / compliance / compliance-supplement.test.js
compliance-supplement.test.js
  1  /**
  2   * Compliance Supplement Tests
  3   * Covers uncovered lines in src/utils/compliance.js:
  4   *   - isOptedOut early return when both phone and email are null
  5   *   - shouldBlockSMS E2E test mode branch (DATABASE_PATH includes 'test-e2e')
  6   *   - shouldBlockSMS opted_out path
  7   *   - removeOptOut returns false when record doesn't exist
  8   *   - processStartKeyword returns false when not a start keyword
  9   *   - isBusinessHours with invalid timezone (error branch → returns false)
 10   *   - addOptOut throws on missing phone AND email
 11   *   - addOptOut throws on invalid method
 12   *   - addOptOut handles UNIQUE conflict (already opted out) path
 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';
 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,
 43      rescored_at DATETIME
 44    );
 45  
 46    CREATE TABLE unsubscribed_emails (
 47      id INTEGER PRIMARY KEY AUTOINCREMENT,
 48      email TEXT NOT NULL UNIQUE COLLATE NOCASE,
 49      unsubscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP
 50    );
 51  `);
 52  
 53  // ─── Mock db.js BEFORE importing compliance.js ───────────────────────────────
 54  
 55  mock.module('../../src/utils/db.js', {
 56    namedExports: createPgMock(db),
 57  });
 58  
 59  mock.module('../../../mmo-platform/src/suppression.js', {
 60    namedExports: {
 61      openDb: () => ({ close: () => {} }),
 62      addSuppression: () => {},
 63    },
 64  });
 65  
 66  // Import AFTER mock.module
 67  const {
 68    isBusinessHours,
 69    addOptOut,
 70    removeOptOut,
 71    isOptedOut,
 72    shouldBlockSMS,
 73    processStartKeyword,
 74  } = await import('../../src/utils/compliance.js');
 75  
 76  function clearDb() {
 77    db.prepare('DELETE FROM opt_outs').run();
 78    db.prepare('DELETE FROM sites').run();
 79    db.prepare('DELETE FROM unsubscribed_emails').run();
 80  }
 81  
 82  // ─── isBusinessHours – error branch ──────────────────────────────────────────
 83  
 84  describe('isBusinessHours - invalid timezone', () => {
 85    test('returns false for invalid timezone (error branch)', () => {
 86      const result = isBusinessHours('Not/A/Valid/Timezone');
 87      assert.strictEqual(result, false);
 88    });
 89  
 90    test('returns false for empty string timezone', () => {
 91      const result = isBusinessHours('');
 92      assert.strictEqual(result, false);
 93    });
 94  });
 95  
 96  // ─── isOptedOut – both null early return ─────────────────────────────────────
 97  
 98  describe('isOptedOut - both null', () => {
 99    test('returns false immediately when both phone and email are null', async () => {
100      clearDb();
101      const result = await isOptedOut(null, null, 'sms');
102      assert.strictEqual(result, false);
103    });
104  
105    test('returns false immediately when both phone and email are undefined', async () => {
106      clearDb();
107      const result = await isOptedOut(undefined, undefined, 'email');
108      assert.strictEqual(result, false);
109    });
110  });
111  
112  // ─── addOptOut – validation errors ───────────────────────────────────────────
113  
114  describe('addOptOut - validation', () => {
115    test('throws when both phone and email are null', async () => {
116      clearDb();
117      await assert.rejects(
118        () => addOptOut(null, null, 'sms'),
119        /Must provide phone or email/
120      );
121    });
122  
123    test('throws when method is invalid', async () => {
124      clearDb();
125      await assert.rejects(
126        () => addOptOut('+61412345678', null, 'fax'),
127        /Invalid method: fax/
128      );
129    });
130  });
131  
132  // ─── addOptOut – UNIQUE conflict path ─────────────────────────────────────────
133  
134  describe('addOptOut - UNIQUE conflict', () => {
135    test('calling addOptOut twice for the same phone+method does not throw', async () => {
136      clearDb();
137  
138      const id1 = await addOptOut('+61412345678', null, 'sms');
139      assert.ok(id1 > 0);
140  
141      // Second insert — ON CONFLICT DO UPDATE in PG translates to upsert in SQLite
142      const id2 = await addOptOut('+61412345678', null, 'sms');
143      assert.equal(typeof id2, 'number');
144    });
145  });
146  
147  // ─── removeOptOut – false return ─────────────────────────────────────────────
148  
149  describe('removeOptOut - not found', () => {
150    test('returns false when phone is not in opt-out list', async () => {
151      clearDb();
152      const removed = await removeOptOut('+61400000000', null, 'sms');
153      assert.strictEqual(removed, false);
154    });
155  
156    test('returns false when email is not in opt-out list', async () => {
157      clearDb();
158      const removed = await removeOptOut(null, 'nothere@example.com', 'email');
159      assert.strictEqual(removed, false);
160    });
161  
162    test('throws when both phone and email are null', async () => {
163      clearDb();
164      await assert.rejects(
165        () => removeOptOut(null, null, 'sms'),
166        /Must provide phone or email/
167      );
168    });
169  });
170  
171  // ─── shouldBlockSMS – opted_out path ────────────────────────────────────────
172  
173  describe('shouldBlockSMS - opted_out path', () => {
174    test('returns blocked=true with reason opted_out when phone is in opt-out list', async () => {
175      clearDb();
176      await addOptOut('+61412345678', null, 'sms');
177      const result = await shouldBlockSMS('+61412345678', 1);
178      assert.strictEqual(result.blocked, true);
179      assert.strictEqual(result.reason, 'opted_out');
180    });
181  });
182  
183  // ─── shouldBlockSMS – E2E test mode ────────────────────────────────────────
184  
185  describe('shouldBlockSMS - E2E mode', () => {
186    let savedDbPath;
187  
188    beforeEach(() => {
189      savedDbPath = process.env.DATABASE_PATH;
190    });
191  
192    // afterEach not available in this scope — cleanup in each test
193    test('skips business hours check when DATABASE_PATH includes test-e2e', async () => {
194      clearDb();
195  
196      process.env.DATABASE_PATH = '/tmp/test-e2e-sites.db';
197  
198      // No opt-out inserted, E2E mode skips business-hours check
199      const result = await shouldBlockSMS('+61400000000', 999);
200      assert.strictEqual(result.blocked, false);
201  
202      if (savedDbPath !== undefined) {
203        process.env.DATABASE_PATH = savedDbPath;
204      } else {
205        delete process.env.DATABASE_PATH;
206      }
207    });
208  });
209  
210  // ─── processStartKeyword – not a start keyword ───────────────────────────────
211  
212  describe('processStartKeyword - not a start keyword', () => {
213    test('returns isResubscribeRequest=false for a regular message', async () => {
214      clearDb();
215      const result = await processStartKeyword('Hello, how are you?', '+61412345678');
216      assert.strictEqual(result.isResubscribeRequest, false);
217      assert.strictEqual(result.resubscribed, false);
218    });
219  
220    test('returns isResubscribeRequest=false for "STOP" (not a start keyword)', async () => {
221      clearDb();
222      const result = await processStartKeyword('STOP', '+61412345678');
223      assert.strictEqual(result.isResubscribeRequest, false);
224      assert.strictEqual(result.resubscribed, false);
225    });
226  
227    test('returns isResubscribeRequest=false for whitespace-only message', async () => {
228      clearDb();
229      const result = await processStartKeyword('   ', '+61412345678');
230      assert.strictEqual(result.isResubscribeRequest, false);
231      assert.strictEqual(result.resubscribed, false);
232    });
233  
234    test('START keyword with opt-out not present returns resubscribed=false', async () => {
235      clearDb();
236      // Phone is NOT in opt-out list, so removeOptOut returns false → resubscribed=false
237      const result = await processStartKeyword('START', '+61499999999');
238      assert.strictEqual(result.isResubscribeRequest, true);
239      assert.strictEqual(result.resubscribed, false);
240    });
241  
242    test('UNSTOP keyword works and removes opt-out entry', async () => {
243      clearDb();
244      await addOptOut('+61412345678', null, 'sms');
245      const result = await processStartKeyword('UNSTOP', '+61412345678');
246      assert.strictEqual(result.isResubscribeRequest, true);
247      assert.strictEqual(result.resubscribed, true);
248      assert.strictEqual(await isOptedOut('+61412345678', null, 'sms'), false);
249    });
250  });