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