322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
/**
|
|
* tests/analytics/dbt-contract.test.ts
|
|
* =====================================
|
|
* BI-P022 — DBT CORE CANONICAL TRANSFORMATION (the CONTRACT).
|
|
*
|
|
* This repo does not run dbt; N0D (n0d#38) does, against this contract. So
|
|
* what is testable here is exactly what the contract must refuse, and the
|
|
* interesting refusals are the ones that look like successes:
|
|
*
|
|
* * `status: "success"` with ZERO nodes executed — a dbt selector that
|
|
* matches nothing exits 0. The dbt-shaped false green.
|
|
* * a result whose observed dbt version differs from the pin — the models
|
|
* mean something different and nothing says so.
|
|
* * a branch name as the revision — "main" today and "main" tomorrow are
|
|
* different projects, and BI-P022's DoD is about reproducibility.
|
|
* * a production run against an unreviewed runtime, which is BI-P022's
|
|
* "after current license/security verification" turned into a gate.
|
|
*
|
|
* The scaffold itself is asserted against the filesystem, because a directory
|
|
* convention that nothing checks is a directory convention that drifts.
|
|
*/
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { SiaxError } from '@/src/platform/errors';
|
|
import {
|
|
assertDbtRuntimeCertified,
|
|
assertLayerDependenciesLegal,
|
|
assertValidDbtJobRequest,
|
|
assertValidDbtJobResult,
|
|
DBT_LAYER_TIERS,
|
|
DBT_LAYERS,
|
|
DBT_RUNTIME_PIN,
|
|
REQUIRED_DBT_ARTIFACTS,
|
|
REQUIRED_DBT_DIRECTORIES,
|
|
tierForModelPath,
|
|
type DbtJobRequest,
|
|
type DbtJobResult,
|
|
} from '@/src/analytics/transform/dbt-contract';
|
|
|
|
const DBT_ROOT = path.join(process.cwd(), 'analytics/dbt');
|
|
const SHA = 'a'.repeat(40);
|
|
|
|
const CERTIFIED_PIN = {
|
|
...DBT_RUNTIME_PIN,
|
|
licenseReviewed: true,
|
|
securityReviewed: true,
|
|
reviewedBy: 'u_1',
|
|
reviewedAt: '2026-09-07T00:00:00Z',
|
|
};
|
|
|
|
function request(over: Partial<DbtJobRequest> = {}): DbtJobRequest {
|
|
return {
|
|
tenant_id: 't_alpha',
|
|
project_id: 'p_main',
|
|
dbt_project: 'siax_analytics',
|
|
revision: SHA,
|
|
command: 'build',
|
|
target_layer: 'marts',
|
|
environment: 'ci',
|
|
runtime: DBT_RUNTIME_PIN,
|
|
idempotency_key: 'idem-1',
|
|
expected_target_schema: 't_alpha_marts',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function result(over: Partial<DbtJobResult> = {}): DbtJobResult {
|
|
return {
|
|
tenant_id: 't_alpha',
|
|
dbt_project: 'siax_analytics',
|
|
revision: SHA,
|
|
command: 'build',
|
|
status: 'success',
|
|
started_at: '2026-09-07T00:00:00Z',
|
|
finished_at: '2026-09-07T00:05:00Z',
|
|
artifacts: {
|
|
'manifest.json': 'st0re://a/manifest.json',
|
|
'run_results.json': 'st0re://a/run_results.json',
|
|
},
|
|
nodes: [
|
|
{ unique_id: 'model.siax_analytics.mart_sales_daily', resource_type: 'model', status: 'success' },
|
|
],
|
|
observed_dbt_version: DBT_RUNTIME_PIN.version,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe('BI-P022 — the scaffold exists and matches the declared conventions', () => {
|
|
it.each([...REQUIRED_DBT_DIRECTORIES])('analytics/dbt/%s exists', (dir) => {
|
|
expect(fs.existsSync(path.join(DBT_ROOT, dir)), `${dir} is missing`).toBe(true);
|
|
});
|
|
|
|
it('declares a reference project covering raw -> staging -> curated -> serving', () => {
|
|
expect(fs.existsSync(path.join(DBT_ROOT, 'models/sources/sources.yml'))).toBe(true);
|
|
expect(fs.existsSync(path.join(DBT_ROOT, 'models/staging/stg_sales_events.sql'))).toBe(true);
|
|
expect(fs.existsSync(path.join(DBT_ROOT, 'models/intermediate/int_sales_enriched.sql'))).toBe(true);
|
|
expect(fs.existsSync(path.join(DBT_ROOT, 'models/marts/mart_sales_daily.sql'))).toBe(true);
|
|
expect(fs.existsSync(path.join(DBT_ROOT, 'snapshots/snap_customers.sql'))).toBe(true);
|
|
});
|
|
|
|
it('the staging model applies the tenant predicate at the first modelled layer', () => {
|
|
const sql = fs.readFileSync(path.join(DBT_ROOT, 'models/staging/stg_sales_events.sql'), 'utf8');
|
|
expect(sql).toContain('where tenant_id = \'{{ var("siax_tenant_id") }}\'');
|
|
});
|
|
|
|
it('the mart reads intermediate, never a source directly', () => {
|
|
const sql = fs.readFileSync(path.join(DBT_ROOT, 'models/marts/mart_sales_daily.sql'), 'utf8');
|
|
expect(sql).toContain("ref('int_sales_enriched')");
|
|
expect(sql).not.toContain('source(');
|
|
});
|
|
|
|
it('ships a test that fails on an EMPTY mart', () => {
|
|
// Without it, every column test passes trivially over zero rows.
|
|
const sql = fs.readFileSync(path.join(DBT_ROOT, 'tests/assert_mart_is_not_empty.sql'), 'utf8');
|
|
expect(sql).toContain('= 0');
|
|
});
|
|
|
|
it('ships a cross-tenant test even though it should be impossible to fail', () => {
|
|
const sql = fs.readFileSync(path.join(DBT_ROOT, 'tests/assert_no_cross_tenant_rows.sql'), 'utf8');
|
|
expect(sql).toContain('tenant_id !=');
|
|
});
|
|
|
|
it("maps the four layers onto BI-P003's tiers", () => {
|
|
expect(DBT_LAYER_TIERS).toEqual({
|
|
sources: 'RAW',
|
|
staging: 'NORMALIZED',
|
|
intermediate: 'CURATED',
|
|
marts: 'SERVING',
|
|
});
|
|
expect([...DBT_LAYERS]).toEqual(['sources', 'staging', 'intermediate', 'marts']);
|
|
});
|
|
|
|
it('assigns a tier from a model path, and refuses one it cannot place', () => {
|
|
expect(tierForModelPath('models/marts/mart_sales_daily.sql')).toBe('SERVING');
|
|
expect(tierForModelPath('analytics/dbt/models/staging/x.sql')).toBe('NORMALIZED');
|
|
expect(() => tierForModelPath('models/scratch/x.sql')).toThrowError(SiaxError);
|
|
});
|
|
});
|
|
|
|
describe('BI-P022 — the version rule is a GATE, not a paragraph', () => {
|
|
it('the shipped pin is honestly UNREVIEWED', () => {
|
|
// If someone flips these without doing the review, that is a deliberate
|
|
// act visible in a diff — which is the point of storing the state.
|
|
expect(DBT_RUNTIME_PIN.licenseReviewed).toBe(false);
|
|
expect(DBT_RUNTIME_PIN.securityReviewed).toBe(false);
|
|
expect(DBT_RUNTIME_PIN.line).toBe('stable');
|
|
});
|
|
|
|
it('refuses a PRODUCTION run against an unreviewed runtime', () => {
|
|
expect(() => assertDbtRuntimeCertified(DBT_RUNTIME_PIN, 'production')).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('allows development and CI against the same unreviewed runtime', () => {
|
|
expect(() => assertDbtRuntimeCertified(DBT_RUNTIME_PIN, 'ci')).not.toThrow();
|
|
expect(() => assertDbtRuntimeCertified(DBT_RUNTIME_PIN, 'development')).not.toThrow();
|
|
});
|
|
|
|
it('allows production once both reviews are recorded', () => {
|
|
expect(() => assertDbtRuntimeCertified(CERTIFIED_PIN, 'production')).not.toThrow();
|
|
});
|
|
|
|
it('refuses an ALPHA line in production without a separate certification reference', () => {
|
|
const alpha = { ...CERTIFIED_PIN, line: 'alpha' as const };
|
|
expect(() => assertDbtRuntimeCertified(alpha, 'production')).toThrowError(SiaxError);
|
|
expect(() =>
|
|
assertDbtRuntimeCertified({ ...alpha, separateCertificationRef: 'CERT-2026-11' }, 'production'),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('refuses a version range — a pin is exact or it is not a pin', () => {
|
|
expect(() => assertDbtRuntimeCertified({ ...CERTIFIED_PIN, version: '1.10' }, 'ci')).toThrowError(
|
|
SiaxError,
|
|
);
|
|
expect(() => assertDbtRuntimeCertified({ ...CERTIFIED_PIN, version: '^1.10.0' }, 'ci')).toThrowError(
|
|
SiaxError,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('BI-P022 — the job request', () => {
|
|
it('accepts a well-formed request', () => {
|
|
expect(() => assertValidDbtJobRequest(request())).not.toThrow();
|
|
});
|
|
|
|
it("refuses a branch name as the revision — BI-P022's DoD is reproducibility", () => {
|
|
for (const rev of ['main', 'HEAD', 'v1.0.0', 'a'.repeat(39)]) {
|
|
expect(() => assertValidDbtJobRequest(request({ revision: rev }))).toThrowError(SiaxError);
|
|
}
|
|
});
|
|
|
|
it('refuses a target schema that is not scoped to the tenant', () => {
|
|
let thrown: unknown;
|
|
try {
|
|
assertValidDbtJobRequest(request({ expected_target_schema: 't_beta_marts' }));
|
|
} catch (e) {
|
|
thrown = e;
|
|
}
|
|
expect((thrown as SiaxError).code).toBe('AUTHORIZATION_DENIED');
|
|
});
|
|
|
|
it('refuses a selector carrying shell metacharacters', () => {
|
|
for (const selector of ['stg_sales; rm -rf /', '$(whoami)', 'a`b`', 'x && y', 'a|b']) {
|
|
expect(() => assertValidDbtJobRequest(request({ select: [selector] }))).toThrowError(SiaxError);
|
|
}
|
|
// ...while accepting real dbt selector syntax.
|
|
expect(() =>
|
|
assertValidDbtJobRequest(
|
|
request({ select: ['tag:tier:SERVING+', 'state:modified', '+mart_sales_daily'] }),
|
|
),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('refuses a job targeting the sources layer — sources are declared, not built', () => {
|
|
expect(() => assertValidDbtJobRequest(request({ target_layer: 'sources' }))).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('refuses a request with no idempotency key', () => {
|
|
expect(() => assertValidDbtJobRequest(request({ idempotency_key: '' }))).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('refuses a production request on the unreviewed pin', () => {
|
|
expect(() =>
|
|
assertValidDbtJobRequest(
|
|
request({ environment: 'production', expected_target_schema: 't_alpha_marts' }),
|
|
),
|
|
).toThrowError(SiaxError);
|
|
});
|
|
});
|
|
|
|
describe('BI-P022 — the job RESULT is untrusted input', () => {
|
|
it('accepts a well-formed result', () => {
|
|
expect(() => assertValidDbtJobResult(result(), request())).not.toThrow();
|
|
});
|
|
|
|
it('REFUSES success with zero nodes — a selector matching nothing exits 0', () => {
|
|
let thrown: unknown;
|
|
try {
|
|
assertValidDbtJobResult(result({ nodes: [] }), request());
|
|
} catch (e) {
|
|
thrown = e;
|
|
}
|
|
expect(thrown).toBeInstanceOf(SiaxError);
|
|
expect((thrown as SiaxError).message).toContain('zero nodes');
|
|
});
|
|
|
|
it('refuses success while nodes failed', () => {
|
|
expect(() =>
|
|
assertValidDbtJobResult(
|
|
result({ nodes: [{ unique_id: 't1', resource_type: 'test', status: 'fail' }] }),
|
|
request(),
|
|
),
|
|
).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('refuses a runtime that is not the pinned one', () => {
|
|
expect(() => assertValidDbtJobResult(result({ observed_dbt_version: '1.9.0' }), request())).toThrowError(
|
|
SiaxError,
|
|
);
|
|
});
|
|
|
|
it('refuses a result for a different revision', () => {
|
|
expect(() => assertValidDbtJobResult(result({ revision: 'b'.repeat(40) }), request())).toThrowError(
|
|
SiaxError,
|
|
);
|
|
});
|
|
|
|
it('refuses a result for a different tenant', () => {
|
|
expect(() => assertValidDbtJobResult(result({ tenant_id: 't_beta' }), request())).toThrowError(SiaxError);
|
|
});
|
|
|
|
it.each([...REQUIRED_DBT_ARTIFACTS])('refuses a result missing %s', (artifact) => {
|
|
const artifacts = { ...result().artifacts };
|
|
delete artifacts[artifact];
|
|
expect(() => assertValidDbtJobResult(result({ artifacts }), request())).toThrowError(SiaxError);
|
|
});
|
|
});
|
|
|
|
describe('BI-P022 — layer dependencies are checked against the graph, not the convention', () => {
|
|
it("accepts the reference project's own edges", () => {
|
|
expect(() =>
|
|
assertLayerDependenciesLegal([
|
|
{ from: 'models/staging/stg_sales_events.sql', to: 'models/sources/sources.yml' },
|
|
{ from: 'models/intermediate/int_sales_enriched.sql', to: 'models/staging/stg_sales_events.sql' },
|
|
{ from: 'models/marts/mart_sales_daily.sql', to: 'models/intermediate/int_sales_enriched.sql' },
|
|
]),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('refuses a mart reading a source directly — raw data reaching a dashboard', () => {
|
|
expect(() =>
|
|
assertLayerDependenciesLegal([
|
|
{ from: 'models/marts/mart_sales_daily.sql', to: 'models/sources/sources.yml' },
|
|
]),
|
|
).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('refuses a mart reading staging — one layer skipped is still a skip', () => {
|
|
expect(() =>
|
|
assertLayerDependenciesLegal([
|
|
{ from: 'models/marts/mart_sales_daily.sql', to: 'models/staging/stg_sales_events.sql' },
|
|
]),
|
|
).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('refuses a staging model reading a mart — an upward edge', () => {
|
|
expect(() =>
|
|
assertLayerDependenciesLegal([
|
|
{ from: 'models/staging/stg_sales_events.sql', to: 'models/marts/mart_sales_daily.sql' },
|
|
]),
|
|
).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('allows a model reading another in the same layer', () => {
|
|
expect(() =>
|
|
assertLayerDependenciesLegal([
|
|
{ from: 'models/staging/stg_sales_events.sql', to: 'models/staging/stg_customers.sql' },
|
|
]),
|
|
).not.toThrow();
|
|
});
|
|
});
|