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