Files
0pt/tests/integration/data-quality-clickhouse.integration.test.ts
T
Claude Opus 5 6cb00add57
CI (SIAX Cloud) / integration (push) Successful in 43s
CI (SIAX Cloud) / security (push) Successful in 23s
CI (SIAX Cloud) / quality (push) Successful in 57s
Security Scan (SIAX Cloud) / security (push) Successful in 21s
CI (SIAX Cloud) / security (pull_request) Successful in 23s
CI (SIAX Cloud) / integration (pull_request) Successful in 40s
CI (SIAX Cloud) / quality (pull_request) Successful in 55s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 25s
feat(analytics): BI-P025/BI-P026 — quality providers that fail on empty, freshness that refuses to be silent
Both prompts are about a FALSE GREEN, so both are tested by constructing the
false green and requiring a failure.

BI-P025. Three provider kinds, and the interface makes the difference visible
instead of implying parity: `providerKind` is "executing" or "ingesting". The
native provider runs checks; the dbt and Great Expectations adapters INGEST
results produced elsewhere (n0d#38 owns dbt execution against BI-P022, and GE
is Python — nothing here runs it). Calling all three "providers" while only
one executes is how "we have Great Expectations coverage" ends up in a status
report.

THE FALSE GREEN, proven against a live ClickHouse 24.8.14.39: a `not_null`
check over an EMPTY table returns violations=0, evaluated=0. The raw counts
are asserted first — the engine really does say zero-over-zero — and then the
provider is required to call it a FAILURE, not a pass. Every rule compiles to
a VIOLATION expression rather than a "rows that are fine" count, so the
distinction cannot be lost at the runner layer.

Same treatment for the ingesting providers: a MISSING dbt result is a failure,
and dbt's `error`, `warn` and `skipped` are each asserted not to be passes.
`skipped` in particular is the exact shape of l0re's 28 silently-skipped
Postgres tests.

The gate: zero verdicts QUARANTINES. "No rules ran" and "everything passed"
produce the same green in most implementations, which is how a quality gate
stops gating without anyone noticing — api0's `trivy --exit-code 0` in another
costume. A later passing run does not auto-release either; release takes a
principal and a reason, or a flapping rule would quarantine and un-quarantine
a dataset between two reads of the same dashboard.

BI-P025's DoD is the READ side: a quarantined projection is REFUSED, not
annotated. An annotation on a dashboard nobody reads is not a quarantine.

Live counts, all exact, from evidence/bi-p025/native-quality-live.json:
not_null 5/118, range 3, distribution 4, unique 11. The unique figure is 11
rather than the 6 the first version of the test asserted — the five
empty-string customers are duplicates of each other, so not_null and unique
overlap on the same defect. The engine was right and the fixture arithmetic
was wrong; the breakdown is written into the test rather than the number
quietly adjusted.

Tenant isolation re-proven on this path specifically, because a quality run
uses an ADMINISTRATIVE credential: with 618 rows in the base table, t_alpha
evaluates 118 and t_beta 500, and t_beta's blocking failure quarantines t_beta
alone. Without the forced predicate one tenant's dirty rows would quarantine
another tenant's projection.

BI-P026. Five states, and the DoD's operative word is "silently". So the gate
sits at the boundary every result crosses: a result with NO freshness envelope
is unreleasable — not defaulted to FRESH, not defaulted to UNKNOWN — because
"we forgot to attach it" and "the data is current" are indistinguishable to a
reader, and any default makes forgetting the silent path. A non-current state
without an explicit acknowledgement is refused, so a consumer that ignores the
envelope cannot render stale numbers as current.

FAILED outranks age: a pipeline that just failed may still hold
recently-timestamped data from the previous run, and reporting that as FRESH
is how a broken pipeline stays invisible for a day. UNKNOWN is never collapsed
into FRESH.

54 unit tests, 11 live tests. typecheck + eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 02:40:35 +02:00

245 lines
10 KiB
TypeScript

