Files
0pt/tests/analytics/dataset-contract.test.ts
T

297 lines
9.0 KiB
TypeScript

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