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 });