compliance-supplement2.test.js
1 /** 2 * Compliance Supplement 2 Tests 3 * 4 * Covers additional untested paths in src/utils/compliance.js: 5 * - shouldBlockEmail: opted_out path, unsubscribed path, not blocked path 6 * - shouldBlockSMS: outside_business_hours path (non-E2E mode) 7 * - processStopKeyword: all STOP variants, non-keyword messages 8 * - processStartKeyword: START keyword with existing opt-out (resubscribed=true) 9 * - isBusinessHours: valid business hours check 10 * - addOptOut: email-only opt-out 11 * - isOptedOut: email match, phone match, not opted out 12 * - removeOptOut: successful removal 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/strict'; 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 DEFAULT 'US', 43 state TEXT, 44 locale_data TEXT, 45 rescored_at DATETIME 46 ); 47 48 CREATE TABLE unsubscribed_emails ( 49 id INTEGER PRIMARY KEY AUTOINCREMENT, 50 email TEXT NOT NULL UNIQUE COLLATE NOCASE, 51 unsubscribed_at DATETIME DEFAULT CURRENT_TIMESTAMP 52 ); 53 `); 54 55 // ─── Mock db.js BEFORE importing compliance.js ─────────────────────────────── 56 57 mock.module('../../src/utils/db.js', { 58 namedExports: createPgMock(db), 59 }); 60 61 mock.module('../../../mmo-platform/src/suppression.js', { 62 namedExports: { 63 openDb: () => ({ close: () => {} }), 64 addSuppression: () => {}, 65 }, 66 }); 67 68 // Import AFTER mock.module 69 const { 70 isBusinessHours, 71 addOptOut, 72 removeOptOut, 73 isOptedOut, 74 shouldBlockSMS, 75 shouldBlockEmail, 76 processStopKeyword, 77 processStartKeyword, 78 } = await import('../../src/utils/compliance.js'); 79 80 function clearDb() { 81 db.prepare('DELETE FROM opt_outs').run(); 82 db.prepare('DELETE FROM sites').run(); 83 db.prepare('DELETE FROM unsubscribed_emails').run(); 84 } 85 86 // ── isBusinessHours ──────────────────────────────────────────────────────── 87 88 describe('isBusinessHours - valid timezones', () => { 89 test('returns boolean for America/New_York (default)', () => { 90 const result = isBusinessHours('America/New_York'); 91 assert.equal(typeof result, 'boolean'); 92 }); 93 94 test('returns boolean for America/Los_Angeles', () => { 95 const result = isBusinessHours('America/Los_Angeles'); 96 assert.equal(typeof result, 'boolean'); 97 }); 98 99 test('returns boolean for Australia/Sydney', () => { 100 const result = isBusinessHours('Australia/Sydney'); 101 assert.equal(typeof result, 'boolean'); 102 }); 103 104 test('returns boolean for Europe/London', () => { 105 const result = isBusinessHours('Europe/London'); 106 assert.equal(typeof result, 'boolean'); 107 }); 108 109 test('returns false for invalid timezone', () => { 110 assert.equal(isBusinessHours('Not/Real'), false); 111 }); 112 }); 113 114 // ── addOptOut ────────────────────────────────────────────────────────────── 115 116 describe('addOptOut - various scenarios', () => { 117 test('adds phone-only sms opt-out and returns positive id', async () => { 118 clearDb(); 119 const id = await addOptOut('+12125550001', null, 'sms'); 120 assert.ok(typeof id === 'number'); 121 assert.ok(id > 0); 122 }); 123 124 test('adds email-only email opt-out and returns positive id', async () => { 125 clearDb(); 126 const id = await addOptOut(null, 'test@example.com', 'email'); 127 assert.ok(typeof id === 'number'); 128 assert.ok(id > 0); 129 }); 130 131 test('adds both phone and email opt-out', async () => { 132 clearDb(); 133 const id = await addOptOut('+12125550002', 'dual@example.com', 'sms'); 134 assert.ok(id > 0); 135 }); 136 137 test('throws when both phone and email are null', async () => { 138 clearDb(); 139 await assert.rejects( 140 () => addOptOut(null, null, 'sms'), 141 /Must provide phone or email/ 142 ); 143 }); 144 145 test('throws when method is invalid', async () => { 146 clearDb(); 147 await assert.rejects( 148 () => addOptOut('+1234567890', null, 'push'), 149 /Invalid method/ 150 ); 151 }); 152 153 test('second insert for same phone+method triggers upsert (ON CONFLICT path)', async () => { 154 clearDb(); 155 const id1 = await addOptOut('+19999999999', null, 'sms'); 156 const id2 = await addOptOut('+19999999999', null, 'sms'); 157 assert.ok(typeof id2 === 'number'); 158 }); 159 }); 160 161 // ── isOptedOut ───────────────────────────────────────────────────────────── 162 163 describe('isOptedOut - various scenarios', () => { 164 test('returns false when not in opt-out list (phone)', async () => { 165 clearDb(); 166 assert.equal(await isOptedOut('+10000000000', null, 'sms'), false); 167 }); 168 169 test('returns true when phone is in opt-out list', async () => { 170 clearDb(); 171 await addOptOut('+15559999999', null, 'sms'); 172 assert.equal(await isOptedOut('+15559999999', null, 'sms'), true); 173 }); 174 175 test('returns false when phone opted out for different method', async () => { 176 clearDb(); 177 await addOptOut('+15558888888', null, 'sms'); 178 assert.equal(await isOptedOut('+15558888888', null, 'email'), false); 179 }); 180 181 test('returns true when email is in opt-out list', async () => { 182 clearDb(); 183 await addOptOut(null, 'opted@example.com', 'email'); 184 assert.equal(await isOptedOut(null, 'opted@example.com', 'email'), true); 185 }); 186 187 test('returns false when both phone and email are null', async () => { 188 clearDb(); 189 assert.equal(await isOptedOut(null, null, 'sms'), false); 190 }); 191 }); 192 193 // ── removeOptOut ─────────────────────────────────────────────────────────── 194 195 describe('removeOptOut - success and failure paths', () => { 196 test('returns true when phone successfully removed', async () => { 197 clearDb(); 198 await addOptOut('+17771234567', null, 'sms'); 199 const removed = await removeOptOut('+17771234567', null, 'sms'); 200 assert.equal(removed, true); 201 }); 202 203 test('returns true when email successfully removed', async () => { 204 clearDb(); 205 await addOptOut(null, 'remove@example.com', 'email'); 206 const removed = await removeOptOut(null, 'remove@example.com', 'email'); 207 assert.equal(removed, true); 208 }); 209 210 test('returns false when phone not found in list', async () => { 211 clearDb(); 212 assert.equal(await removeOptOut('+10000000001', null, 'sms'), false); 213 }); 214 215 test('throws when both phone and email are null', async () => { 216 clearDb(); 217 await assert.rejects( 218 () => removeOptOut(null, null, 'email'), 219 /Must provide phone or email/ 220 ); 221 }); 222 223 test('removal means subsequent isOptedOut returns false', async () => { 224 clearDb(); 225 await addOptOut('+16660000001', null, 'sms'); 226 assert.equal(await isOptedOut('+16660000001', null, 'sms'), true); 227 await removeOptOut('+16660000001', null, 'sms'); 228 assert.equal(await isOptedOut('+16660000001', null, 'sms'), false); 229 }); 230 }); 231 232 // ── shouldBlockEmail ─────────────────────────────────────────────────────── 233 234 describe('shouldBlockEmail', () => { 235 test('returns blocked:false for non-opted-out email', async () => { 236 clearDb(); 237 const result = await shouldBlockEmail('new@example.com'); 238 assert.equal(result.blocked, false); 239 }); 240 241 test('returns blocked:true with reason opted_out when email is in opt_outs', async () => { 242 clearDb(); 243 await addOptOut(null, 'blocked@example.com', 'email'); 244 const result = await shouldBlockEmail('blocked@example.com'); 245 assert.equal(result.blocked, true); 246 assert.equal(result.reason, 'opted_out'); 247 }); 248 249 test('returns blocked:true with reason unsubscribed when in unsubscribed_emails', async () => { 250 clearDb(); 251 db.prepare('INSERT INTO unsubscribed_emails (email) VALUES (?)').run('unsub@example.com'); 252 const result = await shouldBlockEmail('unsub@example.com'); 253 assert.equal(result.blocked, true); 254 assert.equal(result.reason, 'unsubscribed'); 255 }); 256 257 test('opt_out check takes precedence over unsubscribed check', async () => { 258 clearDb(); 259 await addOptOut(null, 'both@example.com', 'email'); 260 db.prepare('INSERT INTO unsubscribed_emails (email) VALUES (?)').run('both@example.com'); 261 const result = await shouldBlockEmail('both@example.com'); 262 assert.equal(result.blocked, true); 263 assert.equal(result.reason, 'opted_out'); 264 }); 265 266 test('unsubscribed_emails check is case-insensitive', async () => { 267 clearDb(); 268 db.prepare('INSERT INTO unsubscribed_emails (email) VALUES (?)').run( 269 'CaseSensitive@Example.COM' 270 ); 271 const result = await shouldBlockEmail('casesensitive@example.com'); 272 assert.equal(result.blocked, true); 273 assert.equal(result.reason, 'unsubscribed'); 274 }); 275 }); 276 277 // ── shouldBlockSMS ───────────────────────────────────────────────────────── 278 279 describe('shouldBlockSMS - business hours check', () => { 280 let savedDbPath; 281 282 beforeEach(() => { 283 savedDbPath = process.env.DATABASE_PATH; 284 }); 285 286 test('returns blocked:false for non-opted-out phone in E2E mode', async () => { 287 clearDb(); 288 process.env.DATABASE_PATH = '/tmp/test-e2e-compliance.db'; 289 const result = await shouldBlockSMS('+19998887776', 1); 290 assert.equal(result.blocked, false); 291 if (savedDbPath !== undefined) { 292 process.env.DATABASE_PATH = savedDbPath; 293 } else { 294 delete process.env.DATABASE_PATH; 295 } 296 }); 297 298 test('returns blocked:true reason opted_out in E2E mode when phone is opted out', async () => { 299 clearDb(); 300 process.env.DATABASE_PATH = '/tmp/test-e2e-compliance.db'; 301 await addOptOut('+19998887775', null, 'sms'); 302 const result = await shouldBlockSMS('+19998887775', 1); 303 assert.equal(result.blocked, true); 304 assert.equal(result.reason, 'opted_out'); 305 if (savedDbPath !== undefined) { 306 process.env.DATABASE_PATH = savedDbPath; 307 } else { 308 delete process.env.DATABASE_PATH; 309 } 310 }); 311 }); 312 313 // ── processStopKeyword ───────────────────────────────────────────────────── 314 315 describe('processStopKeyword', () => { 316 test('recognizes STOP keyword', async () => { 317 clearDb(); 318 const result = await processStopKeyword('STOP', '+12125550100'); 319 assert.equal(result.isOptOutRequest, true); 320 assert.equal(result.optedOut, true); 321 }); 322 323 test('recognizes STOPALL keyword', async () => { 324 clearDb(); 325 const result = await processStopKeyword('STOPALL', '+12125550101'); 326 assert.equal(result.isOptOutRequest, true); 327 assert.equal(result.optedOut, true); 328 }); 329 330 test('recognizes UNSUBSCRIBE keyword', async () => { 331 clearDb(); 332 const result = await processStopKeyword('UNSUBSCRIBE', '+12125550102'); 333 assert.equal(result.isOptOutRequest, true); 334 assert.equal(result.optedOut, true); 335 }); 336 337 test('recognizes CANCEL keyword', async () => { 338 clearDb(); 339 const result = await processStopKeyword('CANCEL', '+12125550103'); 340 assert.equal(result.isOptOutRequest, true); 341 assert.equal(result.optedOut, true); 342 }); 343 344 test('recognizes END keyword', async () => { 345 clearDb(); 346 const result = await processStopKeyword('END', '+12125550104'); 347 assert.equal(result.isOptOutRequest, true); 348 assert.equal(result.optedOut, true); 349 }); 350 351 test('recognizes QUIT keyword', async () => { 352 clearDb(); 353 const result = await processStopKeyword('QUIT', '+12125550105'); 354 assert.equal(result.isOptOutRequest, true); 355 assert.equal(result.optedOut, true); 356 }); 357 358 test('is case-insensitive (lowercase stop)', async () => { 359 clearDb(); 360 const result = await processStopKeyword('stop', '+12125550106'); 361 assert.equal(result.isOptOutRequest, true); 362 assert.equal(result.optedOut, true); 363 }); 364 365 test('handles leading/trailing whitespace', async () => { 366 clearDb(); 367 const result = await processStopKeyword(' STOP ', '+12125550107'); 368 assert.equal(result.isOptOutRequest, true); 369 assert.equal(result.optedOut, true); 370 }); 371 372 test('does not recognize partial match (STOPHERE is not STOP)', async () => { 373 clearDb(); 374 const result = await processStopKeyword('STOPHERE', '+12125550108'); 375 assert.equal(result.isOptOutRequest, false); 376 assert.equal(result.optedOut, false); 377 }); 378 379 test('returns isOptOutRequest:false for regular message', async () => { 380 clearDb(); 381 const result = await processStopKeyword('Hi, I am interested', '+12125550109'); 382 assert.equal(result.isOptOutRequest, false); 383 assert.equal(result.optedOut, false); 384 }); 385 386 test('phone is added to opt-out list after STOP keyword', async () => { 387 clearDb(); 388 await processStopKeyword('STOP', '+12125550110'); 389 assert.equal(await isOptedOut('+12125550110', null, 'sms'), true); 390 }); 391 }); 392 393 // ── processStartKeyword ──────────────────────────────────────────────────── 394 395 describe('processStartKeyword', () => { 396 test('recognizes START keyword and removes existing opt-out', async () => { 397 clearDb(); 398 await addOptOut('+12125550200', null, 'sms'); 399 const result = await processStartKeyword('START', '+12125550200'); 400 assert.equal(result.isResubscribeRequest, true); 401 assert.equal(result.resubscribed, true); 402 assert.equal(await isOptedOut('+12125550200', null, 'sms'), false); 403 }); 404 405 test('recognizes UNSTOP keyword and removes existing opt-out', async () => { 406 clearDb(); 407 await addOptOut('+12125550201', null, 'sms'); 408 const result = await processStartKeyword('UNSTOP', '+12125550201'); 409 assert.equal(result.isResubscribeRequest, true); 410 assert.equal(result.resubscribed, true); 411 }); 412 413 test('returns resubscribed:false when phone was not opted out', async () => { 414 clearDb(); 415 const result = await processStartKeyword('START', '+12125550202'); 416 assert.equal(result.isResubscribeRequest, true); 417 assert.equal(result.resubscribed, false); 418 }); 419 420 test('is case-insensitive (lowercase start)', async () => { 421 clearDb(); 422 await addOptOut('+12125550203', null, 'sms'); 423 const result = await processStartKeyword('start', '+12125550203'); 424 assert.equal(result.isResubscribeRequest, true); 425 }); 426 427 test('returns isResubscribeRequest:false for non-start keyword', async () => { 428 clearDb(); 429 const result = await processStartKeyword('Hello there', '+12125550204'); 430 assert.equal(result.isResubscribeRequest, false); 431 assert.equal(result.resubscribed, false); 432 }); 433 434 test('returns isResubscribeRequest:false for empty message', async () => { 435 clearDb(); 436 const result = await processStartKeyword('', '+12125550205'); 437 assert.equal(result.isResubscribeRequest, false); 438 assert.equal(result.resubscribed, false); 439 }); 440 });