297 lines
9.0 KiB
TypeScript
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/);
|
|
}
|
|
});
|
|
});
|