/ tests / reports / scan-email-templates.test.js
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, '&amp;')), '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 &amp; Value Prop', 'Call to Action', 'Trust Signals',
190        'Urgency &amp; Availability', 'Value Proposition',
191        'Page Hook &amp; 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  });