Files
0pt/tests/analytics/dashboard-release.test.ts
T

213 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* tests/analytics/dashboard-release.test.ts
* ==========================================
* BI-P061 — DASHBOARD VERSIONING. DoD: "rollback to previous dashboard
* definition".
*
* A rollback test that only asserts "the service returned version 4" proves
* nothing: the interesting failure is a rollback that publishes a DIFFERENT
* document while reporting success. So every rollback assertion here compares
* BI-P060 canonical hashes and re-plans the restored dashboard, and one test
* corrupts the stored document underneath a release to show that serving it
* is refused rather than silently rendered.
*/
import { describe, expect, it } from 'vitest';
import { SiaxError } from '@/src/platform/errors';
import {
dashboardDocumentHash,
parseDashboardDocument,
planDashboard,
} from '@/src/analytics/dashboards/dashboard-document';
import {
DashboardReleaseService,
InMemoryDashboardReleaseStore,
isLegalTransition,
DASHBOARD_RELEASE_STATES,
type DashboardReleaseState,
} from '@/src/analytics/dashboards/dashboard-release';
const SCOPE = { tenant_id: 't_alpha', project_id: 'p_main' };
function doc(title: string, metric = 'revenue') {
return {
id: 'dash_sales',
title,
version: 1,
layout: { grid_columns: 12, row_height_px: 40 },
parameters: [],
filters: [],
widgets: [
{
widget_id: 'w_1',
title,
source: { kind: 'metric', dataset: 'ds_sales', metric },
visualization: { type: 'big_number', encoding: { value: metric } },
layout: { x: 0, y: 0, w: 4, h: 2 },
},
],
refresh: { mode: 'manual', max_staleness_seconds: 3600 },
permissions: { visibility: 'tenant' },
};
}
function service() {
let n = 0;
let t = Date.parse('2026-09-07T10:00:00.000Z');
return new DashboardReleaseService(
new InMemoryDashboardReleaseStore(),
() => new Date((t += 1000)),
() => `id_${++n}`,
);
}
describe('BI-P061 release lifecycle', () => {
it('the transition table is exactly DRAFT -> PUBLISHED -> SUPERSEDED', () => {
const legal: Array<[DashboardReleaseState, DashboardReleaseState]> = [
['draft', 'published'],
['published', 'superseded'],
];
for (const from of DASHBOARD_RELEASE_STATES) {
for (const to of DASHBOARD_RELEASE_STATES) {
const expected = legal.some(([f, t]) => f === from && t === to);
expect(isLegalTransition(from, to), `${from} -> ${to}`).toBe(expected);
}
}
// Named explicitly because each is a real request someone will make.
expect(isLegalTransition('published', 'draft')).toBe(false);
expect(isLegalTransition('superseded', 'published')).toBe(false);
expect(isLegalTransition('draft', 'superseded')).toBe(false);
});
it('publishing supersedes the previous release, and only one is ever live', async () => {
const svc = service();
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
const v2 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v2') });
const result = await svc.publish({ scope: SCOPE, releaseId: v2.release.release_id });
expect(result.published.version).toBe(2);
expect(result.superseded?.version).toBe(1);
expect(result.superseded?.superseded_by_release_id).toBe(v2.release.release_id);
const history = await svc.history(SCOPE, 'dash_sales');
expect(history.map((r) => `${r.version}:${r.state}`)).toEqual(['1:superseded', '2:published']);
expect((await svc.live(SCOPE, 'dash_sales'))?.version).toBe(2);
});
it('a second open draft is refused rather than racing the first', async () => {
const svc = service();
await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
await expect(
svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1-again') }),
).rejects.toThrow(/already has an open draft/);
});
it('a superseded release cannot be re-published', async () => {
const svc = service();
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
const v2 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v2') });
await svc.publish({ scope: SCOPE, releaseId: v2.release.release_id });
await expect(svc.publish({ scope: SCOPE, releaseId: v1.release.release_id })).rejects.toThrow(
/is superseded, not draft/,
);
});
it('another tenant cannot see, publish or roll back this tenant’s releases', async () => {
const svc = service();
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
const other = { tenant_id: 't_beta', project_id: 'p_main' };
await expect(svc.publish({ scope: other, releaseId: v1.release.release_id })).rejects.toThrow(
/does not exist in this tenant/,
);
expect(await svc.live(other, 'dash_sales')).toBeUndefined();
await expect(svc.rollback({ scope: other, dashboardId: 'dash_sales', toVersion: 1 })).rejects.toThrow(
/no version 1 in this tenant/,
);
});
});
describe('BI-P061 rollback — the DoD', () => {
it('restores the earlier definition byte-for-byte, as a NEW version', async () => {
const svc = service();
const original = doc('Sales v1', 'revenue');
const originalHash = dashboardDocumentHash(parseDashboardDocument(original));
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: original });
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
const v2 = await svc.draft({
scope: SCOPE,
dashboardId: 'dash_sales',
document: doc('Sales v2 (broken)', 'revenue_net'),
});
await svc.publish({ scope: SCOPE, releaseId: v2.release.release_id });
expect((await svc.live(SCOPE, 'dash_sales'))?.document_hash).not.toBe(originalHash);
const rolled = await svc.rollback({ scope: SCOPE, dashboardId: 'dash_sales', toVersion: 1 });
// Faithful: same canonical document.
expect(rolled.document_hash).toBe(originalHash);
// New version, not a resurrected row — the history stays linear.
expect(rolled.release.version).toBe(3);
expect(rolled.release.rollback_of_version).toBe(1);
const history = await svc.history(SCOPE, 'dash_sales');
expect(history.map((r) => `${r.version}:${r.state}`)).toEqual([
'1:superseded',
'2:superseded',
'3:published',
]);
// And the restored dashboard actually renders as the original did.
const live = await svc.live(SCOPE, 'dash_sales');
const restored = await svc.documentOf(SCOPE, live!);
expect(planDashboard(restored)).toEqual(planDashboard(parseDashboardDocument(original)));
});
it('rolling back to the live version is refused as a no-op', async () => {
const svc = service();
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
await expect(svc.rollback({ scope: SCOPE, dashboardId: 'dash_sales', toVersion: 1 })).rejects.toThrow(
/already live/,
);
});
it('a stored document that no longer matches its released hash is refused, not rendered', async () => {
const store = new InMemoryDashboardReleaseStore();
const svc = new DashboardReleaseService(
store,
() => new Date(),
() => crypto.randomUUID(),
);
const v1 = await svc.draft({ scope: SCOPE, dashboardId: 'dash_sales', document: doc('v1') });
const { published } = await svc.publish({ scope: SCOPE, releaseId: v1.release.release_id });
// Tamper with storage the way a bad migration or a manual UPDATE would.
const stored = await store.getVersion(SCOPE, published.dashboard_version_id);
const tampered = JSON.parse(stored!.layout_json);
tampered.widgets[0].source.metric = 'revenue_including_vat';
await store.insertVersionAndRelease(
{ ...stored!, dashboard_version_id: 'tampered', version: 99, layout_json: JSON.stringify(tampered) },
{
...published,
release_id: 'r_tampered',
dashboard_version_id: 'tampered',
version: 99,
state: 'draft',
published_at: undefined,
},
);
// documentOf must compare hashes, not trust the row.
await expect(svc.documentOf(SCOPE, { ...published, dashboard_version_id: 'tampered' })).rejects.toThrow(
/refusing to serve an unapproved definition/,
);
await expect(svc.documentOf(SCOPE, { ...published, dashboard_version_id: 'tampered' })).rejects.toThrow(
SiaxError,
);
});
});