/ tests / proposals / template-proposals-unit.test.js
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  });