/ tests / pipeline / score-refresh-supplement.test.js
score-refresh-supplement.test.js
  1  /**
  2   * Score Refresh Supplement Tests
  3   *
  4   * Covers uncovered branches in src/reports/score-refresh.js:
  5   * - Lines 330-334: axios error with response.data logging
  6   * - Lines 358-359: response without overall_calculation object
  7   * - Lines 446-470: retry axios call throws → fallback replacements
  8   * - Lines 481-484: usage tokens in LLM response
  9   */
 10  
 11  import { describe, test, mock, beforeEach } from 'node:test';
 12  import assert from 'node:assert/strict';
 13  
 14  process.env.OPENROUTER_API_KEY = 'test-openrouter-key';
 15  process.env.SCORING_MODEL = 'openai/gpt-4o-mini';
 16  
 17  const axiosPostMock = mock.fn();
 18  mock.module('axios', {
 19    defaultExport: {
 20      post: axiosPostMock,
 21    },
 22  });
 23  
 24  mock.module('dotenv', {
 25    defaultExport: { config: () => {} },
 26    namedExports: { config: () => {} },
 27  });
 28  
 29  const { default: sharp } = await import('sharp');
 30  
 31  const testFullPage = await sharp({
 32    create: { width: 1440, height: 5000, channels: 3, background: { r: 200, g: 200, b: 200 } },
 33  })
 34    .png()
 35    .toBuffer();
 36  
 37  const testAboveFold = await sharp({
 38    create: { width: 1440, height: 900, channels: 3, background: { r: 100, g: 100, b: 100 } },
 39  })
 40    .png()
 41    .toBuffer();
 42  
 43  const { refreshScore } = await import('../../src/reports/score-refresh.js');
 44  
 45  const baseScore = {
 46    website_url: 'https://example.com',
 47    overall_calculation: {
 48      conversion_score: 62,
 49      letter_grade: 'D-',
 50    },
 51    factor_scores: {
 52      headline_quality: { score: 6, reasoning: 'Generic headline', evidence: 'Stock text' },
 53      call_to_action: { score: 4, reasoning: 'CTA buried', evidence: 'No visible CTA' },
 54    },
 55    problem_areas: [
 56      {
 57        factor: 'call_to_action',
 58        description: 'CTA not visible',
 59        approximate_y_position_percent: 65,
 60        severity: 'high',
 61        recommendation: 'Move CTA above fold',
 62      },
 63    ],
 64    technical_assessment: { ssl_enabled: true, mobile_responsive: true },
 65    strategic_recommendations: [
 66      { priority: 1, category: 'quick_win', description: 'Add CTA', expected_impact: 'high' },
 67    ],
 68  };
 69  
 70  describe('refreshScore - supplement branch coverage', () => {
 71    beforeEach(() => {
 72      axiosPostMock.mock.resetCalls();
 73    });
 74  
 75    // ─── Lines 330-334: axios error with response.data logging ───────────────
 76  
 77    test('logs response.data on axios error before rethrowing (lines 330-334)', async () => {
 78      const apiError = new Error('OpenRouter API 429 Too Many Requests');
 79      apiError.response = {
 80        status: 429,
 81        data: { error: { message: 'Rate limit exceeded', code: 429 } },
 82      };
 83  
 84      axiosPostMock.mock.mockImplementation(async () => {
 85        throw apiError;
 86      });
 87  
 88      await assert.rejects(
 89        () =>
 90          refreshScore({
 91            url: 'https://example.com',
 92            fullPageBuffer: testFullPage,
 93            aboveFoldBuffer: testAboveFold,
 94            htmlContent: '<html>Test</html>',
 95            httpHeaders: {},
 96          }),
 97        err => {
 98          assert.equal(err.message, 'OpenRouter API 429 Too Many Requests');
 99          return true;
100        }
101      );
102    });
103  
104    test('rethrows axios error even without response.data (lines 330-334)', async () => {
105      const networkError = new Error('ECONNREFUSED');
106      // No .response property — the if guard handles this gracefully
107      axiosPostMock.mock.mockImplementation(async () => {
108        throw networkError;
109      });
110  
111      await assert.rejects(
112        () =>
113          refreshScore({
114            url: 'https://example.com',
115            fullPageBuffer: testFullPage,
116            aboveFoldBuffer: testAboveFold,
117            htmlContent: '',
118            httpHeaders: {},
119          }),
120        err => {
121          assert.equal(err.message, 'ECONNREFUSED');
122          return true;
123        }
124      );
125    });
126  
127    // ─── Lines 358-359: response without overall_calculation ─────────────────
128  
129    test('creates overall_calculation when absent from LLM response (lines 358-359)', async () => {
130      const responseWithoutOverall = {
131        ...baseScore,
132        overall_calculation: undefined, // Deliberately missing
133        factor_scores: {
134          headline_quality: { score: 7, reasoning: 'OK headline', evidence: 'Clear text' },
135          call_to_action: { score: 5, reasoning: 'CTA present', evidence: 'Button visible' },
136        },
137      };
138  
139      axiosPostMock.mock.mockImplementation(async () => ({
140        data: {
141          choices: [
142            {
143              message: {
144                content: JSON.stringify(responseWithoutOverall),
145              },
146            },
147          ],
148        },
149      }));
150  
151      const result = await refreshScore({
152        url: 'https://example.com',
153        fullPageBuffer: testFullPage,
154        aboveFoldBuffer: testAboveFold,
155        htmlContent: '<html><body>Test page content</body></html>',
156        httpHeaders: {},
157      });
158  
159      assert.ok(result.overall_calculation, 'overall_calculation should be created if absent');
160      assert.ok(
161        typeof result.overall_calculation.conversion_score === 'number',
162        'conversion_score should be computed and set'
163      );
164      assert.ok(result.overall_calculation.letter_grade, 'letter_grade should be set');
165    });
166  
167    // ─── Lines 481-484: usage tokens in LLM response ─────────────────────────
168  
169    test('logs usage tokens when present in LLM response (lines 481-484)', async () => {
170      axiosPostMock.mock.mockImplementation(async () => ({
171        data: {
172          choices: [{ message: { content: JSON.stringify(baseScore) } }],
173          usage: {
174            prompt_tokens: 1200,
175            completion_tokens: 400,
176            total_tokens: 1600,
177          },
178        },
179      }));
180  
181      // Should not throw — usage logging is informational only
182      const result = await refreshScore({
183        url: 'https://example.com',
184        fullPageBuffer: testFullPage,
185        aboveFoldBuffer: testAboveFold,
186        htmlContent: '<html><body>Test page</body></html>',
187        httpHeaders: {},
188      });
189  
190      assert.ok(result, 'Should return result when usage tokens are present');
191      assert.ok(
192        typeof result.overall_calculation.conversion_score === 'number',
193        'Should still compute score correctly'
194      );
195    });
196  
197    test('handles response without usage field gracefully (falsy usage skips lines 481-484)', async () => {
198      axiosPostMock.mock.mockImplementation(async () => ({
199        data: {
200          choices: [{ message: { content: JSON.stringify(baseScore) } }],
201          // No usage field
202        },
203      }));
204  
205      const result = await refreshScore({
206        url: 'https://example.com',
207        fullPageBuffer: testFullPage,
208        aboveFoldBuffer: testAboveFold,
209        htmlContent: '<html><body>Test page</body></html>',
210        httpHeaders: {},
211      });
212  
213      assert.ok(result, 'Should work without usage field');
214    });
215  
216    // ─── Lines 446-470: retry axios throws → fallback replacements ───────────
217  
218    test('applies factor_scores fallback when retry axios call throws (lines 446-470)', async () => {
219      // First call returns score with bad copy ref (not in HTML)
220      const responseWithBadRef = {
221        ...baseScore,
222        factor_scores: {
223          headline_quality: {
224            score: 6,
225            reasoning: 'Generic headline',
226            current_text: 'THIS TEXT DOES NOT EXIST ON PAGE ZZZZZ9999',
227          },
228        },
229      };
230  
231      let callCount = 0;
232      axiosPostMock.mock.mockImplementation(async () => {
233        callCount++;
234        if (callCount === 1) {
235          return {
236            data: {
237              choices: [{ message: { content: JSON.stringify(responseWithBadRef) } }],
238            },
239          };
240        }
241        // Second call (retry) throws an error
242        const retryError = new Error('Retry LLM call timed out');
243        retryError.response = { data: { error: 'timeout' } };
244        throw retryError;
245      });
246  
247      // Should not throw — fallback replacements are applied
248      const result = await refreshScore({
249        url: 'https://example.com',
250        fullPageBuffer: testFullPage,
251        aboveFoldBuffer: testAboveFold,
252        htmlContent: '<html><body>Simple content only</body></html>',
253        httpHeaders: {},
254      });
255  
256      assert.ok(result, 'Should return result after fallback');
257      // The factor_scores.headline_quality.current_text should be null (replaced by fallback)
258      assert.strictEqual(
259        result.factor_scores?.headline_quality?.current_text,
260        null,
261        'fallback should set current_text to null'
262      );
263    });
264  
265    test('applies problem_areas fallback when retry throws (lines 458-468)', async () => {
266      const responseWithBadProblemRef = {
267        ...baseScore,
268        problem_areas: [
269          {
270            factor: 'call_to_action',
271            description: 'CTA not visible',
272            approximate_y_position_percent: 65,
273            severity: 'high',
274            recommendation: 'Fix CTA',
275            current_text: 'MISSING PROBLEM TEXT ZZZZZ9999',
276          },
277        ],
278      };
279  
280      let callCount = 0;
281      axiosPostMock.mock.mockImplementation(async () => {
282        callCount++;
283        if (callCount === 1) {
284          return {
285            data: {
286              choices: [{ message: { content: JSON.stringify(responseWithBadProblemRef) } }],
287            },
288          };
289        }
290        throw new Error('Retry failed with network error');
291      });
292  
293      const result = await refreshScore({
294        url: 'https://example.com',
295        fullPageBuffer: testFullPage,
296        aboveFoldBuffer: testAboveFold,
297        htmlContent: '<html><body>Just some simple text here</body></html>',
298        httpHeaders: {},
299      });
300  
301      assert.ok(result, 'Should return result after fallback');
302      assert.strictEqual(
303        result.problem_areas?.[0]?.current_text,
304        null,
305        'problem_areas fallback should set current_text to null'
306      );
307    });
308  });