template-proposals-supplement2.test.js
1 /** 2 * Template Proposals Supplement Test 2 3 * 4 * Targets genuinely uncovered code paths in src/utils/template-proposals.js: 5 * - Lines 58-69: extractTemplateFields null/no-sections default return 6 * - Lines 78-86: factor_scores new-format path in extractTemplateFields 7 * - Lines 89-104: sections legacy format path in extractTemplateFields 8 * - Lines 109-113: factors[0] fallback when no factors extracted 9 * - Lines 126-130: critical_weaknesses secondary weakness name branch 10 * - Lines 222-230: loadTemplates English flat-path fallback 11 * - Lines 249-270: selectTemplate throw on empty/null and 1000+ conversion rate sort 12 * - Lines 440-461: translateWeaknessIfNeeded (via generateTemplateProposal) 13 * - Lines 468-487: shortenSmsWithHaiku exported function 14 * - Lines 508-521: non-English language translation in generateTemplateProposal 15 * - Lines 534-545: SMS too-long re-spin and Haiku shorten path 16 * - Line 552: fallback subject line in generateTemplateProposal 17 */ 18 19 process.env.DATABASE_PATH = '/tmp/test-template-proposals-supplement2.db'; 20 process.env.NODE_ENV = 'test'; 21 process.env.LOGS_DIR = '/tmp/test-logs'; 22 process.env.OPENROUTER_API_KEY = 'test-key-supplement2'; 23 24 import { test, describe, mock } from 'node:test'; 25 import assert from 'node:assert/strict'; 26 import * as realFs from 'fs'; 27 28 // ───────────────────────────────────────────────────────────── 29 // Mock llm-provider so callLLM is fully under test control 30 // ───────────────────────────────────────────────────────────── 31 const callLLMMock = mock.fn(async () => ({ content: 'mocked translation' })); 32 33 mock.module('../../src/utils/llm-provider.js', { 34 namedExports: { 35 callLLM: callLLMMock, 36 getProvider: mock.fn(() => 'openrouter'), 37 getProviderDisplayName: mock.fn(() => 'OpenRouter'), 38 }, 39 defaultExport: { callLLM: callLLMMock }, 40 }); 41 42 // Mock llm-usage-tracker to prevent DB access in tests 43 mock.module('../../src/utils/llm-usage-tracker.js', { 44 namedExports: { 45 logLLMUsage: mock.fn(() => {}), 46 }, 47 }); 48 49 // Mock readFileSync so loadTemplates is fully under test control; 50 // keep all other fs exports intact (logger needs existsSync, etc.) 51 const readFileSyncMock = mock.fn(realFs.readFileSync); 52 mock.module('fs', { 53 namedExports: { 54 ...realFs, 55 readFileSync: readFileSyncMock, 56 }, 57 }); 58 59 const { 60 extractTemplateFields, 61 loadTemplates, 62 selectTemplate, 63 populateTemplate, 64 generateTemplateProposal, 65 shortenSmsWithHaiku, 66 } = await import('../../src/utils/template-proposals.js'); 67 68 // ───────────────────────────────────────────────────────────── 69 // Shared helpers 70 // ───────────────────────────────────────────────────────────── 71 72 /** 73 * Build scoreData using the NEW flat factor_scores format. 74 * This exercises lines 75-87 (the if(scoreData.factor_scores) branch). 75 */ 76 function makeFactorScoreData(overrides = {}) { 77 return { 78 factor_scores: { 79 call_to_action: { score: 2, evidence: 'No clear CTA button', reasoning: 'Weak CTA' }, 80 trust_signals: { score: 4, evidence: 'Few reviews', reasoning: 'Missing testimonials' }, 81 headline_quality: { score: 6, evidence: 'Generic headline', reasoning: 'Vague copy' }, 82 contextual_appropriateness: { 83 score: 7, 84 evidence: 'Relevant', 85 reasoning: 'OK', 86 industry_context: 'plumbing', 87 }, 88 }, 89 overall_calculation: { 90 conversion_score: 38, 91 letter_grade: 'F', 92 }, 93 ...overrides, 94 }; 95 } 96 97 /** 98 * Build a minimal templates array for loadTemplates mocking. 99 */ 100 function makeMockTemplates(contact_method = 'email') { 101 return [ 102 { 103 id: `${contact_method}_s2_001`, 104 channel: contact_method, 105 body_spintax: 'Hi [firstname|there], we noticed [secondary_weakness] at [domain].', 106 subject_spintax: '{Your|The} [kwd] website audit', 107 sends: 0, 108 conversions: 0, 109 approach: 'problem-solution', 110 tested: false, 111 }, 112 ]; 113 } 114 115 function mockReadFileWithTemplates(contact_method = 'email') { 116 readFileSyncMock.mock.mockImplementation(() => 117 JSON.stringify({ templates: makeMockTemplates(contact_method) }) 118 ); 119 } 120 121 // Standard analyzeScoreJson response — used by generateTemplateProposal Pass 1. 122 // Any test calling generateTemplateProposal must return this format on the first callLLM call. 123 const ANALYSIS_MOCK_RESPONSE = JSON.stringify({ 124 recommendation: 'add a clear call-to-action button', 125 industry: 'plumbing', 126 recommendation_sms: 'fix CTA', 127 }); 128 129 // ───────────────────────────────────────────────────────────── 130 // 1. extractTemplateFields — new factor_scores format (lines 78-86) 131 // ───────────────────────────────────────────────────────────── 132 133 describe('extractTemplateFields — factor_scores new format', () => { 134 test('reads primaryWeakness from lowest-score factor_scores entry', () => { 135 const fields = extractTemplateFields(makeFactorScoreData()); 136 // call_to_action has score 2 (lowest) → mapped via FACTOR_LABELS 137 assert.equal(fields.primaryWeakness, "no clear way to call or book — visitors don't know how to contact you"); 138 }); 139 140 test('reads secondaryWeakness from second-lowest factor_scores entry', () => { 141 const fields = extractTemplateFields(makeFactorScoreData()); 142 // trust_signals has score 4 (second lowest) → mapped via FACTOR_LABELS 143 assert.equal(fields.secondaryWeakness, "no reviews or licences visible on your site — nothing to prove you're legit"); 144 }); 145 146 test('sets quickImprovementOpportunity from QUICK_FIX_LABELS for the primary key', () => { 147 const fields = extractTemplateFields(makeFactorScoreData()); 148 // call_to_action key → QUICK_FIX_LABELS.call_to_action 149 assert.ok(fields.quickImprovementOpportunity.includes('call-to-action')); 150 }); 151 152 test('uses evidence field from factor_scores criteria (new format)', () => { 153 const fields = extractTemplateFields(makeFactorScoreData()); 154 assert.equal(fields.evidence, 'No clear CTA button'); 155 }); 156 157 test('extracts industry from factor_scores.contextual_appropriateness.industry_context', () => { 158 const fields = extractTemplateFields(makeFactorScoreData()); 159 assert.equal(fields.industry, 'plumbing'); 160 }); 161 162 test('falls back to key.replace(/_/g, space) for unknown factor names', () => { 163 const data = { 164 factor_scores: { 165 unknown_custom_factor: { score: 1, evidence: 'Bad', reasoning: 'Very bad' }, 166 }, 167 overall_calculation: { conversion_score: 10, letter_grade: 'F' }, 168 }; 169 const fields = extractTemplateFields(data); 170 // Not in FACTOR_LABELS → name = 'unknown custom factor' 171 assert.equal(fields.primaryWeakness, 'unknown custom factor'); 172 }); 173 174 test('skips factor_scores entries where score is not a number', () => { 175 const data = { 176 factor_scores: { 177 bad_entry: { score: 'not-a-number', evidence: 'x', reasoning: 'x' }, 178 good_entry: { score: 3, evidence: 'Real issue', reasoning: 'Real reason' }, 179 }, 180 overall_calculation: { conversion_score: 20, letter_grade: 'F' }, 181 }; 182 const fields = extractTemplateFields(data); 183 // Only good_entry has a numeric score — it becomes primary 184 assert.equal(fields.primaryWeakness, 'good entry'); 185 }); 186 187 test('returns grade from overall_calculation when using factor_scores', () => { 188 const fields = extractTemplateFields(makeFactorScoreData()); 189 assert.equal(fields.grade, 'F'); 190 assert.equal(fields.score, 38); 191 }); 192 193 test('clamps impact between 20 and 50 for factor_scores path', () => { 194 const fields = extractTemplateFields(makeFactorScoreData()); 195 assert.ok(fields.impact >= 20 && fields.impact <= 50, `impact ${fields.impact} out of range`); 196 }); 197 }); 198 199 // ───────────────────────────────────────────────────────────── 200 // 2. extractTemplateFields — critical_weaknesses secondary name (lines 126-130) 201 // ───────────────────────────────────────────────────────────── 202 203 describe('extractTemplateFields — critical_weaknesses secondary weakness', () => { 204 test('uses critical_weaknesses[1] as secondary weakness name when two items present', () => { 205 const data = makeFactorScoreData({ 206 critical_weaknesses: ['No clear CTA.', 'Missing trust signals.'], 207 }); 208 const fields = extractTemplateFields(data); 209 // secondaryWeaknessName = cw[1] lowercased with trailing dot stripped 210 assert.equal(fields.secondaryWeakness, 'missing trust signals'); 211 }); 212 213 test('uses critical_weaknesses[0] when only one item present', () => { 214 const data = makeFactorScoreData({ 215 critical_weaknesses: ['Weak headline copy.'], 216 }); 217 const fields = extractTemplateFields(data); 218 assert.equal(fields.secondaryWeakness, 'weak headline copy'); 219 }); 220 221 test('strips trailing period from critical_weaknesses entry', () => { 222 const data = makeFactorScoreData({ 223 critical_weaknesses: ['Something.', 'Another issue.'], 224 }); 225 const fields = extractTemplateFields(data); 226 assert.ok(!fields.secondaryWeakness.endsWith('.')); 227 }); 228 229 test('lowercases first character of critical_weaknesses entry', () => { 230 const data = makeFactorScoreData({ 231 critical_weaknesses: ['Unclear Value Proposition.'], 232 }); 233 const fields = extractTemplateFields(data); 234 assert.equal(fields.secondaryWeakness[0], fields.secondaryWeakness[0].toLowerCase()); 235 }); 236 237 test('falls back to factor-based secondary when critical_weaknesses is empty array', () => { 238 const data = makeFactorScoreData({ critical_weaknesses: [] }); 239 const fields = extractTemplateFields(data); 240 // Empty array → no cwSecondary → uses factors[1] 241 assert.equal(fields.secondaryWeakness, "no reviews or licences visible on your site — nothing to prove you're legit"); 242 }); 243 244 test('ignores non-array critical_weaknesses values', () => { 245 const data = makeFactorScoreData({ critical_weaknesses: 'not an array' }); 246 const fields = extractTemplateFields(data); 247 // Non-array → treated as [] → uses factor-based secondary 248 assert.equal(fields.secondaryWeakness, "no reviews or licences visible on your site — nothing to prove you're legit"); 249 }); 250 }); 251 252 // ───────────────────────────────────────────────────────────── 253 // 3. loadTemplates — English flat-path fallback (lines 222-230) 254 // ───────────────────────────────────────────────────────────── 255 256 describe('loadTemplates — English flat-path fallback', () => { 257 test('tries lang-specific path first, then falls back to flat path for English', () => { 258 let callCount = 0; 259 const capturedPaths = []; 260 readFileSyncMock.mock.mockImplementation(path => { 261 callCount++; 262 capturedPaths.push(String(path)); 263 // First call (lang-specific path like AU/en/email.json) throws 264 if (callCount === 1) throw new Error('ENOENT: no such file'); 265 // Second call (flat path like AU/email.json) succeeds 266 return JSON.stringify({ templates: makeMockTemplates('email') }); 267 }); 268 269 const templates = loadTemplates('AU', 'en', 'email'); 270 assert.equal(templates.length, 1); 271 assert.equal(templates[0].id, 'email_s2_001'); 272 assert.equal(callCount, 2); 273 // First path should include '/en/' 274 assert.ok(capturedPaths[0].includes('/en/'), `Expected /en/ in: ${capturedPaths[0]}`); 275 }); 276 277 test('returns templates from flat path when lang-specific path has empty templates', () => { 278 let callCount = 0; 279 readFileSyncMock.mock.mockImplementation(() => { 280 callCount++; 281 if (callCount === 1) { 282 // lang-specific path returns empty templates array 283 return JSON.stringify({ templates: [] }); 284 } 285 // flat path returns valid templates 286 return JSON.stringify({ templates: makeMockTemplates('email') }); 287 }); 288 289 const templates = loadTemplates('GB', 'en', 'email'); 290 assert.equal(templates.length, 1); 291 }); 292 293 test('throws when lang is non-English and no lang-specific template found', () => { 294 readFileSyncMock.mock.mockImplementation(() => { 295 throw new Error('ENOENT: no such file'); 296 }); 297 298 assert.throws( 299 () => loadTemplates('DE', 'de', 'email'), 300 err => err.message.includes('No templates for DE/de/email') 301 ); 302 }); 303 304 test('attempts flat path as fallback for all languages (including non-English)', () => { 305 let callCount = 0; 306 readFileSyncMock.mock.mockImplementation(() => { 307 callCount++; 308 throw new Error('ENOENT'); 309 }); 310 311 try { 312 loadTemplates('FR', 'fr', 'sms'); 313 } catch (_) { 314 // expected to throw 315 } 316 // Tries lang-specific path (FR/fr/sms.json) + flat path (FR/sms.json) — at least 2 attempts 317 assert.ok(callCount >= 2, `Should try multiple paths, got ${callCount}`); 318 }); 319 }); 320 321 // ───────────────────────────────────────────────────────────── 322 // 4. shortenSmsWithHaiku — exported function (lines 468-487) 323 // ───────────────────────────────────────────────────────────── 324 325 describe('shortenSmsWithHaiku', () => { 326 test('returns shortened text when LLM returns a shorter non-empty string', async () => { 327 const longText = 'A'.repeat(200); 328 const shortText = 'A'.repeat(100); 329 // polishProposalWithHaiku expects JSON { body: '...' } 330 callLLMMock.mock.mockImplementationOnce(async () => ({ 331 content: JSON.stringify({ body: shortText }), 332 })); 333 334 const result = await shortenSmsWithHaiku(longText); 335 assert.equal(result, shortText); 336 }); 337 338 test('returns original text when LLM returns a longer string', async () => { 339 const original = 'Short SMS text here'; 340 const longer = `${original} with extra content added by LLM`; 341 callLLMMock.mock.mockImplementationOnce(async () => ({ content: longer })); 342 343 const result = await shortenSmsWithHaiku(original); 344 assert.equal(result, original); 345 }); 346 347 test('returns original text when LLM returns empty content', async () => { 348 const original = 'Some SMS text'; 349 callLLMMock.mock.mockImplementationOnce(async () => ({ content: '' })); 350 351 const result = await shortenSmsWithHaiku(original); 352 assert.equal(result, original); 353 }); 354 355 test('returns original text when LLM returns null content', async () => { 356 const original = 'Some SMS text'; 357 callLLMMock.mock.mockImplementationOnce(async () => ({ content: null })); 358 359 const result = await shortenSmsWithHaiku(original); 360 assert.equal(result, original); 361 }); 362 363 test('returns original text when LLM throws an error', async () => { 364 const original = 'SMS that fails to shorten'; 365 callLLMMock.mock.mockImplementationOnce(async () => { 366 throw new Error('API rate limit exceeded'); 367 }); 368 369 const result = await shortenSmsWithHaiku(original); 370 assert.equal(result, original); 371 }); 372 }); 373 374 // ───────────────────────────────────────────────────────────── 375 // 5. generateTemplateProposal — SMS too-long path (lines 534-545) 376 // ───────────────────────────────────────────────────────────── 377 378 describe('generateTemplateProposal — SMS too-long shortening path', () => { 379 test('triggers re-spin loop when SMS proposal exceeds 160 chars', async () => { 380 // Use a template body with only populated tokens that guarantees > 160 chars 381 const longBody = 382 'Hi [firstname|there], I have thoroughly reviewed your [industry] website at [domain] and our comprehensive audit shows multiple significant conversion issues that are currently preventing potential customers from taking action on your site.'; 383 readFileSyncMock.mock.mockImplementation(() => 384 JSON.stringify({ 385 templates: [ 386 { 387 id: 'sms_long_001', 388 channel: 'sms', 389 body_spintax: longBody, 390 subject_spintax: null, 391 sends: 0, 392 conversions: 0, 393 }, 394 ], 395 }) 396 ); 397 398 // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku/shortenSmsWithHaiku 399 callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE })); 400 callLLMMock.mock.mockImplementation(async () => ({ 401 content: JSON.stringify({ body: 'Short SMS.' }), 402 })); 403 404 const siteData = { domain: 'example.com', country_code: 'AU', keyword: 'plumber' }; 405 const scoreData = makeFactorScoreData(); 406 const contact = { name: 'John', channel: 'sms', uri: '+61412345678' }; 407 408 const result = await generateTemplateProposal(siteData, scoreData, contact); 409 assert.ok(result.proposalText, 'should have proposal text'); 410 assert.equal(result.templateId, 'sms_long_001'); 411 }); 412 413 test('calls Haiku shortener when re-spinning still leaves SMS over 160 chars', async () => { 414 // Build a body that always renders well over 160 chars regardless of spin 415 const alwaysLongBody = 416 'Hi there, I have reviewed your website at [domain] and found significant conversion issues including [secondary_weakness] which is seriously affecting your bottom line right now according to our analysis team.'; 417 readFileSyncMock.mock.mockImplementation(() => 418 JSON.stringify({ 419 templates: [ 420 { 421 id: 'sms_always_long', 422 channel: 'sms', 423 body_spintax: alwaysLongBody, 424 subject_spintax: null, 425 sends: 0, 426 conversions: 0, 427 }, 428 ], 429 }) 430 ); 431 432 const shortened = 'Short version.'; 433 // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku (shorten) 434 callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE })); 435 callLLMMock.mock.mockImplementation(async () => ({ 436 content: JSON.stringify({ body: shortened }), 437 })); 438 439 const siteData = { domain: 'longdomain.com', country_code: 'AU', keyword: 'dentist' }; 440 const scoreData = makeFactorScoreData(); 441 const contact = { channel: 'sms', uri: '+61400000000' }; 442 443 const result = await generateTemplateProposal(siteData, scoreData, contact); 444 // Since the body renders over 160 chars and Haiku returns something shorter, 445 // the result should be the Haiku-shortened version 446 assert.ok(result.proposalText.length <= shortened.length || result.proposalText.length < 161); 447 }); 448 }); 449 450 // ───────────────────────────────────────────────────────────── 451 // 6. generateTemplateProposal — non-English translation path (lines 508-521) 452 // ───────────────────────────────────────────────────────────── 453 454 describe('generateTemplateProposal — non-English language translation', () => { 455 test('generates proposal for non-English language_code via analyzeScoreJson', async () => { 456 const mockTemplates = makeMockTemplates('email'); 457 readFileSyncMock.mock.mockImplementation(() => JSON.stringify({ templates: mockTemplates })); 458 459 // Call 1 = analyzeScoreJson (language passed in); call 2 = polishProposalWithHaiku 460 callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE })); 461 callLLMMock.mock.mockImplementation(async () => ({ 462 content: JSON.stringify({ body: 'translated text' }), 463 })); 464 465 const siteData = { 466 domain: 'example.de', 467 country_code: 'DE', 468 language_code: 'de', 469 keyword: 'klempner', 470 }; 471 const scoreData = makeFactorScoreData(); 472 const contact = { name: 'Hans', channel: 'email', uri: 'hans@example.de' }; 473 474 const result = await generateTemplateProposal(siteData, scoreData, contact); 475 assert.ok(result.proposalText, 'should produce a proposal'); 476 assert.equal(result.templateId, 'email_s2_001'); 477 }); 478 479 test('does not translate when language_code is en', async () => { 480 mockReadFileWithTemplates('email'); 481 let callLLMCount = 0; 482 callLLMMock.mock.mockImplementation(async () => { 483 callLLMCount++; 484 // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku — no translation calls for English 485 if (callLLMCount === 1) return { content: ANALYSIS_MOCK_RESPONSE }; 486 return { content: JSON.stringify({ body: 'polished text' }) }; 487 }); 488 489 const siteData = { 490 domain: 'example.co.uk', 491 country_code: 'GB', 492 language_code: 'en', 493 keyword: 'plumber', 494 }; 495 const scoreData = makeFactorScoreData(); 496 const contact = { channel: 'email', uri: 'info@example.co.uk' }; 497 498 const result = await generateTemplateProposal(siteData, scoreData, contact); 499 // Haiku analysis + polish = 2 calls for any language; no extra translation calls for English 500 assert.ok( 501 callLLMCount <= 2, 502 'Only Haiku analysis and polish should be called, no translation for English' 503 ); 504 assert.ok(result.proposalText, 'Should generate a proposal for English'); 505 }); 506 507 test('does not translate when language_code is null', async () => { 508 mockReadFileWithTemplates('email'); 509 let callLLMCount = 0; 510 callLLMMock.mock.mockImplementation(async () => { 511 callLLMCount++; 512 // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku — no translation for null language 513 if (callLLMCount === 1) return { content: ANALYSIS_MOCK_RESPONSE }; 514 return { content: JSON.stringify({ body: 'polished text' }) }; 515 }); 516 517 const siteData = { 518 domain: 'example.com', 519 country_code: 'US', 520 language_code: null, 521 keyword: 'dentist', 522 }; 523 const scoreData = makeFactorScoreData(); 524 const contact = { channel: 'email', uri: 'info@example.com' }; 525 526 const result = await generateTemplateProposal(siteData, scoreData, contact); 527 // Haiku analysis + polish = 2 calls; no extra translation calls when language_code is null 528 assert.ok( 529 callLLMCount <= 2, 530 'Only Haiku analysis and polish should be called, no translation for null language' 531 ); 532 assert.ok(result.proposalText, 'Should generate a proposal when language_code is null'); 533 }); 534 535 test('handles unknown language code gracefully (no translation, no throw)', async () => { 536 mockReadFileWithTemplates('email'); 537 // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku — no translation for unknown code 538 callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE })); 539 callLLMMock.mock.mockImplementation(async () => ({ 540 content: JSON.stringify({ body: 'polished text' }), 541 })); 542 543 const siteData = { 544 domain: 'example.com', 545 country_code: 'AU', 546 language_code: 'xx', // Not in LANG_NAMES 547 keyword: 'plumber', 548 }; 549 const scoreData = makeFactorScoreData(); 550 const contact = { channel: 'email', uri: 'info@example.com' }; 551 552 // Should not throw even with unknown language code 553 const result = await generateTemplateProposal(siteData, scoreData, contact); 554 assert.ok(result.proposalText); 555 }); 556 }); 557 558 // ───────────────────────────────────────────────────────────── 559 // 7. generateTemplateProposal — fallback subject line (line 552) 560 // ───────────────────────────────────────────────────────────── 561 562 describe('generateTemplateProposal — fallback subject line', () => { 563 test('throws when all subject_spintax resolve to empty string after population', async () => { 564 // Template with subject_spintax that contains ONLY an unknown placeholder 565 // populateTemplate resolves [nonexistent_merge_field] → '' (falsy) → no usable subject 566 // Current behavior: throws rather than silently omitting subject 567 readFileSyncMock.mock.mockImplementation(() => 568 JSON.stringify({ 569 templates: [ 570 { 571 id: 'email_emptysub_001', 572 channel: 'email', 573 body_spintax: 'Hello [firstname|there], check out [domain].', 574 subject_spintax: '[nonexistent_merge_field]', 575 sends: 0, 576 conversions: 0, 577 }, 578 ], 579 }) 580 ); 581 // Call 1 = analyzeScoreJson (must succeed); subject check throws before polish is called 582 callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE })); 583 callLLMMock.mock.mockImplementation(async () => ({ content: null })); 584 585 const siteData = { domain: 'test.com', country_code: 'AU', keyword: 'builder' }; 586 const scoreData = makeFactorScoreData(); 587 const contact = { channel: 'email', uri: 'test@test.com' }; 588 589 await assert.rejects( 590 () => generateTemplateProposal(siteData, scoreData, contact), 591 /No usable subject_spintax found/, 592 'Should throw when all subject candidates resolve to empty' 593 ); 594 }); 595 596 test('returns null subject for sms channel regardless of template subject_spintax', async () => { 597 readFileSyncMock.mock.mockImplementation(() => 598 JSON.stringify({ 599 templates: [ 600 { 601 id: 'sms_s2_subj', 602 channel: 'sms', 603 body_spintax: 'Hi, check [domain].', 604 subject_spintax: 'Has subject', // should be ignored for sms 605 sends: 0, 606 conversions: 0, 607 }, 608 ], 609 }) 610 ); 611 // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku (null falls back to original) 612 callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE })); 613 callLLMMock.mock.mockImplementation(async () => ({ content: null })); 614 615 const siteData = { domain: 'test.com', country_code: 'AU', keyword: 'plumber' }; 616 const scoreData = makeFactorScoreData(); 617 const contact = { channel: 'sms', uri: '+61400000000' }; 618 619 const result = await generateTemplateProposal(siteData, scoreData, contact); 620 assert.equal(result.subjectLine, null, 'SMS proposals should have null subjectLine'); 621 }); 622 623 test('form channel uses subject line (treated same as email)', async () => { 624 readFileSyncMock.mock.mockImplementation(() => 625 JSON.stringify({ 626 templates: [ 627 { 628 id: 'email_form_001', 629 channel: 'email', 630 body_spintax: 'Hi [firstname|there], we can help [domain].', 631 subject_spintax: '{Great|Good} news for [domain]', 632 sends: 0, 633 conversions: 0, 634 }, 635 ], 636 }) 637 ); 638 // Call 1 = analyzeScoreJson; call 2 = polishProposalWithHaiku (null falls back to original) 639 callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE })); 640 callLLMMock.mock.mockImplementation(async () => ({ content: null })); 641 642 const siteData = { domain: 'formsite.com', country_code: 'AU', keyword: 'plumber' }; 643 const scoreData = makeFactorScoreData(); 644 const contact = { channel: 'form', uri: 'https://formsite.com/contact' }; 645 646 const result = await generateTemplateProposal(siteData, scoreData, contact); 647 assert.ok(result.subjectLine !== null, 'form channel should have a subject line'); 648 }); 649 }); 650 651 // ───────────────────────────────────────────────────────────── 652 // 8. populateTemplate — [firstname|fallback] pattern 653 // ───────────────────────────────────────────────────────────── 654 655 describe('populateTemplate — firstname fallback patterns', () => { 656 const siteData = { domain: 'mysite.com', keyword: 'electrician' }; 657 const fields = { 658 primaryWeakness: 'weak call-to-action', 659 secondaryWeakness: 'insufficient trust signals', 660 quickImprovementOpportunity: 'add a clear CTA button', 661 evidence: 'No CTA found', 662 reasoning: 'Visitors are confused', 663 industry: 'electrical', 664 score: 40, 665 grade: 'F', 666 impact: 35, 667 }; 668 669 test('[firstname|there] falls back to "there" when no valid name', () => { 670 const result = populateTemplate('Hi [firstname|there]!', fields, siteData, null); 671 assert.ok(result.includes('there'), `Expected "there" in: ${result}`); 672 }); 673 674 test('[firstname|there] uses real name when valid person name provided', () => { 675 const contact = { name: 'Alice' }; 676 const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact); 677 assert.ok(result.includes('Alice'), `Expected "Alice" in: ${result}`); 678 }); 679 680 test('[domain] placeholder is populated', () => { 681 const result = populateTemplate('Site: [domain]', fields, siteData); 682 assert.ok(result.includes('mysite.com'), `Expected "mysite.com" in: ${result}`); 683 }); 684 685 test('[grade] placeholder is populated', () => { 686 const result = populateTemplate('Grade: [grade]', fields, siteData); 687 assert.ok(result.includes('F'), `Expected "F" in: ${result}`); 688 }); 689 690 test('[industry] is resolved from analysisData', () => { 691 const analysisData = { industry: 'electrical', recommendation: 'upgrade your website' }; 692 const result = populateTemplate('Industry: [industry]', fields, siteData, null, analysisData); 693 assert.ok(result.includes('electrical'), `Expected "electrical" in: ${result}`); 694 }); 695 696 test('NON_PERSON_WORDS like "team" reject name as greeting', () => { 697 const contact = { name: 'team' }; 698 const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact); 699 assert.ok( 700 result.includes('there'), 701 `Expected "there" fallback for "team" name, got: ${result}` 702 ); 703 }); 704 705 test('names with digits are rejected as non-person', () => { 706 const contact = { name: 'John123' }; 707 const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact); 708 assert.ok(result.includes('there'), `Expected "there" for name with digits, got: ${result}`); 709 }); 710 711 test('very short names (1 char) are rejected as non-person', () => { 712 const contact = { name: 'A' }; 713 const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact); 714 assert.ok(result.includes('there'), `Expected "there" for single-char name, got: ${result}`); 715 }); 716 717 test('triple-hyphenated names are rejected as non-person', () => { 718 const contact = { name: 'Mary-Jane-Watson-Smith' }; 719 // 4 parts split by hyphen → triple-hyphen check (>2) fails 720 const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact); 721 assert.ok(result.includes('there'), `Expected "there" for triple-hyphen name, got: ${result}`); 722 }); 723 }); 724 725 // ───────────────────────────────────────────────────────────── 726 // 9. extractTemplateFields — null/no-sections default return (lines 58-69) 727 // ───────────────────────────────────────────────────────────── 728 729 describe('extractTemplateFields — null and no-sections defaults', () => { 730 test('returns default fields when scoreData is null', () => { 731 const fields = extractTemplateFields(null); 732 assert.equal(fields.primaryWeakness, 'weak call-to-action'); 733 assert.equal(fields.secondaryWeakness, 'unclear value proposition'); 734 assert.equal(fields.grade, 'F'); 735 assert.equal(fields.score, 0); 736 assert.equal(fields.impact, 30); 737 assert.equal(fields.industry, 'local service'); 738 assert.ok(fields.evidence.length > 0); 739 assert.ok(fields.reasoning.length > 0); 740 assert.ok(fields.quickImprovementOpportunity.length > 0); 741 }); 742 743 test('returns default fields when scoreData is undefined', () => { 744 const fields = extractTemplateFields(undefined); 745 assert.equal(fields.primaryWeakness, 'weak call-to-action'); 746 assert.equal(fields.grade, 'F'); 747 }); 748 749 test('returns default fields when scoreData has neither sections nor factor_scores', () => { 750 const fields = extractTemplateFields({ 751 overall_calculation: { conversion_score: 20, letter_grade: 'F' }, 752 }); 753 assert.equal(fields.primaryWeakness, 'weak call-to-action'); 754 assert.equal(fields.grade, 'F'); 755 }); 756 }); 757 758 // ───────────────────────────────────────────────────────────── 759 // 10. extractTemplateFields — sections legacy format (lines 89-104) 760 // ───────────────────────────────────────────────────────────── 761 762 describe('extractTemplateFields — sections legacy format', () => { 763 test('extracts factors from nested sections.criteria format', () => { 764 const data = { 765 sections: { 766 conversion: { 767 criteria: { 768 cta_clarity: { score: 2, explanation: 'No button', reasoning: 'Weak CTA' }, 769 trust_signals: { 770 score: 4, 771 explanation: 'Few reviews', 772 reasoning: 'Missing testimonials', 773 }, 774 }, 775 }, 776 }, 777 overall_calculation: { conversion_score: 45, letter_grade: 'F' }, 778 }; 779 const fields = extractTemplateFields(data); 780 assert.equal(fields.primaryWeakness, 'cta_clarity'); 781 assert.equal(fields.secondaryWeakness, 'trust_signals'); 782 }); 783 784 test('skips criteria entries with non-numeric score in sections format', () => { 785 const data = { 786 sections: { 787 main: { 788 criteria: { 789 bad: { score: 'not-a-number', explanation: 'x' }, 790 good: { score: 3, explanation: 'Real problem', reasoning: 'Reason' }, 791 }, 792 }, 793 }, 794 overall_calculation: { conversion_score: 30, letter_grade: 'F' }, 795 }; 796 const fields = extractTemplateFields(data); 797 assert.equal(fields.primaryWeakness, 'good'); 798 }); 799 800 test('skips section that has no criteria property', () => { 801 const data = { 802 sections: { 803 empty_section: { score: 5 }, // no .criteria 804 real_section: { 805 criteria: { 806 my_criterion: { score: 3, explanation: 'Issue', reasoning: 'Reason' }, 807 }, 808 }, 809 }, 810 overall_calculation: { conversion_score: 30, letter_grade: 'F' }, 811 }; 812 const fields = extractTemplateFields(data); 813 assert.equal(fields.primaryWeakness, 'my_criterion'); 814 }); 815 816 test('uses explanation as reasoning fallback in sections format', () => { 817 const data = { 818 sections: { 819 main: { 820 criteria: { 821 crit: { score: 3, explanation: 'Explanation text', reasoning: '' }, 822 }, 823 }, 824 }, 825 overall_calculation: { conversion_score: 30, letter_grade: 'F' }, 826 }; 827 const fields = extractTemplateFields(data); 828 // reasoning falls back to explanation when reasoning is empty string 829 assert.equal(fields.reasoning, 'Explanation text'); 830 }); 831 }); 832 833 // ───────────────────────────────────────────────────────────── 834 // 11. extractTemplateFields — factors[0] fallback (lines 109-113) 835 // ───────────────────────────────────────────────────────────── 836 837 describe('extractTemplateFields — empty factors fallback', () => { 838 test('uses default primaryWeakness when no valid criteria found', () => { 839 const data = { 840 sections: { 841 main: { 842 criteria: { 843 bad: { score: 'NaN', explanation: 'Bad' }, 844 // no numeric score criteria 845 }, 846 }, 847 }, 848 overall_calculation: { conversion_score: 20, letter_grade: 'F' }, 849 }; 850 const fields = extractTemplateFields(data); 851 assert.equal(fields.primaryWeakness, 'weak call-to-action'); 852 }); 853 854 test('uses default secondaryWeakness when only one valid criterion found', () => { 855 const data = { 856 sections: { 857 main: { 858 criteria: { 859 only_one: { score: 5, explanation: 'Issue', reasoning: 'Reason' }, 860 }, 861 }, 862 }, 863 overall_calculation: { conversion_score: 50, letter_grade: 'F' }, 864 }; 865 const fields = extractTemplateFields(data); 866 assert.equal(fields.primaryWeakness, 'only_one'); 867 assert.equal(fields.secondaryWeakness, 'unclear value proposition'); 868 }); 869 }); 870 871 // ───────────────────────────────────────────────────────────── 872 // 12. selectTemplate — throws on empty/null; 1000+ conversion sort (lines 249-270) 873 // ───────────────────────────────────────────────────────────── 874 875 describe('selectTemplate — edge cases and conversion rate sort', () => { 876 test('throws with empty templates array', () => { 877 assert.throws( 878 () => selectTemplate([], {}, 'sms'), 879 err => err.message.includes('No templates available for channel: sms') 880 ); 881 }); 882 883 test('throws with null templates', () => { 884 assert.throws( 885 () => selectTemplate(null, {}, 'email'), 886 err => err instanceof Error 887 ); 888 }); 889 890 test('weights by conversion rate when both templates have 1000+ sends', () => { 891 const templates = [ 892 { id: 'low_conv', sends: 1000, conversions: 10, body_spintax: 'Low' }, 893 { id: 'high_conv', sends: 2000, conversions: 400, body_spintax: 'High' }, 894 ]; 895 const selected = selectTemplate(templates, {}, 'email'); 896 // high_conv has 20% rate vs low_conv at 1% — should pick high_conv 897 assert.equal(selected.id, 'high_conv'); 898 }); 899 900 test('prefers lower sends template when only one has 1000+ sends', () => { 901 const templates = [ 902 { id: 'over_1000', sends: 1500, conversions: 150, body_spintax: 'Over' }, 903 { id: 'under_1000', sends: 50, conversions: 0, body_spintax: 'Under' }, 904 ]; 905 const selected = selectTemplate(templates, {}, 'email'); 906 // under_1000 has fewer sends → rotation testing logic 907 assert.equal(selected.id, 'under_1000'); 908 }); 909 }); 910 911 // ───────────────────────────────────────────────────────────── 912 // 13. loadTemplates — flat path catch (line 228-229) and throw (line 233) 913 // ───────────────────────────────────────────────────────────── 914 915 describe('loadTemplates — flat path error handling and final throw', () => { 916 test('throws when both lang-specific and flat paths fail for English', () => { 917 readFileSyncMock.mock.mockImplementation(() => { 918 throw new Error('ENOENT'); 919 }); 920 921 assert.throws( 922 () => loadTemplates('ZZ', 'en', 'email'), 923 err => err.message.includes('No templates for ZZ/en/email') 924 ); 925 }); 926 927 test('throws when lang-specific path has empty templates and flat path throws', () => { 928 let callCount = 0; 929 readFileSyncMock.mock.mockImplementation(() => { 930 callCount++; 931 if (callCount === 1) return JSON.stringify({ templates: [] }); // empty lang-specific 932 throw new Error('ENOENT'); // flat path fails 933 }); 934 935 assert.throws( 936 () => loadTemplates('AU', 'en', 'sms'), 937 err => err.message.includes('No templates for AU/en/sms') 938 ); 939 }); 940 941 test('throws when lang-specific path returns no templates array and flat path also returns empty', () => { 942 let callCount = 0; 943 readFileSyncMock.mock.mockImplementation(() => { 944 callCount++; 945 // Both paths return {} with no templates key 946 return JSON.stringify({}); 947 }); 948 949 assert.throws( 950 () => loadTemplates('US', 'en', 'email'), 951 err => err.message.includes('No templates for US/en/email') 952 ); 953 }); 954 }); 955 956 // ───────────────────────────────────────────────────────────── 957 // 14. translateWeaknessIfNeeded error path (lines 458-460) 958 // Exercised via generateTemplateProposal with non-English language 959 // ───────────────────────────────────────────────────────────── 960 961 describe('polishProposalWithHaiku — error handling path', () => { 962 test('falls back to original text when Haiku polish throws', async () => { 963 mockReadFileWithTemplates('email'); 964 // Call 1 = analyzeScoreJson (succeeds); call 2 = polishProposalWithHaiku (throws → falls back) 965 callLLMMock.mock.mockImplementationOnce(async () => ({ content: ANALYSIS_MOCK_RESPONSE })); 966 callLLMMock.mock.mockImplementation(async () => { 967 throw new Error('LLM API error during polish'); 968 }); 969 970 const siteData = { 971 domain: 'example.de', 972 country_code: 'DE', 973 language_code: 'de', // triggers translateWeaknessIfNeeded 974 keyword: 'klempner', 975 }; 976 const scoreData = makeFactorScoreData(); 977 const contact = { channel: 'email', uri: 'info@example.de' }; 978 979 // Should not throw — polishProposalWithHaiku catches the error and falls back to original text 980 const result = await generateTemplateProposal(siteData, scoreData, contact); 981 assert.ok(result.proposalText, 'should produce a proposal even when polish fails'); 982 }); 983 });