/ tests / reports / score-refresh.test.js
score-refresh.test.js
  1  /**
  2   * Tests for src/reports/score-refresh.js
  3   *
  4   * Covers:
  5   *   - refreshScore: missing OPENROUTER_API_KEY throws
  6   *   - refreshScore: happy path returns scoreJson with computed score + grade
  7   *   - refreshScore: empty LLM response throws
  8   *   - refreshScore: invalid JSON in LLM response throws
  9   *   - refreshScore: thinking blocks are stripped before JSON parse
 10   *   - refreshScore: markdown code block wrapping stripped
 11   *   - refreshScore: uses English for unknown country code
 12   *   - refreshScore: targetLanguage override
 13   *   - refreshScore: reasoning config added for anthropic/ models
 14   *   - refreshScore: no reasoning config for non-anthropic models
 15   *   - refreshScore: copy reference validation triggers retry
 16   *   - refreshScore: retry failure falls back to null replacements
 17   *   - refreshScore: persistent mismatch after retry nulls problem_areas refs
 18   *   - refreshScore: copy reference validation passes cleanly
 19   *   - refreshScore: null htmlContent skips copy validation
 20   *   - refreshScore: adds overall_calculation when missing
 21   *   - refreshScore: API error re-throws
 22   *
 23   * Run: NODE_ENV=test LOGS_DIR=/tmp/test-logs node --experimental-test-module-mocks --test tests/reports/score-refresh.test.js
 24   */
 25  
 26  import { describe, it, mock } from 'node:test';
 27  import assert from 'node:assert/strict';
 28  import Database from 'better-sqlite3';
 29  import { createPgMock } from '../helpers/pg-mock.js';
 30  
 31  // ── Set env vars BEFORE any module imports ────────────────────────────────────
 32  
 33  process.env.OPENROUTER_API_KEY = 'test-openrouter-key';
 34  process.env.NODE_ENV = 'test';
 35  process.env.LOGS_DIR = '/tmp/test-logs';
 36  
 37  // ── Mutable axios controller ──────────────────────────────────────────────────
 38  // score-refresh.js does `const { default: axios } = await import('axios')` on
 39  // each call, so we mock axios once but use a mutable handler that each test can
 40  // swap out.
 41  
 42  const axiosController = {
 43    post: null, // set by each test
 44  };
 45  
 46  const defaultScoreJson = () => ({
 47    factor_scores: {
 48      headline_quality: { score: 7, current_text: 'Welcome to our site' },
 49    },
 50    problem_areas: [],
 51    strategic_recommendations: ['Fix headline'],
 52    report_narratives: { summary: 'Needs improvement.' },
 53  });
 54  
 55  axiosController.post = async () => ({
 56    data: {
 57      choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }],
 58      usage: { prompt_tokens: 1000, completion_tokens: 500 },
 59    },
 60  });
 61  
 62  mock.module('axios', {
 63    defaultExport: {
 64      post: async (...args) => axiosController.post(...args),
 65    },
 66  });
 67  
 68  // ── Mock sharp (native module) ────────────────────────────────────────────────
 69  
 70  const sharpController = {
 71    width: 800,
 72    height: 600,
 73  };
 74  
 75  mock.module('sharp', {
 76    defaultExport: mock.fn(buf => ({
 77      metadata: async () => ({ width: sharpController.width, height: sharpController.height }),
 78      resize: () => ({
 79        jpeg: () => ({
 80          toBuffer: async () => Buffer.from('resized-jpeg'),
 81        }),
 82      }),
 83      jpeg: () => ({
 84        toBuffer: async () => Buffer.from('jpeg-direct'),
 85      }),
 86      toBuffer: async () => buf,
 87    })),
 88  });
 89  
 90  // ── Mock node:fs readFileSync ─────────────────────────────────────────────────
 91  
 92  const mockAuditPrompt = 'Audit this website: {target_language}\nReturn JSON only.';
 93  
 94  mock.module('node:fs', {
 95    namedExports: {
 96      readFileSync: mock.fn((filePath, _enc) => {
 97        if (String(filePath).includes('AUDIT-REPORT-SCORING')) {
 98          return mockAuditPrompt;
 99        }
100        throw Object.assign(new Error(`ENOENT: no such file: ${filePath}`), { code: 'ENOENT' });
101      }),
102    },
103  });
104  
105  // ── Mock db.js via createPgMock ───────────────────────────────────────────────
106  
107  const dbController = {
108    outreachRows: [],
109    inboundRows: [],
110    throwOnNew: false,
111  };
112  
113  // score-refresh.js calls getAll() twice for a siteId: once for outreach rows,
114  // once for inbound rows. We intercept at the db.js level.
115  mock.module('../../src/utils/db.js', {
116    namedExports: {
117      getPool: () => ({}),
118      closePool: async () => {},
119      query: async () => ({ rows: [], rowCount: 0 }),
120      run: async () => ({ changes: 0 }),
121      withTransaction: async fn => fn({ query: async () => ({ rows: [], rowCount: 0 }) }),
122      getOne: async () => null,
123      getAll: async (sql) => {
124        if (dbController.throwOnNew) throw new Error('DB connection failed');
125        // Distinguish outreach query (direction = 'outbound') from conversation query (direction = 'inbound')
126        if (sql && sql.includes("'outbound'")) {
127          return dbController.outreachRows;
128        }
129        return dbController.inboundRows;
130      },
131      createDatabaseConnection: () => ({}),
132      closeDatabaseConnection: async () => {},
133    },
134  });
135  
136  // ── Mock load-env ─────────────────────────────────────────────────────────────
137  
138  mock.module('../../src/utils/load-env.js', { defaultExport: {} });
139  
140  // ── Mock logger ───────────────────────────────────────────────────────────────
141  
142  mock.module('../../src/utils/logger.js', {
143    defaultExport: class {
144      info() {}
145      success() {}
146      error() {}
147      warn() {}
148    },
149  });
150  
151  // ── Mock score.js ─────────────────────────────────────────────────────────────
152  
153  const scoreController = { score: 65, grade: 'D' };
154  
155  mock.module('../../src/score.js', {
156    namedExports: {
157      computeScoreFromFactors: mock.fn(() => scoreController.score),
158      computeGrade: mock.fn(() => scoreController.grade),
159    },
160  });
161  
162  // ── Import module under test AFTER all mocks ──────────────────────────────────
163  
164  const { refreshScore } = await import('../../src/reports/score-refresh.js');
165  
166  // ── Helpers ───────────────────────────────────────────────────────────────────
167  
168  function makeSiteData(overrides = {}) {
169    return {
170      url: 'https://example.com',
171      fullPageBuffer: Buffer.from('full-page-screenshot'),
172      aboveFoldBuffer: Buffer.from('above-fold-screenshot'),
173      htmlContent: '<html><body>Welcome to our site. Call us now.</body></html>',
174      httpHeaders: { 'content-type': 'text/html' },
175      siteId: null,
176      countryCode: 'AU',
177      ...overrides,
178    };
179  }
180  
181  function resetAxiosToDefault() {
182    axiosController.post = async () => ({
183      data: {
184        choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }],
185        usage: { prompt_tokens: 1000, completion_tokens: 500 },
186      },
187    });
188  }
189  
190  // ── Tests ─────────────────────────────────────────────────────────────────────
191  
192  describe('refreshScore', () => {
193    it('returns scoreJson with computed score and grade on happy path', async () => {
194      resetAxiosToDefault();
195      const result = await refreshScore(makeSiteData());
196      assert.ok(result, 'should return a result');
197      assert.ok(result.overall_calculation, 'should have overall_calculation');
198      assert.equal(result.overall_calculation.conversion_score, 65);
199      assert.equal(result.overall_calculation.letter_grade, 'D');
200    });
201  
202    it('throws when OPENROUTER_API_KEY is not set', async () => {
203      const savedKey = process.env.OPENROUTER_API_KEY;
204      delete process.env.OPENROUTER_API_KEY;
205      try {
206        await assert.rejects(() => refreshScore(makeSiteData()), /OPENROUTER_API_KEY not configured/);
207      } finally {
208        process.env.OPENROUTER_API_KEY = savedKey;
209      }
210    });
211  
212    it('throws when LLM response content is null (empty response)', async () => {
213      axiosController.post = async () => ({
214        data: { choices: [{ message: { content: null } }] },
215      });
216      await assert.rejects(() => refreshScore(makeSiteData()), /Empty LLM response/);
217      resetAxiosToDefault();
218    });
219  
220    it('throws when LLM response content is empty string', async () => {
221      axiosController.post = async () => ({
222        data: { choices: [{ message: { content: '' } }] },
223      });
224      await assert.rejects(() => refreshScore(makeSiteData()), /Empty LLM response/);
225      resetAxiosToDefault();
226    });
227  
228    it('throws when LLM response is not valid JSON', async () => {
229      axiosController.post = async () => ({
230        data: { choices: [{ message: { content: 'NOT JSON AT ALL !!!!' } }] },
231      });
232      await assert.rejects(
233        () => refreshScore(makeSiteData()),
234        /Failed to parse LLM response as JSON/
235      );
236      resetAxiosToDefault();
237    });
238  
239    it('strips thinking blocks before parsing JSON', async () => {
240      const scoreJson = defaultScoreJson();
241      axiosController.post = async () => ({
242        data: {
243          choices: [
244            {
245              message: {
246                content: `<thinking>I need to analyze this website carefully.</thinking>${JSON.stringify(scoreJson)}`,
247              },
248            },
249          ],
250          usage: { prompt_tokens: 100, completion_tokens: 200 },
251        },
252      });
253      const result = await refreshScore(makeSiteData());
254      assert.ok(result.factor_scores, 'should have parsed factor_scores');
255      assert.ok(result.overall_calculation, 'should have overall_calculation added');
256      resetAxiosToDefault();
257    });
258  
259    it('strips markdown code block wrapping from LLM response', async () => {
260      const scoreJson = defaultScoreJson();
261      axiosController.post = async () => ({
262        data: {
263          choices: [
264            {
265              message: {
266                content: `\`\`\`json\n${JSON.stringify(scoreJson)}\n\`\`\``,
267              },
268            },
269          ],
270          usage: { prompt_tokens: 100, completion_tokens: 200 },
271        },
272      });
273      const result = await refreshScore(makeSiteData());
274      assert.ok(result.factor_scores, 'should have parsed factor_scores after stripping markdown');
275      resetAxiosToDefault();
276    });
277  
278    it('uses English as default language for unknown country code', async () => {
279      const capturedBodies = [];
280      axiosController.post = async (url, body) => {
281        capturedBodies.push(body);
282        return {
283          data: {
284            choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }],
285            usage: { prompt_tokens: 50, completion_tokens: 50 },
286          },
287        };
288      };
289      await refreshScore(makeSiteData({ countryCode: 'XX' }));
290      assert.equal(capturedBodies.length, 1);
291      const msgText = capturedBodies[0].messages[0].content[0].text;
292      assert.ok(msgText.includes('English'), 'unknown country code should fall back to English');
293      resetAxiosToDefault();
294    });
295  
296    it('uses targetLanguage override when provided', async () => {
297      const capturedBodies = [];
298      axiosController.post = async (url, body) => {
299        capturedBodies.push(body);
300        return {
301          data: {
302            choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }],
303            usage: { prompt_tokens: 50, completion_tokens: 50 },
304          },
305        };
306      };
307      await refreshScore(makeSiteData({ targetLanguage: 'Klingon', countryCode: 'AU' }));
308      assert.equal(capturedBodies.length, 1);
309      const msgText = capturedBodies[0].messages[0].content[0].text;
310      assert.ok(msgText.includes('Klingon'), 'should use targetLanguage override');
311      resetAxiosToDefault();
312    });
313  
314    it('adds reasoning config for anthropic/ model prefix', async () => {
315      const capturedBodies = [];
316      const savedModel = process.env.AUDIT_REPORT_MODEL;
317      process.env.AUDIT_REPORT_MODEL = 'anthropic/claude-opus-4';
318      axiosController.post = async (url, body) => {
319        capturedBodies.push(body);
320        return {
321          data: {
322            choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }],
323            usage: { prompt_tokens: 50, completion_tokens: 50 },
324          },
325        };
326      };
327      await refreshScore(makeSiteData());
328      assert.equal(capturedBodies.length, 1);
329      assert.ok(capturedBodies[0].reasoning, 'anthropic model should have reasoning config');
330      assert.equal(capturedBodies[0].reasoning.enabled, true);
331      process.env.AUDIT_REPORT_MODEL = savedModel;
332      resetAxiosToDefault();
333    });
334  
335    it('does not add reasoning config for non-anthropic model', async () => {
336      const capturedBodies = [];
337      const savedModel = process.env.AUDIT_REPORT_MODEL;
338      process.env.AUDIT_REPORT_MODEL = 'openai/gpt-4o';
339      axiosController.post = async (url, body) => {
340        capturedBodies.push(body);
341        return {
342          data: {
343            choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }],
344            usage: { prompt_tokens: 50, completion_tokens: 50 },
345          },
346        };
347      };
348      await refreshScore(makeSiteData());
349      assert.equal(capturedBodies.length, 1);
350      assert.equal(
351        capturedBodies[0].reasoning,
352        undefined,
353        'non-anthropic model should not have reasoning'
354      );
355      process.env.AUDIT_REPORT_MODEL = savedModel;
356      resetAxiosToDefault();
357    });
358  
359    it('logs usage info when response has usage data (no throw)', async () => {
360      // usage is logged when response.data.usage exists — just ensure no crash
361      resetAxiosToDefault();
362      const result = await refreshScore(makeSiteData());
363      assert.ok(result.overall_calculation);
364    });
365  
366    it('handles siteId with DB returning outreach and conversation rows', async () => {
367      dbController.outreachRows = [
368        {
369          created_at: '2026-01-01 10:00:00',
370          contact_method: 'sms',
371          status: 'delivered',
372          proposal_text: 'Hi, we found issues with your site',
373        },
374      ];
375      dbController.inboundRows = [
376        {
377          created_at: '2026-01-01 11:00:00',
378          direction: 'inbound',
379          channel: 'sms',
380          message_body: 'Thanks, interested',
381          intent: 'positive',
382        },
383      ];
384      resetAxiosToDefault();
385  
386      const result = await refreshScore(makeSiteData({ siteId: 42 }));
387      assert.ok(result, 'should return result with communication context');
388      assert.ok(result.overall_calculation);
389  
390      // Reset
391      dbController.outreachRows = [];
392      dbController.inboundRows = [];
393    });
394  
395    it('handles DB error on siteId gracefully (returns empty context)', async () => {
396      dbController.throwOnNew = true;
397      resetAxiosToDefault();
398  
399      const result = await refreshScore(makeSiteData({ siteId: 99 }));
400      assert.ok(result, 'should still return result despite DB error');
401      assert.ok(result.overall_calculation);
402  
403      dbController.throwOnNew = false;
404    });
405  
406    it('handles API error (re-throws after logging)', async () => {
407      const apiError = new Error('Network timeout');
408      apiError.response = { data: { error: { message: 'Rate limited' } } };
409      axiosController.post = async () => {
410        throw apiError;
411      };
412      await assert.rejects(() => refreshScore(makeSiteData()), /Network timeout/);
413      resetAxiosToDefault();
414    });
415  
416    it('handles API error without response.data (re-throws)', async () => {
417      axiosController.post = async () => {
418        throw new Error('Connection refused');
419      };
420      await assert.rejects(() => refreshScore(makeSiteData()), /Connection refused/);
421      resetAxiosToDefault();
422    });
423  
424    it('copy reference validation passes cleanly when all refs found in HTML', async () => {
425      const htmlContent =
426        '<html><body>Welcome to our site. Call us now for a free quote.</body></html>';
427      const cleanScore = {
428        factor_scores: {
429          headline_quality: { score: 7, current_text: 'welcome to our site' },
430        },
431        problem_areas: [{ factor: 'cta', current_text: 'call us now' }],
432        strategic_recommendations: ['Improve CTA'],
433      };
434      axiosController.post = async () => ({
435        data: {
436          choices: [{ message: { content: JSON.stringify(cleanScore) } }],
437          usage: { prompt_tokens: 100, completion_tokens: 100 },
438        },
439      });
440  
441      const result = await refreshScore(makeSiteData({ htmlContent }));
442      assert.ok(result, 'should return result');
443      assert.ok(result.overall_calculation);
444      resetAxiosToDefault();
445    });
446  
447    it('copy reference validation triggers retry when mismatches exist in factor_scores and problem_areas', async () => {
448      const htmlContent = '<html><body>Real page content here and nothing else</body></html>';
449      const scoreWithBadRef = {
450        factor_scores: {
451          headline_quality: { score: 5, current_text: 'PHANTOM TEXT NOT ON PAGE XXXXXX' },
452        },
453        problem_areas: [{ factor: 'cta', current_text: 'ANOTHER PHANTOM TEXT YYY' }],
454        strategic_recommendations: [],
455      };
456      const scoreRetry = {
457        factor_scores: {
458          headline_quality: { score: 5, current_text: 'real page content here' },
459        },
460        problem_areas: [{ factor: 'cta', current_text: 'real page content here' }],
461        strategic_recommendations: [],
462      };
463  
464      let callCount = 0;
465      axiosController.post = async () => {
466        callCount++;
467        const content =
468          callCount === 1 ? JSON.stringify(scoreWithBadRef) : JSON.stringify(scoreRetry);
469        return {
470          data: {
471            choices: [{ message: { content } }],
472            usage: { prompt_tokens: 100, completion_tokens: 100 },
473          },
474        };
475      };
476  
477      const result = await refreshScore(makeSiteData({ htmlContent }));
478      assert.ok(result, 'should return result after retry');
479      assert.equal(callCount, 2, 'should have made two API calls (initial + retry)');
480      resetAxiosToDefault();
481    });
482  
483    it('retry failure falls back to null replacements for unverified refs', async () => {
484      const htmlContent = '<html><body>Real page content only</body></html>';
485      const scoreWithBadRef = {
486        factor_scores: {
487          headline_quality: { score: 5, current_text: 'PHANTOM FACTOR TEXT ZZZZ' },
488        },
489        problem_areas: [{ factor: 'cta', current_text: 'PHANTOM PROBLEM TEXT WWWW' }],
490        strategic_recommendations: [],
491      };
492  
493      let callCount = 0;
494      axiosController.post = async () => {
495        callCount++;
496        if (callCount === 1) {
497          return {
498            data: {
499              choices: [{ message: { content: JSON.stringify(scoreWithBadRef) } }],
500              usage: { prompt_tokens: 100, completion_tokens: 100 },
501            },
502          };
503        }
504        throw new Error('Retry API failure');
505      };
506  
507      const result = await refreshScore(makeSiteData({ htmlContent }));
508      assert.ok(result, 'should return result despite retry failure');
509      assert.equal(
510        result.factor_scores?.headline_quality?.current_text,
511        null,
512        'factor current_text should be nulled after fallback'
513      );
514      assert.ok(
515        result.factor_scores?.headline_quality?.current_text_context,
516        'should have context description'
517      );
518      assert.equal(
519        result.problem_areas?.[0]?.current_text,
520        null,
521        'problem_area current_text should be nulled after fallback'
522      );
523      assert.ok(
524        result.problem_areas?.[0]?.current_text_context,
525        'problem_area should have context description'
526      );
527      resetAxiosToDefault();
528    });
529  
530    it('persistent mismatch after retry nulls both factor_scores and problem_areas refs', async () => {
531      const htmlContent = '<html><body>Only this text is real</body></html>';
532      const scoreWithBadRef = {
533        factor_scores: {
534          headline_quality: { score: 5, current_text: 'PHANTOM TEXT NOT REAL AAAA' },
535        },
536        problem_areas: [{ factor: 'hero', current_text: 'PHANTOM PROBLEM NOT REAL BBBB' }],
537        strategic_recommendations: [],
538      };
539  
540      // Both calls return refs that won't match the HTML
541      axiosController.post = async () => ({
542        data: {
543          choices: [{ message: { content: JSON.stringify(scoreWithBadRef) } }],
544          usage: { prompt_tokens: 100, completion_tokens: 100 },
545        },
546      });
547  
548      const result = await refreshScore(makeSiteData({ htmlContent }));
549      assert.equal(
550        result.problem_areas?.[0]?.current_text,
551        null,
552        'problem_areas ref should be nulled after persistent mismatch'
553      );
554      assert.ok(
555        result.problem_areas?.[0]?.current_text_context,
556        'should have context description for problem_area'
557      );
558      assert.equal(
559        result.factor_scores?.headline_quality?.current_text,
560        null,
561        'factor ref should be nulled after persistent mismatch'
562      );
563      resetAxiosToDefault();
564    });
565  
566    it('handles null htmlContent — skips copy validation entirely', async () => {
567      resetAxiosToDefault();
568      const result = await refreshScore(makeSiteData({ htmlContent: null }));
569      assert.ok(result, 'should return result with null htmlContent');
570      assert.ok(result.overall_calculation);
571    });
572  
573    it('adds overall_calculation when missing from LLM response', async () => {
574      const scoreWithoutCalc = {
575        factor_scores: { headline_quality: { score: 7 } },
576        problem_areas: [],
577      };
578      axiosController.post = async () => ({
579        data: {
580          choices: [{ message: { content: JSON.stringify(scoreWithoutCalc) } }],
581          usage: { prompt_tokens: 50, completion_tokens: 50 },
582        },
583      });
584      const result = await refreshScore(makeSiteData());
585      assert.ok(result.overall_calculation, 'should create overall_calculation');
586      assert.equal(typeof result.overall_calculation.conversion_score, 'number');
587      resetAxiosToDefault();
588    });
589  
590    it('handles response without usage field (no crash)', async () => {
591      axiosController.post = async () => ({
592        data: {
593          choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }],
594          // no usage field
595        },
596      });
597      const result = await refreshScore(makeSiteData());
598      assert.ok(result.overall_calculation, 'should work without usage data');
599      resetAxiosToDefault();
600    });
601  
602    it('includes outreach context section when siteId has outreach messages', async () => {
603      dbController.outreachRows = [
604        {
605          created_at: '2026-01-01 10:00:00',
606          contact_method: 'email',
607          status: 'delivered',
608          proposal_text: 'Hello, we can help your website',
609        },
610      ];
611      dbController.inboundRows = [];
612  
613      const capturedBodies = [];
614      axiosController.post = async (url, body) => {
615        capturedBodies.push(body);
616        return {
617          data: {
618            choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }],
619            usage: { prompt_tokens: 50, completion_tokens: 50 },
620          },
621        };
622      };
623  
624      await refreshScore(makeSiteData({ siteId: 10 }));
625      assert.equal(capturedBodies.length, 1);
626      const msgText = capturedBodies[0].messages[0].content[0].text;
627      assert.ok(msgText.includes('Previous Outreach Messages'), 'should include outreach section');
628  
629      dbController.outreachRows = [];
630      resetAxiosToDefault();
631    });
632  
633    it('includes conversation history section when siteId has inbound messages', async () => {
634      dbController.outreachRows = [];
635      dbController.inboundRows = [
636        {
637          created_at: '2026-01-01 11:00:00',
638          direction: 'inbound',
639          channel: 'sms',
640          message_body: 'Yes please send me more info',
641          intent: 'positive',
642        },
643      ];
644  
645      const capturedBodies = [];
646      axiosController.post = async (url, body) => {
647        capturedBodies.push(body);
648        return {
649          data: {
650            choices: [{ message: { content: JSON.stringify(defaultScoreJson()) } }],
651            usage: { prompt_tokens: 50, completion_tokens: 50 },
652          },
653        };
654      };
655  
656      await refreshScore(makeSiteData({ siteId: 11 }));
657      assert.equal(capturedBodies.length, 1);
658      const msgText = capturedBodies[0].messages[0].content[0].text;
659      assert.ok(
660        msgText.includes('Previous Conversation History'),
661        'should include conversation section'
662      );
663  
664      dbController.inboundRows = [];
665      resetAxiosToDefault();
666    });
667  
668    it('uses null siteId — skips DB entirely and returns empty context', async () => {
669      resetAxiosToDefault();
670      // siteId: null skips fetchCommunicationContext entirely
671      const result = await refreshScore(makeSiteData({ siteId: null }));
672      assert.ok(result, 'should return result');
673      assert.ok(result.overall_calculation);
674    });
675  
676    it('handles htmlContent truncated to 15000 chars for HTML excerpt', async () => {
677      const longHtml = `<html><body>${'a'.repeat(20000)}</body></html>`;
678      resetAxiosToDefault();
679      const result = await refreshScore(makeSiteData({ htmlContent: longHtml }));
680      assert.ok(result, 'should return result with long HTML');
681    });
682  });