/ tests / pipeline / score-refresh.test.js
score-refresh.test.js
  1  /**
  2   * Score Refresh Unit Tests
  3   * Tests refreshScore() with mocked LLM API
  4   */
  5  
  6  import { describe, test, mock, beforeEach } from 'node:test';
  7  import assert from 'node:assert/strict';
  8  
  9  // Must set env before importing
 10  process.env.OPENROUTER_API_KEY = 'test-openrouter-key';
 11  process.env.SCORING_MODEL = 'openai/gpt-4o-mini';
 12  
 13  // Mock axios (dynamically imported by score-refresh)
 14  const axiosPostMock = mock.fn();
 15  mock.module('axios', {
 16    defaultExport: {
 17      post: axiosPostMock,
 18    },
 19  });
 20  
 21  // Mock dotenv
 22  mock.module('dotenv', {
 23    defaultExport: { config: () => {} },
 24    namedExports: { config: () => {} },
 25  });
 26  
 27  // Create test image buffers
 28  const { default: sharp } = await import('sharp');
 29  
 30  const testFullPage = await sharp({
 31    create: { width: 1440, height: 5000, channels: 3, background: { r: 200, g: 200, b: 200 } },
 32  })
 33    .png()
 34    .toBuffer();
 35  
 36  const testAboveFold = await sharp({
 37    create: { width: 1440, height: 900, channels: 3, background: { r: 100, g: 100, b: 100 } },
 38  })
 39    .png()
 40    .toBuffer();
 41  
 42  const { refreshScore } = await import('../../src/reports/score-refresh.js');
 43  
 44  const sampleScoreResponse = {
 45    website_url: 'https://example.com',
 46    overall_calculation: {
 47      conversion_score: 62,
 48      letter_grade: 'D-',
 49      grade_interpretation: 'Below average conversion potential',
 50    },
 51    factor_scores: {
 52      headline_quality: { score: 6, reasoning: 'Generic headline', evidence: 'Uses stock text' },
 53      call_to_action: { score: 4, reasoning: 'CTA buried below fold', evidence: 'No visible CTA' },
 54    },
 55    problem_areas: [
 56      {
 57        factor: 'call_to_action',
 58        description: 'Primary CTA is not visible above the fold',
 59        approximate_y_position_percent: 65,
 60        severity: 'high',
 61        recommendation: 'Move primary CTA above the fold',
 62      },
 63    ],
 64    technical_assessment: {
 65      ssl_enabled: true,
 66      security_headers_missing: ['Content-Security-Policy'],
 67      mobile_responsive: true,
 68    },
 69    strategic_recommendations: [
 70      {
 71        priority: 1,
 72        category: 'quick_win',
 73        description: 'Add above-fold CTA',
 74        expected_impact: 'high',
 75        estimated_effort: 'low',
 76      },
 77    ],
 78  };
 79  
 80  describe('refreshScore', () => {
 81    beforeEach(() => {
 82      axiosPostMock.mock.resetCalls();
 83    });
 84  
 85    test('calls LLM and returns parsed score JSON', async () => {
 86      axiosPostMock.mock.mockImplementation(async () => ({
 87        data: {
 88          choices: [
 89            {
 90              message: {
 91                content: JSON.stringify(sampleScoreResponse),
 92              },
 93            },
 94          ],
 95        },
 96      }));
 97  
 98      const result = await refreshScore({
 99        url: 'https://example.com',
100        fullPageBuffer: testFullPage,
101        aboveFoldBuffer: testAboveFold,
102        htmlContent: '<html><body>Test page</body></html>',
103        httpHeaders: { 'content-type': 'text/html' },
104      });
105  
106      // conversion_score is computed from factor_scores, not taken directly from LLM response
107      assert.ok(
108        typeof result.overall_calculation.conversion_score === 'number',
109        'conversion_score should be a number'
110      );
111      assert.ok(result.overall_calculation.letter_grade, 'Should have a letter grade');
112      assert.ok(result.problem_areas.length > 0);
113      assert.equal(result.problem_areas[0].factor, 'call_to_action');
114    });
115  
116    test('sends images as base64 in LLM request', async () => {
117      axiosPostMock.mock.mockImplementation(async () => ({
118        data: {
119          choices: [{ message: { content: JSON.stringify(sampleScoreResponse) } }],
120        },
121      }));
122  
123      await refreshScore({
124        url: 'https://example.com',
125        fullPageBuffer: testFullPage,
126        aboveFoldBuffer: testAboveFold,
127        htmlContent: '<html>Test</html>',
128        httpHeaders: {},
129      });
130  
131      const call = axiosPostMock.mock.calls[0];
132      const { messages } = call.arguments[1];
133      assert.equal(messages.length, 1);
134  
135      const { content } = messages[0];
136      assert.equal(content.length, 3); // text + 2 images
137      assert.equal(content[0].type, 'text');
138      assert.equal(content[1].type, 'image_url');
139      assert.equal(content[2].type, 'image_url');
140      assert.ok(content[1].image_url.url.startsWith('data:image/'));
141    });
142  
143    test('handles JSON wrapped in markdown code blocks', async () => {
144      axiosPostMock.mock.mockImplementation(async () => ({
145        data: {
146          choices: [
147            {
148              message: {
149                content: `\`\`\`json\n${JSON.stringify(sampleScoreResponse)}\n\`\`\``,
150              },
151            },
152          ],
153        },
154      }));
155  
156      const result = await refreshScore({
157        url: 'https://example.com',
158        fullPageBuffer: testFullPage,
159        aboveFoldBuffer: testAboveFold,
160        htmlContent: '<html>Test</html>',
161        httpHeaders: {},
162      });
163  
164      // conversion_score is computed from factor_scores (not taken from LLM response)
165      assert.ok(
166        typeof result.overall_calculation.conversion_score === 'number',
167        'conversion_score should be a computed number'
168      );
169    });
170  
171    test('throws on empty LLM response', async () => {
172      axiosPostMock.mock.mockImplementation(async () => ({
173        data: {
174          choices: [{ message: { content: '' } }],
175        },
176      }));
177  
178      await assert.rejects(
179        () =>
180          refreshScore({
181            url: 'https://example.com',
182            fullPageBuffer: testFullPage,
183            aboveFoldBuffer: testAboveFold,
184            htmlContent: '',
185            httpHeaders: {},
186          }),
187        err => {
188          assert.ok(err.message.includes('Empty LLM response'));
189          return true;
190        }
191      );
192    });
193  
194    test('throws on invalid JSON response', async () => {
195      axiosPostMock.mock.mockImplementation(async () => ({
196        data: {
197          choices: [{ message: { content: 'This is not JSON at all' } }],
198        },
199      }));
200  
201      await assert.rejects(
202        () =>
203          refreshScore({
204            url: 'https://example.com',
205            fullPageBuffer: testFullPage,
206            aboveFoldBuffer: testAboveFold,
207            htmlContent: '',
208            httpHeaders: {},
209          }),
210        err => {
211          assert.ok(err.message.includes('Failed to parse'));
212          return true;
213        }
214      );
215    });
216  
217    test('throws when OPENROUTER_API_KEY not set', async () => {
218      const origKey = process.env.OPENROUTER_API_KEY;
219      delete process.env.OPENROUTER_API_KEY;
220  
221      await assert.rejects(
222        () =>
223          refreshScore({
224            url: 'https://example.com',
225            fullPageBuffer: testFullPage,
226            aboveFoldBuffer: testAboveFold,
227            htmlContent: '',
228            httpHeaders: {},
229          }),
230        err => {
231          assert.ok(err.message.includes('OPENROUTER_API_KEY'));
232          return true;
233        }
234      );
235  
236      process.env.OPENROUTER_API_KEY = origKey;
237    });
238  
239    test('retries when current_text not found in HTML (validateCopyReferences path)', async () => {
240      // First response includes a current_text that won't be in the HTML
241      const responseWithBadCopyRef = {
242        ...sampleScoreResponse,
243        factor_scores: {
244          headline_quality: {
245            score: 6,
246            reasoning: 'Generic headline',
247            evidence: 'Uses stock text',
248            current_text: 'THIS TEXT DOES NOT EXIST ON THE PAGE ZZZZZ',
249          },
250        },
251      };
252  
253      // Second response (retry) returns valid data
254      const retryResponse = { ...sampleScoreResponse };
255  
256      let callCount = 0;
257      axiosPostMock.mock.mockImplementation(async () => {
258        callCount++;
259        return {
260          data: {
261            choices: [
262              {
263                message: {
264                  content: JSON.stringify(callCount === 1 ? responseWithBadCopyRef : retryResponse),
265                },
266              },
267            ],
268          },
269        };
270      });
271  
272      const result = await refreshScore({
273        url: 'https://example.com',
274        fullPageBuffer: testFullPage,
275        aboveFoldBuffer: testAboveFold,
276        htmlContent: '<html><body>Simple page content only</body></html>',
277        httpHeaders: {},
278      });
279  
280      assert.ok(result, 'Should return a result even with copy ref issues');
281      // axios called at least twice (once for initial, once for retry)
282      assert.ok(axiosPostMock.mock.calls.length >= 2, 'Should retry when copy refs not found');
283    });
284  
285    test('applies fallback when retry also fails (validateCopyReferences fallback path)', async () => {
286      const responseWithBadCopyRef = {
287        ...sampleScoreResponse,
288        factor_scores: {
289          headline_quality: {
290            score: 6,
291            reasoning: 'Generic headline',
292            current_text: 'THIS TEXT DOES NOT EXIST ON THE PAGE ZZZZZ',
293          },
294        },
295        problem_areas: [
296          {
297            factor: 'call_to_action',
298            description: 'CTA issue',
299            approximate_y_position_percent: 65,
300            severity: 'high',
301            recommendation: 'Fix CTA',
302            current_text: 'THIS ALSO DOES NOT EXIST ZZZZZ',
303          },
304        ],
305      };
306  
307      axiosPostMock.mock.mockImplementation(async () => {
308        // Both initial and retry fail with same bad copy refs
309        return {
310          data: {
311            choices: [
312              {
313                message: {
314                  content: JSON.stringify(responseWithBadCopyRef),
315                },
316              },
317            ],
318          },
319        };
320      });
321  
322      // Should not throw — fallback replaces current_text with null
323      const result = await refreshScore({
324        url: 'https://example.com',
325        fullPageBuffer: testFullPage,
326        aboveFoldBuffer: testAboveFold,
327        htmlContent: '<html><body>Simple page content</body></html>',
328        httpHeaders: {},
329      });
330  
331      assert.ok(result, 'Should return result after fallback');
332    });
333  
334    test('uses countryCode to determine target language', async () => {
335      axiosPostMock.mock.mockImplementation(async () => ({
336        data: {
337          choices: [{ message: { content: JSON.stringify(sampleScoreResponse) } }],
338        },
339      }));
340  
341      await refreshScore({
342        url: 'https://example.de',
343        fullPageBuffer: testFullPage,
344        aboveFoldBuffer: testAboveFold,
345        htmlContent: '<html><body>German page</body></html>',
346        httpHeaders: {},
347        countryCode: 'DE', // Should trigger getTargetLanguage → 'German'
348      });
349  
350      const call = axiosPostMock.mock.calls[0];
351      const textContent = call.arguments[1].messages[0].content[0].text;
352      assert.ok(textContent.includes('German'), 'Prompt should include German language reference');
353    });
354  
355    test('uses explicit targetLanguage when provided', async () => {
356      axiosPostMock.mock.mockImplementation(async () => ({
357        data: {
358          choices: [{ message: { content: JSON.stringify(sampleScoreResponse) } }],
359        },
360      }));
361  
362      await refreshScore({
363        url: 'https://example.com',
364        fullPageBuffer: testFullPage,
365        aboveFoldBuffer: testAboveFold,
366        htmlContent: '<html><body>Test page</body></html>',
367        httpHeaders: {},
368        targetLanguage: 'French (Québécois)',
369      });
370  
371      const call = axiosPostMock.mock.calls[0];
372      const textContent = call.arguments[1].messages[0].content[0].text;
373      assert.ok(textContent.includes('French'), 'Prompt should include the explicit language');
374    });
375  
376    test('truncates HTML content to 15K chars', async () => {
377      const longHtml = `<html>${'x'.repeat(20000)}</html>`;
378  
379      axiosPostMock.mock.mockImplementation(async () => ({
380        data: {
381          choices: [{ message: { content: JSON.stringify(sampleScoreResponse) } }],
382        },
383      }));
384  
385      await refreshScore({
386        url: 'https://example.com',
387        fullPageBuffer: testFullPage,
388        aboveFoldBuffer: testAboveFold,
389        htmlContent: longHtml,
390        httpHeaders: {},
391      });
392  
393      const call = axiosPostMock.mock.calls[0];
394      const textContent = call.arguments[1].messages[0].content[0].text;
395      // The HTML excerpt should be truncated to 15000 chars, so the full 20K+ HTML should NOT appear
396      assert.ok(!textContent.includes(longHtml), 'Full 20K HTML should not appear in prompt');
397      // Should contain the truncated version (first 15000 chars)
398      assert.ok(textContent.includes(longHtml.substring(0, 100)), 'Should contain beginning of HTML');
399    });
400  });