/ tests / utils / compliance-validator.test.js
compliance-validator.test.js
  1  /**
  2   * Tests for src/utils/compliance-validator.js
  3   *
  4   * Covers validateCompliance() for email and sms channels:
  5   * - Blocked channel detection
  6   * - CAN-SPAM physical address requirement
  7   * - Subject line validation (RE:/FWD: blocking, ALL CAPS warning)
  8   * - SMS sender ID injection
  9   * - SMS character count warnings
 10   * - Edge cases: unknown country, null/undefined country, empty text, missing data files
 11   *
 12   * Strategy: mock.module() at top level with mutable handler functions.
 13   * Each test sets its handlers, then does a cache-bust dynamic import
 14   * to get a fresh module execution (which re-runs the top-level readFileSync).
 15   */
 16  
 17  import { describe, test, mock } from 'node:test';
 18  import assert from 'node:assert';
 19  
 20  // ─── Mutable handler functions (changed per test) ───────────────────────────
 21  
 22  let readFileSyncHandler = () => '{}';
 23  let requiresPhysicalAddressHandler = () => false;
 24  let requiresSenderIdInBodyHandler = () => false;
 25  let getSmsEnderIdHandler = () => '-Team, Support';
 26  
 27  // Track logger calls for assertion
 28  const loggerWarnCalls = [];
 29  const loggerInfoCalls = [];
 30  
 31  // ─── Mock all dependencies at top level (once) ──────────────────────────────
 32  
 33  mock.module('../../src/utils/logger.js', {
 34    defaultExport: class MockLogger {
 35      constructor() {
 36        this.warn = (...args) => loggerWarnCalls.push(args);
 37        this.info = (...args) => loggerInfoCalls.push(args);
 38        this.error = () => {};
 39        this.debug = () => {};
 40      }
 41    },
 42  });
 43  
 44  mock.module('fs', {
 45    namedExports: {
 46      readFileSync: (filePath, encoding) => readFileSyncHandler(filePath, encoding),
 47    },
 48  });
 49  
 50  mock.module('path', {
 51    namedExports: {
 52      join: (...args) => args.join('/'),
 53      dirname: p => p.replace(/\/[^/]+$/, ''),
 54    },
 55  });
 56  
 57  mock.module('url', {
 58    namedExports: {
 59      fileURLToPath: url => url.replace('file://', ''),
 60    },
 61  });
 62  
 63  mock.module('../../src/utils/email-compliance.js', {
 64    namedExports: {
 65      requiresPhysicalAddress: cc => requiresPhysicalAddressHandler(cc),
 66    },
 67  });
 68  
 69  mock.module('../../src/utils/sms-compliance.js', {
 70    namedExports: {
 71      requiresSenderIdInBody: cc => requiresSenderIdInBodyHandler(cc),
 72      getSmsEnderId: () => getSmsEnderIdHandler(),
 73    },
 74  });
 75  
 76  // ─── Helpers ────────────────────────────────────────────────────────────────
 77  
 78  let importCounter = 0;
 79  
 80  /**
 81   * Fresh import of compliance-validator with cache busting.
 82   * Each import re-executes the module top-level code (readFileSync calls).
 83   */
 84  async function freshImport() {
 85    importCounter++;
 86    const mod = await import(
 87      `../../src/utils/compliance-validator.js?t=${Date.now()}_${importCounter}`
 88    );
 89    return mod.validateCompliance;
 90  }
 91  
 92  function setupHandlers({ requirements = {}, blockedChannels = { blocks: [] }, opts = {} } = {}) {
 93    readFileSyncHandler = (filePath, _enc) => {
 94      if (filePath.includes('requirements.json')) {
 95        return JSON.stringify(requirements);
 96      }
 97      if (filePath.includes('blocked-channels.json')) {
 98        return JSON.stringify(blockedChannels);
 99      }
100      throw new Error(`Unexpected readFileSync: ${filePath}`);
101    };
102  
103    requiresPhysicalAddressHandler = opts.requiresPhysicalAddress || (() => false);
104    requiresSenderIdInBodyHandler = opts.requiresSenderIdInBody || (() => false);
105    getSmsEnderIdHandler = opts.getSmsEnderId || (() => '-Team, Support');
106  
107    // Clear logger call tracking
108    loggerWarnCalls.length = 0;
109    loggerInfoCalls.length = 0;
110  }
111  
112  let savedPhysicalAddress;
113  
114  function savePhysicalAddress() {
115    savedPhysicalAddress = process.env.CAN_SPAM_PHYSICAL_ADDRESS;
116  }
117  
118  function restorePhysicalAddress() {
119    if (savedPhysicalAddress === undefined) {
120      delete process.env.CAN_SPAM_PHYSICAL_ADDRESS;
121    } else {
122      process.env.CAN_SPAM_PHYSICAL_ADDRESS = savedPhysicalAddress;
123    }
124  }
125  
126  // ─── 1. Email channel ──────────────────────────────────────────────────────
127  
128  describe('validateCompliance - email channel', () => {
129    describe('blocked channel', () => {
130      test('returns ok=false when country+email is in blocked-channels list', async () => {
131        setupHandlers({
132          blockedChannels: {
133            blocks: [{ country: 'DE', channel: 'email', reason: 'GDPR cold email prohibited' }],
134          },
135        });
136  
137        const validateCompliance = await freshImport();
138        const result = validateCompliance('Hello there!', 'email', 'DE');
139  
140        assert.strictEqual(result.ok, false);
141        assert.strictEqual(result.blocked, true);
142        assert.ok(result.reason.includes('Channel blocked for DE'));
143        assert.ok(result.reason.includes('GDPR cold email prohibited'));
144        assert.strictEqual(result.modifiedText, null);
145      });
146    });
147  
148    describe('physical address requirement', () => {
149      test('returns ok=false when CAN_SPAM_PHYSICAL_ADDRESS not set and required by requirements.json', async () => {
150        setupHandlers({
151          requirements: {
152            US: { email: { requiresPhysicalAddress: true } },
153          },
154        });
155  
156        savePhysicalAddress();
157        delete process.env.CAN_SPAM_PHYSICAL_ADDRESS;
158  
159        try {
160          const validateCompliance = await freshImport();
161          const result = validateCompliance('Buy our product!', 'email', 'US');
162  
163          assert.strictEqual(result.ok, false);
164          assert.strictEqual(result.blocked, true);
165          assert.ok(result.reason.includes('CAN_SPAM_PHYSICAL_ADDRESS'));
166          assert.ok(result.reason.includes('US'));
167        } finally {
168          restorePhysicalAddress();
169        }
170      });
171  
172      test('returns ok=false when CAN_SPAM_PHYSICAL_ADDRESS is whitespace-only', async () => {
173        setupHandlers({
174          opts: {
175            requiresPhysicalAddress: () => true,
176          },
177        });
178  
179        savePhysicalAddress();
180        process.env.CAN_SPAM_PHYSICAL_ADDRESS = '   ';
181  
182        try {
183          const validateCompliance = await freshImport();
184          const result = validateCompliance('Hello', 'email', 'AU');
185  
186          assert.strictEqual(result.ok, false);
187          assert.strictEqual(result.blocked, true);
188          assert.ok(result.reason.includes('CAN_SPAM_PHYSICAL_ADDRESS'));
189        } finally {
190          restorePhysicalAddress();
191        }
192      });
193  
194      test('passes when CAN_SPAM_PHYSICAL_ADDRESS is set and physical address required', async () => {
195        setupHandlers({
196          requirements: {
197            US: { email: { requiresPhysicalAddress: true } },
198          },
199        });
200  
201        savePhysicalAddress();
202        process.env.CAN_SPAM_PHYSICAL_ADDRESS = '123 Main St, New York, NY 10001';
203  
204        try {
205          const validateCompliance = await freshImport();
206          const result = validateCompliance('Great products for you!', 'email', 'US');
207  
208          assert.strictEqual(result.ok, true);
209          assert.strictEqual(result.blocked, false);
210          assert.strictEqual(result.reason, null);
211          assert.strictEqual(result.modifiedText, null);
212        } finally {
213          restorePhysicalAddress();
214        }
215      });
216  
217      test('checks email-compliance module when requirements.json has no physical address flag', async () => {
218        // requirements.json has no requiresPhysicalAddress, but email-compliance says yes
219        setupHandlers({
220          requirements: { AU: { email: {} } },
221          opts: {
222            requiresPhysicalAddress: () => true,
223          },
224        });
225  
226        savePhysicalAddress();
227        delete process.env.CAN_SPAM_PHYSICAL_ADDRESS;
228  
229        try {
230          const validateCompliance = await freshImport();
231          const result = validateCompliance('Hello', 'email', 'AU');
232  
233          assert.strictEqual(result.ok, false);
234          assert.strictEqual(result.blocked, true);
235          assert.ok(result.reason.includes('CAN_SPAM_PHYSICAL_ADDRESS'));
236        } finally {
237          restorePhysicalAddress();
238        }
239      });
240    });
241  
242    describe('subject line validation', () => {
243      test('blocks subject starting with RE:', async () => {
244        setupHandlers();
245  
246        const validateCompliance = await freshImport();
247        const text = 'Subject: RE: Your Website Audit\nHi there, I noticed...';
248        const result = validateCompliance(text, 'email', 'AU');
249  
250        assert.strictEqual(result.ok, false);
251        assert.strictEqual(result.blocked, true);
252        assert.ok(result.reason.includes('RE:'));
253        assert.ok(result.reason.includes('must not start with'));
254      });
255  
256      test('blocks subject starting with FWD: (case insensitive)', async () => {
257        setupHandlers();
258  
259        const validateCompliance = await freshImport();
260        const text = 'Subject: fwd: Important Message\nBody text here';
261        const result = validateCompliance(text, 'email', 'AU');
262  
263        assert.strictEqual(result.ok, false);
264        assert.strictEqual(result.blocked, true);
265        // The reason includes the actual subject text which has fwd:
266        assert.ok(result.reason.includes('must not start with'));
267      });
268  
269      test('blocks subject starting with Re: (mixed case)', async () => {
270        setupHandlers();
271  
272        const validateCompliance = await freshImport();
273        const text = 'Subject: Re: Following up\nBody here';
274        const result = validateCompliance(text, 'email', 'GB');
275  
276        assert.strictEqual(result.ok, false);
277        assert.strictEqual(result.blocked, true);
278        assert.ok(result.reason.includes('must not start with'));
279      });
280  
281      test('warns but does not block when subject has many ALL CAPS words', async () => {
282        setupHandlers();
283  
284        const validateCompliance = await freshImport();
285        // 4 ALL CAPS words with length > 2: FREE, AMAZING, DEAL, TODAY
286        const text = 'Subject: FREE AMAZING DEAL TODAY Now\nBody text';
287        const result = validateCompliance(text, 'email', 'IN');
288  
289        // Should pass (warning only, not blocking)
290        assert.strictEqual(result.ok, true);
291        assert.strictEqual(result.blocked, false);
292  
293        // Logger should have been called with a warning about caps
294        const capsWarning = loggerWarnCalls.find(
295          args => typeof args[0] === 'string' && args[0].includes('ALL CAPS')
296        );
297        assert.ok(capsWarning, 'Expected a logger.warn call about ALL CAPS words');
298      });
299  
300      test('does not warn about caps when 3 or fewer ALL CAPS words', async () => {
301        setupHandlers();
302  
303        const validateCompliance = await freshImport();
304        // Only 3 ALL CAPS words (>2 chars): BIG, RED, CAR
305        const text = 'Subject: BIG RED CAR sale\nBody text';
306        const result = validateCompliance(text, 'email', 'AU');
307  
308        assert.strictEqual(result.ok, true);
309  
310        const capsWarning = loggerWarnCalls.find(
311          args => typeof args[0] === 'string' && args[0].includes('ALL CAPS')
312        );
313        assert.strictEqual(capsWarning, undefined, 'Should not warn for 3 or fewer caps words');
314      });
315  
316      test('normal email with clean subject passes', async () => {
317        setupHandlers();
318  
319        const validateCompliance = await freshImport();
320        const text = 'Subject: Your Website Review\nHi, I noticed your site could use some help.';
321        const result = validateCompliance(text, 'email', 'AU');
322  
323        assert.strictEqual(result.ok, true);
324        assert.strictEqual(result.blocked, false);
325        assert.strictEqual(result.reason, null);
326        assert.strictEqual(result.modifiedText, null);
327      });
328  
329      test('email without Subject: header passes (no subject to validate)', async () => {
330        setupHandlers();
331  
332        const validateCompliance = await freshImport();
333        const result = validateCompliance('Just a body with no subject header', 'email', 'AU');
334  
335        assert.strictEqual(result.ok, true);
336        assert.strictEqual(result.blocked, false);
337      });
338    });
339  });
340  
341  // ─── 2. SMS channel ─────────────────────────────────────────────────────────
342  
343  describe('validateCompliance - sms channel', () => {
344    describe('blocked channel', () => {
345      test('returns ok=false when country+sms is in blocked-channels list', async () => {
346        setupHandlers({
347          blockedChannels: {
348            blocks: [{ country: 'JP', channel: 'sms', reason: 'No SMS compliance framework' }],
349          },
350        });
351  
352        const validateCompliance = await freshImport();
353        const result = validateCompliance('Hello!', 'sms', 'JP');
354  
355        assert.strictEqual(result.ok, false);
356        assert.strictEqual(result.blocked, true);
357        assert.ok(result.reason.includes('Channel blocked for JP'));
358        assert.ok(result.reason.includes('No SMS compliance framework'));
359      });
360    });
361  
362    describe('sender ID injection', () => {
363      test('appends sender ID when required by requirements.json and not already present', async () => {
364        setupHandlers({
365          requirements: {
366            US: { sms: { requiresSenderIdInBody: true } },
367          },
368          opts: {
369            getSmsEnderId: () => '-Mike, Audit&Fix',
370          },
371        });
372  
373        const validateCompliance = await freshImport();
374        const result = validateCompliance('Your site needs work!', 'sms', 'US');
375  
376        assert.strictEqual(result.ok, true);
377        assert.strictEqual(result.blocked, false);
378        assert.strictEqual(result.modifiedText, 'Your site needs work! -Mike, Audit&Fix');
379      });
380  
381      test('appends sender ID when required by sms-compliance module (not requirements.json)', async () => {
382        setupHandlers({
383          opts: {
384            requiresSenderIdInBody: () => true,
385            getSmsEnderId: () => '-Team, WebFix',
386          },
387        });
388  
389        const validateCompliance = await freshImport();
390        const result = validateCompliance('Check your site!', 'sms', 'CA');
391  
392        assert.strictEqual(result.ok, true);
393        assert.strictEqual(result.modifiedText, 'Check your site! -Team, WebFix');
394      });
395  
396      test('does not duplicate sender ID when already present in text', async () => {
397        setupHandlers({
398          requirements: {
399            US: { sms: { requiresSenderIdInBody: true } },
400          },
401          opts: {
402            getSmsEnderId: () => '-Mike, Audit&Fix',
403          },
404        });
405  
406        const validateCompliance = await freshImport();
407        const text = 'Your site needs work! -Mike, Audit&Fix';
408        const result = validateCompliance(text, 'sms', 'US');
409  
410        assert.strictEqual(result.ok, true);
411        // No modification needed since sender ID already present
412        assert.strictEqual(result.modifiedText, null);
413      });
414  
415      test('logs info when sender ID is appended', async () => {
416        setupHandlers({
417          requirements: {
418            CA: { sms: { requiresSenderIdInBody: true } },
419          },
420          opts: {
421            getSmsEnderId: () => '-Bob, FixIt',
422          },
423        });
424  
425        const validateCompliance = await freshImport();
426        validateCompliance('Hi there!', 'sms', 'CA');
427  
428        const infoLog = loggerInfoCalls.find(
429          args => typeof args[0] === 'string' && args[0].includes('Appended sender ID')
430        );
431        assert.ok(infoLog, 'Expected logger.info call about appending sender ID');
432        assert.ok(infoLog[0].includes('CA'), 'Log should mention country code');
433      });
434    });
435  
436    describe('character count warnings', () => {
437      test('warns when US SMS exceeds 137 chars (safe max for US/CA)', async () => {
438        setupHandlers();
439  
440        const validateCompliance = await freshImport();
441        const longText = 'A'.repeat(140);
442        const result = validateCompliance(longText, 'sms', 'US');
443  
444        // Should still pass (warning, not blocking)
445        assert.strictEqual(result.ok, true);
446        assert.strictEqual(result.blocked, false);
447  
448        const charWarning = loggerWarnCalls.find(
449          args => typeof args[0] === 'string' && args[0].includes('140 chars')
450        );
451        assert.ok(charWarning, 'Expected a warn call about SMS character count for US');
452        assert.ok(
453          charWarning[0].includes('safe max 137'),
454          'Warning should mention safe max 137 for US'
455        );
456      });
457  
458      test('warns when CA SMS exceeds 137 chars (same as US)', async () => {
459        setupHandlers();
460  
461        const validateCompliance = await freshImport();
462        const longText = 'B'.repeat(138);
463        const result = validateCompliance(longText, 'sms', 'CA');
464  
465        assert.strictEqual(result.ok, true);
466  
467        const charWarning = loggerWarnCalls.find(
468          args => typeof args[0] === 'string' && args[0].includes('safe max 137')
469        );
470        assert.ok(charWarning, 'Expected warning about safe max 137 for CA');
471      });
472  
473      test('warns when non-US/CA SMS exceeds 153 chars', async () => {
474        setupHandlers();
475  
476        const validateCompliance = await freshImport();
477        const longText = 'C'.repeat(160);
478        const result = validateCompliance(longText, 'sms', 'AU');
479  
480        assert.strictEqual(result.ok, true);
481        assert.strictEqual(result.blocked, false);
482  
483        const charWarning = loggerWarnCalls.find(
484          args => typeof args[0] === 'string' && args[0].includes('safe max 153')
485        );
486        assert.ok(charWarning, 'Expected warning about safe max 153 for AU');
487      });
488  
489      test('no warning when non-US SMS is within 153 char limit', async () => {
490        setupHandlers();
491  
492        const validateCompliance = await freshImport();
493        const shortText = 'D'.repeat(100);
494        const result = validateCompliance(shortText, 'sms', 'GB');
495  
496        assert.strictEqual(result.ok, true);
497  
498        const charWarning = loggerWarnCalls.find(
499          args =>
500            typeof args[0] === 'string' && args[0].includes('chars') && args[0].includes('safe max')
501        );
502        assert.strictEqual(charWarning, undefined, 'Should not warn for SMS within limit');
503      });
504  
505      test('no warning when US SMS is exactly 137 chars', async () => {
506        setupHandlers();
507  
508        const validateCompliance = await freshImport();
509        const exactText = 'E'.repeat(137);
510        const result = validateCompliance(exactText, 'sms', 'US');
511  
512        assert.strictEqual(result.ok, true);
513  
514        const charWarning = loggerWarnCalls.find(
515          args =>
516            typeof args[0] === 'string' && args[0].includes('chars') && args[0].includes('safe max')
517        );
518        assert.strictEqual(charWarning, undefined, 'Should not warn for SMS at exact limit');
519      });
520  
521      test('character count includes appended sender ID', async () => {
522        setupHandlers({
523          requirements: {
524            US: { sms: { requiresSenderIdInBody: true } },
525          },
526          opts: {
527            getSmsEnderId: () => '-Mike, Audit&Fix',
528          },
529        });
530  
531        const validateCompliance = await freshImport();
532        // 125 chars of text + ' -Mike, Audit&Fix' (17 chars) = 142 chars > 137 limit
533        const text = 'X'.repeat(125);
534        const result = validateCompliance(text, 'sms', 'US');
535  
536        assert.strictEqual(result.ok, true);
537        assert.ok(result.modifiedText, 'Text should have sender ID appended');
538  
539        const charWarning = loggerWarnCalls.find(
540          args => typeof args[0] === 'string' && args[0].includes('safe max 137')
541        );
542        assert.ok(charWarning, 'Should warn because text + sender ID exceeds 137 chars');
543      });
544    });
545  
546    describe('normal SMS', () => {
547      test('passes for normal short SMS with no special requirements', async () => {
548        setupHandlers();
549  
550        const validateCompliance = await freshImport();
551        const result = validateCompliance(
552          'Hi, I noticed your site could use a refresh.',
553          'sms',
554          'AU'
555        );
556  
557        assert.strictEqual(result.ok, true);
558        assert.strictEqual(result.blocked, false);
559        assert.strictEqual(result.reason, null);
560        assert.strictEqual(result.modifiedText, null);
561      });
562    });
563  });
564  
565  // ─── 3. Edge cases ──────────────────────────────────────────────────────────
566  
567  describe('validateCompliance - edge cases', () => {
568    test('unknown country code does not crash', async () => {
569      setupHandlers();
570  
571      const validateCompliance = await freshImport();
572      const result = validateCompliance('Hello there', 'sms', 'ZZ');
573  
574      assert.strictEqual(result.ok, true);
575      assert.strictEqual(result.blocked, false);
576    });
577  
578    test('null country code defaults to AU', async () => {
579      // Put a block for AU to verify default is used
580      setupHandlers({
581        blockedChannels: {
582          blocks: [{ country: 'AU', channel: 'sms', reason: 'AU blocked for test' }],
583        },
584      });
585  
586      const validateCompliance = await freshImport();
587      const result = validateCompliance('Hello', 'sms', null);
588  
589      // Should be blocked because null defaults to 'AU' and AU+sms is blocked
590      assert.strictEqual(result.ok, false);
591      assert.strictEqual(result.blocked, true);
592      assert.ok(result.reason.includes('AU'));
593    });
594  
595    test('undefined country code defaults to AU', async () => {
596      setupHandlers({
597        blockedChannels: {
598          blocks: [{ country: 'AU', channel: 'email', reason: 'AU email blocked for test' }],
599        },
600      });
601  
602      const validateCompliance = await freshImport();
603      const result = validateCompliance('Hello', 'email', undefined);
604  
605      // Should be blocked because undefined defaults to 'AU' and AU+email is blocked
606      assert.strictEqual(result.ok, false);
607      assert.strictEqual(result.blocked, true);
608      assert.ok(result.reason.includes('AU'));
609    });
610  
611    test('empty proposal text does not crash for email', async () => {
612      setupHandlers();
613  
614      const validateCompliance = await freshImport();
615      const result = validateCompliance('', 'email', 'AU');
616  
617      assert.strictEqual(result.ok, true);
618      assert.strictEqual(result.blocked, false);
619      assert.strictEqual(result.modifiedText, null);
620    });
621  
622    test('empty proposal text does not crash for SMS', async () => {
623      setupHandlers();
624  
625      const validateCompliance = await freshImport();
626      const result = validateCompliance('', 'sms', 'AU');
627  
628      assert.strictEqual(result.ok, true);
629      assert.strictEqual(result.blocked, false);
630    });
631  
632    test('missing requirements.json and blocked-channels.json falls back gracefully', async () => {
633      // Both readFileSync calls will throw
634      readFileSyncHandler = () => {
635        throw new Error('ENOENT: no such file or directory');
636      };
637      requiresPhysicalAddressHandler = () => false;
638      requiresSenderIdInBodyHandler = () => false;
639      getSmsEnderIdHandler = () => '-Team, Support';
640      loggerWarnCalls.length = 0;
641  
642      const validateCompliance = await freshImport();
643      const result = validateCompliance('Hello there', 'email', 'US');
644  
645      assert.strictEqual(result.ok, true);
646      assert.strictEqual(result.blocked, false);
647  
648      // Should have logged warnings about missing files
649      const fileWarnings = loggerWarnCalls.filter(
650        args => typeof args[0] === 'string' && args[0].includes('Could not load')
651      );
652      assert.strictEqual(fileWarnings.length, 2, 'Should warn about both missing files');
653    });
654  
655    test('blocked channel entry with no reason shows default text', async () => {
656      setupHandlers({
657        blockedChannels: {
658          blocks: [{ country: 'CN', channel: 'sms' }],
659        },
660      });
661  
662      const validateCompliance = await freshImport();
663      const result = validateCompliance('Hello', 'sms', 'CN');
664  
665      assert.strictEqual(result.ok, false);
666      assert.strictEqual(result.blocked, true);
667      assert.ok(result.reason.includes('no reason given'));
668    });
669  
670    test('country code is uppercased (lowercase input works)', async () => {
671      setupHandlers({
672        requirements: {
673          US: { sms: { requiresSenderIdInBody: true } },
674        },
675        opts: {
676          getSmsEnderId: () => '-Mike, Audit&Fix',
677        },
678      });
679  
680      const validateCompliance = await freshImport();
681      // Lowercase 'us' should be treated as 'US'
682      const result = validateCompliance('Your site needs help!', 'sms', 'us');
683  
684      assert.strictEqual(result.ok, true);
685      // US requirements.json says requiresSenderIdInBody=true, so sender ID should be appended
686      assert.strictEqual(result.modifiedText, 'Your site needs help! -Mike, Audit&Fix');
687    });
688  
689    test('return shape always has ok, blocked, reason, modifiedText', async () => {
690      setupHandlers();
691  
692      const validateCompliance = await freshImport();
693  
694      // Passing case
695      const pass = validateCompliance('Hi', 'sms', 'AU');
696      assert.ok('ok' in pass);
697      assert.ok('blocked' in pass);
698      assert.ok('reason' in pass);
699      assert.ok('modifiedText' in pass);
700  
701      // Blocked case
702      setupHandlers({
703        blockedChannels: {
704          blocks: [{ country: 'AU', channel: 'email', reason: 'test' }],
705        },
706      });
707      const validateCompliance2 = await freshImport();
708      const fail = validateCompliance2('Hi', 'email', 'AU');
709      assert.ok('ok' in fail);
710      assert.ok('blocked' in fail);
711      assert.ok('reason' in fail);
712      assert.ok('modifiedText' in fail);
713    });
714  
715    test('default export has validateCompliance method', async () => {
716      setupHandlers();
717  
718      const mod = await import(
719        `../../src/utils/compliance-validator.js?default_export_${Date.now()}`
720      );
721  
722      assert.ok(mod.default, 'default export should exist');
723      assert.strictEqual(typeof mod.default.validateCompliance, 'function');
724      assert.strictEqual(typeof mod.validateCompliance, 'function');
725  
726      // Both named and default exports should produce same results
727      const result1 = mod.validateCompliance('Hi', 'sms', 'AU');
728      const result2 = mod.default.validateCompliance('Hi', 'sms', 'AU');
729  
730      assert.deepStrictEqual(result1, result2);
731    });
732  
733    test('only blocked-channels.json missing falls back to empty blocks', async () => {
734      readFileSyncHandler = filePath => {
735        if (filePath.includes('requirements.json')) {
736          return JSON.stringify({ US: { email: { requiresPhysicalAddress: false } } });
737        }
738        throw new Error('ENOENT: no such file');
739      };
740      requiresPhysicalAddressHandler = () => false;
741      requiresSenderIdInBodyHandler = () => false;
742      getSmsEnderIdHandler = () => '-Team, Support';
743      loggerWarnCalls.length = 0;
744  
745      const validateCompliance = await freshImport();
746      const result = validateCompliance('Hello', 'email', 'US');
747  
748      assert.strictEqual(result.ok, true);
749  
750      // Should warn about blocked-channels.json only
751      const fileWarnings = loggerWarnCalls.filter(
752        args => typeof args[0] === 'string' && args[0].includes('Could not load')
753      );
754      assert.strictEqual(fileWarnings.length, 1, 'Should warn about one missing file');
755      assert.ok(fileWarnings[0][0].includes('blocked-channels'));
756    });
757  
758    test('blockedChannels with no blocks array does not crash', async () => {
759      readFileSyncHandler = filePath => {
760        if (filePath.includes('requirements.json')) return JSON.stringify({});
761        if (filePath.includes('blocked-channels.json')) return JSON.stringify({});
762        throw new Error(`Unexpected: ${filePath}`);
763      };
764      requiresPhysicalAddressHandler = () => false;
765      requiresSenderIdInBodyHandler = () => false;
766      getSmsEnderIdHandler = () => '-Team, Support';
767      loggerWarnCalls.length = 0;
768  
769      const validateCompliance = await freshImport();
770      // blockedChannels has no .blocks property, code uses (blockedChannels.blocks || [])
771      const result = validateCompliance('Hello', 'sms', 'AU');
772  
773      assert.strictEqual(result.ok, true);
774      assert.strictEqual(result.blocked, false);
775    });
776  });