/** * 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, ); }); });