/ tests / compliance / tcpa-timezone.test.js
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  });