tcpa-timezone.test.js
1 /** 2 * P2b: TCPA Timezone Business Hours Tests 3 * 4 * Security/compliance tests verifying that outbound SMS/calls are blocked 5 * outside the 8am-9pm window in the recipient's local timezone. Uses 6 * isBusinessHours() from compliance.js and detectTimezone() from 7 * timezone-detector.js. 8 * 9 * Uses mock.module() to intercept db.js imports for shouldBlockSMS. 10 * getSiteTimezone() from timezone-detector.js still accepts a db parameter 11 * directly and is tested with a real SQLite db. 12 */ 13 14 import { test, describe, mock } from 'node:test'; 15 import assert from 'node:assert/strict'; 16 import Database from 'better-sqlite3'; 17 import { createPgMock } from '../helpers/pg-mock.js'; 18 19 // ─── Create shared in-memory test DB ───────────────────────────────────────── 20 21 const db = new Database(':memory:'); 22 23 db.pragma('journal_mode = WAL'); 24 db.exec(` 25 CREATE TABLE IF NOT EXISTS opt_outs ( 26 id INTEGER PRIMARY KEY AUTOINCREMENT, 27 phone TEXT, 28 email TEXT, 29 method TEXT NOT NULL, 30 opted_out_at DATETIME DEFAULT CURRENT_TIMESTAMP, 31 UNIQUE(phone, method), 32 UNIQUE(email, method) 33 ); 34 35 CREATE TABLE IF NOT EXISTS sites ( 36 id INTEGER PRIMARY KEY AUTOINCREMENT, 37 domain TEXT NOT NULL, 38 city TEXT, 39 country_code TEXT DEFAULT 'US', 40 state TEXT, 41 rescored_at DATETIME 42 ); 43 44 CREATE TABLE IF NOT EXISTS messages ( 45 id INTEGER PRIMARY KEY AUTOINCREMENT, 46 site_id INTEGER REFERENCES sites(id), 47 direction TEXT, 48 contact_method TEXT, 49 contact_uri TEXT, 50 message_body TEXT, 51 delivery_status TEXT, 52 message_type TEXT DEFAULT 'outreach', 53 raw_payload TEXT, 54 created_at TEXT DEFAULT (datetime('now')), 55 read_at TEXT 56 ); 57 `); 58 59 // ─── Mock db.js BEFORE importing compliance.js ─────────────────────────────── 60 61 mock.module('../../src/utils/db.js', { 62 namedExports: createPgMock(db), 63 }); 64 65 mock.module('../../../mmo-platform/src/suppression.js', { 66 namedExports: { 67 openDb: () => ({ close: () => {} }), 68 addSuppression: () => {}, 69 }, 70 }); 71 72 // Import AFTER mock.module 73 const { isBusinessHours, shouldBlockSMS } = await import('../../src/utils/compliance.js'); 74 const { detectTimezone, getSiteTimezone } = await import('../../src/utils/timezone-detector.js'); 75 76 // ─── Helpers ───────────────────────────────────────────────────────────────── 77 78 /** 79 * Get the current hour in a given IANA timezone. 80 */ 81 function currentHourIn(timezone) { 82 const formatter = new Intl.DateTimeFormat('en-US', { 83 timeZone: timezone, 84 hour: 'numeric', 85 hour12: false, 86 }); 87 return parseInt(formatter.format(new Date()), 10); 88 } 89 90 /** 91 * Determine if the current wall clock in a timezone is within 8-20 (inclusive). 92 */ 93 function isCurrentlyBusinessHours(timezone) { 94 const hour = currentHourIn(timezone); 95 return isWithinBusinessRange(hour); 96 } 97 98 function isWithinBusinessRange(hour) { 99 return hour >= 8 && hour < 21; 100 } 101 102 // Helper: set E2E mode to skip business hours during shouldBlockSMS calls 103 async function shouldBlockSMSE2E(phone, siteId) { 104 const saved = process.env.DATABASE_PATH; 105 process.env.DATABASE_PATH = '/tmp/test-e2e-tcpa-tz.db'; 106 const result = await shouldBlockSMS(phone, siteId); 107 if (saved !== undefined) { 108 process.env.DATABASE_PATH = saved; 109 } else { 110 delete process.env.DATABASE_PATH; 111 } 112 return result; 113 } 114 115 // ─── P2b-1: US Pacific timezone ───────────────────────────────────────────── 116 117 describe('TCPA: US Pacific timezone', () => { 118 test('isBusinessHours for America/Los_Angeles matches current wall clock', () => { 119 const tz = 'America/Los_Angeles'; 120 const expected = isCurrentlyBusinessHours(tz); 121 assert.equal(isBusinessHours(tz), expected, 122 `At ${currentHourIn(tz)}:00 PT, business hours should be ${expected}`); 123 }); 124 125 test('detectTimezone maps Los Angeles to America/Los_Angeles', () => { 126 assert.equal(detectTimezone('Los Angeles', 'US'), 'America/Los_Angeles'); 127 }); 128 129 test('detectTimezone maps San Francisco to America/Los_Angeles', () => { 130 assert.equal(detectTimezone('San Francisco', 'US'), 'America/Los_Angeles'); 131 }); 132 133 test('6am PT is outside business hours (blocked)', () => { 134 const tz = detectTimezone('Los Angeles', 'US'); 135 assert.equal(tz, 'America/Los_Angeles'); 136 assert.ok(6 < 8, 'hour 6 is below the 8am threshold (blocked by isBusinessHours)'); 137 }); 138 }); 139 140 // ─── P2b-2: US Eastern timezone ────────────────────────────────────────────── 141 142 describe('TCPA: US Eastern timezone', () => { 143 test('isBusinessHours for America/New_York matches current wall clock', () => { 144 const tz = 'America/New_York'; 145 const expected = isCurrentlyBusinessHours(tz); 146 assert.equal(isBusinessHours(tz), expected, 147 `At ${currentHourIn(tz)}:00 ET, business hours should be ${expected}`); 148 }); 149 150 test('detectTimezone maps New York to America/New_York', () => { 151 assert.equal(detectTimezone('New York', 'US'), 'America/New_York'); 152 }); 153 154 test('9am ET is within business hours (allowed)', () => { 155 const tz = detectTimezone('New York', 'US'); 156 assert.equal(tz, 'America/New_York'); 157 assert.ok(isWithinBusinessRange(9), 'hour 9 is within 8-21 range (allowed by isBusinessHours)'); 158 }); 159 160 test('shouldBlockSMS for US Eastern site respects business hours', async () => { 161 const siteId = db.prepare( 162 "INSERT INTO sites (domain, city, country_code) VALUES ('plumber-nyc.com', 'New York', 'US')" 163 ).run().lastInsertRowid; 164 165 const result = await shouldBlockSMS('+12125551234', Number(siteId)); 166 const expected = isCurrentlyBusinessHours('America/New_York'); 167 if (expected) { 168 assert.equal(result.blocked, false, 'should be allowed during business hours'); 169 } else { 170 assert.equal(result.blocked, true, 'should be blocked outside business hours'); 171 assert.equal(result.reason, 'outside_business_hours'); 172 } 173 }); 174 }); 175 176 // ─── P2b-3: UK timezone ──────────────────────────────────────────────────── 177 178 describe('TCPA: UK timezone', () => { 179 test('isBusinessHours for Europe/London matches current wall clock', () => { 180 const tz = 'Europe/London'; 181 const expected = isCurrentlyBusinessHours(tz); 182 assert.equal(isBusinessHours(tz), expected, 183 `At ${currentHourIn(tz)}:00 GMT/BST, business hours should be ${expected}`); 184 }); 185 186 test('detectTimezone maps GB country code to Europe/London', () => { 187 assert.equal(detectTimezone(null, 'GB'), 'Europe/London'); 188 }); 189 190 test('detectTimezone maps UK alias to Europe/London', () => { 191 assert.equal(detectTimezone(null, 'UK'), 'Europe/London'); 192 }); 193 194 test('9pm (21:00) UK is outside business hours (blocked)', () => { 195 assert.ok(!isWithinBusinessRange(21), 'hour 21 fails the < 21 check (blocked)'); 196 }); 197 198 test('8:59pm UK (hour 20) is within business hours (allowed)', () => { 199 assert.ok(isWithinBusinessRange(20), 'hour 20 is the last allowed hour'); 200 }); 201 202 test('shouldBlockSMS for UK site respects business hours', async () => { 203 const siteId = db.prepare( 204 "INSERT INTO sites (domain, city, country_code) VALUES ('plumber-london.co.uk', 'London', 'UK')" 205 ).run().lastInsertRowid; 206 207 const result = await shouldBlockSMS('+447700900123', Number(siteId)); 208 const expected = isCurrentlyBusinessHours('Europe/London'); 209 if (expected) { 210 assert.equal(result.blocked, false); 211 } else { 212 assert.equal(result.blocked, true); 213 assert.equal(result.reason, 'outside_business_hours'); 214 } 215 }); 216 }); 217 218 // ─── P2b-4: Australia timezone ─────────────────────────────────────────────── 219 220 describe('TCPA: Australia timezone', () => { 221 test('isBusinessHours for Australia/Sydney matches current wall clock', () => { 222 const tz = 'Australia/Sydney'; 223 const expected = isCurrentlyBusinessHours(tz); 224 assert.equal(isBusinessHours(tz), expected, 225 `At ${currentHourIn(tz)}:00 AEST/AEDT, business hours should be ${expected}`); 226 }); 227 228 test('detectTimezone maps AU country code to Australia/Sydney', () => { 229 assert.equal(detectTimezone(null, 'AU'), 'Australia/Sydney'); 230 }); 231 232 test('detectTimezone maps Melbourne to Australia/Melbourne', () => { 233 assert.equal(detectTimezone('Melbourne', 'AU'), 'Australia/Melbourne'); 234 }); 235 236 test('detectTimezone maps Perth to Australia/Perth', () => { 237 assert.equal(detectTimezone('Perth', 'AU'), 'Australia/Perth'); 238 }); 239 240 test('8am AEST is within business hours (allowed)', () => { 241 assert.ok(isWithinBusinessRange(8), 'hour 8 is the earliest allowed hour'); 242 }); 243 244 test('7am AEST is outside business hours (blocked)', () => { 245 assert.ok(!isWithinBusinessRange(7), 'hour 7 fails the >= 8 check (blocked)'); 246 }); 247 248 test('shouldBlockSMS for AU site respects business hours', async () => { 249 const siteId = db.prepare( 250 "INSERT INTO sites (domain, city, country_code) VALUES ('plumber-sydney.com.au', 'Sydney', 'AU')" 251 ).run().lastInsertRowid; 252 253 const result = await shouldBlockSMS('+61412345678', Number(siteId)); 254 const expected = isCurrentlyBusinessHours('Australia/Sydney'); 255 if (expected) { 256 assert.equal(result.blocked, false); 257 } else { 258 assert.equal(result.blocked, true); 259 assert.equal(result.reason, 'outside_business_hours'); 260 } 261 }); 262 }); 263 264 // ─── P2b-5: Edge case — area code from different timezone than billing ────── 265 266 describe('TCPA: timezone mismatch edge cases', () => { 267 test('US default timezone is America/New_York when no city provided', () => { 268 const tz = detectTimezone(null, 'US'); 269 assert.equal(tz, 'America/New_York', 'defaults to Eastern for US without city'); 270 }); 271 272 test('site with city in different timezone than area code implies', () => { 273 const siteId = db.prepare( 274 "INSERT INTO sites (domain, city, country_code) VALUES ('nyc-area-code-dallas-biz.com', 'Dallas', 'US')" 275 ).run().lastInsertRowid; 276 277 const tz = getSiteTimezone(Number(siteId), db); 278 assert.equal(tz, 'America/Chicago', 'timezone based on site city, not phone area code'); 279 }); 280 281 test('site with unknown city falls back to country default', () => { 282 const siteId = db.prepare( 283 "INSERT INTO sites (domain, city, country_code) VALUES ('unknown-city.com', 'Smallville', 'US')" 284 ).run().lastInsertRowid; 285 286 const tz = getSiteTimezone(Number(siteId), db); 287 assert.equal(tz, 'America/New_York', 'unknown US city falls back to Eastern'); 288 }); 289 290 test('site with no city and no country defaults to America/New_York', () => { 291 const siteId = db.prepare( 292 "INSERT INTO sites (domain, city, country_code) VALUES ('no-location.com', NULL, NULL)" 293 ).run().lastInsertRowid; 294 295 const tz = getSiteTimezone(Number(siteId), db); 296 assert.equal(tz, 'America/New_York', 'no location data defaults to Eastern'); 297 }); 298 299 test('invalid timezone string causes isBusinessHours to return false (safe default)', () => { 300 const result = isBusinessHours('Invalid/Timezone'); 301 assert.equal(result, false, 'invalid timezone blocks (fail-safe)'); 302 }); 303 304 test('Canadian city-level timezone resolution (Vancouver = Pacific)', () => { 305 assert.equal(detectTimezone('Vancouver', 'CA'), 'America/Vancouver'); 306 }); 307 308 test('Canadian Newfoundland half-hour offset is resolved', () => { 309 assert.equal(detectTimezone("St. John's", 'CA'), 'America/St_Johns'); 310 }); 311 312 test('opted-out number is blocked regardless of business hours', async () => { 313 const siteId = db.prepare( 314 "INSERT INTO sites (domain, city, country_code) VALUES ('opted-out-test.com', 'New York', 'US')" 315 ).run().lastInsertRowid; 316 317 db.prepare( 318 "INSERT INTO opt_outs (phone, method) VALUES ('+15551234567', 'sms')" 319 ).run(); 320 321 // Use E2E mode so we ONLY test the opted-out logic (skip business hours) 322 const result = await shouldBlockSMSE2E('+15551234567', Number(siteId)); 323 assert.equal(result.blocked, true, 'opted-out number is always blocked'); 324 assert.equal(result.reason, 'opted_out', 'reason is opted_out, not business hours'); 325 }); 326 }); 327 328 // ─── P2b-6: Boundary hour testing ────────────────────────────────────────── 329 330 describe('TCPA: boundary hours (7:59am, 8:00am, 8:59pm, 9:00pm)', () => { 331 test('hour 8 is the first allowed hour', () => { 332 assert.ok(isWithinBusinessRange(8), 'hour 8 is allowed'); 333 }); 334 335 test('hour 7 is the last blocked morning hour', () => { 336 assert.ok(!isWithinBusinessRange(7), 'hour 7 is blocked'); 337 }); 338 339 test('hour 20 is the last allowed evening hour', () => { 340 assert.ok(isWithinBusinessRange(20), 'hour 20 is allowed'); 341 }); 342 343 test('hour 21 is the first blocked evening hour', () => { 344 assert.ok(!isWithinBusinessRange(21), 'hour 21 is blocked'); 345 }); 346 347 test('midnight (hour 0) is blocked', () => { 348 assert.ok(!isWithinBusinessRange(0), 'hour 0 is blocked'); 349 }); 350 351 test('noon (hour 12) is allowed', () => { 352 assert.ok(isWithinBusinessRange(12), 'hour 12 is allowed'); 353 }); 354 });