213 lines
8.6 KiB
TypeScript
213 lines
8.6 KiB
TypeScript
/**
|
||
* 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,
|
||
);
|
||
});
|
||
});
|