/ tests / utils / llm-response-validator.test.js
llm-response-validator.test.js
  1  /**
  2   * LLM Response Validator Tests
  3   *
  4   * Tests for validateScoringResponse, validateEnrichmentResponse,
  5   * validateClassificationResponse, and validateProposalResponse.
  6   * Pure validation logic — no external dependencies beyond Logger (noop in test).
  7   */
  8  
  9  import { test, describe } from 'node:test';
 10  import assert from 'node:assert/strict';
 11  
 12  import {
 13    validateScoringResponse,
 14    validateEnrichmentResponse,
 15    validateClassificationResponse,
 16    validateProposalResponse,
 17  } from '../../src/utils/llm-response-validator.js';
 18  
 19  // ─── validateScoringResponse ─────────────────────────────────────────────────
 20  
 21  describe('validateScoringResponse', () => {
 22    test('returns null/undefined unchanged', () => {
 23      assert.equal(validateScoringResponse(null), null);
 24      assert.equal(validateScoringResponse(undefined), undefined);
 25    });
 26  
 27    test('returns object without factor_scores unchanged', () => {
 28      const input = { overall_calculation: 75 };
 29      const result = validateScoringResponse(input);
 30      assert.deepStrictEqual(result, { overall_calculation: 75 });
 31    });
 32  
 33    test('clamps factor scores above 10 down to 10', () => {
 34      const input = {
 35        factor_scores: {
 36          headline_quality: { score: 15, reasoning: 'great' },
 37        },
 38      };
 39      validateScoringResponse(input);
 40      assert.equal(input.factor_scores.headline_quality.score, 10);
 41    });
 42  
 43    test('clamps factor scores below 0 up to 0', () => {
 44      const input = {
 45        factor_scores: {
 46          cta_effectiveness: { score: -3, reasoning: 'bad' },
 47        },
 48      };
 49      validateScoringResponse(input);
 50      assert.equal(input.factor_scores.cta_effectiveness.score, 0);
 51    });
 52  
 53    test('leaves valid factor scores unchanged', () => {
 54      const input = {
 55        factor_scores: {
 56          trust_signals: { score: 7, reasoning: 'decent' },
 57        },
 58      };
 59      validateScoringResponse(input);
 60      assert.equal(input.factor_scores.trust_signals.score, 7);
 61    });
 62  
 63    test('handles NaN score by defaulting to 0', () => {
 64      const input = {
 65        factor_scores: {
 66          mobile_responsiveness: { score: NaN, reasoning: 'n/a' },
 67        },
 68      };
 69      validateScoringResponse(input);
 70      assert.equal(input.factor_scores.mobile_responsiveness.score, 0);
 71    });
 72  
 73    test('handles string score by defaulting to 0', () => {
 74      const input = {
 75        factor_scores: {
 76          page_speed_indicators: { score: 'fast', reasoning: 'n/a' },
 77        },
 78      };
 79      validateScoringResponse(input);
 80      assert.equal(input.factor_scores.page_speed_indicators.score, 0);
 81    });
 82  
 83    test('drops unexpected top-level fields', () => {
 84      const input = {
 85        factor_scores: {},
 86        overall_calculation: 50,
 87        malicious_field: 'injected instruction',
 88        another_bad_field: true,
 89      };
 90      validateScoringResponse(input);
 91      assert.equal(input.malicious_field, undefined);
 92      assert.equal(input.another_bad_field, undefined);
 93    });
 94  
 95    test('preserves all allowed top-level fields', () => {
 96      const input = {
 97        factor_scores: {},
 98        overall_calculation: 72,
 99        industry_classification: 'plumbing',
100        key_strengths: ['fast'],
101        critical_weaknesses: ['no CTA'],
102        quick_wins: ['add CTA'],
103        site_classification: 'local_business',
104      };
105      validateScoringResponse(input);
106      assert.equal(input.overall_calculation, 72);
107      assert.equal(input.industry_classification, 'plumbing');
108      assert.deepStrictEqual(input.key_strengths, ['fast']);
109      assert.deepStrictEqual(input.critical_weaknesses, ['no CTA']);
110      assert.deepStrictEqual(input.quick_wins, ['add CTA']);
111      assert.equal(input.site_classification, 'local_business');
112    });
113  
114    test('skips factors not in the expected list', () => {
115      const input = {
116        factor_scores: {
117          made_up_factor: { score: 999, reasoning: 'nope' },
118          headline_quality: { score: 5, reasoning: 'ok' },
119        },
120      };
121      validateScoringResponse(input);
122      // made_up_factor is not in EXPECTED_FACTORS so it is not clamped
123      assert.equal(input.factor_scores.made_up_factor.score, 999);
124      assert.equal(input.factor_scores.headline_quality.score, 5);
125    });
126  
127    test('handles factor entry that is not an object gracefully', () => {
128      const input = {
129        factor_scores: {
130          headline_quality: 'not an object',
131        },
132      };
133      // Should not throw
134      const result = validateScoringResponse(input);
135      assert.equal(result.factor_scores.headline_quality, 'not an object');
136    });
137  
138    test('handles factor entry missing score key', () => {
139      const input = {
140        factor_scores: {
141          headline_quality: { reasoning: 'no score key here' },
142        },
143      };
144      const result = validateScoringResponse(input);
145      assert.equal(result.factor_scores.headline_quality.score, undefined);
146    });
147  
148    test('clamps boundary value 0 is kept', () => {
149      const input = {
150        factor_scores: {
151          visual_hierarchy: { score: 0, reasoning: 'none' },
152        },
153      };
154      validateScoringResponse(input);
155      assert.equal(input.factor_scores.visual_hierarchy.score, 0);
156    });
157  
158    test('clamps boundary value 10 is kept', () => {
159      const input = {
160        factor_scores: {
161          value_proposition: { score: 10, reasoning: 'perfect' },
162        },
163      };
164      validateScoringResponse(input);
165      assert.equal(input.factor_scores.value_proposition.score, 10);
166    });
167  
168    test('returns the same object reference (mutates in place)', () => {
169      const input = { factor_scores: {} };
170      const result = validateScoringResponse(input);
171      assert.equal(result, input);
172    });
173  });
174  
175  // ─── validateEnrichmentResponse ──────────────────────────────────────────────
176  
177  describe('validateEnrichmentResponse', () => {
178    test('returns null/undefined unchanged', () => {
179      assert.equal(validateEnrichmentResponse(null), null);
180      assert.equal(validateEnrichmentResponse(undefined), undefined);
181    });
182  
183    test('keeps valid email address objects', () => {
184      const input = {
185        email_addresses: [
186          { email: 'info@example.com', source: 'page' },
187        ],
188      };
189      validateEnrichmentResponse(input);
190      assert.equal(input.email_addresses.length, 1);
191      assert.equal(input.email_addresses[0].email, 'info@example.com');
192    });
193  
194    test('drops invalid email address objects', () => {
195      const input = {
196        email_addresses: [
197          { email: 'not-an-email', source: 'page' },
198          { email: 'valid@test.com' },
199        ],
200      };
201      validateEnrichmentResponse(input);
202      assert.equal(input.email_addresses.length, 1);
203      assert.equal(input.email_addresses[0].email, 'valid@test.com');
204    });
205  
206    test('handles email entries as plain strings', () => {
207      const input = {
208        email_addresses: ['good@example.com', 'bad-email'],
209      };
210      validateEnrichmentResponse(input);
211      assert.equal(input.email_addresses.length, 1);
212    });
213  
214    test('drops email entries with null or empty email', () => {
215      const input = {
216        email_addresses: [
217          { email: null },
218          { email: '' },
219          { notEmail: 'foo@bar.com' },
220        ],
221      };
222      validateEnrichmentResponse(input);
223      assert.equal(input.email_addresses.length, 0);
224    });
225  
226    test('keeps valid social profile URLs', () => {
227      const input = {
228        social_profiles: [
229          { url: 'https://facebook.com/biz', platform: 'facebook' },
230          { url: 'http://twitter.com/biz', platform: 'twitter' },
231        ],
232      };
233      validateEnrichmentResponse(input);
234      assert.equal(input.social_profiles.length, 2);
235    });
236  
237    test('drops social profiles without http/https prefix', () => {
238      const input = {
239        social_profiles: [
240          { url: 'facebook.com/biz', platform: 'facebook' },
241          { url: 'ftp://something.com', platform: 'other' },
242          { url: '', platform: 'none' },
243          { url: null },
244        ],
245      };
246      validateEnrichmentResponse(input);
247      assert.equal(input.social_profiles.length, 0);
248    });
249  
250    test('handles social profile entries as plain strings', () => {
251      const input = {
252        social_profiles: [
253          'https://facebook.com/biz',
254          'not-a-url',
255        ],
256      };
257      validateEnrichmentResponse(input);
258      assert.equal(input.social_profiles.length, 1);
259    });
260  
261    test('keeps valid 2-letter country codes', () => {
262      const input = { country_code: 'AU' };
263      validateEnrichmentResponse(input);
264      assert.equal(input.country_code, 'AU');
265    });
266  
267    test('removes invalid country codes', () => {
268      const input = { country_code: 'australia' };
269      validateEnrichmentResponse(input);
270      assert.equal(input.country_code, undefined);
271    });
272  
273    test('removes lowercase country codes', () => {
274      const input = { country_code: 'au' };
275      validateEnrichmentResponse(input);
276      assert.equal(input.country_code, undefined);
277    });
278  
279    test('removes 3-letter country codes', () => {
280      const input = { country_code: 'AUS' };
281      validateEnrichmentResponse(input);
282      assert.equal(input.country_code, undefined);
283    });
284  
285    test('handles response with no arrays gracefully', () => {
286      const input = { business_name: 'Test Corp' };
287      const result = validateEnrichmentResponse(input);
288      assert.equal(result.business_name, 'Test Corp');
289    });
290  
291    test('returns the same object reference', () => {
292      const input = {};
293      const result = validateEnrichmentResponse(input);
294      assert.equal(result, input);
295    });
296  });
297  
298  // ─── validateClassificationResponse ──────────────────────────────────────────
299  
300  describe('validateClassificationResponse', () => {
301    test('returns null/undefined unchanged', () => {
302      assert.equal(validateClassificationResponse(null), null);
303      assert.equal(validateClassificationResponse(undefined), undefined);
304    });
305  
306    test('keeps valid classification "interested"', () => {
307      const input = { classification: 'interested', confidence: 0.9, reasoning: 'positive reply' };
308      validateClassificationResponse(input);
309      assert.equal(input.classification, 'interested');
310    });
311  
312    test('keeps valid classification "not_interested"', () => {
313      const input = { classification: 'not_interested', confidence: 0.8, reasoning: 'rejected' };
314      validateClassificationResponse(input);
315      assert.equal(input.classification, 'not_interested');
316    });
317  
318    test('keeps valid classification "question"', () => {
319      const input = { classification: 'question', confidence: 0.7, reasoning: 'asked about pricing' };
320      validateClassificationResponse(input);
321      assert.equal(input.classification, 'question');
322    });
323  
324    test('keeps valid classification "unsubscribe"', () => {
325      const input = { classification: 'unsubscribe', confidence: 0.95, reasoning: 'explicit opt-out' };
326      validateClassificationResponse(input);
327      assert.equal(input.classification, 'unsubscribe');
328    });
329  
330    test('defaults invalid classification to "question"', () => {
331      const input = { classification: 'maybe_interested', confidence: 0.5, reasoning: 'vague' };
332      validateClassificationResponse(input);
333      assert.equal(input.classification, 'question');
334    });
335  
336    test('defaults null classification to "question"', () => {
337      const input = { classification: null, confidence: 0.5, reasoning: 'test' };
338      validateClassificationResponse(input);
339      assert.equal(input.classification, 'question');
340    });
341  
342    test('clamps confidence above 1 down to 1', () => {
343      const input = { classification: 'interested', confidence: 1.5, reasoning: 'sure' };
344      validateClassificationResponse(input);
345      assert.equal(input.confidence, 1);
346    });
347  
348    test('clamps confidence below 0 up to 0', () => {
349      const input = { classification: 'interested', confidence: -0.3, reasoning: 'unsure' };
350      validateClassificationResponse(input);
351      assert.equal(input.confidence, 0);
352    });
353  
354    test('leaves valid confidence unchanged', () => {
355      const input = { classification: 'interested', confidence: 0.5, reasoning: 'ok' };
356      validateClassificationResponse(input);
357      assert.equal(input.confidence, 0.5);
358    });
359  
360    test('boundary: confidence 0 is kept', () => {
361      const input = { classification: 'interested', confidence: 0, reasoning: 'zero' };
362      validateClassificationResponse(input);
363      assert.equal(input.confidence, 0);
364    });
365  
366    test('boundary: confidence 1 is kept', () => {
367      const input = { classification: 'interested', confidence: 1, reasoning: 'certain' };
368      validateClassificationResponse(input);
369      assert.equal(input.confidence, 1);
370    });
371  
372    test('does not clamp undefined confidence', () => {
373      const input = { classification: 'interested', reasoning: 'test' };
374      validateClassificationResponse(input);
375      assert.equal(input.confidence, undefined);
376    });
377  
378    test('defaults missing reasoning', () => {
379      const input = { classification: 'interested', confidence: 0.8 };
380      validateClassificationResponse(input);
381      assert.equal(input.reasoning, 'No reasoning provided');
382    });
383  
384    test('defaults null reasoning', () => {
385      const input = { classification: 'interested', confidence: 0.8, reasoning: null };
386      validateClassificationResponse(input);
387      assert.equal(input.reasoning, 'No reasoning provided');
388    });
389  
390    test('defaults numeric reasoning', () => {
391      const input = { classification: 'interested', confidence: 0.8, reasoning: 42 };
392      validateClassificationResponse(input);
393      assert.equal(input.reasoning, 'No reasoning provided');
394    });
395  
396    test('keeps valid string reasoning unchanged', () => {
397      const input = { classification: 'interested', confidence: 0.8, reasoning: 'They said yes' };
398      validateClassificationResponse(input);
399      assert.equal(input.reasoning, 'They said yes');
400    });
401  
402    test('returns the same object reference', () => {
403      const input = { classification: 'question' };
404      const result = validateClassificationResponse(input);
405      assert.equal(result, input);
406    });
407  });
408  
409  // ─── validateProposalResponse ────────────────────────────────────────────────
410  
411  describe('validateProposalResponse', () => {
412    test('returns null/undefined unchanged', () => {
413      assert.equal(validateProposalResponse(null), null);
414      assert.equal(validateProposalResponse(undefined), undefined);
415    });
416  
417    test('returns object without variants array unchanged', () => {
418      const input = { summary: 'test' };
419      const result = validateProposalResponse(input, 3);
420      assert.deepStrictEqual(result, { summary: 'test' });
421    });
422  
423    test('returns object with non-array variants unchanged', () => {
424      const input = { variants: 'not an array' };
425      const result = validateProposalResponse(input, 3);
426      assert.equal(result.variants, 'not an array');
427    });
428  
429    test('passes through valid proposal with only auditandfix.com URLs', () => {
430      const input = {
431        variants: [
432          { variant_number: 1, proposal_text: 'Check https://auditandfix.com/report for details' },
433          { variant_number: 2, proposal_text: 'Visit https://www.auditandfix.com/offer today' },
434        ],
435      };
436      const result = validateProposalResponse(input, 2);
437      assert.equal(result.variants.length, 2);
438    });
439  
440    test('does not remove variants with suspicious URLs (logs only)', () => {
441      const input = {
442        variants: [
443          { variant_number: 1, proposal_text: 'Visit https://evil.com/phish for more' },
444        ],
445      };
446      const result = validateProposalResponse(input, 1);
447      // Variants are not removed, just logged
448      assert.equal(result.variants.length, 1);
449      assert.ok(result.variants[0].proposal_text.includes('https://evil.com/phish'));
450    });
451  
452    test('handles variants with empty proposal_text', () => {
453      const input = {
454        variants: [
455          { variant_number: 1, proposal_text: '' },
456        ],
457      };
458      const result = validateProposalResponse(input, 1);
459      assert.equal(result.variants.length, 1);
460    });
461  
462    test('handles variants with no proposal_text key', () => {
463      const input = {
464        variants: [
465          { variant_number: 1 },
466        ],
467      };
468      const result = validateProposalResponse(input, 1);
469      assert.equal(result.variants.length, 1);
470    });
471  
472    test('returns the same object reference', () => {
473      const input = { variants: [] };
474      const result = validateProposalResponse(input, 0);
475      assert.equal(result, input);
476    });
477  });