/ tests / reports / audit-report-generator.test.js
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  });