195 lines
7.4 KiB
TypeScript
195 lines
7.4 KiB
TypeScript
/**
|
|
* BI-P002 (domain model) + BI-P003 (data tiers), the parts provable without
|
|
* infrastructure. The Postgres half — real tables, real immutability
|
|
* triggers, real tenant scoping — is
|
|
* tests/analytics/analytics-domain-postgres.realinfra.test.ts.
|
|
*
|
|
* The registry-vs-migration test below is the one that earns its place: it
|
|
* reads sql/008_analytics_domain_model.sql from disk and asserts a CREATE
|
|
* TABLE exists for every one of the nineteen canonical entities. A future
|
|
* rename, or an entity quietly dropped from the migration, fails HERE rather
|
|
* than at 3am against a database that turns out to be missing a table.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import {
|
|
ANALYTICS_DOMAIN_ENTITIES,
|
|
ANALYTICS_ENTITY_TABLES,
|
|
IMMUTABLE_VERSION_ENTITIES,
|
|
} from '@/src/analytics/domain/entities';
|
|
import {
|
|
ANALYTICS_TIERS,
|
|
assertReadableByBiSurface,
|
|
assertTierLineageSane,
|
|
BI_READABLE_TIERS,
|
|
isAnalyticsTier,
|
|
TIER_RANK,
|
|
} from '@/src/analytics/domain/tiers';
|
|
|
|
const MIGRATION = fs.readFileSync(path.join(process.cwd(), 'sql', '008_analytics_domain_model.sql'), 'utf8');
|
|
|
|
describe('BI-P002 canonical entity set', () => {
|
|
it('lists exactly the nineteen entities the program names, verbatim', () => {
|
|
expect(ANALYTICS_DOMAIN_ENTITIES).toEqual([
|
|
'AnalyticsDataset',
|
|
'DatasetVersion',
|
|
'DatasetField',
|
|
'DatasetGrant',
|
|
'MetricDefinition',
|
|
'MetricVersion',
|
|
'DimensionDefinition',
|
|
'SemanticModel',
|
|
'AnalyticsQuery',
|
|
'QueryExecution',
|
|
'QueryResult',
|
|
'DashboardDefinition',
|
|
'DashboardVersion',
|
|
'ReportDefinition',
|
|
'ReportRun',
|
|
'DataQualityRule',
|
|
'DataQualityRun',
|
|
'LineageReference',
|
|
'BIApplicationBinding',
|
|
]);
|
|
expect(ANALYTICS_DOMAIN_ENTITIES).toHaveLength(19);
|
|
});
|
|
|
|
it('sql/008 defines a table for every entity (no entity is a type without storage)', () => {
|
|
const missing: string[] = [];
|
|
for (const entity of ANALYTICS_DOMAIN_ENTITIES) {
|
|
const table = ANALYTICS_ENTITY_TABLES[entity];
|
|
if (!new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`).test(MIGRATION)) {
|
|
missing.push(`${entity} -> ${table}`);
|
|
}
|
|
}
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it('every tenant-owned table carries tenant_id AND project_id', () => {
|
|
// Split on the table definitions and check each block.
|
|
const blocks = MIGRATION.split(/CREATE TABLE IF NOT EXISTS /).slice(1);
|
|
const offenders: string[] = [];
|
|
for (const block of blocks) {
|
|
const name = block.split(/[\s(]/)[0];
|
|
if (!Object.values(ANALYTICS_ENTITY_TABLES).includes(name)) continue;
|
|
const body = block.slice(0, block.indexOf(');'));
|
|
if (!/\btenant_id TEXT NOT NULL\b/.test(body) || !/\bproject_id TEXT NOT NULL\b/.test(body)) {
|
|
offenders.push(name);
|
|
}
|
|
}
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it('the four immutable version entities have a BEFORE UPDATE OR DELETE trigger in sql/008', () => {
|
|
expect(IMMUTABLE_VERSION_ENTITIES).toEqual([
|
|
'DatasetVersion',
|
|
'MetricVersion',
|
|
'SemanticModel',
|
|
'DashboardVersion',
|
|
]);
|
|
for (const entity of IMMUTABLE_VERSION_ENTITIES) {
|
|
const table = ANALYTICS_ENTITY_TABLES[entity];
|
|
expect(
|
|
new RegExp(`BEFORE UPDATE OR DELETE ON ${table}\\b`).test(MIGRATION),
|
|
`${entity} (${table}) has no immutability trigger`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('BIApplicationBinding storage carries NO credential column — the ownership boundary is structural', () => {
|
|
const block = MIGRATION.split('CREATE TABLE IF NOT EXISTS opt_analytics_bi_application_binding')[1] ?? '';
|
|
const body = block.slice(0, block.indexOf(');'));
|
|
for (const forbidden of ['password', 'dsn', 'secret', 'connection_string', 'clickhouse_url', 'token']) {
|
|
expect(body.toLowerCase()).not.toContain(forbidden);
|
|
}
|
|
});
|
|
|
|
it('sql/008 is idempotent-shaped: every CREATE is IF NOT EXISTS / OR REPLACE', () => {
|
|
const creates =
|
|
MIGRATION.match(/^CREATE (TABLE|INDEX|TRIGGER|FUNCTION|OR REPLACE FUNCTION)[^\n]*/gm) ?? [];
|
|
const nonIdempotent = creates.filter(
|
|
(line) => !/IF NOT EXISTS/.test(line) && !/OR REPLACE/.test(line) && !/^CREATE TRIGGER/.test(line),
|
|
);
|
|
expect(nonIdempotent).toEqual([]);
|
|
// Triggers cannot take IF NOT EXISTS in Postgres, so each one must be
|
|
// preceded by a DROP TRIGGER IF EXISTS to stay replayable.
|
|
const triggers = MIGRATION.match(/^CREATE TRIGGER (\w+)/gm) ?? [];
|
|
for (const t of triggers) {
|
|
const name = t.replace('CREATE TRIGGER ', '');
|
|
expect(MIGRATION).toContain(`DROP TRIGGER IF EXISTS ${name}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('BI-P003 analytics data tiers', () => {
|
|
it('canonicalizes exactly RAW / NORMALIZED / CURATED / SERVING', () => {
|
|
expect(ANALYTICS_TIERS).toEqual(['RAW', 'NORMALIZED', 'CURATED', 'SERVING']);
|
|
expect(isAnalyticsTier('CURATED')).toBe(true);
|
|
expect(isAnalyticsTier('raw')).toBe(false);
|
|
expect(isAnalyticsTier('BRONZE')).toBe(false);
|
|
});
|
|
|
|
it('no business dashboard reads a RAW/NORMALIZED dataset by default', () => {
|
|
expect(BI_READABLE_TIERS).toEqual(['SERVING', 'CURATED']);
|
|
for (const tier of ['RAW', 'NORMALIZED'] as const) {
|
|
expect(() =>
|
|
assertReadableByBiSurface({ dataset_id: 'ds_1', tier }, 'dashboard:exec-overview'),
|
|
).toThrowError(/may not read/);
|
|
}
|
|
for (const tier of ['SERVING', 'CURATED'] as const) {
|
|
expect(() => assertReadableByBiSurface({ dataset_id: 'ds_1', tier }, 'dashboard:x')).not.toThrow();
|
|
}
|
|
});
|
|
|
|
it('a recorded exception is the only way a RAW dataset reaches a BI surface', () => {
|
|
expect(() =>
|
|
assertReadableByBiSurface(
|
|
{
|
|
dataset_id: 'ds_1',
|
|
tier: 'RAW',
|
|
raw_access_exception: 'INC-2026-09-06 forensic review, expires 2026-10-01',
|
|
},
|
|
'dashboard:incident',
|
|
),
|
|
).not.toThrow();
|
|
// An empty/whitespace "exception" is not an exception.
|
|
expect(() =>
|
|
assertReadableByBiSurface(
|
|
{ dataset_id: 'ds_1', tier: 'RAW', raw_access_exception: ' ' },
|
|
'dashboard:x',
|
|
),
|
|
).toThrowError(/may not read/);
|
|
});
|
|
|
|
it('tier order is enforced: a CURATED dataset cannot be built from SERVING', () => {
|
|
expect(TIER_RANK.RAW).toBeLessThan(TIER_RANK.NORMALIZED);
|
|
expect(TIER_RANK.NORMALIZED).toBeLessThan(TIER_RANK.CURATED);
|
|
expect(TIER_RANK.CURATED).toBeLessThan(TIER_RANK.SERVING);
|
|
expect(() => assertTierLineageSane('CURATED', 'SERVING')).toThrowError(/tier order/);
|
|
expect(() => assertTierLineageSane('SERVING', 'CURATED')).not.toThrow();
|
|
expect(() => assertTierLineageSane('CURATED', 'CURATED')).not.toThrow();
|
|
});
|
|
|
|
it('sql/008 constrains tier at the DATABASE level, not only in TypeScript', () => {
|
|
expect(MIGRATION).toContain("CHECK (tier IN ('RAW','NORMALIZED','CURATED','SERVING'))");
|
|
});
|
|
|
|
it("BI-P003's eight required declarations are all NOT NULL in sql/008", () => {
|
|
const block = MIGRATION.split('CREATE TABLE IF NOT EXISTS opt_analytics_dataset ')[1] ?? '';
|
|
const body = block.slice(0, block.indexOf(');'));
|
|
for (const col of [
|
|
'tier',
|
|
'schema_contract_id',
|
|
'owner_id',
|
|
'source_ref',
|
|
'freshness_slo_seconds',
|
|
'retention_days',
|
|
'classification',
|
|
'quality_rule_ids',
|
|
]) {
|
|
expect(new RegExp(`\\b${col}\\b[^,]*NOT NULL`).test(body), `${col} is nullable`).toBe(true);
|
|
}
|
|
});
|
|
});
|