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