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