/**
* tests/integration/data-quality-clickhouse.integration.test.ts
* ==============================================================
* BI-P025 — the native quality provider against a live ClickHouse.
*
* The unit suite proves the provider's LOGIC (an empty dataset is a failure, a
* missing dbt result is a failure). This suite proves the part a fake runner
* cannot: that the compiled expressions are valid ClickHouse and count the
* right rows, including the empty-dataset case where a real engine returns
* zero violations over zero rows and every naive implementation reports green.
*
* It also re-proves BI-P011's forced tenant predicate on this path: a quality
* run executes on an ADMINISTRATIVE credential (it must read the base table),
* so if the predicate were missing here, one tenant's dirty rows would
* quarantine another tenant's projection.
*
* Gated on CLICKHOUSE_URL. With the variable set and the server down, this
* suite FAILS rather than skipping.
*/
import fs from 'node:fs';
import path from 'node:path';
import { beforeAll, describe, expect, it } from 'vitest';
import { NativeQualityProvider, type QualityRuleSpec } from '@/src/analytics/quality/quality-provider';
import { ClickHouseQualityRunner } from '@/src/analytics/quality/clickhouse-quality-runner';
import { InMemoryQuarantineStore, QualityGate } from '@/src/analytics/quality/quarantine';
const BASE_URL = process.env.CLICKHOUSE_URL ?? '';
const AUTH = process.env.CLICKHOUSE_AUTH ?? 'default:';
const suite = BASE_URL ? describe : describe.skip;
const DB = 'opt_quality_test';
const EVIDENCE_PATH = path.join(process.cwd(), 'evidence/bi-p025/native-quality-live.json');
async function admin(sql: string): Promise<{ ok: boolean; body: string }> {
const url = new URL(BASE_URL);
url.searchParams.set('query', sql);
const res = await fetch(url.toString(), {
method: 'POST',
headers: { Authorization: `Basic ${Buffer.from(AUTH).toString('base64')}` },
body: '',
signal: AbortSignal.timeout(60_000),
});
return { ok: res.ok, body: await res.text() };
}
const runner = new ClickHouseQualityRunner({
baseUrl: BASE_URL || 'http://unset',
basicAuth: AUTH,
database: DB,
tables: { ds_sales: 'sales', ds_empty: 'empty_sales' },
});
const provider = new NativeQualityProvider(runner);
const evidence: Record<string, unknown> = {};
function rule(over: Partial<QualityRuleSpec>): QualityRuleSpec {
return {
rule_id: 'q1',
tenant_id: 't_alpha',
dataset_id: 'ds_sales',
rule: 'not_null',
field: 'customer',
severity: 'block',
params: {},
...over,
};
}
beforeAll(async () => {
if (!BASE_URL) return;
const v = await admin('SELECT version()');
if (!v.ok) throw new Error('CLICKHOUSE_URL is set but ClickHouse is not answering');
evidence.clickhouse_version = v.body.trim();
evidence.recorded_at = new Date().toISOString();
await admin(`DROP DATABASE IF EXISTS ${DB}`);
await admin(`CREATE DATABASE ${DB}`);
await admin(
`CREATE TABLE ${DB}.sales (tenant_id String, dataset_id String, customer String, amount Float64, ` +
`channel String, event_time DateTime) ENGINE = MergeTree ORDER BY (tenant_id, event_time)`,
);
await admin(
`CREATE TABLE ${DB}.empty_sales (tenant_id String, dataset_id String, customer String, amount Float64, ` +
`channel String, event_time DateTime) ENGINE = MergeTree ORDER BY (tenant_id, event_time)`,
);
// t_alpha: 100 clean rows, then deliberate defects.
await admin(
`INSERT INTO ${DB}.sales SELECT 't_alpha', 'ds_sales', 'cust_' || toString(number), number, 'web', now() ` +
`FROM numbers(100)`,
);
// 5 rows with an empty customer (the not_null violation).
await admin(
`INSERT INTO ${DB}.sales SELECT 't_alpha', 'ds_sales', '', number, 'web', now() FROM numbers(5)`,
);
// 3 rows out of range (amount > 1000).
await admin(
`INSERT INTO ${DB}.sales SELECT 't_alpha', 'ds_sales', 'big_' || toString(number), 5000, 'web', now() ` +
`FROM numbers(3)`,
);
// 4 rows with an unaccepted channel.
await admin(
`INSERT INTO ${DB}.sales SELECT 't_alpha', 'ds_sales', 'ch_' || toString(number), 1, 'carrier_pigeon', now() ` +
`FROM numbers(4)`,
);
// 6 rows sharing two duplicate keys (3 each) — the unique violation.
await admin(
`INSERT INTO ${DB}.sales SELECT 't_alpha', 'ds_sales', 'dup_' || toString(number % 2), number, 'web', now() ` +
`FROM numbers(6)`,
);
// t_beta: FILTHY. Every rule fails here. If the tenant predicate is missing,
// t_alpha's counts jump and this tenant quarantines the other one.
await admin(
`INSERT INTO ${DB}.sales SELECT 't_beta', 'ds_sales', '', 999999, 'carrier_pigeon', now() FROM numbers(500)`,
);
}, 120_000);
suite('BI-P025 live — the compiled expressions are valid SQL and count the right rows', () => {
it("not_null finds exactly the 5 empty-customer rows, out of t_alpha's 118", async () => {
const verdict = await provider.evaluate(rule({ rule: 'not_null', field: 'customer' }));
expect(verdict.evaluated_rows).toBe(118);
expect(verdict.violation_count).toBe(5);
expect(verdict.passed).toBe(false);
evidence.not_null = { evaluated: verdict.evaluated_rows, violations: verdict.violation_count };
});
it('range finds exactly the 3 rows over the maximum', async () => {
const verdict = await provider.evaluate(
rule({ rule: 'range', field: 'amount', params: { min: 0, max: 1000 } }),
);
expect(verdict.violation_count).toBe(3);
evidence.range = { violations: verdict.violation_count };
});
it('distribution finds exactly the 4 rows outside the accepted values', async () => {
const verdict = await provider.evaluate(
rule({ rule: 'distribution', field: 'channel', params: { accepted_values: ['web', 'retail'] } }),
);
expect(verdict.violation_count).toBe(4);
evidence.distribution = { violations: verdict.violation_count };
});
it('unique counts ROWS sitting on a duplicated key, not the number of keys', async () => {
const verdict = await provider.evaluate(rule({ rule: 'unique', field: 'customer' }));
// 11, and the arithmetic is worth writing down because the first version
// of this test asserted 6 and the engine disagreed:
// * `dup_0` and `dup_1` appear 3 times each -> 6 rows
// * the five EMPTY customers are all the same key '' -> 5 rows
// The empty strings are duplicates of one another, so `not_null` and
// `unique` overlap on the same defect. That is the engine being right and
// the fixture arithmetic being wrong, not a rule that over-counts.
expect(verdict.violation_count).toBe(11);
evidence.unique = {
violations: verdict.violation_count,
breakdown: '6 rows on dup_0/dup_1, plus 5 rows sharing the empty-string key',
};
});
it('freshness finds no violation for rows written just now', async () => {
const verdict = await provider.evaluate(
rule({ rule: 'freshness', params: { timestamp_field: 'event_time', max_age_seconds: 3600 } }),
);
expect(verdict.violation_count).toBe(0);
expect(verdict.passed).toBe(true);
});
it('a hostile accepted-value is bound, not executed', async () => {
const verdict = await provider.evaluate(
rule({
rule: 'distribution',
field: 'channel',
params: { accepted_values: ['web', 'retail', "'); DROP TABLE sales; --"] },
}),
);
expect(verdict.violation_count).toBe(4);
// The table is still there, with its rows.
const after = await admin(`SELECT count() FROM ${DB}.sales`);
expect(Number(after.body.trim())).toBe(618);
});
});
suite('BI-P025 live — the EMPTY dataset false green, against a real engine', () => {
it('a real ClickHouse returns 0 violations over 0 rows, and the provider calls it a FAILURE', async () => {
// The counterfactual first: the raw counts genuinely look clean.
const raw = await runner.count({
tenant_id: 't_alpha',
dataset_id: 'ds_empty',
violationExpression: "customer IS NULL OR customer = ''",
params: {},
});
expect(raw).toEqual({ violations: 0, evaluated: 0 });
// A naive implementation reports green here. This one does not.
const verdict = await provider.evaluate(rule({ dataset_id: 'ds_empty' }));
expect(verdict.passed).toBe(false);
expect(verdict.detail).toContain('EMPTY');
evidence.empty_dataset = {
raw_counts_from_clickhouse: raw,
verdict_passed: verdict.passed,
note: 'a real engine returns zero violations over zero rows; the provider refuses to call that a pass',
};
});
});
suite("BI-P025 live — a quality run cannot read another tenant's rows", () => {
it("t_alpha's counts are unaffected by t_beta's 500 filthy rows", async () => {
const all = await admin(`SELECT count() FROM ${DB}.sales`);
expect(Number(all.body.trim())).toBe(618); // 118 alpha + 500 beta
const alpha = await provider.evaluate(
rule({ tenant_id: 't_alpha', rule: 'not_null', field: 'customer' }),
);
const beta = await provider.evaluate(rule({ tenant_id: 't_beta', rule: 'not_null', field: 'customer' }));
expect(alpha.evaluated_rows).toBe(118);
expect(alpha.violation_count).toBe(5);
expect(beta.evaluated_rows).toBe(500);
expect(beta.violation_count).toBe(500);
evidence.tenant_isolation = {
base_table_rows: 618,
alpha_evaluated: alpha.evaluated_rows,
alpha_violations: alpha.violation_count,
beta_evaluated: beta.evaluated_rows,
beta_violations: beta.violation_count,
};
});
it("t_beta's blocking failure quarantines t_beta only", async () => {
const gate = new QualityGate(new InMemoryQuarantineStore());
const beta = await provider.evaluate(rule({ tenant_id: 't_beta', rule: 'not_null', field: 'customer' }));
await gate.apply('t_beta', 'ds_sales', [beta]);
await expect(gate.assertProjectionReadable('t_beta', 'ds_sales')).rejects.toThrow(/QUARANTINED/);
await expect(gate.assertProjectionReadable('t_alpha', 'ds_sales')).resolves.toBeUndefined();
});
it('refuses a dataset with no registered physical table rather than guessing one', async () => {
await expect(provider.evaluate(rule({ dataset_id: 'ds_unregistered' }))).rejects.toThrow();
});
it('writes the evidence file', () => {
fs.mkdirSync(path.dirname(EVIDENCE_PATH), { recursive: true });
fs.writeFileSync(EVIDENCE_PATH, `${JSON.stringify(evidence, null, 2)}\n`);
expect(fs.existsSync(EVIDENCE_PATH)).toBe(true);
});
});