/** * Schema validation tests - TDD approach * * These tests verify that our schemas correctly validate API responses, * particularly around conversation.list metadata.secondOpinion.synthesis.response * * This would catch regressions where frontend tests fail because * backend doesn't populate expected fields. */ import { beforeEach, describe, expect, it } from '@jest/globals'; import { schemas } from '../schemas/index.js'; import Ajv from 'ajv'; import addFormats from 'ajv-formats'; describe('Schema Validation - TDD Regression Tests', () => { let ajv: Ajv; beforeEach(() => { ajv = new Ajv({ strict: false, allErrors: true }); addFormats(ajv); }); describe('conversation.list schema', () => { it('should validate minimal conversation list response', () => { const validate = ajv.compile(schemas.conversationList); const minimalResponse = { conversations: [] }; const isValid = validate(minimalResponse); expect(isValid).toBe(true); }); it('should validate conversation with basic metadata', () => { const validate = ajv.compile(schemas.conversationList); const response = { conversations: [{ id: 'conv-123', title: 'Test Conversation', userId: 'user-456', createdAt: '2025-01-01T00:00:00Z', messageCount: 5 }] }; const isValid = validate(response); if (!isValid) { console.error('Validation errors:', validate.errors); } expect(isValid).toBe(true); }); it('should require synthesis.response when synthesis is present (REGRESSION)', () => { const validate = ajv.compile(schemas.conversationList); // This is the case that was failing in frontend tests: // Backend was returning synthesis object without response field const responseWithIncompleteMetadata = { conversations: [{ id: 'conv-123', title: 'Test Conversation', userId: 'user-456', createdAt: '2025-01-01T00:00:00Z', metadata: { secondOpinion: { primary: { model: 'cerebras', response: 'Primary response' }, synthesis: { model: 'gemini-2.5-flash', // Missing required 'response' field - this should FAIL validation tokensUsed: 100, latencyMs: 500 } } } }] }; const isValid = validate(responseWithIncompleteMetadata); // This test should FAIL initially (RED phase) // The schema requires synthesis.response when synthesis is present expect(isValid).toBe(false); expect(validate.errors).toBeDefined(); expect(validate.errors?.some(e => e.instancePath.includes('synthesis') && e.params?.missingProperty === 'response' )).toBe(true); }); it('should validate complete conversation with synthesis metadata', () => { const validate = ajv.compile(schemas.conversationList); const completeResponse = { conversations: [{ id: 'conv-123', title: 'Test Conversation', userId: 'user-456', createdAt: '2025-01-01T00:00:00Z', updatedAt: '2025-01-01T12:00:00Z', messageCount: 5, metadata: { secondOpinion: { primary: { model: 'cerebras', response: 'Primary response here', tokensUsed: 50, latencyMs: 200 }, secondaryOpinions: [{ model: 'gemini', response: 'Secondary response here', tokensUsed: 60, latencyMs: 300 }], synthesis: { model: 'gemini-2.5-flash', response: 'Synthesized response combining all opinions', modelId: 'gemini-2.5-flash-latest', modelDisplayName: 'Gemini 2.5 Flash', tokensUsed: 100, latencyMs: 500, cost: 0.001 } }, persistenceStatus: { userMessageSaved: true, assistantMessageSaved: true, userMessageId: 'msg-user-1', assistantMessageId: 'msg-assistant-1', error: null } } }], cursor: null }; const isValid = validate(completeResponse); if (!isValid) { console.error('Validation errors:', validate.errors); } expect(isValid).toBe(true); }); it('should allow synthesis to be omitted entirely', () => { const validate = ajv.compile(schemas.conversationList); const responseWithoutSynthesis = { conversations: [{ id: 'conv-123', title: 'Test Conversation', userId: 'user-456', createdAt: '2025-01-01T00:00:00Z', metadata: { secondOpinion: { primary: { model: 'cerebras', response: 'Primary response only' } // No synthesis field at all - this is OK } } }] }; const isValid = validate(responseWithoutSynthesis); expect(isValid).toBe(true); }); }); describe('agent.second-opinion schema', () => { it('should require synthesis.response when synthesis is included', () => { const validate = ajv.compile(schemas.agentSecondOpinion); const responseWithoutSynthesisResponse = { conversationId: 'conv-123', primary: { model: 'cerebras', response: 'Primary opinion' }, synthesis: { model: 'gemini', // Missing 'response' - should fail tokensUsed: 100 } }; const isValid = validate(responseWithoutSynthesisResponse); expect(isValid).toBe(false); expect(validate.errors?.some(e => e.params?.missingProperty === 'response' )).toBe(true); }); it('should validate complete second opinion with synthesis', () => { const validate = ajv.compile(schemas.agentSecondOpinion); const completeResponse = { conversationId: 'conv-123', primary: { model: 'cerebras', response: 'Primary opinion here', tokensUsed: 50 }, secondaryOpinions: [{ model: 'gemini', response: 'Secondary opinion here', tokensUsed: 60 }], synthesis: { model: 'gemini-2.5-flash', response: 'Synthesized analysis combining all opinions', tokensUsed: 100, latencyMs: 500 } }; const isValid = validate(completeResponse); if (!isValid) { console.error('Validation errors:', validate.errors); } expect(isValid).toBe(true); }); }); describe('Schema exports', () => { it('should export all required schemas', () => { expect(schemas).toBeDefined(); expect(schemas.agentSecondOpinion).toBeDefined(); expect(schemas.conversationDelete).toBeDefined(); expect(schemas.conversationGetHistory).toBeDefined(); expect(schemas.conversationHealth).toBeDefined(); expect(schemas.conversationList).toBeDefined(); expect(schemas.conversationSendMessage).toBeDefined(); }); it('should have valid JSON schema structure', () => { Object.entries(schemas).forEach(([name, schema]) => { expect(schema).toBeDefined(); expect(typeof schema).toBe('object'); expect(schema).toHaveProperty('$schema'); expect(schema).toHaveProperty('type'); // Should be compilable by AJV const validate = ajv.compile(schema); expect(validate).toBeDefined(); expect(typeof validate).toBe('function'); }); }); }); });