audit-report-generator.test.js
1 /** 2 * Tests for src/reports/audit-report-generator.js 3 * 4 * Covers: 5 * - getGradeColor() — all grade letters + edge cases 6 * - getScoreColor() — all threshold boundaries 7 * - COLORS constant — expected keys and hex values 8 * - FACTOR_LABELS — all 10 factors present 9 * - FACTOR_WEIGHTS — all 10 factors, weights sum to 100 10 * - generateAuditReport() — PDF generation with various data shapes 11 */ 12 13 import { test, describe, after } from 'node:test'; 14 import assert from 'node:assert/strict'; 15 import { existsSync, unlinkSync, mkdirSync } from 'fs'; 16 import { join } from 'path'; 17 import { tmpdir } from 'os'; 18 19 import { 20 generateAuditReport, 21 getGradeColor, 22 getScoreColor, 23 COLORS, 24 FACTOR_LABELS, 25 FACTOR_WEIGHTS, 26 } from '../../src/reports/audit-report-generator.js'; 27 28 const OUTPUT_DIR = join(tmpdir(), 'audit-report-tests'); 29 mkdirSync(OUTPUT_DIR, { recursive: true }); 30 const generatedFiles = []; 31 32 after(() => { 33 for (const f of generatedFiles) { 34 if (existsSync(f)) { 35 try { unlinkSync(f); } catch { /* ignore */ } 36 } 37 } 38 }); 39 40 // --------------------------------------------------------------------------- 41 // COLORS constant 42 // --------------------------------------------------------------------------- 43 describe('COLORS', () => { 44 test('contains all expected color keys', () => { 45 const expectedKeys = [ 46 'navy', 'orange', 'lightGray', 'charcoal', 'mediumGray', 47 'white', 'gradeA', 'gradeB', 'gradeC', 'gradeD', 'gradeF', 48 ]; 49 for (const key of expectedKeys) { 50 assert.ok(key in COLORS, `Missing COLORS.${key}`); 51 } 52 }); 53 54 test('all values are valid hex color strings', () => { 55 for (const [key, value] of Object.entries(COLORS)) { 56 assert.match(value, /^#[0-9a-fA-F]{6}$/, `COLORS.${key} = "${value}" is not valid hex`); 57 } 58 }); 59 }); 60 61 // --------------------------------------------------------------------------- 62 // FACTOR_LABELS 63 // --------------------------------------------------------------------------- 64 describe('FACTOR_LABELS', () => { 65 const expectedFactors = [ 66 'headline_quality', 67 'value_proposition', 68 'unique_selling_proposition', 69 'call_to_action', 70 'urgency_messaging', 71 'hook_engagement', 72 'trust_signals', 73 'imagery_design', 74 'offer_clarity', 75 'contextual_appropriateness', 76 ]; 77 78 test('contains all 10 factors', () => { 79 assert.equal(Object.keys(FACTOR_LABELS).length, 10); 80 for (const factor of expectedFactors) { 81 assert.ok(factor in FACTOR_LABELS, `Missing FACTOR_LABELS.${factor}`); 82 } 83 }); 84 85 test('all labels are non-empty strings', () => { 86 for (const [key, value] of Object.entries(FACTOR_LABELS)) { 87 assert.equal(typeof value, 'string', `FACTOR_LABELS.${key} should be string`); 88 assert.ok(value.length > 0, `FACTOR_LABELS.${key} should not be empty`); 89 } 90 }); 91 }); 92 93 // --------------------------------------------------------------------------- 94 // FACTOR_WEIGHTS 95 // --------------------------------------------------------------------------- 96 describe('FACTOR_WEIGHTS', () => { 97 test('contains the same keys as FACTOR_LABELS', () => { 98 const labelKeys = Object.keys(FACTOR_LABELS).sort(); 99 const weightKeys = Object.keys(FACTOR_WEIGHTS).sort(); 100 assert.deepEqual(weightKeys, labelKeys); 101 }); 102 103 test('weights sum to 100', () => { 104 const sum = Object.values(FACTOR_WEIGHTS).reduce((a, b) => a + b, 0); 105 assert.equal(sum, 100, `Weights sum to ${sum}, expected 100`); 106 }); 107 108 test('all weights are positive integers', () => { 109 for (const [key, value] of Object.entries(FACTOR_WEIGHTS)) { 110 assert.equal(typeof value, 'number', `FACTOR_WEIGHTS.${key} should be number`); 111 assert.ok(Number.isInteger(value), `FACTOR_WEIGHTS.${key} should be integer`); 112 assert.ok(value > 0, `FACTOR_WEIGHTS.${key} should be positive`); 113 } 114 }); 115 }); 116 117 // --------------------------------------------------------------------------- 118 // getGradeColor 119 // --------------------------------------------------------------------------- 120 describe('getGradeColor', () => { 121 test('returns gradeA color for A grades', () => { 122 assert.equal(getGradeColor('A'), COLORS.gradeA); 123 assert.equal(getGradeColor('A+'), COLORS.gradeA); 124 assert.equal(getGradeColor('A-'), COLORS.gradeA); 125 }); 126 127 test('returns gradeB color for B grades', () => { 128 assert.equal(getGradeColor('B'), COLORS.gradeB); 129 assert.equal(getGradeColor('B+'), COLORS.gradeB); 130 assert.equal(getGradeColor('B-'), COLORS.gradeB); 131 }); 132 133 test('returns gradeC color for C grades', () => { 134 assert.equal(getGradeColor('C'), COLORS.gradeC); 135 assert.equal(getGradeColor('C+'), COLORS.gradeC); 136 assert.equal(getGradeColor('C-'), COLORS.gradeC); 137 }); 138 139 test('returns gradeD color for D grades', () => { 140 assert.equal(getGradeColor('D'), COLORS.gradeD); 141 assert.equal(getGradeColor('D+'), COLORS.gradeD); 142 assert.equal(getGradeColor('D-'), COLORS.gradeD); 143 }); 144 145 test('returns gradeF color for F grade', () => { 146 assert.equal(getGradeColor('F'), COLORS.gradeF); 147 }); 148 149 test('returns gradeF color for unknown letter grades', () => { 150 assert.equal(getGradeColor('Z'), COLORS.gradeF); 151 assert.equal(getGradeColor('X'), COLORS.gradeF); 152 assert.equal(getGradeColor('E'), COLORS.gradeF); 153 }); 154 155 test('returns mediumGray for null/undefined/empty', () => { 156 assert.equal(getGradeColor(null), COLORS.mediumGray); 157 assert.equal(getGradeColor(undefined), COLORS.mediumGray); 158 assert.equal(getGradeColor(''), COLORS.mediumGray); 159 }); 160 161 test('handles lowercase grade input', () => { 162 assert.equal(getGradeColor('a+'), COLORS.gradeA); 163 assert.equal(getGradeColor('b'), COLORS.gradeB); 164 assert.equal(getGradeColor('c-'), COLORS.gradeC); 165 assert.equal(getGradeColor('d'), COLORS.gradeD); 166 assert.equal(getGradeColor('f'), COLORS.gradeF); 167 }); 168 }); 169 170 // --------------------------------------------------------------------------- 171 // getScoreColor 172 // --------------------------------------------------------------------------- 173 describe('getScoreColor', () => { 174 test('returns gradeA for scores >= 90', () => { 175 assert.equal(getScoreColor(90), COLORS.gradeA); 176 assert.equal(getScoreColor(95), COLORS.gradeA); 177 assert.equal(getScoreColor(100), COLORS.gradeA); 178 }); 179 180 test('returns gradeB for scores 80-89', () => { 181 assert.equal(getScoreColor(80), COLORS.gradeB); 182 assert.equal(getScoreColor(85), COLORS.gradeB); 183 assert.equal(getScoreColor(89), COLORS.gradeB); 184 }); 185 186 test('returns gradeC for scores 70-79', () => { 187 assert.equal(getScoreColor(70), COLORS.gradeC); 188 assert.equal(getScoreColor(75), COLORS.gradeC); 189 assert.equal(getScoreColor(79), COLORS.gradeC); 190 }); 191 192 test('returns gradeD for scores 60-69', () => { 193 assert.equal(getScoreColor(60), COLORS.gradeD); 194 assert.equal(getScoreColor(65), COLORS.gradeD); 195 assert.equal(getScoreColor(69), COLORS.gradeD); 196 }); 197 198 test('returns gradeF for scores below 60', () => { 199 assert.equal(getScoreColor(59), COLORS.gradeF); 200 assert.equal(getScoreColor(30), COLORS.gradeF); 201 assert.equal(getScoreColor(0), COLORS.gradeF); 202 }); 203 204 test('handles exact boundary values', () => { 205 assert.equal(getScoreColor(90), COLORS.gradeA); 206 assert.equal(getScoreColor(89), COLORS.gradeB); 207 assert.equal(getScoreColor(80), COLORS.gradeB); 208 assert.equal(getScoreColor(79), COLORS.gradeC); 209 assert.equal(getScoreColor(70), COLORS.gradeC); 210 assert.equal(getScoreColor(69), COLORS.gradeD); 211 assert.equal(getScoreColor(60), COLORS.gradeD); 212 assert.equal(getScoreColor(59), COLORS.gradeF); 213 }); 214 215 test('handles fractional scores', () => { 216 assert.equal(getScoreColor(89.5), COLORS.gradeB); 217 assert.equal(getScoreColor(89.9), COLORS.gradeB); 218 assert.equal(getScoreColor(90.0), COLORS.gradeA); 219 assert.equal(getScoreColor(59.9), COLORS.gradeF); 220 assert.equal(getScoreColor(60.0), COLORS.gradeD); 221 }); 222 223 test('handles negative scores', () => { 224 assert.equal(getScoreColor(-1), COLORS.gradeF); 225 assert.equal(getScoreColor(-100), COLORS.gradeF); 226 }); 227 }); 228 229 // --------------------------------------------------------------------------- 230 // generateAuditReport — PDF generation 231 // --------------------------------------------------------------------------- 232 describe('generateAuditReport', () => { 233 function makeScoreJson(overrides = {}) { 234 return { 235 overall_calculation: { 236 conversion_score: 65, 237 letter_grade: 'D+', 238 grade_interpretation: 'This site has conversion issues that need addressing.', 239 ...overrides.overall_calculation, 240 }, 241 factor_scores: { 242 headline_quality: { score: 5, reasoning: 'Generic headline', evidence: 'Welcome to Our Site' }, 243 value_proposition: { score: 6, reasoning: 'Benefits mentioned but vague' }, 244 unique_selling_proposition: { score: 3, reasoning: 'No differentiation' }, 245 call_to_action: { score: 5, reasoning: 'CTA below fold', evidence: 'Contact Us' }, 246 urgency_messaging: { score: 2, reasoning: 'No urgency messaging' }, 247 hook_engagement: { score: 7, reasoning: 'Professional hero image' }, 248 trust_signals: { score: 4, reasoning: 'No reviews visible' }, 249 imagery_design: { score: 7, reasoning: 'Clean design' }, 250 offer_clarity: { score: 5, reasoning: 'Services listed but pricing unclear' }, 251 contextual_appropriateness: { score: 8, reasoning: 'Appropriate for trades' }, 252 ...overrides.factor_scores, 253 }, 254 key_strengths: overrides.key_strengths ?? ['Professional imagery', 'Clean design'], 255 critical_weaknesses: overrides.critical_weaknesses ?? ['Generic headline', 'No trust signals'], 256 quick_improvement_opportunities: overrides.quick_improvement_opportunities ?? ['Move CTA above fold'], 257 strategic_recommendations: overrides.strategic_recommendations ?? ['Redesign CTA button'], 258 technical_assessment: { 259 ssl_status: 'https', 260 ssl_impact: 'Site is served over HTTPS, which is good for trust.', 261 security_headers_present: ['hsts', 'x-frame-options'], 262 performance_indicators: ['gzip', 'cache-control'], 263 ...overrides.technical_assessment, 264 }, 265 ...overrides, 266 }; 267 } 268 269 test('generates a PDF file and returns its path', async () => { 270 const outputPath = join(OUTPUT_DIR, `audit-basic-${Date.now()}.pdf`); 271 generatedFiles.push(outputPath); 272 273 const result = await generateAuditReport({ 274 domain: 'example.com', 275 url: 'https://example.com', 276 scoreJson: makeScoreJson(), 277 aboveFoldBuffer: null, 278 problemCrops: [], 279 outputPath, 280 }); 281 282 assert.equal(result, outputPath); 283 assert.ok(existsSync(outputPath), 'PDF file should exist'); 284 }); 285 286 test('generated PDF has non-zero size', async () => { 287 const outputPath = join(OUTPUT_DIR, `audit-size-${Date.now()}.pdf`); 288 generatedFiles.push(outputPath); 289 290 await generateAuditReport({ 291 domain: 'example.com', 292 url: 'https://example.com', 293 scoreJson: makeScoreJson(), 294 aboveFoldBuffer: null, 295 problemCrops: [], 296 outputPath, 297 }); 298 299 const { statSync } = await import('fs'); 300 const stat = statSync(outputPath); 301 assert.ok(stat.size > 0, 'PDF should not be empty'); 302 }); 303 304 test('handles A+ grade score (high score path)', async () => { 305 const outputPath = join(OUTPUT_DIR, `audit-aplus-${Date.now()}.pdf`); 306 generatedFiles.push(outputPath); 307 308 const result = await generateAuditReport({ 309 domain: 'excellent.com', 310 url: 'https://excellent.com', 311 scoreJson: makeScoreJson({ 312 overall_calculation: { conversion_score: 97, letter_grade: 'A+', grade_interpretation: 'Exceptional.' }, 313 }), 314 aboveFoldBuffer: null, 315 problemCrops: [], 316 outputPath, 317 }); 318 319 assert.ok(existsSync(result)); 320 }); 321 322 test('handles F grade score (low score path)', async () => { 323 const outputPath = join(OUTPUT_DIR, `audit-fgrade-${Date.now()}.pdf`); 324 generatedFiles.push(outputPath); 325 326 const result = await generateAuditReport({ 327 domain: 'failing.com', 328 url: 'https://failing.com', 329 scoreJson: makeScoreJson({ 330 overall_calculation: { conversion_score: 35, letter_grade: 'F', grade_interpretation: 'Failing site.' }, 331 }), 332 aboveFoldBuffer: null, 333 problemCrops: [], 334 outputPath, 335 }); 336 337 assert.ok(existsSync(result)); 338 }); 339 340 test('handles missing overall_calculation gracefully', async () => { 341 const outputPath = join(OUTPUT_DIR, `audit-nocalc-${Date.now()}.pdf`); 342 generatedFiles.push(outputPath); 343 344 const result = await generateAuditReport({ 345 domain: 'minimal.com', 346 url: 'https://minimal.com', 347 scoreJson: { 348 factor_scores: {}, 349 key_strengths: [], 350 critical_weaknesses: [], 351 quick_improvement_opportunities: [], 352 strategic_recommendations: [], 353 technical_assessment: {}, 354 }, 355 aboveFoldBuffer: null, 356 problemCrops: [], 357 outputPath, 358 }); 359 360 assert.ok(existsSync(result)); 361 }); 362 363 test('handles empty factor_scores', async () => { 364 const outputPath = join(OUTPUT_DIR, `audit-nofactors-${Date.now()}.pdf`); 365 generatedFiles.push(outputPath); 366 367 const result = await generateAuditReport({ 368 domain: 'nofactors.com', 369 url: 'https://nofactors.com', 370 scoreJson: makeScoreJson({ factor_scores: {} }), 371 aboveFoldBuffer: null, 372 problemCrops: [], 373 outputPath, 374 }); 375 376 assert.ok(existsSync(result)); 377 }); 378 379 test('handles problemCrops with all severity levels', async () => { 380 const outputPath = join(OUTPUT_DIR, `audit-crops-${Date.now()}.pdf`); 381 generatedFiles.push(outputPath); 382 383 // Valid 10x10 red PNG generated via sharp 384 const minimalPNG = Buffer.from( 385 '89504e470d0a1a0a0000000d494844520000000a0000000a08060000008d32cfbd' + 386 '0000000970485973000003e8000003e801b57b526b0000001649444154789c63f8' + 387 'cfc0f09f18cc30aaf03f5d15020092c3c739485a8e2a0000000049454e44ae426082', 388 'hex' 389 ); 390 391 const result = await generateAuditReport({ 392 domain: 'problems.com', 393 url: 'https://problems.com', 394 scoreJson: makeScoreJson(), 395 aboveFoldBuffer: null, 396 problemCrops: [ 397 { 398 factor: 'headline_quality', 399 imageBuffer: minimalPNG, 400 description: 'Generic headline needs work', 401 recommendation: 'Add location and service', 402 severity: 'high', 403 }, 404 { 405 factor: 'trust_signals', 406 imageBuffer: minimalPNG, 407 description: 'No trust signals visible', 408 recommendation: 'Add Google Reviews', 409 severity: 'medium', 410 }, 411 { 412 factor: 'urgency_messaging', 413 imageBuffer: minimalPNG, 414 description: 'No urgency messaging', 415 recommendation: 'Add a limited offer', 416 severity: 'low', 417 }, 418 ], 419 outputPath, 420 }); 421 422 assert.ok(existsSync(result)); 423 }); 424 425 test('handles problemCrops with undefined severity', async () => { 426 const outputPath = join(OUTPUT_DIR, `audit-noseverity-${Date.now()}.pdf`); 427 generatedFiles.push(outputPath); 428 429 const result = await generateAuditReport({ 430 domain: 'noseverity.com', 431 url: 'https://noseverity.com', 432 scoreJson: makeScoreJson(), 433 aboveFoldBuffer: null, 434 problemCrops: [ 435 { 436 factor: 'call_to_action', 437 imageBuffer: null, 438 description: 'CTA issue', 439 recommendation: 'Fix CTA', 440 // severity intentionally omitted — defaults to 'medium' 441 }, 442 ], 443 outputPath, 444 }); 445 446 assert.ok(existsSync(result)); 447 }); 448 449 test('handles HTTP-only site (ssl_status = http)', async () => { 450 const outputPath = join(OUTPUT_DIR, `audit-http-${Date.now()}.pdf`); 451 generatedFiles.push(outputPath); 452 453 const result = await generateAuditReport({ 454 domain: 'insecure.com', 455 url: 'http://insecure.com', 456 scoreJson: makeScoreJson({ 457 technical_assessment: { 458 ssl_status: 'http', 459 ssl_impact: 'Site is not served over HTTPS — this hurts trust.', 460 security_headers_present: [], 461 performance_indicators: [], 462 }, 463 }), 464 aboveFoldBuffer: null, 465 problemCrops: [], 466 outputPath, 467 }); 468 469 assert.ok(existsSync(result)); 470 }); 471 472 test('handles visionUsed flag', async () => { 473 const outputPath = join(OUTPUT_DIR, `audit-vision-${Date.now()}.pdf`); 474 generatedFiles.push(outputPath); 475 476 const result = await generateAuditReport({ 477 domain: 'vision.com', 478 url: 'https://vision.com', 479 scoreJson: makeScoreJson(), 480 aboveFoldBuffer: null, 481 problemCrops: [], 482 outputPath, 483 visionUsed: true, 484 }); 485 486 assert.ok(existsSync(result)); 487 }); 488 489 test('handles strategic_recommendations as objects', async () => { 490 const outputPath = join(OUTPUT_DIR, `audit-recobj-${Date.now()}.pdf`); 491 generatedFiles.push(outputPath); 492 493 const result = await generateAuditReport({ 494 domain: 'recs.com', 495 url: 'https://recs.com', 496 scoreJson: makeScoreJson({ 497 strategic_recommendations: [ 498 { description: 'Rewrite headline', priority: 1 }, 499 { description: 'Add testimonials', priority: 2 }, 500 { not_a_description: true }, // exercises JSON.stringify fallback 501 ], 502 }), 503 aboveFoldBuffer: null, 504 problemCrops: [], 505 outputPath, 506 }); 507 508 assert.ok(existsSync(result)); 509 }); 510 511 test('handles scoreJson with no grade_interpretation', async () => { 512 const outputPath = join(OUTPUT_DIR, `audit-nointerp-${Date.now()}.pdf`); 513 generatedFiles.push(outputPath); 514 515 const result = await generateAuditReport({ 516 domain: 'nointerp.com', 517 url: 'https://nointerp.com', 518 scoreJson: makeScoreJson({ 519 overall_calculation: { conversion_score: 70, letter_grade: 'C-' }, 520 }), 521 aboveFoldBuffer: null, 522 problemCrops: [], 523 outputPath, 524 }); 525 526 assert.ok(existsSync(result)); 527 }); 528 529 test('handles empty strengths and weaknesses', async () => { 530 const outputPath = join(OUTPUT_DIR, `audit-empty-lists-${Date.now()}.pdf`); 531 generatedFiles.push(outputPath); 532 533 const result = await generateAuditReport({ 534 domain: 'empty.com', 535 url: 'https://empty.com', 536 scoreJson: makeScoreJson({ 537 key_strengths: [], 538 critical_weaknesses: [], 539 quick_improvement_opportunities: [], 540 strategic_recommendations: [], 541 }), 542 aboveFoldBuffer: null, 543 problemCrops: [], 544 outputPath, 545 }); 546 547 assert.ok(existsSync(result)); 548 }); 549 550 test('handles factor with null data', async () => { 551 const outputPath = join(OUTPUT_DIR, `audit-nullfactor-${Date.now()}.pdf`); 552 generatedFiles.push(outputPath); 553 554 const result = await generateAuditReport({ 555 domain: 'nullfactor.com', 556 url: 'https://nullfactor.com', 557 scoreJson: makeScoreJson({ 558 factor_scores: { 559 headline_quality: null, 560 value_proposition: { score: 6 }, 561 }, 562 }), 563 aboveFoldBuffer: null, 564 problemCrops: [], 565 outputPath, 566 }); 567 568 assert.ok(existsSync(result)); 569 }); 570 571 test('handles all security headers present', async () => { 572 const outputPath = join(OUTPUT_DIR, `audit-allheaders-${Date.now()}.pdf`); 573 generatedFiles.push(outputPath); 574 575 const result = await generateAuditReport({ 576 domain: 'secure.com', 577 url: 'https://secure.com', 578 scoreJson: makeScoreJson({ 579 technical_assessment: { 580 ssl_status: 'https', 581 security_headers_present: [ 582 'hsts', 583 'csp', 584 'x-frame-options', 585 'x-content-type-options', 586 'referrer-policy', 587 ], 588 performance_indicators: ['gzip', 'cache-control', 'http2'], 589 }, 590 }), 591 aboveFoldBuffer: null, 592 problemCrops: [], 593 outputPath, 594 }); 595 596 assert.ok(existsSync(result)); 597 }); 598 599 test('handles no security headers present', async () => { 600 const outputPath = join(OUTPUT_DIR, `audit-noheaders-${Date.now()}.pdf`); 601 generatedFiles.push(outputPath); 602 603 const result = await generateAuditReport({ 604 domain: 'noheaders.com', 605 url: 'https://noheaders.com', 606 scoreJson: makeScoreJson({ 607 technical_assessment: { 608 ssl_status: 'https', 609 security_headers_present: [], 610 performance_indicators: [], 611 }, 612 }), 613 aboveFoldBuffer: null, 614 problemCrops: [], 615 outputPath, 616 }); 617 618 assert.ok(existsSync(result)); 619 }); 620 621 test('creates output directory if it does not exist', async () => { 622 const nestedDir = join(OUTPUT_DIR, `nested-${Date.now()}`, 'deep'); 623 const outputPath = join(nestedDir, 'report.pdf'); 624 generatedFiles.push(outputPath); 625 626 const result = await generateAuditReport({ 627 domain: 'nested.com', 628 url: 'https://nested.com', 629 scoreJson: makeScoreJson(), 630 aboveFoldBuffer: null, 631 problemCrops: [], 632 outputPath, 633 }); 634 635 assert.ok(existsSync(result)); 636 }); 637 638 test('handles N/A grade', async () => { 639 const outputPath = join(OUTPUT_DIR, `audit-nagrade-${Date.now()}.pdf`); 640 generatedFiles.push(outputPath); 641 642 const result = await generateAuditReport({ 643 domain: 'nagrade.com', 644 url: 'https://nagrade.com', 645 scoreJson: { 646 factor_scores: {}, 647 key_strengths: [], 648 critical_weaknesses: [], 649 quick_improvement_opportunities: [], 650 technical_assessment: {}, 651 }, 652 aboveFoldBuffer: null, 653 problemCrops: [], 654 outputPath, 655 }); 656 657 assert.ok(existsSync(result)); 658 }); 659 660 test('handles many problem crops (page overflow)', async () => { 661 const outputPath = join(OUTPUT_DIR, `audit-manycrops-${Date.now()}.pdf`); 662 generatedFiles.push(outputPath); 663 664 // Valid 10x10 red PNG generated via sharp 665 const minimalPNG = Buffer.from( 666 '89504e470d0a1a0a0000000d494844520000000a0000000a08060000008d32cfbd' + 667 '0000000970485973000003e8000003e801b57b526b0000001649444154789c63f8' + 668 'cfc0f09f18cc30aaf03f5d15020092c3c739485a8e2a0000000049454e44ae426082', 669 'hex' 670 ); 671 672 const crops = Array.from({ length: 10 }, (_, i) => ({ 673 factor: Object.keys(FACTOR_LABELS)[i % 10], 674 imageBuffer: minimalPNG, 675 description: `Issue ${i + 1}: this is a detailed description of a conversion problem that needs to be fixed urgently.`, 676 recommendation: `Fix recommendation ${i + 1}: implement the suggested change to improve conversion rates.`, 677 severity: ['high', 'medium', 'low'][i % 3], 678 })); 679 680 const result = await generateAuditReport({ 681 domain: 'manycrops.com', 682 url: 'https://manycrops.com', 683 scoreJson: makeScoreJson(), 684 aboveFoldBuffer: null, 685 problemCrops: crops, 686 outputPath, 687 }); 688 689 assert.ok(existsSync(result)); 690 }); 691 692 test('handles many quick wins and recommendations (page overflow)', async () => { 693 const outputPath = join(OUTPUT_DIR, `audit-manyrecs-${Date.now()}.pdf`); 694 generatedFiles.push(outputPath); 695 696 const result = await generateAuditReport({ 697 domain: 'manyrecs.com', 698 url: 'https://manyrecs.com', 699 scoreJson: makeScoreJson({ 700 quick_improvement_opportunities: Array.from({ length: 20 }, (_, i) => `Quick win ${i + 1}: do this change`), 701 strategic_recommendations: Array.from({ length: 15 }, (_, i) => `Strategic recommendation ${i + 1}: implement this`), 702 }), 703 aboveFoldBuffer: null, 704 problemCrops: [], 705 outputPath, 706 }); 707 708 assert.ok(existsSync(result)); 709 }); 710 711 test('handles unknown factor key in factor_scores', async () => { 712 const outputPath = join(OUTPUT_DIR, `audit-unknownfactor-${Date.now()}.pdf`); 713 generatedFiles.push(outputPath); 714 715 const result = await generateAuditReport({ 716 domain: 'unknown.com', 717 url: 'https://unknown.com', 718 scoreJson: makeScoreJson({ 719 factor_scores: { 720 made_up_factor: { score: 5, reasoning: 'This factor does not exist in FACTOR_LABELS' }, 721 headline_quality: { score: 7, reasoning: 'Good headline' }, 722 }, 723 }), 724 aboveFoldBuffer: null, 725 problemCrops: [], 726 outputPath, 727 }); 728 729 assert.ok(existsSync(result)); 730 }); 731 });