// src/schemas/schema-preparation.test.ts import { prepareSchemaForProvider } from './validate'; import { Providers } from '@/common'; describe('prepareSchemaForProvider', () => { describe('basic passthrough', () => { it('returns schema unchanged when strict is false', () => { const schema = { type: 'object', properties: { name: { type: 'string' }, }, }; const { schema: result, warnings } = prepareSchemaForProvider( schema, Providers.OPENAI, false, ); expect(result).toEqual(schema); expect(warnings).toEqual([]); }); it('returns schema with additionalProperties already set', () => { const schema = { type: 'object', properties: { name: { type: 'string' }, }, required: ['name'], additionalProperties: false, }; const { schema: result } = prepareSchemaForProvider( schema, Providers.OPENAI, ); expect(result.additionalProperties).toBe(false); }); }); describe('additionalProperties enforcement', () => { it('adds additionalProperties: false to root object', () => { const schema = { type: 'object', properties: { name: { type: 'string' }, }, }; const { schema: result, warnings } = prepareSchemaForProvider( schema, Providers.ANTHROPIC, ); expect(result.additionalProperties).toBe(false); // Root object doesn't produce a warning for additionalProperties }); it('adds additionalProperties: false to nested objects', () => { const schema = { type: 'object', properties: { address: { type: 'object', properties: { street: { type: 'string' }, city: { type: 'string' }, }, }, }, additionalProperties: false, }; const { schema: result, warnings } = prepareSchemaForProvider( schema, Providers.OPENAI, ); const addressProp = (result.properties as Record>).address; expect(addressProp.additionalProperties).toBe(false); expect(warnings.some(w => w.includes('additionalProperties'))).toBe(true); }); it('adds additionalProperties: false to deeply nested objects', () => { const schema = { type: 'object', properties: { level1: { type: 'object', properties: { level2: { type: 'object', properties: { value: { type: 'string' }, }, }, }, }, }, additionalProperties: false, }; const { schema: result } = prepareSchemaForProvider( schema, Providers.ANTHROPIC, ); const level1 = (result.properties as Record>).level1; expect(level1.additionalProperties).toBe(false); const level2 = (level1.properties as Record>).level2; expect(level2.additionalProperties).toBe(false); }); }); describe('required properties enforcement', () => { it('adds all properties to required when missing', () => { const schema = { type: 'object', properties: { name: { type: 'string' }, age: { type: 'number' }, }, }; const { schema: result, warnings } = prepareSchemaForProvider( schema, Providers.OPENAI, ); expect(result.required).toEqual(expect.arrayContaining(['name', 'age'])); expect(warnings.some(w => w.includes('required'))).toBe(true); }); it('adds missing properties to existing required array', () => { const schema = { type: 'object', properties: { name: { type: 'string' }, age: { type: 'number' }, email: { type: 'string' }, }, required: ['name'], }; const { schema: result } = prepareSchemaForProvider( schema, Providers.ANTHROPIC, ); expect(result.required).toEqual(expect.arrayContaining(['name', 'age', 'email'])); }); it('does not duplicate already-required properties', () => { const schema = { type: 'object', properties: { name: { type: 'string' }, age: { type: 'number' }, }, required: ['name', 'age'], }; const { schema: result } = prepareSchemaForProvider( schema, Providers.OPENAI, ); expect(result.required).toEqual(['name', 'age']); }); it('adds required to nested objects', () => { const schema = { type: 'object', properties: { person: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'number' }, }, }, }, additionalProperties: false, }; const { schema: result } = prepareSchemaForProvider( schema, Providers.OPENAI, ); const person = (result.properties as Record>).person; expect(person.required).toEqual(expect.arrayContaining(['name', 'age'])); }); }); describe('numeric constraint stripping', () => { it('strips minimum/maximum from number properties', () => { const schema = { type: 'object', properties: { age: { type: 'number', minimum: 0, maximum: 150 }, }, required: ['age'], additionalProperties: false, }; const { schema: result, warnings } = prepareSchemaForProvider( schema, Providers.ANTHROPIC, ); const ageProp = (result.properties as Record>).age; expect(ageProp.minimum).toBeUndefined(); expect(ageProp.maximum).toBeUndefined(); expect(ageProp.description).toContain('minimum: 0'); expect(ageProp.description).toContain('maximum: 150'); expect(warnings.some(w => w.includes('minimum'))).toBe(true); expect(warnings.some(w => w.includes('maximum'))).toBe(true); }); it('strips multipleOf from integer properties', () => { const schema = { type: 'object', properties: { count: { type: 'integer', multipleOf: 5 }, }, required: ['count'], additionalProperties: false, }; const { schema: result, warnings } = prepareSchemaForProvider( schema, Providers.OPENAI, ); const countProp = (result.properties as Record>).count; expect(countProp.multipleOf).toBeUndefined(); expect(countProp.description).toContain('multipleOf: 5'); expect(warnings.some(w => w.includes('multipleOf'))).toBe(true); }); it('appends to existing description when stripping constraints', () => { const schema = { type: 'object', properties: { score: { type: 'number', description: 'User score', minimum: 0, maximum: 100 }, }, required: ['score'], additionalProperties: false, }; const { schema: result } = prepareSchemaForProvider( schema, Providers.ANTHROPIC, ); const scoreProp = (result.properties as Record>).score; expect(scoreProp.description).toContain('User score'); expect(scoreProp.description).toContain('minimum: 0'); }); }); describe('string constraint stripping', () => { it('strips minLength/maxLength from string properties', () => { const schema = { type: 'object', properties: { name: { type: 'string', minLength: 1, maxLength: 100 }, }, required: ['name'], additionalProperties: false, }; const { schema: result, warnings } = prepareSchemaForProvider( schema, Providers.OPENAI, ); const nameProp = (result.properties as Record>).name; expect(nameProp.minLength).toBeUndefined(); expect(nameProp.maxLength).toBeUndefined(); expect(nameProp.description).toContain('minLength: 1'); expect(nameProp.description).toContain('maxLength: 100'); expect(warnings.length).toBeGreaterThan(0); }); it('strips pattern from string properties', () => { const schema = { type: 'object', properties: { email: { type: 'string', pattern: '^[a-z]+@[a-z]+\\.[a-z]+$' }, }, required: ['email'], additionalProperties: false, }; const { schema: result } = prepareSchemaForProvider( schema, Providers.ANTHROPIC, ); const emailProp = (result.properties as Record>).email; expect(emailProp.pattern).toBeUndefined(); expect(emailProp.description).toContain('pattern:'); }); }); describe('array constraint stripping', () => { it('strips minItems/maxItems from array properties', () => { const schema = { type: 'object', properties: { tags: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 10 }, }, required: ['tags'], additionalProperties: false, }; const { schema: result, warnings } = prepareSchemaForProvider( schema, Providers.OPENAI, ); const tagsProp = (result.properties as Record>).tags; expect(tagsProp.minItems).toBeUndefined(); expect(tagsProp.maxItems).toBeUndefined(); expect(tagsProp.description).toContain('minItems: 1'); expect(warnings.some(w => w.includes('minItems'))).toBe(true); }); }); describe('array items with object schemas', () => { it('prepares object schemas inside array items', () => { const schema = { type: 'object', properties: { people: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'number' }, }, }, }, }, additionalProperties: false, }; const { schema: result } = prepareSchemaForProvider( schema, Providers.OPENAI, ); const peopleProp = (result.properties as Record>).people; const items = peopleProp.items as Record; expect(items.additionalProperties).toBe(false); expect(items.required).toEqual(expect.arrayContaining(['name', 'age'])); }); }); describe('anyOf handling', () => { it('prepares object schemas within anyOf', () => { const schema = { type: 'object', properties: { result: { anyOf: [ { type: 'object', properties: { value: { type: 'string' } } }, { type: 'null' }, ], }, }, additionalProperties: false, }; const { schema: result } = prepareSchemaForProvider( schema, Providers.ANTHROPIC, ); const resultProp = (result.properties as Record>).result; const anyOf = resultProp.anyOf as Record[]; const objectVariant = anyOf.find(v => v.type === 'object'); expect(objectVariant?.additionalProperties).toBe(false); }); }); describe('$defs handling', () => { it('prepares schemas in $defs', () => { const schema = { type: 'object', properties: { item: { $ref: '#/$defs/Item' }, }, $defs: { Item: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, }, }, }, additionalProperties: false, }; const { schema: result } = prepareSchemaForProvider( schema, Providers.OPENAI, ); const defs = result.$defs as Record>; expect(defs.Item.additionalProperties).toBe(false); expect(defs.Item.required).toEqual(expect.arrayContaining(['id', 'name'])); }); }); describe('nesting depth warnings', () => { it('warns when nesting exceeds OpenAI limit of 5', () => { // Build a deeply nested schema let innermost: Record = { type: 'string' }; for (let i = 0; i < 7; i++) { innermost = { type: 'object', properties: { nested: innermost }, additionalProperties: false, required: ['nested'], }; } const { warnings } = prepareSchemaForProvider( innermost as Record, Providers.OPENAI, ); expect(warnings.some(w => w.includes('nesting depth') && w.includes('exceeds'))).toBe(true); }); it('does not warn about nesting depth for Anthropic', () => { let innermost: Record = { type: 'string' }; for (let i = 0; i < 7; i++) { innermost = { type: 'object', properties: { nested: innermost }, additionalProperties: false, required: ['nested'], }; } const { warnings } = prepareSchemaForProvider( innermost as Record, Providers.ANTHROPIC, ); expect(warnings.some(w => w.includes('nesting depth'))).toBe(false); }); }); describe('provider-specific behavior', () => { it('works correctly for Bedrock', () => { const schema = { type: 'object', properties: { name: { type: 'string', maxLength: 50 }, score: { type: 'number', minimum: 0 }, }, }; const { schema: result } = prepareSchemaForProvider( schema, Providers.BEDROCK, ); expect(result.additionalProperties).toBe(false); expect(result.required).toEqual(expect.arrayContaining(['name', 'score'])); const nameProp = (result.properties as Record>).name; expect(nameProp.maxLength).toBeUndefined(); }); it('does not mutate the original schema', () => { const schema = { type: 'object', properties: { name: { type: 'string', minLength: 1 }, }, }; const original = JSON.parse(JSON.stringify(schema)); prepareSchemaForProvider(schema, Providers.OPENAI); expect(schema).toEqual(original); }); }); });