scan-email-templates.test.js
1 /** 2 * Tests for scan-email-templates.js 3 * 4 * Covers the single export getEmailTemplate(emailNum, segment, tokens) which 5 * dispatches to email1..email7 builder functions. Each builder produces 6 * { subject, html, text }. Tests validate: 7 * 8 * - Return shape (subject, html, text — all non-empty strings) 9 * - Subject line content per segment and email number 10 * - HTML structure (DOCTYPE, wrapper table, header, footer, CTA buttons) 11 * - Text fallback content (CTA URLs, footer, unsubscribe link) 12 * - Segment-specific framing (A = critical, B = average, C = almost there) 13 * - Localisation helpers (ise/ize suffix, math/maths) 14 * - Token interpolation (domain, score, grade, prices, URLs) 15 * - Spintax resolution (no unresolved {…|…} in output) 16 * - Score table in email 1 (factor rows, status labels) 17 * - Credit mechanic mention (emails 3, 6, 7) 18 * - Error handling (invalid emailNum) 19 * - All 7 emails x 3 segments = 21 template combinations 20 */ 21 22 import { test, describe, beforeEach } from 'node:test'; 23 import assert from 'node:assert/strict'; 24 25 // Set persona/brand env vars before import 26 process.env.PERSONA_NAME = 'Marcus Webb'; 27 process.env.PERSONA_FIRST_NAME = 'Marcus'; 28 process.env.BRAND_NAME = 'Audit&Fix'; 29 process.env.BRAND_DOMAIN = 'auditandfix.com'; 30 process.env.BRAND_URL = 'https://auditandfix.com'; 31 32 import { getEmailTemplate } from '../../src/reports/scan-email-templates.js'; 33 34 // --------------------------------------------------------------------------- 35 // Shared test fixtures 36 // --------------------------------------------------------------------------- 37 38 function makeTokens(overrides = {}) { 39 return { 40 domain: 'example-plumbing.com.au', 41 score: 52, 42 grade: 'D', 43 worst_factor_key: 'trust_signals', 44 worst_factor_label: 'Trust Signals', 45 worst_factor_score: 2, 46 second_worst_label: 'Call to Action', 47 factors: { 48 headline_quality: 6, 49 call_to_action: 3, 50 trust_signals: 2, 51 urgency_messaging: 5, 52 value_proposition: 4, 53 hook_engagement: 7, 54 mobile_experience: 8, 55 contact_accessibility: 6, 56 social_proof: 3, 57 visual_hierarchy: 5, 58 }, 59 price_quickfixes: '$97', 60 price_fullaudit: '$337', 61 price_auditfix: '$625', 62 order_url_qf: 'https://auditandfix.com/order/qf/abc123', 63 order_url_fa: 'https://auditandfix.com/order/fa/abc123', 64 scan_url: 'https://auditandfix.com/scan/abc123', 65 unsubscribe_url: 'https://auditandfix.com/unsub/abc123', 66 country_code: 'AU', 67 ...overrides, 68 }; 69 } 70 71 /** Assert no unresolved spintax remains in text */ 72 function assertNoSpintax(str, label) { 73 // After spin(), there should be no {option|option} patterns left. 74 // Single braces can appear in CSS/HTML, so we specifically look for the 75 // pipe-delimited pattern characteristic of unresolved spintax. 76 const spintaxPattern = /\{[^{}]*\|[^{}]*\}/; 77 assert.equal( 78 spintaxPattern.test(str), 79 false, 80 `Unresolved spintax found in ${label}: ${str.match(spintaxPattern)?.[0]}` 81 ); 82 } 83 84 // --------------------------------------------------------------------------- 85 // getEmailTemplate — dispatch & error handling 86 // --------------------------------------------------------------------------- 87 88 describe('getEmailTemplate dispatch', () => { 89 const tokens = makeTokens(); 90 91 test('throws for emailNum 0', () => { 92 assert.throws(() => getEmailTemplate(0, 'A', tokens), /No template for email 0/); 93 }); 94 95 test('throws for emailNum 8', () => { 96 assert.throws(() => getEmailTemplate(8, 'A', tokens), /No template for email 8/); 97 }); 98 99 test('throws for negative emailNum', () => { 100 assert.throws(() => getEmailTemplate(-1, 'A', tokens), /No template for email -1/); 101 }); 102 103 test('returns object with subject, html, text for valid emailNum 1-7', () => { 104 for (let n = 1; n <= 7; n++) { 105 const result = getEmailTemplate(n, 'A', tokens); 106 assert.equal(typeof result.subject, 'string', `email ${n} subject should be string`); 107 assert.equal(typeof result.html, 'string', `email ${n} html should be string`); 108 assert.equal(typeof result.text, 'string', `email ${n} text should be string`); 109 assert.ok(result.subject.length > 0, `email ${n} subject should be non-empty`); 110 assert.ok(result.html.length > 0, `email ${n} html should be non-empty`); 111 assert.ok(result.text.length > 0, `email ${n} text should be non-empty`); 112 } 113 }); 114 }); 115 116 // --------------------------------------------------------------------------- 117 // All 7 emails x 3 segments — shape, spintax resolution, structure 118 // --------------------------------------------------------------------------- 119 120 describe('all email/segment combinations', () => { 121 const segments = ['A', 'B', 'C']; 122 const tokens = makeTokens(); 123 124 for (let n = 1; n <= 7; n++) { 125 for (const seg of segments) { 126 test(`email ${n} segment ${seg} — resolves spintax completely`, () => { 127 const { subject, html, text } = getEmailTemplate(n, seg, tokens); 128 assertNoSpintax(subject, `email${n}/${seg} subject`); 129 assertNoSpintax(html, `email${n}/${seg} html`); 130 assertNoSpintax(text, `email${n}/${seg} text`); 131 }); 132 133 test(`email ${n} segment ${seg} — html has DOCTYPE and wrapper`, () => { 134 const { html } = getEmailTemplate(n, seg, tokens); 135 assert.ok(html.includes('<!DOCTYPE html>'), 'should have DOCTYPE'); 136 assert.ok(html.includes('<body'), 'should have body tag'); 137 assert.ok(html.includes(process.env.BRAND_NAME.replace(/&/g, '&')), 'should have brand header'); 138 assert.ok(html.includes('</html>'), 'should close html'); 139 }); 140 141 test(`email ${n} segment ${seg} — html footer has unsubscribe link`, () => { 142 const { html } = getEmailTemplate(n, seg, tokens); 143 assert.ok(html.includes(tokens.unsubscribe_url), 'html footer should have unsubscribe URL'); 144 assert.ok(html.includes('Unsubscribe'), 'html footer should have Unsubscribe text'); 145 assert.ok(html.includes(process.env.BRAND_DOMAIN), 'html footer should have brand domain'); 146 }); 147 148 test(`email ${n} segment ${seg} — text footer has unsubscribe link`, () => { 149 const { text } = getEmailTemplate(n, seg, tokens); 150 assert.ok(text.includes(tokens.unsubscribe_url), 'text footer should have unsubscribe URL'); 151 assert.ok(text.includes(process.env.PERSONA_FIRST_NAME), 'text footer should have sender name'); 152 assert.ok(text.includes(process.env.BRAND_NAME), 'text footer should have brand name'); 153 }); 154 155 test(`email ${n} segment ${seg} — contains domain token`, () => { 156 const { html, text } = getEmailTemplate(n, seg, tokens); 157 assert.ok(html.includes(tokens.domain), `html should contain domain`); 158 assert.ok(text.includes(tokens.domain), `text should contain domain`); 159 }); 160 } 161 } 162 }); 163 164 // --------------------------------------------------------------------------- 165 // Email 1 — Score delivery (transactional) 166 // --------------------------------------------------------------------------- 167 168 describe('email 1 — score delivery', () => { 169 const tokens = makeTokens(); 170 171 test('subject includes domain, score, and grade', () => { 172 const { subject } = getEmailTemplate(1, 'A', tokens); 173 assert.ok(subject.includes(tokens.domain)); 174 assert.ok(subject.includes(String(tokens.score))); 175 assert.ok(subject.includes(tokens.grade)); 176 }); 177 178 test('subject format is consistent across segments', () => { 179 for (const seg of ['A', 'B', 'C']) { 180 const { subject } = getEmailTemplate(1, seg, tokens); 181 assert.match(subject, /score is \d+\/100/); 182 assert.match(subject, /grade [A-F][+-]?/i); 183 } 184 }); 185 186 test('html contains score table with all 10 factor rows', () => { 187 const { html } = getEmailTemplate(1, 'A', tokens); 188 const factorLabels = [ 189 'Headline & Value Prop', 'Call to Action', 'Trust Signals', 190 'Urgency & Availability', 'Value Proposition', 191 'Page Hook & First Impression', 'Mobile Experience', 192 'Contact Accessibility', 'Social Proof', 'Visual Hierarchy', 193 ]; 194 // Factor/Score/Status header 195 assert.ok(html.includes('Factor'), 'table should have Factor header'); 196 assert.ok(html.includes('Score'), 'table should have Score header'); 197 assert.ok(html.includes('Status'), 'table should have Status header'); 198 // Spot check some factor labels in the table 199 assert.ok(html.includes('Trust Signals'), 'should have Trust Signals row'); 200 assert.ok(html.includes('Mobile Experience'), 'should have Mobile Experience row'); 201 }); 202 203 test('text version contains score table rows', () => { 204 const { text } = getEmailTemplate(1, 'A', tokens); 205 assert.ok(text.includes('Trust Signals'), 'text should have Trust Signals'); 206 assert.ok(text.includes('CRITICAL'), 'text should have CRITICAL status for low-scoring factors'); 207 }); 208 209 test('score table marks factors <= 3 as Critical', () => { 210 const { html } = getEmailTemplate(1, 'A', tokens); 211 // trust_signals=2 and call_to_action=3 should be Critical 212 assert.ok(html.includes('Critical'), 'should mark low scores as Critical'); 213 }); 214 215 test('score table marks factors 4-6 as Needs Work', () => { 216 const { html } = getEmailTemplate(1, 'A', tokens); 217 assert.ok(html.includes('Needs Work'), 'should mark mid scores as Needs Work'); 218 }); 219 220 test('score table marks factors >= 7 as Good', () => { 221 const { html } = getEmailTemplate(1, 'A', tokens); 222 assert.ok(html.includes('Good'), 'should mark high scores as Good'); 223 }); 224 225 test('segment A framing mentions critical territory', () => { 226 const tokensA = makeTokens({ score: 35, grade: 'F' }); 227 const { html, text } = getEmailTemplate(1, 'A', tokensA); 228 assert.ok(html.includes('critical territory'), 'segment A should mention critical'); 229 assert.ok(text.includes('critical territory'), 'segment A text should mention critical'); 230 }); 231 232 test('segment B framing mentions average', () => { 233 const tokensB = makeTokens({ score: 65, grade: 'C' }); 234 const { html, text } = getEmailTemplate(1, 'B', tokensB); 235 assert.ok(html.includes('average'), 'segment B should mention average'); 236 }); 237 238 test('segment C framing mentions above average', () => { 239 const tokensC = makeTokens({ score: 79, grade: 'B' }); 240 const { html, text } = getEmailTemplate(1, 'C', tokensC); 241 assert.ok(html.includes('above average'), 'segment C should mention above average'); 242 }); 243 244 test('segment A/B CTA is Quick Fixes with correct price', () => { 245 for (const seg of ['A', 'B']) { 246 const { html, text } = getEmailTemplate(1, seg, tokens); 247 assert.ok(html.includes(tokens.order_url_qf), `segment ${seg} html should have QF URL`); 248 assert.ok(html.includes(tokens.price_quickfixes), `segment ${seg} html should have QF price`); 249 assert.ok(text.includes(tokens.order_url_qf), `segment ${seg} text should have QF URL`); 250 } 251 }); 252 253 test('segment C CTA is Full Audit with correct price', () => { 254 const { html, text } = getEmailTemplate(1, 'C', tokens); 255 assert.ok(html.includes(tokens.order_url_fa), 'segment C html should have FA URL'); 256 assert.ok(html.includes(tokens.price_fullaudit), 'segment C html should have FA price'); 257 assert.ok(text.includes(tokens.order_url_fa), 'segment C text should have FA URL'); 258 }); 259 260 test('html contains scan results link', () => { 261 const { html } = getEmailTemplate(1, 'A', tokens); 262 assert.ok(html.includes(tokens.scan_url), 'should include scan URL'); 263 }); 264 265 test('handles null factor values gracefully (shows dash)', () => { 266 const tokensNullFactor = makeTokens({ 267 factors: { 268 headline_quality: null, 269 call_to_action: 3, 270 trust_signals: 2, 271 urgency_messaging: 5, 272 value_proposition: 4, 273 hook_engagement: 7, 274 mobile_experience: 8, 275 contact_accessibility: 6, 276 social_proof: 3, 277 visual_hierarchy: 5, 278 }, 279 }); 280 const { html } = getEmailTemplate(1, 'A', tokensNullFactor); 281 // null factor should show dash in status 282 assert.ok(html.includes('>\u2014<'), 'null factor should show em dash'); 283 }); 284 285 test('handles missing factors object gracefully', () => { 286 const tokensNoFactors = makeTokens({ factors: undefined }); 287 // Should not throw 288 const { html } = getEmailTemplate(1, 'A', tokensNoFactors); 289 assert.ok(html.length > 0, 'should still produce html'); 290 }); 291 292 test('footer for segment C does not include "asked for improvement tips"', () => { 293 const { html, text } = getEmailTemplate(1, 'C', tokens); 294 assert.ok(!html.includes('asked for improvement tips'), 'segment C footer should not have tips reason'); 295 }); 296 297 test('footer for segment A includes "asked for improvement tips"', () => { 298 const { html, text } = getEmailTemplate(1, 'A', tokens); 299 assert.ok(html.includes('asked for improvement tips'), 'segment A footer should have tips reason'); 300 }); 301 }); 302 303 // --------------------------------------------------------------------------- 304 // Email 2 — Worst factor deep-dive 305 // --------------------------------------------------------------------------- 306 307 describe('email 2 — worst factor deep-dive', () => { 308 const tokens = makeTokens(); 309 310 test('subject is spun and non-empty for all segments', () => { 311 for (const seg of ['A', 'B', 'C']) { 312 const { subject } = getEmailTemplate(2, seg, tokens); 313 assert.ok(subject.length > 5, `segment ${seg} subject should be meaningful`); 314 assertNoSpintax(subject, `email2/${seg} subject`); 315 } 316 }); 317 318 test('html body contains worst factor content', () => { 319 const { html } = getEmailTemplate(2, 'A', tokens); 320 // The body should reference the worst factor score 321 assert.ok( 322 html.includes(String(tokens.worst_factor_score)), 323 'should reference worst factor score' 324 ); 325 }); 326 327 test('segment A/B CTA is Quick Fixes', () => { 328 for (const seg of ['A', 'B']) { 329 const { html } = getEmailTemplate(2, seg, tokens); 330 assert.ok(html.includes('Quick Fixes'), `segment ${seg} should have QF CTA`); 331 assert.ok(html.includes(tokens.order_url_qf), `segment ${seg} should have QF URL`); 332 } 333 }); 334 335 test('segment C CTA is Full Audit', () => { 336 const { html } = getEmailTemplate(2, 'C', tokens); 337 assert.ok(html.includes('Full Audit'), 'segment C should have FA CTA'); 338 assert.ok(html.includes(tokens.order_url_fa), 'segment C should have FA URL'); 339 }); 340 341 test('uses localised spelling for AU country code', () => { 342 const tokensAU = makeTokens({ country_code: 'AU' }); 343 const { html } = getEmailTemplate(2, 'A', tokensAU); 344 // AU should use -ise forms, so "prioritised" should appear somewhere 345 // or the opener should use analyse, etc. — at minimum no -ize 346 // Since spintax picks randomly, we just verify it produces valid output 347 assert.ok(html.length > 100, 'should produce substantial html for AU'); 348 }); 349 350 test('uses US spelling for US country code', () => { 351 const tokensUS = makeTokens({ country_code: 'US' }); 352 const { html } = getEmailTemplate(2, 'A', tokensUS); 353 assert.ok(html.length > 100, 'should produce substantial html for US'); 354 }); 355 356 test('handles unknown worst_factor_key with fallback opener', () => { 357 const tokensUnknown = makeTokens({ 358 worst_factor_key: 'some_unknown_factor', 359 worst_factor_label: 'Unknown Factor', 360 worst_factor_score: 3, 361 }); 362 const { html } = getEmailTemplate(2, 'A', tokensUnknown); 363 assert.ok(html.includes('Unknown Factor') || html.includes('3/10'), 364 'should use fallback opener with factor label or score'); 365 }); 366 367 test('factor-specific openers work for each known factor key', () => { 368 const factorKeys = [ 369 'headline_quality', 'call_to_action', 'trust_signals', 370 'urgency_messaging', 'value_proposition', 'hook_engagement', 371 'mobile_experience', 'contact_accessibility', 'social_proof', 372 'visual_hierarchy', 373 ]; 374 for (const key of factorKeys) { 375 const t = makeTokens({ worst_factor_key: key, worst_factor_label: key, worst_factor_score: 3 }); 376 const { html } = getEmailTemplate(2, 'B', t); 377 assert.ok(html.length > 200, `email 2 should produce output for factor ${key}`); 378 assertNoSpintax(html, `email2/B/${key} html`); 379 } 380 }); 381 }); 382 383 // --------------------------------------------------------------------------- 384 // Email 3 — Social proof / case study 385 // --------------------------------------------------------------------------- 386 387 describe('email 3 — social proof / case study', () => { 388 const tokens = makeTokens(); 389 390 test('subject is deterministic per segment (no spintax)', () => { 391 assert.equal(getEmailTemplate(3, 'A', tokens).subject, 392 'From an F to a passing grade \u2014 here\'s what changed'); 393 assert.equal(getEmailTemplate(3, 'B', tokens).subject, 394 'From a C to a B in two weeks'); 395 assert.equal(getEmailTemplate(3, 'C', tokens).subject, 396 'One report, three changes, B+ to A-'); 397 }); 398 399 test('html contains a case study story (spun)', () => { 400 for (const seg of ['A', 'B', 'C']) { 401 const { html } = getEmailTemplate(3, seg, tokens); 402 // All stories mention "Rescanned" or "scored" 403 assert.ok(html.includes('Rescanned') || html.includes('scored'), 404 `segment ${seg} story should mention rescanning or scores`); 405 } 406 }); 407 408 test('html contains credit mechanic mention', () => { 409 const { html, text } = getEmailTemplate(3, 'A', tokens); 410 assert.ok( 411 html.includes('credit') || html.includes('Credit') || 412 html.includes(tokens.price_quickfixes), 413 'should mention credit mechanic or QF price' 414 ); 415 }); 416 417 test('CTA is Quick Fixes for all segments', () => { 418 for (const seg of ['A', 'B', 'C']) { 419 const { html } = getEmailTemplate(3, seg, tokens); 420 assert.ok(html.includes(tokens.order_url_qf), `segment ${seg} should have QF URL`); 421 assert.ok(html.includes(tokens.price_quickfixes), `segment ${seg} should have QF price`); 422 } 423 }); 424 425 test('text version contains QF order URL', () => { 426 const { text } = getEmailTemplate(3, 'B', tokens); 427 assert.ok(text.includes(tokens.order_url_qf), 'text should have QF URL'); 428 }); 429 }); 430 431 // --------------------------------------------------------------------------- 432 // Email 4 — No-code objection handler 433 // --------------------------------------------------------------------------- 434 435 describe('email 4 — no-code objection handler', () => { 436 const tokens = makeTokens(); 437 438 test('subject varies by segment', () => { 439 assert.equal(getEmailTemplate(4, 'A', tokens).subject, 440 "You don't need a web developer to fix this"); 441 assert.equal(getEmailTemplate(4, 'B', tokens).subject, 442 '3 of your top 5 issues need zero code'); 443 assert.equal(getEmailTemplate(4, 'C', tokens).subject, 444 "These fixes don't require a developer"); 445 }); 446 447 test('html addresses the "need a developer" objection', () => { 448 const { html } = getEmailTemplate(4, 'A', tokens); 449 // The spun content should mention developers, code, or technical concepts 450 const mentionsDev = html.includes('developer') || html.includes('dev') 451 || html.includes('code') || html.includes('technical'); 452 assert.ok(mentionsDev, 'should address the developer/code objection'); 453 }); 454 455 test('segment-specific urgency framing', () => { 456 const tokensA = makeTokens({ score: 35, grade: 'F' }); 457 const { html: htmlA } = getEmailTemplate(4, 'A', tokensA); 458 assert.ok(htmlA.includes('35') || htmlA.includes(String(tokensA.score)), 459 'segment A urgency should reference score'); 460 461 const tokensB = makeTokens({ score: 68, grade: 'C' }); 462 const { html: htmlB } = getEmailTemplate(4, 'B', tokensB); 463 assert.ok(htmlB.includes('68') || htmlB.includes(String(tokensB.score)), 464 'segment B urgency should reference score'); 465 }); 466 467 test('CTA is Quick Fixes for all segments', () => { 468 for (const seg of ['A', 'B', 'C']) { 469 const { html, text } = getEmailTemplate(4, seg, tokens); 470 assert.ok(html.includes(tokens.order_url_qf), `segment ${seg} html should have QF URL`); 471 assert.ok(text.includes(tokens.order_url_qf), `segment ${seg} text should have QF URL`); 472 } 473 }); 474 }); 475 476 // --------------------------------------------------------------------------- 477 // Email 5 — Full Audit pivot 478 // --------------------------------------------------------------------------- 479 480 describe('email 5 — Full Audit pivot', () => { 481 const tokens = makeTokens(); 482 483 test('subject varies by segment and includes localised spelling', () => { 484 const tokensAU = makeTokens({ country_code: 'AU' }); 485 const { subject: subC } = getEmailTemplate(5, 'C', tokensAU); 486 assert.ok(subC.includes('prioritised'), 'AU segment C subject should use -ised'); 487 488 const tokensUS = makeTokens({ country_code: 'US' }); 489 const { subject: subCUS } = getEmailTemplate(5, 'C', tokensUS); 490 assert.ok(subCUS.includes('prioritized'), 'US segment C subject should use -ized'); 491 }); 492 493 test('subject for segment A includes domain', () => { 494 const { subject } = getEmailTemplate(5, 'A', tokens); 495 assert.ok(subject.includes(tokens.domain), 'segment A subject should include domain'); 496 }); 497 498 test('primary CTA is Full Audit', () => { 499 for (const seg of ['A', 'B', 'C']) { 500 const { html } = getEmailTemplate(5, seg, tokens); 501 assert.ok(html.includes(tokens.order_url_fa), `segment ${seg} should have FA URL`); 502 assert.ok(html.includes(tokens.price_fullaudit), `segment ${seg} should have FA price`); 503 } 504 }); 505 506 test('secondary CTA mentions Quick Fixes with credit', () => { 507 const { html, text } = getEmailTemplate(5, 'A', tokens); 508 assert.ok(html.includes(tokens.order_url_qf), 'should have secondary QF URL'); 509 assert.ok(html.includes('credited') || html.includes('credit'), 510 'should mention credit toward Full Audit'); 511 assert.ok(text.includes('credited') || text.includes('credit'), 512 'text should mention credit'); 513 }); 514 515 test('mentions Audit + Implementation option with price', () => { 516 const { html, text } = getEmailTemplate(5, 'A', tokens); 517 const mentionsImpl = html.includes(tokens.price_auditfix) 518 || html.includes('Implementation'); 519 assert.ok(mentionsImpl, 'should mention Audit + Implementation option'); 520 }); 521 522 test('text version contains both order URLs', () => { 523 const { text } = getEmailTemplate(5, 'B', tokens); 524 assert.ok(text.includes(tokens.order_url_fa), 'text should have FA URL'); 525 assert.ok(text.includes(tokens.order_url_qf), 'text should have QF URL'); 526 }); 527 }); 528 529 // --------------------------------------------------------------------------- 530 // Email 6 — Credit mechanic spotlight 531 // --------------------------------------------------------------------------- 532 533 describe('email 6 — credit mechanic spotlight', () => { 534 const tokens = makeTokens(); 535 536 test('subject is same for all segments (uses domain)', () => { 537 for (const seg of ['A', 'B', 'C']) { 538 const { subject } = getEmailTemplate(6, seg, tokens); 539 assert.ok(subject.includes(tokens.domain), `segment ${seg} subject should include domain`); 540 assert.ok(subject.includes('Quick question'), `segment ${seg} subject should match pattern`); 541 } 542 }); 543 544 test('html explains credit mechanic with both prices', () => { 545 const { html } = getEmailTemplate(6, 'A', tokens); 546 assert.ok(html.includes(tokens.price_quickfixes), 'should mention QF price'); 547 assert.ok(html.includes(tokens.price_fullaudit), 'should mention FA price'); 548 }); 549 550 test('uses localised "maths" for AU/GB', () => { 551 const tokensAU = makeTokens({ country_code: 'AU' }); 552 const { html: htmlAU } = getEmailTemplate(6, 'A', tokensAU); 553 // The word "maths" or "math" appears in the explainSpintax as part of 554 // "Here's the maths:" — but only if that spin variant is selected. 555 // We can't guarantee which variant is chosen, so just check it doesn't crash. 556 assert.ok(htmlAU.length > 100, 'AU should produce valid output'); 557 }); 558 559 test('uses localised "math" for US/CA', () => { 560 const tokensUS = makeTokens({ country_code: 'US' }); 561 const { html: htmlUS } = getEmailTemplate(6, 'A', tokensUS); 562 assert.ok(htmlUS.length > 100, 'US should produce valid output'); 563 }); 564 565 test('html contains scan URL', () => { 566 const { html } = getEmailTemplate(6, 'B', tokens); 567 assert.ok(html.includes(tokens.scan_url), 'should include scan URL'); 568 }); 569 570 test('CTA is Quick Fixes', () => { 571 const { html, text } = getEmailTemplate(6, 'A', tokens); 572 assert.ok(html.includes(tokens.order_url_qf), 'html should have QF URL'); 573 assert.ok(text.includes(tokens.order_url_qf), 'text should have QF URL'); 574 }); 575 }); 576 577 // --------------------------------------------------------------------------- 578 // Email 7 — Soft close 579 // --------------------------------------------------------------------------- 580 581 describe('email 7 — soft close', () => { 582 const tokens = makeTokens(); 583 584 test('subject is same for all segments', () => { 585 for (const seg of ['A', 'B', 'C']) { 586 const { subject } = getEmailTemplate(7, seg, tokens); 587 assert.equal(subject, `Last note about ${tokens.domain}`); 588 } 589 }); 590 591 test('html contains summary bullet points', () => { 592 const { html } = getEmailTemplate(7, 'A', tokens); 593 assert.ok(html.includes(`${tokens.score}/100`), 'summary should include score'); 594 assert.ok(html.includes(`grade ${tokens.grade}`), 'summary should include grade'); 595 assert.ok(html.includes(tokens.worst_factor_label), 'summary should include worst factor'); 596 assert.ok(html.includes(tokens.second_worst_label), 'summary should include second worst'); 597 assert.ok(html.includes(tokens.price_quickfixes), 'summary should include QF price'); 598 assert.ok(html.includes(tokens.price_fullaudit), 'summary should include FA price'); 599 }); 600 601 test('html has both order URLs in summary', () => { 602 const { html } = getEmailTemplate(7, 'B', tokens); 603 assert.ok(html.includes(tokens.order_url_qf), 'should have QF order URL'); 604 assert.ok(html.includes(tokens.order_url_fa), 'should have FA order URL'); 605 }); 606 607 test('text version has summary bullet points', () => { 608 const { text } = getEmailTemplate(7, 'A', tokens); 609 assert.ok(text.includes(`${tokens.score}/100`), 'text summary should include score'); 610 assert.ok(text.includes(tokens.worst_factor_label), 'text should include worst factor'); 611 assert.ok(text.includes(tokens.second_worst_label), 'text should include second worst'); 612 assert.ok(text.includes('credited'), 'text should mention credit mechanic'); 613 }); 614 615 test('text version has both order URLs', () => { 616 const { text } = getEmailTemplate(7, 'C', tokens); 617 assert.ok(text.includes(tokens.order_url_qf), 'text should have QF URL'); 618 assert.ok(text.includes(tokens.order_url_fa), 'text should have FA URL'); 619 }); 620 621 test('mentions scan URL for future reference', () => { 622 const { html, text } = getEmailTemplate(7, 'A', tokens); 623 assert.ok(html.includes(tokens.scan_url) || text.includes(tokens.scan_url), 624 'should include scan URL'); 625 }); 626 627 test('closing is respectful (last email language)', () => { 628 const { text } = getEmailTemplate(7, 'B', tokens); 629 const hasLastEmail = text.includes('last email') || text.includes('Last email') 630 || text.includes('wrapping up') || text.includes('last one'); 631 assert.ok(hasLastEmail, 'should indicate this is the last email'); 632 }); 633 }); 634 635 // --------------------------------------------------------------------------- 636 // Localisation helpers (exercised indirectly) 637 // --------------------------------------------------------------------------- 638 639 describe('localisation across emails', () => { 640 test('AU country_code produces valid output for all emails', () => { 641 const tokensAU = makeTokens({ country_code: 'AU' }); 642 for (let n = 1; n <= 7; n++) { 643 const { html } = getEmailTemplate(n, 'A', tokensAU); 644 assert.ok(html.length > 100, `email ${n} AU should produce valid output`); 645 } 646 }); 647 648 test('GB country_code produces valid output for all emails', () => { 649 const tokensGB = makeTokens({ country_code: 'GB', price_quickfixes: '\u00a347', price_fullaudit: '\u00a3159', price_auditfix: '\u00a3350' }); 650 for (let n = 1; n <= 7; n++) { 651 const { html } = getEmailTemplate(n, 'B', tokensGB); 652 assert.ok(html.length > 100, `email ${n} GB should produce valid output`); 653 } 654 }); 655 656 test('US country_code produces valid output for all emails', () => { 657 const tokensUS = makeTokens({ country_code: 'US', price_quickfixes: '$67', price_fullaudit: '$297', price_auditfix: '$497' }); 658 for (let n = 1; n <= 7; n++) { 659 const { html } = getEmailTemplate(n, 'C', tokensUS); 660 assert.ok(html.length > 100, `email ${n} US should produce valid output`); 661 } 662 }); 663 664 test('CA country_code produces valid output', () => { 665 const tokensCA = makeTokens({ country_code: 'CA' }); 666 const { html } = getEmailTemplate(2, 'A', tokensCA); 667 assert.ok(html.length > 100, 'CA should produce valid output'); 668 }); 669 670 test('null/undefined country_code defaults gracefully', () => { 671 const tokensNull = makeTokens({ country_code: null }); 672 const { html } = getEmailTemplate(2, 'A', tokensNull); 673 assert.ok(html.length > 100, 'null country_code should not crash'); 674 675 const tokensUndef = makeTokens({ country_code: undefined }); 676 const { html: htmlU } = getEmailTemplate(2, 'A', tokensUndef); 677 assert.ok(htmlU.length > 100, 'undefined country_code should not crash'); 678 }); 679 }); 680 681 // --------------------------------------------------------------------------- 682 // HTML structure validation 683 // --------------------------------------------------------------------------- 684 685 describe('HTML structure', () => { 686 const tokens = makeTokens(); 687 688 test('html wrapper has proper email structure', () => { 689 const { html } = getEmailTemplate(1, 'A', tokens); 690 assert.ok(html.includes('width="600"'), 'should have 600px wrapper table'); 691 assert.ok(html.includes('background-color:#f7f7f7'), 'should have gray background'); 692 assert.ok(html.includes('background-color:#1a365d'), 'should have navy header'); 693 assert.ok(html.includes('font-family:Georgia,serif'), 'should use Georgia serif font'); 694 }); 695 696 test('CTA buttons have proper styling', () => { 697 const { html } = getEmailTemplate(1, 'A', tokens); 698 assert.ok(html.includes('background-color:#2b6cb0'), 'CTA should have blue background'); 699 assert.ok(html.includes('border-radius:4px'), 'CTA should have rounded corners'); 700 assert.ok(html.includes('text-decoration:none'), 'CTA link should have no underline'); 701 }); 702 703 test('footer has horizontal rule', () => { 704 const { html } = getEmailTemplate(1, 'A', tokens); 705 assert.ok(html.includes('<hr'), 'footer should have horizontal rule'); 706 assert.ok(html.includes('border-top: 1px solid #e2e2e2'), 'hr should have light border'); 707 }); 708 709 test('physical address placeholder is present', () => { 710 const { html, text } = getEmailTemplate(1, 'A', tokens); 711 assert.ok(html.includes('[Physical Address Placeholder]'), 'html should have address placeholder'); 712 assert.ok(text.includes('[Physical Address Placeholder]'), 'text should have address placeholder'); 713 }); 714 }); 715 716 // --------------------------------------------------------------------------- 717 // Token interpolation edge cases 718 // --------------------------------------------------------------------------- 719 720 describe('token edge cases', () => { 721 test('domain with special characters renders correctly', () => { 722 const tokensSpecial = makeTokens({ domain: "o'briens-plumbing.com" }); 723 const { html, text, subject } = getEmailTemplate(1, 'A', tokensSpecial); 724 assert.ok(html.includes("o'briens-plumbing.com"), 'html should contain domain with apostrophe'); 725 assert.ok(subject.includes("o'briens-plumbing.com"), 'subject should contain domain'); 726 }); 727 728 test('score at boundary values (0, 100)', () => { 729 const tokens0 = makeTokens({ score: 0, grade: 'F' }); 730 const { subject: sub0 } = getEmailTemplate(1, 'A', tokens0); 731 assert.ok(sub0.includes('0/100'), 'should handle score 0'); 732 733 const tokens100 = makeTokens({ score: 100, grade: 'A+' }); 734 const { subject: sub100 } = getEmailTemplate(1, 'C', tokens100); 735 assert.ok(sub100.includes('100/100'), 'should handle score 100'); 736 }); 737 738 test('worst_factor_score at boundary (0 and 10)', () => { 739 const tokens0 = makeTokens({ worst_factor_score: 0 }); 740 const { html } = getEmailTemplate(2, 'A', tokens0); 741 assert.ok(html.includes('0/10') || html.includes('0'), 'should handle factor score 0'); 742 743 const tokens10 = makeTokens({ worst_factor_score: 10 }); 744 const { html: html10 } = getEmailTemplate(2, 'C', tokens10); 745 assert.ok(html10.includes('10/10') || html10.includes('10'), 'should handle factor score 10'); 746 }); 747 748 test('GBP prices render correctly', () => { 749 const tokensGBP = makeTokens({ 750 price_quickfixes: '\u00a347', 751 price_fullaudit: '\u00a3159', 752 price_auditfix: '\u00a3350', 753 country_code: 'GB', 754 }); 755 const { html } = getEmailTemplate(5, 'A', tokensGBP); 756 assert.ok(html.includes('\u00a3'), 'should render pound sign'); 757 }); 758 }); 759 760 // --------------------------------------------------------------------------- 761 // Spintax produces variation (probabilistic — run multiple times) 762 // --------------------------------------------------------------------------- 763 764 describe('spintax variation', () => { 765 const tokens = makeTokens(); 766 767 test('email 2 subject varies across runs (probabilistic)', () => { 768 const subjects = new Set(); 769 for (let i = 0; i < 20; i++) { 770 const { subject } = getEmailTemplate(2, 'A', tokens); 771 subjects.add(subject); 772 } 773 // With 5 spintax variants, 20 runs should produce at least 2 unique 774 assert.ok(subjects.size >= 2, 775 `expected variation in subjects but got ${subjects.size} unique out of 20 runs`); 776 }); 777 778 test('email 4 body varies across runs (probabilistic)', () => { 779 const bodies = new Set(); 780 for (let i = 0; i < 10; i++) { 781 const { text } = getEmailTemplate(4, 'B', tokens); 782 bodies.add(text); 783 } 784 assert.ok(bodies.size >= 2, 785 `expected variation in bodies but got ${bodies.size} unique out of 10 runs`); 786 }); 787 }); 788 789 // --------------------------------------------------------------------------- 790 // Cross-email consistency 791 // --------------------------------------------------------------------------- 792 793 describe('cross-email consistency', () => { 794 const tokens = makeTokens(); 795 796 test('all emails reference the same domain', () => { 797 for (let n = 1; n <= 7; n++) { 798 const { text } = getEmailTemplate(n, 'A', tokens); 799 assert.ok(text.includes(tokens.domain), 800 `email ${n} text should reference domain`); 801 } 802 }); 803 804 test('all emails have persona signature', () => { 805 for (let n = 1; n <= 7; n++) { 806 const { text } = getEmailTemplate(n, 'B', tokens); 807 assert.ok(text.includes(process.env.PERSONA_FIRST_NAME), `email ${n} should have persona name in footer`); 808 } 809 }); 810 811 test('email sequence CTA progression: QF then FA then QF then FA+QF', () => { 812 // Email 1: QF (A/B) or FA (C) 813 assert.ok(getEmailTemplate(1, 'A', tokens).html.includes(tokens.order_url_qf)); 814 assert.ok(getEmailTemplate(1, 'C', tokens).html.includes(tokens.order_url_fa)); 815 816 // Email 2: QF (A/B) or FA (C) 817 assert.ok(getEmailTemplate(2, 'B', tokens).html.includes(tokens.order_url_qf)); 818 assert.ok(getEmailTemplate(2, 'C', tokens).html.includes(tokens.order_url_fa)); 819 820 // Email 3: QF for all 821 assert.ok(getEmailTemplate(3, 'A', tokens).html.includes(tokens.order_url_qf)); 822 823 // Email 5: FA primary 824 assert.ok(getEmailTemplate(5, 'A', tokens).html.includes(tokens.order_url_fa)); 825 826 // Email 6: QF 827 assert.ok(getEmailTemplate(6, 'A', tokens).html.includes(tokens.order_url_qf)); 828 829 // Email 7: both 830 const e7html = getEmailTemplate(7, 'A', tokens).html; 831 assert.ok(e7html.includes(tokens.order_url_qf)); 832 assert.ok(e7html.includes(tokens.order_url_fa)); 833 }); 834 });