import { describe, it, expect } from 'vitest'; import { validateEntity, validatePerson, validateExperience, validateSkill, validateOrganization, validateProject, validateCertification, validateTandem, isKebabCase, isValidDate, isTemporallyConsistent, ValidationResult, } from './validate'; // --- Helper: minimal valid entity headers --- const baseHeader = (type: string) => ({ id: 'test-entity', type, created: '2025-01-15', modified: '2025-01-15', }); describe('isKebabCase', () => { it('accepts valid kebab-case strings', () => { expect(isKebabCase('hello')).toBe(true); expect(isKebabCase('hello-world')).toBe(true); expect(isKebabCase('a-b-c')).toBe(true); expect(isKebabCase('test123')).toBe(true); expect(isKebabCase('my-skill-2')).toBe(true); }); it('rejects invalid kebab-case strings', () => { expect(isKebabCase('')).toBe(false); expect(isKebabCase('-leading')).toBe(false); expect(isKebabCase('trailing-')).toBe(false); expect(isKebabCase('UPPER')).toBe(false); expect(isKebabCase('camelCase')).toBe(false); expect(isKebabCase('has space')).toBe(false); expect(isKebabCase('double--hyphen')).toBe(false); expect(isKebabCase('under_score')).toBe(false); }); }); describe('isValidDate', () => { it('accepts valid ISO dates', () => { expect(isValidDate('2025-01-15')).toBe(true); expect(isValidDate('2025-01')).toBe(true); expect(isValidDate('2020-12-31')).toBe(true); expect(isValidDate('1999-06')).toBe(true); }); it('rejects invalid dates', () => { expect(isValidDate('')).toBe(false); expect(isValidDate('2025')).toBe(false); expect(isValidDate('2025-13')).toBe(false); expect(isValidDate('2025-00')).toBe(false); expect(isValidDate('not-a-date')).toBe(false); expect(isValidDate('2025/01/15')).toBe(false); expect(isValidDate('01-2025')).toBe(false); }); }); describe('isTemporallyConsistent', () => { it('returns true when end is null or undefined', () => { expect(isTemporallyConsistent('2025-01', null)).toBe(true); expect(isTemporallyConsistent('2025-01', undefined)).toBe(true); }); it('returns true when start <= end', () => { expect(isTemporallyConsistent('2020-01', '2025-01')).toBe(true); expect(isTemporallyConsistent('2025-01', '2025-01')).toBe(true); expect(isTemporallyConsistent('2025-01-01', '2025-01-02')).toBe(true); }); it('returns false when start > end', () => { expect(isTemporallyConsistent('2025-06', '2025-01')).toBe(false); expect(isTemporallyConsistent('2025-01-15', '2025-01-01')).toBe(false); }); }); describe('validateEntity', () => { it('rejects null/undefined', () => { expect(validateEntity(null).valid).toBe(false); expect(validateEntity(undefined).valid).toBe(false); }); it('rejects non-objects', () => { expect(validateEntity('string').valid).toBe(false); expect(validateEntity(42).valid).toBe(false); }); it('rejects missing type', () => { const result = validateEntity({ id: 'test' }); expect(result.valid).toBe(false); expect(result.errors).toContain('Missing required field: type'); }); it('rejects invalid type', () => { const result = validateEntity({ ...baseHeader('invalid-type') }); expect(result.valid).toBe(false); }); it('dispatches to correct validator', () => { const person = { ...baseHeader('person'), name: { first: 'Andre', last: 'Knie', display: 'Andre Knie' }, }; expect(validateEntity(person).valid).toBe(true); }); }); describe('validatePerson', () => { it('accepts a valid person', () => { const person = { ...baseHeader('person'), name: { first: 'Andre', last: 'Knie', display: 'Andre Knie' }, }; expect(validatePerson(person).valid).toBe(true); }); it('rejects missing name', () => { const result = validatePerson(baseHeader('person')); expect(result.valid).toBe(false); expect(result.errors).toContain('Missing required field: name'); }); it('rejects missing name subfields', () => { const person = { ...baseHeader('person'), name: { first: 'Andre' } }; const result = validatePerson(person); expect(result.valid).toBe(false); expect(result.errors).toContain('Missing required field: name.last'); expect(result.errors).toContain('Missing required field: name.display'); }); }); describe('validateExperience', () => { it('accepts a valid experience', () => { const exp = { ...baseHeader('experience'), title: 'Lead Developer', organization: 'db-cargo', start: '2023-04', end: null, }; expect(validateExperience(exp).valid).toBe(true); }); it('rejects missing required fields', () => { const result = validateExperience(baseHeader('experience')); expect(result.valid).toBe(false); expect(result.errors).toContain('Missing required field: title'); expect(result.errors).toContain('Missing required field: organization'); expect(result.errors).toContain('Missing required field: start'); }); it('detects temporal inconsistency', () => { const exp = { ...baseHeader('experience'), title: 'Dev', organization: 'org', start: '2025-06', end: '2025-01', }; const result = validateExperience(exp); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('Temporal inconsistency'))).toBe(true); }); }); describe('validateSkill', () => { it('accepts a valid skill', () => { const skill = { ...baseHeader('skill'), name: 'Python', category: 'programming-language', level: 'expert', }; expect(validateSkill(skill).valid).toBe(true); }); it('rejects invalid category', () => { const skill = { ...baseHeader('skill'), name: 'Python', category: 'invalid-cat', level: 'expert', }; const result = validateSkill(skill); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('category'))).toBe(true); }); it('rejects invalid level', () => { const skill = { ...baseHeader('skill'), name: 'Python', category: 'framework', level: 'master', }; const result = validateSkill(skill); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('level'))).toBe(true); }); }); describe('validateOrganization', () => { it('accepts a valid organization', () => { const org = { ...baseHeader('organization'), name: 'DB Cargo AG', industry: 'logistics', }; expect(validateOrganization(org).valid).toBe(true); }); it('rejects missing fields', () => { const result = validateOrganization(baseHeader('organization')); expect(result.valid).toBe(false); expect(result.errors).toContain('Missing required field: name'); expect(result.errors).toContain('Missing required field: industry'); }); }); describe('validateProject', () => { it('accepts a valid project', () => { const project = { ...baseHeader('project'), name: 'AI Platform', organization: 'db-cargo', start: '2024-01', }; expect(validateProject(project).valid).toBe(true); }); it('detects temporal inconsistency', () => { const project = { ...baseHeader('project'), name: 'AI Platform', organization: 'db-cargo', start: '2025-06', end: '2024-01', }; const result = validateProject(project); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('Temporal inconsistency'))).toBe(true); }); }); describe('validateCertification', () => { it('accepts a valid certification', () => { const cert = { ...baseHeader('certification'), name: 'AWS Solutions Architect', issuer: 'Amazon Web Services', date: '2024-03', person: 'andre-knie', }; expect(validateCertification(cert).valid).toBe(true); }); it('rejects missing fields', () => { const result = validateCertification(baseHeader('certification')); expect(result.valid).toBe(false); expect(result.errors).toContain('Missing required field: name'); expect(result.errors).toContain('Missing required field: issuer'); expect(result.errors).toContain('Missing required field: date'); expect(result.errors).toContain('Missing required field: person'); }); }); describe('validateTandem', () => { it('accepts a valid tandem', () => { const tandem = { ...baseHeader('tandem'), partners: ['andre-knie', 'claudia-froldi'], sharedVision: 'Shared vision text', complementarySkills: 'Complementary skills text', collaborationModel: 'Collaboration model text', }; expect(validateTandem(tandem).valid).toBe(true); }); it('rejects partners with wrong count', () => { const tandem = { ...baseHeader('tandem'), partners: ['only-one'], sharedVision: 'Vision', complementarySkills: 'Skills', collaborationModel: 'Model', }; const result = validateTandem(tandem); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('exactly 2'))).toBe(true); }); it('rejects non-array partners', () => { const tandem = { ...baseHeader('tandem'), partners: 'not-an-array', sharedVision: 'Vision', complementarySkills: 'Skills', collaborationModel: 'Model', }; const result = validateTandem(tandem); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('must be an array'))).toBe(true); }); it('rejects missing tandem-specific fields', () => { const tandem = { ...baseHeader('tandem'), partners: ['a', 'b'], }; const result = validateTandem(tandem); expect(result.valid).toBe(false); expect(result.errors).toContain('Missing required field: sharedVision'); expect(result.errors).toContain('Missing required field: complementarySkills'); expect(result.errors).toContain('Missing required field: collaborationModel'); }); it('rejects invalid id format in common fields', () => { const tandem = { id: 'Invalid-ID', type: 'tandem', created: '2025-01-15', modified: '2025-01-15', partners: ['a', 'b'], sharedVision: 'Vision', complementarySkills: 'Skills', collaborationModel: 'Model', }; const result = validateTandem(tandem); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('kebab-case'))).toBe(true); }); }); // --- Property-Based Tests (fast-check) --- import fc from 'fast-check'; // --- Arbitraries --- /** Generates a valid kebab-case ID */ const kebabCaseId = fc .array( fc.stringMatching(/^[a-z0-9]{1,8}$/), { minLength: 1, maxLength: 4 } ) .map(parts => parts.join('-')); /** Generates a valid ISO date (YYYY-MM or YYYY-MM-DD) */ const isoDate = fc.oneof( fc.record({ year: fc.integer({ min: 1990, max: 2030 }), month: fc.integer({ min: 1, max: 12 }), }).map(({ year, month }) => `${year}-${String(month).padStart(2, '0')}`), fc.record({ year: fc.integer({ min: 1990, max: 2030 }), month: fc.integer({ min: 1, max: 12 }), day: fc.integer({ min: 1, max: 28 }), }).map(({ year, month, day }) => `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`) ); /** Generates a valid entity header */ const entityHeader = (type: string) => fc.record({ id: kebabCaseId, type: fc.constant(type), created: isoDate, modified: isoDate, }); /** Generates a valid Person entity */ const personArb = entityHeader('person').chain(header => fc.record({ first: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0), last: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0), display: fc.string({ minLength: 1, maxLength: 40 }).filter(s => s.trim().length > 0), }).map(name => ({ ...header, name: { first: name.first, last: name.last, display: name.display }, })) ); /** Generates a valid Skill entity */ const skillArb = entityHeader('skill').chain(header => fc.record({ name: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), category: fc.constantFrom('programming-language', 'framework', 'methodology', 'soft-skill', 'domain'), level: fc.constantFrom('beginner', 'intermediate', 'advanced', 'expert'), }).map(fields => ({ ...header, ...fields })) ); /** Generates a valid Organization entity */ const orgArb = entityHeader('organization').chain(header => fc.record({ name: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), industry: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0), }).map(fields => ({ ...header, ...fields })) ); /** Generates a valid Experience entity */ const experienceArb = entityHeader('experience').chain(header => fc.record({ title: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), organization: kebabCaseId, start: isoDate, }).map(fields => ({ ...header, ...fields, end: null })) ); /** Generates a valid Project entity */ const projectArb = entityHeader('project').chain(header => fc.record({ name: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), organization: kebabCaseId, start: isoDate, }).map(fields => ({ ...header, ...fields })) ); /** Generates a valid Certification entity */ const certArb = entityHeader('certification').chain(header => fc.record({ name: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), issuer: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), date: isoDate, person: kebabCaseId, }).map(fields => ({ ...header, ...fields })) ); /** Generates a valid Tandem entity */ const tandemArb = entityHeader('tandem').chain(header => fc.record({ partner1: kebabCaseId, partner2: kebabCaseId, sharedVision: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), complementarySkills: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), collaborationModel: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), }).map(fields => ({ ...header, partners: [fields.partner1, fields.partner2] as [string, string], sharedVision: fields.sharedVision, complementarySkills: fields.complementarySkills, collaborationModel: fields.collaborationModel, })) ); /** Generates any valid entity */ const anyValidEntity = fc.oneof( personArb, skillArb, orgArb, experienceArb, projectArb, certArb, tandemArb, ); // --- Property 1: Entity serialization round-trip --- describe('Property 1: Entity serialization round-trip', () => { it('JSON.parse(JSON.stringify(entity)) produces an equivalent entity that passes validation', () => { fc.assert( fc.property(anyValidEntity, (entity) => { const roundTripped = JSON.parse(JSON.stringify(entity)); // All attributes preserved expect(roundTripped).toEqual(entity); // Still passes validation const result = validateEntity(roundTripped); expect(result.valid).toBe(true); }), { numRuns: 100 } ); }); /** * Validates: Requirements 2.1, 8.2 */ }); // --- Property 2: Entity schema validation --- describe('Property 2: Entity schema validation', () => { it('accepts any valid entity with all required attributes present and correctly typed', () => { fc.assert( fc.property(anyValidEntity, (entity) => { const result = validateEntity(entity); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }), { numRuns: 100 } ); }); it('rejects entities missing required fields', () => { const entityTypes = ['person', 'experience', 'skill', 'organization', 'project', 'certification', 'tandem'] as const; fc.assert( fc.property( fc.constantFrom(...entityTypes), kebabCaseId, isoDate, isoDate, (type, id, created, modified) => { // Entity with only header fields — missing type-specific required fields const entity = { id, type, created, modified }; const result = validateEntity(entity); // Person needs name, experience needs title/org/start, etc. // All types have additional required fields beyond the header expect(result.valid).toBe(false); expect(result.errors.length).toBeGreaterThan(0); } ), { numRuns: 100 } ); }); /** * Validates: Requirements 1.1, 1.3 */ }); // --- Property 3: Temporal consistency --- describe('Property 3: Temporal consistency', () => { it('validation passes when start <= end for time-bound entities', () => { fc.assert( fc.property( kebabCaseId, isoDate, isoDate, fc.constantFrom('experience', 'project'), (id, date1, date2, type) => { const [start, end] = date1 <= date2 ? [date1, date2] : [date2, date1]; const entity: Record = { id, type, created: start, modified: start, start, end, }; if (type === 'experience') { entity.title = 'Test Role'; entity.organization = 'test-org'; } else { entity.name = 'Test Project'; entity.organization = 'test-org'; } const result = validateEntity(entity); // Should not have temporal inconsistency errors const temporalErrors = result.errors.filter(e => e.includes('Temporal inconsistency')); expect(temporalErrors).toHaveLength(0); } ), { numRuns: 100 } ); }); it('validation fails when start > end for time-bound entities', () => { fc.assert( fc.property( kebabCaseId, fc.record({ year: fc.integer({ min: 1990, max: 2025 }), month: fc.integer({ min: 1, max: 12 }), }), fc.integer({ min: 1, max: 60 }), fc.constantFrom('experience', 'project'), (id, startParts, monthsLater, type) => { const startStr = `${startParts.year}-${String(startParts.month).padStart(2, '0')}`; // Compute an end date that is strictly before start const endYear = startParts.year - Math.ceil(monthsLater / 12); const endMonth = ((startParts.month - (monthsLater % 12) - 1 + 12) % 12) + 1; const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}`; // Only test when end is actually before start (string comparison) fc.pre(endStr < startStr); const entity: Record = { id, type, created: startStr, modified: startStr, start: startStr, end: endStr, }; if (type === 'experience') { entity.title = 'Test Role'; entity.organization = 'test-org'; } else { entity.name = 'Test Project'; entity.organization = 'test-org'; } const result = validateEntity(entity); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('Temporal inconsistency'))).toBe(true); } ), { numRuns: 100 } ); }); /** * Validates: Requirements 1.4 */ }); // --- Property 15: File naming convention --- describe('Property 15: File naming convention', () => { it('any valid entity ID produces a file name matching .yaml with kebab-case', () => { fc.assert( fc.property(kebabCaseId, (id) => { const fileName = `${id}.yaml`; // File name matches pattern: lowercase letters, numbers, hyphens, ending in .yaml expect(fileName).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*\.yaml$/); // The ID itself is valid kebab-case expect(isKebabCase(id)).toBe(true); }), { numRuns: 100 } ); }); it('non-kebab-case IDs are rejected by validation', () => { const invalidIds = fc.oneof( fc.constant(''), fc.constant('-leading'), fc.constant('trailing-'), fc.constant('UPPERCASE'), fc.constant('camelCase'), fc.constant('has space'), fc.constant('double--hyphen'), fc.constant('under_score'), fc.stringMatching(/^[A-Z]{1,10}$/), ); fc.assert( fc.property(invalidIds, isoDate, isoDate, (id, created, modified) => { const entity = { id, type: 'skill', created, modified, name: 'Test', category: 'framework', level: 'expert', }; const result = validateEntity(entity); expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('kebab-case') || e.includes('id'))).toBe(true); }), { numRuns: 100 } ); }); /** * Validates: Requirements 9.2 */ });