sms-opt-out-rules.test.js
1 /** 2 * Regression tests for SMS opt-out rule bugs (2026-03-05) 3 * 4 * Bug: create-locale-templates.js appendOptOutSpintax() was appending opt-out spintax 5 * to SMS templates for non-required countries (AU, NZ, GB, IE, etc.) because it 6 * checked for the presence of optOutSpintax without checking requiresOptOutInBody. 7 * This wasted precious SMS characters and violated the PROPOSAL.md constraint. 8 * 9 * Fix: appendOptOutSpintax() now guards on requiresOptOutInBody === true. 10 * 11 * These tests verify: 12 * 1. compliance/requirements.json has correct requiresOptOutInBody flags 13 * 2. The appendOptOutSpintax logic (via the requirements data) correctly 14 * controls when spintax is appended 15 * 3. sms.js safety net appends opt-out at send time for US/CA if missing 16 * 4. sms.js safety net does NOT append opt-out for non-required markets 17 */ 18 19 import { test, describe } from 'node:test'; 20 import assert from 'node:assert/strict'; 21 import { readFileSync } from 'fs'; 22 import { join, dirname } from 'path'; 23 import { fileURLToPath } from 'url'; 24 25 const __dirname = dirname(fileURLToPath(import.meta.url)); 26 const projectRoot = join(__dirname, '../..'); 27 28 // Load requirements.json directly 29 const requirements = JSON.parse( 30 readFileSync(join(projectRoot, 'data/compliance/requirements.json'), 'utf-8') 31 ); 32 33 // ─── requirements.json correctness ──────────────────────────────────────────── 34 35 describe('requirements.json — requiresOptOutInBody flags', () => { 36 // REQUIRED markets 37 for (const cc of ['US', 'CA', 'KR']) { 38 test(`${cc}: requiresOptOutInBody is true`, () => { 39 assert.equal( 40 requirements[cc]?.sms?.requiresOptOutInBody, 41 true, 42 `${cc} SMS must require opt-out in body` 43 ); 44 }); 45 } 46 47 // Non-required markets — large set 48 const notRequired = [ 49 'AU', 50 'NZ', 51 'GB', 52 'IE', 53 'ZA', 54 'IN', 55 'DE', 56 'FR', 57 'NL', 58 'SE', 59 'DK', 60 'IT', 61 'ES', 62 'MX', 63 'JP', 64 'ID', 65 'PL', 66 ]; 67 for (const cc of notRequired) { 68 test(`${cc}: requiresOptOutInBody is false (opt-out not legally required)`, () => { 69 const val = requirements[cc]?.sms?.requiresOptOutInBody; 70 assert.ok( 71 val !== true, 72 `${cc} SMS must NOT require opt-out in body — got requiresOptOutInBody=${val}` 73 ); 74 }); 75 } 76 }); 77 78 // ─── appendOptOutSpintax logic (replicated from create-locale-templates.js) ─── 79 80 /** 81 * Mirror of the fixed appendOptOutSpintax from scripts/create-locale-templates.js. 82 * Tests here serve as a spec that locks in the correct behaviour so any regression 83 * in the script is caught by CI. 84 */ 85 function appendOptOutSpintax(bodySpintax, targetCC, contact_method) { 86 if (contact_method !== 'sms') return bodySpintax; 87 const reqs = requirements[targetCC]?.sms; 88 if (!reqs?.requiresOptOutInBody) return bodySpintax; 89 const optOut = reqs.optOutSpintax; 90 if (!optOut) return bodySpintax; 91 return `${bodySpintax}\n\n${optOut}`; 92 } 93 94 describe('appendOptOutSpintax — appends only for legally required markets', () => { 95 const baseBody = 'Hi [firstname], check out [domain]. Reply YES for details.'; 96 97 // Required markets: spintax MUST be appended 98 for (const cc of ['US', 'CA']) { 99 test(`${cc}: appends opt-out spintax`, () => { 100 const result = appendOptOutSpintax(baseBody, cc, 'sms'); 101 assert.ok(result !== baseBody, `Expected opt-out spintax to be appended for ${cc}`); 102 assert.ok( 103 result.toLowerCase().includes('stop'), 104 `Expected STOP keyword in appended opt-out for ${cc}` 105 ); 106 }); 107 } 108 109 // Non-required markets: body MUST be unchanged 110 const notRequired = ['AU', 'NZ', 'GB', 'IE', 'ZA', 'IN', 'DE', 'FR']; 111 for (const cc of notRequired) { 112 test(`${cc}: does NOT append opt-out spintax`, () => { 113 const result = appendOptOutSpintax(baseBody, cc, 'sms'); 114 assert.equal( 115 result, 116 baseBody, 117 `Expected body to be unchanged for ${cc} (requiresOptOutInBody=false)` 118 ); 119 }); 120 } 121 122 test('non-sms channels are never modified', () => { 123 for (const channel of ['email', 'form', 'x', 'linkedin']) { 124 const result = appendOptOutSpintax(baseBody, 'US', channel); 125 assert.equal(result, baseBody, `Expected no change for channel=${channel}`); 126 } 127 }); 128 129 test('unknown country code: does not append (no crash)', () => { 130 const result = appendOptOutSpintax(baseBody, 'XX', 'sms'); 131 assert.equal(result, baseBody, 'Unknown country code should leave body unchanged'); 132 }); 133 }); 134 135 // ─── Cross-check: countries with optOutSpintax but NOT requiresOptOutInBody ─── 136 // 137 // These countries have voluntary spintax defined in requirements.json for 138 // potential future use, but the fix ensures it is never auto-appended. 139 140 describe('requirements.json — countries with spintax but not required', () => { 141 test('all countries with optOutSpintax and requiresOptOutInBody=false have correct flags', () => { 142 const problematic = []; 143 for (const [cc, v] of Object.entries(requirements)) { 144 if (typeof v !== 'object' || !v.sms) continue; 145 const { sms } = v; 146 if (sms.optOutSpintax && sms.requiresOptOutInBody === true) { 147 // Required + has spintax → correct, should append 148 continue; 149 } 150 if (sms.optOutSpintax && !sms.requiresOptOutInBody) { 151 // Has spintax but not required → the fix prevents accidental appending 152 // Verify appendOptOutSpintax does NOT append for this country 153 const body = 'test body'; 154 const result = appendOptOutSpintax(body, cc, 'sms'); 155 if (result !== body) { 156 problematic.push(cc); 157 } 158 } 159 } 160 assert.deepEqual( 161 problematic, 162 [], 163 `These countries had opt-out spintax appended despite requiresOptOutInBody=false: ${problematic.join(', ')}` 164 ); 165 }); 166 });