/** * BI-P004 — ANALYTICS DATA CONTRACT. * * DoD: "Breaking dataset schema changes fail compatibility gate unless * explicitly versioned." Each breaking change class below is tested TWICE: * once under a minor bump (must be REFUSED) and once under a major bump (must * be ACCEPTED). A gate that only ever says yes, or only ever says no, would * pass a one-sided test; this one cannot. */ import { describe, it, expect } from 'vitest'; import { assertContractCompatible, checkContractCompatibility, contractHash, diffContracts, parseSemVer, validateContract, type DatasetContract, } from '@/src/analytics/domain/contract'; function base(): DatasetContract { return { dataset_id: 'ds_vehicle_listings', version: '1.2.0', tier: 'CURATED', fields: [ { name: 'listing_id', type: 'string', nullable: false, role: 'identifier', primary_identifier: true }, { name: 'make', type: 'string', nullable: false, role: 'dimension' }, { name: 'asking_price', type: 'int64', nullable: true, role: 'measure' }, { name: 'listed_at', type: 'timestamp', nullable: false, role: 'attribute' }, ], primary_identifiers: ['listing_id'], dimensions: ['make'], measures: ['asking_price'], freshness_slo_seconds: 3600, retention_days: 730, pii_classification: 'none', source_lineage: ['src_dealer_feed'], quality_rules: [ { rule_id: 'qr_listing_id_unique', kind: 'unique', field_name: 'listing_id', severity: 'block' }, ], allowed_purposes: ['market_analytics'], }; } /** Apply a mutation and set the version. */ function next(mutate: (c: DatasetContract) => void, version: string): DatasetContract { const c: DatasetContract = JSON.parse(JSON.stringify(base())); mutate(c); c.version = version; return c; } const BREAKING_CASES: { name: string; mutate: (c: DatasetContract) => void; code: string }[] = [ { name: 'field removed', mutate: (c) => { c.fields = c.fields.filter((f) => f.name !== 'make'); c.dimensions = []; }, code: 'field_removed', }, { name: 'field type changed', mutate: (c) => { c.fields[2].type = 'float64'; }, code: 'field_type_changed', }, { name: 'field became nullable', mutate: (c) => { c.fields[1].nullable = true; }, code: 'field_became_nullable', }, { name: 'field role changed', mutate: (c) => { c.fields[1].role = 'measure'; }, code: 'field_role_changed', }, { name: 'required field added', mutate: (c) => { c.fields.push({ name: 'vat_rate', type: 'float64', nullable: false, role: 'attribute' }); }, code: 'required_field_added', }, { name: 'primary identifier changed', mutate: (c) => { c.primary_identifiers = ['make']; }, code: 'primary_identifier_changed', }, { name: 'dimension removed', mutate: (c) => { c.dimensions = []; }, code: 'dimension_removed', }, { name: 'measure removed', mutate: (c) => { c.measures = []; }, code: 'measure_removed', }, { name: 'freshness SLO loosened', mutate: (c) => { c.freshness_slo_seconds = 86400; }, code: 'freshness_slo_loosened', }, { name: 'retention shortened', mutate: (c) => { c.retention_days = 30; }, code: 'retention_shortened', }, { name: 'allowed purpose removed', mutate: (c) => { c.allowed_purposes = []; }, code: 'purpose_removed', }, { name: 'PII classification raised', mutate: (c) => { c.pii_classification = 'personal'; }, code: 'pii_classification_raised', }, { name: 'blocking quality rule removed', mutate: (c) => { c.quality_rules = []; }, code: 'blocking_quality_rule_removed', }, { name: 'blocking quality rule downgraded', mutate: (c) => { c.quality_rules[0].severity = 'warn'; }, code: 'blocking_quality_rule_downgraded', }, { name: 'tier changed', mutate: (c) => { c.tier = 'SERVING'; }, code: 'tier_changed', }, ]; describe('BI-P004 compatibility gate', () => { it.each(BREAKING_CASES)('REFUSES $name under a minor bump', ({ mutate, code }) => { const verdict = checkContractCompatibility(base(), next(mutate, '1.3.0')); expect(verdict.compatible).toBe(false); expect(verdict.breaking.map((b) => b.code)).toContain(code); expect(verdict.reason).toMatch(/MAJOR version bump/); }); it.each(BREAKING_CASES)('ACCEPTS $name once explicitly versioned (major bump)', ({ mutate, code }) => { const verdict = checkContractCompatibility(base(), next(mutate, '2.0.0')); expect(verdict.compatible).toBe(true); expect(verdict.explicitly_versioned).toBe(true); expect(verdict.breaking.map((b) => b.code)).toContain(code); }); it('a patch bump is equally refused — the gate is about the MAJOR, not about moving at all', () => { const verdict = checkContractCompatibility( base(), next((c) => c.fields.pop(), '1.2.1'), ); expect(verdict.compatible).toBe(false); }); it('allows genuinely additive changes without a major bump', () => { const additive = next((c) => { c.fields.push({ name: 'mileage_km', type: 'int64', nullable: true, role: 'measure' }); c.measures.push('mileage_km'); c.quality_rules.push({ rule_id: 'qr_mileage_range', kind: 'range', field_name: 'mileage_km', severity: 'warn', }); c.allowed_purposes.push('valuation'); c.retention_days = 1095; c.freshness_slo_seconds = 1800; }, '1.3.0'); const verdict = checkContractCompatibility(base(), additive); expect(verdict.compatible).toBe(true); expect(verdict.breaking).toEqual([]); expect(verdict.explicitly_versioned).toBe(false); }); it('refuses a version that does not move forward', () => { expect( checkContractCompatibility( base(), next(() => {}, '1.2.0'), ).compatible, ).toBe(false); expect( checkContractCompatibility( base(), next(() => {}, '1.1.9'), ).compatible, ).toBe(false); expect( checkContractCompatibility( base(), next(() => {}, '0.9.0'), ).compatible, ).toBe(false); }); it('treats date -> timestamp as a safe widening, and int64 -> float64 as NOT one', () => { const widened = next((c) => { c.fields.push({ name: 'sold_on', type: 'date', nullable: true, role: 'attribute' }); }, '1.3.0'); const later = JSON.parse(JSON.stringify(widened)) as DatasetContract; later.fields[later.fields.length - 1].type = 'timestamp'; later.version = '1.4.0'; expect(checkContractCompatibility(widened, later).compatible).toBe(true); const lossy = next((c) => { c.fields[2].type = 'float64'; }, '1.3.0'); expect(checkContractCompatibility(base(), lossy).compatible).toBe(false); }); it('assertContractCompatible throws a CONFLICT rather than returning a verdict nobody reads', () => { expect(() => assertContractCompatible( base(), next((c) => c.fields.pop(), '1.3.0'), ), ).toThrowError(/MAJOR/); expect(() => assertContractCompatible( base(), next(() => {}, '1.3.0'), ), ).not.toThrow(); }); it('refuses to compare contracts for different datasets', () => { const other = next((c) => { c.dataset_id = 'ds_other'; }, '1.3.0'); expect(() => diffContracts(base(), other)).toThrowError(/SAME dataset/); }); it('validateContract catches internally inconsistent contracts', () => { expect(() => validateContract(base())).not.toThrow(); expect(() => validateContract({ ...base(), version: '1.2' })).toThrowError(/major.minor.patch/); expect(() => validateContract({ ...base(), fields: [] })).toThrowError(/no fields/); expect(() => validateContract({ ...base(), dimensions: ['nope'] })).toThrowError(/unknown field/); expect(() => validateContract({ ...base(), primary_identifiers: [] })).toThrowError( /no primary identifier/, ); expect(() => validateContract({ ...base(), allowed_purposes: [] })).toThrowError(/allowed_purposes/); expect(() => validateContract({ ...base(), freshness_slo_seconds: 0 })).toThrowError(/freshness/); const dup = base(); dup.fields.push({ ...dup.fields[0] }); expect(() => validateContract(dup)).toThrowError(/duplicate field/); }); it('contractHash is order-insensitive over fields but sensitive to meaning', () => { const a = base(); const reordered = JSON.parse(JSON.stringify(a)) as DatasetContract; reordered.fields.reverse(); expect(contractHash(reordered)).toBe(contractHash(a)); const changed = JSON.parse(JSON.stringify(a)) as DatasetContract; changed.fields[1].nullable = true; expect(contractHash(changed)).not.toBe(contractHash(a)); }); it('parseSemVer rejects anything that is not major.minor.patch', () => { expect(parseSemVer('2.10.3')).toEqual({ major: 2, minor: 10, patch: 3 }); for (const bad of ['v1.0.0', '1.0', '1.0.0-rc1', 'latest', '']) { expect(() => parseSemVer(bad)).toThrowError(/major.minor.patch/); } }); });