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