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 });