template-proposals-unit.test.js
1 /** 2 * Template Proposals JavaScript API Unit Tests 3 * Tests extractTemplateFields, loadTemplates, selectTemplate, populateTemplate, 4 * and generateTemplateProposal functions from src/utils/template-proposals.js 5 */ 6 7 import { describe, test, mock } from 'node:test'; 8 import assert from 'node:assert/strict'; 9 import * as realFs from 'fs'; 10 11 // Mock dotenv 12 mock.module('dotenv', { 13 defaultExport: { config: () => {} }, 14 namedExports: { config: () => {} }, 15 }); 16 17 // Mock llm-provider to prevent OPENROUTER_API_KEY missing error at import time 18 mock.module('../../src/utils/llm-provider.js', { 19 namedExports: { 20 callLLM: mock.fn(async () => ({ 21 content: JSON.stringify({ 22 industry: 'plumbing', 23 recommendation: 'Add a clear call-to-action.', 24 recommendation_sms: 'Improve your CTA', 25 }), 26 usage: { promptTokens: 100, completionTokens: 50 }, 27 })), 28 getProvider: mock.fn(() => 'openrouter'), 29 getProviderDisplayName: mock.fn(() => 'OpenRouter'), 30 }, 31 }); 32 33 // Mock llm-usage-tracker to prevent DB access in tests 34 mock.module('../../src/utils/llm-usage-tracker.js', { 35 namedExports: { 36 logLLMUsage: mock.fn(() => {}), 37 }, 38 }); 39 40 // Mock readFileSync while keeping other fs functions intact (logger needs existsSync) 41 const readFileSyncMock = mock.fn(realFs.readFileSync); 42 mock.module('fs', { 43 namedExports: { 44 ...realFs, 45 readFileSync: readFileSyncMock, 46 }, 47 }); 48 49 const { 50 extractTemplateFields, 51 loadTemplates, 52 selectTemplate, 53 populateTemplate, 54 generateTemplateProposal, 55 } = await import('../../src/utils/template-proposals.js'); 56 57 // ───────────────────────────────────────────── 58 // Helper fixtures 59 // ───────────────────────────────────────────── 60 61 function makeScoreData(overrides = {}) { 62 return { 63 sections: { 64 conversion: { 65 criteria: { 66 cta_clarity: { score: 2, explanation: 'CTA is unclear', reasoning: 'No clear button' }, 67 trust_signals: { 68 score: 3, 69 explanation: 'Few trust indicators', 70 reasoning: 'Missing reviews', 71 }, 72 hero_message: { score: 5, explanation: 'Weak hero', reasoning: 'Vague headline' }, 73 }, 74 }, 75 design: { 76 criteria: { 77 mobile_friendly: { 78 score: 7, 79 explanation: 'Mobile acceptable', 80 reasoning: 'Mostly works', 81 }, 82 loading_speed: { score: 8, explanation: 'Fast load', reasoning: 'Good performance' }, 83 }, 84 }, 85 }, 86 overall_calculation: { 87 conversion_score: 45, 88 letter_grade: 'F', 89 }, 90 ...overrides, 91 }; 92 } 93 94 function makeTemplates(overrides = []) { 95 return overrides.length > 0 96 ? overrides 97 : [ 98 { 99 id: 'email_001', 100 channel: 'email', 101 body_spintax: 'Hi [firstname], your [primary_weakness] on [domain] needs work.', 102 subject_spintax: 'Your [industry] website analysis', 103 sends: 0, 104 conversions: 0, 105 }, 106 { 107 id: 'email_002', 108 channel: 'email', 109 body_spintax: 'Hello [firstname], we found issues with [secondary_weakness].', 110 subject_spintax: 'Website audit for [domain]', 111 sends: 50, 112 conversions: 5, 113 }, 114 { 115 id: 'email_003', 116 channel: 'email', 117 body_spintax: 'Dear [firstname], your score is [score] ([grade]).', 118 subject_spintax: null, 119 sends: 2000, 120 conversions: 200, 121 }, 122 ]; 123 } 124 125 // ═══════════════════════════════════════════ 126 // extractTemplateFields 127 // ═══════════════════════════════════════════ 128 129 describe('extractTemplateFields', () => { 130 test('extracts primary and secondary weakness from sections', () => { 131 const fields = extractTemplateFields(makeScoreData()); 132 assert.equal(fields.primaryWeakness, 'cta_clarity'); 133 assert.equal(fields.secondaryWeakness, 'trust_signals'); 134 }); 135 136 test('extracts evidence from primary weakness explanation', () => { 137 const fields = extractTemplateFields(makeScoreData()); 138 assert.equal(fields.evidence, 'CTA is unclear'); 139 }); 140 141 test('extracts score and grade from overall_calculation', () => { 142 const fields = extractTemplateFields(makeScoreData()); 143 assert.equal(fields.score, 45); 144 assert.equal(fields.grade, 'F'); 145 }); 146 147 test('extracts industry from factor_scores', () => { 148 const data = makeScoreData({ 149 factor_scores: { contextual_appropriateness: { industry_context: 'plumbing' } }, 150 }); 151 const fields = extractTemplateFields(data); 152 assert.equal(fields.industry, 'plumbing'); 153 }); 154 155 test('clamps impact between 20 and 50', () => { 156 const fields = extractTemplateFields(makeScoreData()); 157 assert.ok(fields.impact >= 20 && fields.impact <= 50, `impact ${fields.impact} out of range`); 158 }); 159 160 test('returns defaults when scoreData is null', () => { 161 const fields = extractTemplateFields(null); 162 assert.equal(fields.primaryWeakness, 'weak call-to-action'); 163 assert.equal(fields.secondaryWeakness, 'unclear value proposition'); 164 assert.equal(fields.grade, 'F'); 165 assert.equal(fields.score, 0); 166 assert.equal(fields.impact, 30); 167 }); 168 169 test('returns defaults when scoreData has no sections', () => { 170 const fields = extractTemplateFields({ 171 overall_calculation: { conversion_score: 20, letter_grade: 'F' }, 172 }); 173 assert.equal(fields.primaryWeakness, 'weak call-to-action'); 174 }); 175 176 test('falls back to local service when no industry context', () => { 177 const data = makeScoreData(); 178 delete data.factor_scores; 179 const fields = extractTemplateFields(data); 180 assert.equal(fields.industry, 'local service'); 181 }); 182 183 test('falls back to local service when factor_scores missing contextual_appropriateness', () => { 184 const data = makeScoreData({ factor_scores: {} }); 185 const fields = extractTemplateFields(data); 186 assert.equal(fields.industry, 'local service'); 187 }); 188 189 test('handles single criterion - secondaryWeakness gets default', () => { 190 const data = { 191 sections: { 192 main: { 193 criteria: { 194 only_criterion: { score: 5, explanation: 'Just one', reasoning: 'Reason' }, 195 }, 196 }, 197 }, 198 overall_calculation: { conversion_score: 50, letter_grade: 'F' }, 199 }; 200 const fields = extractTemplateFields(data); 201 assert.equal(fields.primaryWeakness, 'only_criterion'); 202 assert.equal(fields.secondaryWeakness, 'unclear value proposition'); 203 }); 204 205 test('uses reasoning field when available', () => { 206 const data = makeScoreData(); 207 const fields = extractTemplateFields(data); 208 assert.equal(fields.reasoning, 'No clear button'); 209 }); 210 }); 211 212 // ═══════════════════════════════════════════ 213 // loadTemplates 214 // ═══════════════════════════════════════════ 215 216 describe('loadTemplates', () => { 217 test('loads and returns templates array for valid country/channel', () => { 218 const mockData = { templates: makeTemplates() }; 219 readFileSyncMock.mock.mockImplementation(() => JSON.stringify(mockData)); 220 221 const templates = loadTemplates('AU', 'en', 'email'); 222 assert.equal(templates.length, 3); 223 assert.equal(templates[0].id, 'email_001'); 224 }); 225 226 test('falls back to email when unsupported channel specified', () => { 227 const mockData = { templates: makeTemplates() }; 228 readFileSyncMock.mock.mockImplementation(() => JSON.stringify(mockData)); 229 230 const templates = loadTemplates('AU', 'en', 'linkedin'); 231 assert.ok(Array.isArray(templates)); 232 }); 233 234 test('throws when country file not found (no US fallback in loadTemplates)', () => { 235 readFileSyncMock.mock.mockImplementation(path => { 236 if (typeof path === 'string' && path.includes('/ZZ/')) { 237 throw new Error('File not found'); 238 } 239 return JSON.stringify({ templates: makeTemplates() }); 240 }); 241 242 assert.throws( 243 () => loadTemplates('ZZ', 'en', 'email'), 244 err => err.message.includes('No templates for ZZ') 245 ); 246 }); 247 248 test('throws when all template paths fail', () => { 249 readFileSyncMock.mock.mockImplementation(path => { 250 if (typeof path === 'string' && path.includes('templates/')) { 251 throw new Error('File not found'); 252 } 253 return realFs.readFileSync(path, 'utf-8'); 254 }); 255 256 assert.throws( 257 () => loadTemplates('US', 'en', 'email'), 258 err => err.message.includes('No templates for US') 259 ); 260 }); 261 262 test('throws when templates key missing from JSON', () => { 263 readFileSyncMock.mock.mockImplementation(() => JSON.stringify({})); 264 assert.throws( 265 () => loadTemplates('AU', 'en', 'sms'), 266 err => err.message.includes('No templates for AU') 267 ); 268 }); 269 270 test('supports sms channel', () => { 271 const mockData = { 272 templates: [ 273 { id: 'sms_001', channel: 'sms', body_spintax: 'Hi [firstname]', sends: 0, conversions: 0 }, 274 ], 275 }; 276 readFileSyncMock.mock.mockImplementation(() => JSON.stringify(mockData)); 277 278 const templates = loadTemplates('AU', 'en', 'sms'); 279 assert.equal(templates.length, 1); 280 assert.equal(templates[0].id, 'sms_001'); 281 }); 282 }); 283 284 // ═══════════════════════════════════════════ 285 // selectTemplate 286 // ═══════════════════════════════════════════ 287 288 describe('selectTemplate', () => { 289 test('throws when no templates provided', () => { 290 assert.throws(() => selectTemplate([], {}, 'email'), { message: /No templates available/ }); 291 }); 292 293 test('throws when templates is null', () => { 294 assert.throws( 295 () => selectTemplate(null, {}, 'email'), 296 err => err instanceof Error 297 ); 298 }); 299 300 test('selects template with fewest sends for rotation testing', () => { 301 const templates = [ 302 { id: 'a', sends: 100, conversions: 5, body_spintax: 'A' }, 303 { id: 'b', sends: 0, conversions: 0, body_spintax: 'B' }, 304 { id: 'c', sends: 50, conversions: 3, body_spintax: 'C' }, 305 ]; 306 const selected = selectTemplate(templates, {}, 'email'); 307 assert.equal(selected.id, 'b'); 308 }); 309 310 test('weights by conversion rate when all templates have 1000+ sends', () => { 311 const templates = [ 312 { id: 'high_conv', sends: 2000, conversions: 400, body_spintax: 'High' }, 313 { id: 'low_conv', sends: 1000, conversions: 10, body_spintax: 'Low' }, 314 { id: 'mid_conv', sends: 1500, conversions: 150, body_spintax: 'Mid' }, 315 ]; 316 const selected = selectTemplate(templates, {}, 'email'); 317 assert.equal(selected.id, 'high_conv'); 318 }); 319 320 test('returns only template when exactly one available', () => { 321 const templates = [{ id: 'only', sends: 0, conversions: 0, body_spintax: 'Only one' }]; 322 const selected = selectTemplate(templates, {}, 'email'); 323 assert.equal(selected.id, 'only'); 324 }); 325 326 test('chooses low-sends template over high-sends when mixed', () => { 327 const templates = [ 328 { id: 'high', sends: 999, conversions: 100, body_spintax: 'High' }, 329 { id: 'low', sends: 5, conversions: 0, body_spintax: 'Low' }, 330 ]; 331 const selected = selectTemplate(templates, {}, 'email'); 332 assert.equal(selected.id, 'low'); 333 }); 334 335 test('handles templates with undefined sends as 0', () => { 336 const templates = [ 337 { id: 'no_sends', body_spintax: 'No sends field' }, 338 { id: 'has_sends', sends: 10, body_spintax: 'Has sends' }, 339 ]; 340 const selected = selectTemplate(templates, {}, 'email'); 341 assert.equal(selected.id, 'no_sends'); 342 }); 343 }); 344 345 // ═══════════════════════════════════════════ 346 // populateTemplate 347 // ═══════════════════════════════════════════ 348 349 describe('populateTemplate', () => { 350 const siteData = { domain: 'test-plumber.com.au', keyword: 'plumber Sydney' }; 351 const fields = { 352 primaryWeakness: 'cta_clarity', 353 secondaryWeakness: 'trust_signals', 354 evidence: 'No clear call-to-action', 355 reasoning: 'Visitors are confused', 356 industry: 'plumbing', 357 score: 45, 358 grade: 'F', 359 impact: 35, 360 }; 361 362 test('replaces [domain] placeholder', () => { 363 const result = populateTemplate('Visit [domain] now', fields, siteData); 364 assert.ok(result.includes('test-plumber.com.au')); 365 }); 366 367 test('replaces [grade] placeholder', () => { 368 const result = populateTemplate('Grade: [grade]', fields, siteData); 369 assert.ok(result.includes('F')); 370 }); 371 372 test('replaces [grade] and [score] placeholders', () => { 373 const result = populateTemplate('Score: [score] ([grade])', fields, siteData); 374 assert.ok(result.includes('45')); 375 assert.ok(result.includes('F')); 376 }); 377 378 test('replaces [industry] placeholder (from analysisData)', () => { 379 const result = populateTemplate('For [industry] businesses', fields, siteData, null, { 380 industry: 'plumbing', 381 }); 382 assert.ok(result.includes('plumbing')); 383 }); 384 385 test('replaces [impact] placeholder', () => { 386 const result = populateTemplate('Costs [impact]% conversions', fields, siteData); 387 assert.ok(result.includes('35')); 388 }); 389 390 test('uses real first name when contact name is provided', () => { 391 const contact = { name: 'Sarah' }; 392 const result = populateTemplate('Hi [firstname]!', fields, siteData, contact); 393 assert.ok(result.includes('Sarah')); 394 }); 395 396 test('uses fallback when contact name is generic (info)', () => { 397 const contact = { name: 'info' }; 398 const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact); 399 assert.ok(result.includes('there')); 400 }); 401 402 test('uses fallback when contact name is admin', () => { 403 const contact = { name: 'admin' }; 404 const result = populateTemplate('Hi [firstname|there]!', fields, siteData, contact); 405 assert.ok(result.includes('there')); 406 }); 407 408 test('uses fallback when no contact provided', () => { 409 const result = populateTemplate('Hi [firstname|there]!', fields, siteData, null); 410 assert.ok(result.includes('there')); 411 }); 412 413 test('extracts business name from domain (strips TLD and hyphens)', () => { 414 const result = populateTemplate('[business_name] audit', fields, siteData); 415 assert.ok(result.includes('test plumber')); 416 }); 417 418 test('replaces [domain] in multi-occurrence template', () => { 419 const result = populateTemplate('[domain] and [domain]', fields, siteData); 420 assert.ok(result.includes('test-plumber.com.au')); 421 }); 422 423 test('replaces multiple occurrences of same placeholder', () => { 424 const result = populateTemplate('[domain] for [domain]', fields, siteData); 425 const count = (result.match(/test-plumber.com.au/g) || []).length; 426 assert.equal(count, 2); 427 }); 428 429 test('uses industry from analysisData when provided', () => { 430 const siteNoKeyword = { domain: 'example.com' }; 431 const result = populateTemplate('[industry] work', fields, siteNoKeyword, null, { 432 industry: 'plumbing', 433 }); 434 assert.ok(result.includes('plumbing')); 435 }); 436 437 test('replaces [recommendation] placeholder from analysisData', () => { 438 const result = populateTemplate('Fix: [recommendation]', fields, siteData, null, { 439 recommendation: 'Add a clear CTA', 440 }); 441 assert.ok(result.includes('Add a clear CTA')); 442 }); 443 444 test('replaces [recommendation_sms] from analysisData', () => { 445 const result = populateTemplate('[recommendation_sms]', fields, siteData, null, { 446 recommendation_sms: 'Improve CTA', 447 }); 448 assert.ok(result.includes('Improve CTA')); 449 }); 450 }); 451 452 // ═══════════════════════════════════════════ 453 // generateTemplateProposal 454 // ═══════════════════════════════════════════ 455 456 describe('generateTemplateProposal', () => { 457 test('generates proposal with template id and text', async () => { 458 const mockData = { 459 templates: [ 460 { 461 id: 'email_test_01', 462 channel: 'email', 463 body_spintax: 'Hi [firstname|there], check [domain] today.', 464 subject_spintax: 'Analysis for [domain]', 465 sends: 0, 466 conversions: 0, 467 }, 468 ], 469 }; 470 readFileSyncMock.mock.mockImplementation(() => JSON.stringify(mockData)); 471 472 const siteData = { domain: 'acme.com', country_code: 'AU', keyword: 'plumber' }; 473 const scoreData = makeScoreData(); 474 const contact = { name: 'John', channel: 'email', uri: 'john@acme.com' }; 475 476 const result = await generateTemplateProposal(siteData, scoreData, contact); 477 478 assert.ok(result.proposalText, 'should have proposal text'); 479 assert.equal(result.templateId, 'email_test_01'); 480 assert.ok(result.subjectLine, 'should have subject line'); 481 assert.ok(result.proposalText.includes('acme')); 482 }); 483 484 test('subject line is null for SMS channel (SMS has no subject)', async () => { 485 const mockData = { 486 templates: [ 487 { 488 id: 'sms_test_01', 489 channel: 'sms', 490 body_spintax: 'Hi [firstname|there], check [domain]', 491 subject_spintax: null, 492 sends: 0, 493 conversions: 0, 494 }, 495 ], 496 }; 497 readFileSyncMock.mock.mockImplementation(() => JSON.stringify(mockData)); 498 499 const siteData = { domain: 'acme.com', country_code: 'AU', keyword: 'plumber' }; 500 const scoreData = makeScoreData(); 501 const contact = { name: 'Jane', channel: 'sms', uri: '+61412345678' }; 502 503 const result = await generateTemplateProposal(siteData, scoreData, contact); 504 505 // SMS channel never has a subject line 506 assert.equal(result.subjectLine, null, 'SMS proposals should have null subject line'); 507 assert.ok(result.proposalText, 'SMS proposals should have proposal text'); 508 }); 509 510 test('uses AU country code when country_code not in site data', async () => { 511 let capturedPath = ''; 512 readFileSyncMock.mock.mockImplementation(path => { 513 if (typeof path === 'string' && path.includes('templates/')) { 514 capturedPath = path; 515 } 516 return JSON.stringify({ 517 templates: [ 518 { 519 id: 'au_01', 520 channel: 'email', 521 body_spintax: 'Hello [firstname|there], visit [domain]', 522 subject_spintax: 'Hi', 523 sends: 0, 524 conversions: 0, 525 }, 526 ], 527 }); 528 }); 529 530 const siteData = { domain: 'acme.com' }; // No country_code — defaults to AU 531 const contact = { channel: 'email', uri: 'test@acme.com' }; 532 533 await generateTemplateProposal(siteData, makeScoreData(), contact); 534 assert.ok( 535 capturedPath.includes('/AU/'), 536 `Should use AU templates (default), got: ${capturedPath}` 537 ); 538 }); 539 });