// @vitest-environment jsdom import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, expect, it, vi } from 'vitest'; import type { ProjectAssessmentHistoryResponse } from '../../src/api/assessor-api-contract'; import { DataHub } from '../../src/features/project-import/DataHub'; import { DatahubMetricsCache, type TimeslotMetricsScope } from '../../src/features/project-import/datahub-metrics-cache'; const api = vi.hoisted(() => ({ health: vi.fn(), getMitreCatalog: vi.fn(), listProjects: vi.fn(), listProjectImportFiles: vi.fn(), listProjectAssessmentHistory: vi.fn(), listProjectGpoBackupStatus: vi.fn(), getTimeslotResultMetrics: vi.fn(), queryUnifiedResultFacets: vi.fn() })); vi.mock('../../src/api/assessor-api-client', () => ({ AssessorApiClient: class { health = api.health; getMitreCatalog = api.getMitreCatalog; listProjects = api.listProjects; listProjectImportFiles = api.listProjectImportFiles; listProjectAssessmentHistory = api.listProjectAssessmentHistory; listProjectGpoBackupStatus = api.listProjectGpoBackupStatus; getTimeslotResultMetrics = api.getTimeslotResultMetrics; queryUnifiedResultFacets = api.queryUnifiedResultFacets; } })); vi.mock('../../src/features/project-import/MicrosoftBaselineLibrary', () => ({ MicrosoftBaselineLibrary: () => null })); const date = '2026-01-01T00:00:00Z'; const totals = (all: number) => ({ all, deviation: all / 2, passed: all / 2, notApplicable: 0, notRun: 0, error: 0 }); const history = (id: string): ProjectAssessmentHistoryResponse => ({ version: 1, projectId: id, assessments: [{ id: `${id}-assessment`, projectId: id, name: 'Synthetic timeslot', createdAt: date, updatedAt: date, effectiveAt: date }], scanRuns: [{ id: `${id}-run`, assessmentId: `${id}-assessment`, sequence: 1, label: 'Synthetic scan', importedAt: date, tool: { id: 'dummy', name: 'Synthetic scanner' } }] }); function deferred() { let resolve!: (value: T) => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); }); it('preloads during overview loading, shares pending requests, reuses counts on selection and revisits, and refreshes changed data', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); let revision = 700; api.health.mockImplementation(async () => ({ status: 'ok', repositoryRevision: revision })); api.getMitreCatalog.mockResolvedValue({ catalog: { version: '19.2', tacticOrder: ['Execution'], techniques: [{ id: 'T1234', name: 'Synthetic', tactics: ['Execution'] }], entities: [] } }); api.listProjects.mockResolvedValue(['alpha', 'beta'].map(id => ({ id, name: id, attributes: {}, createdAt: date, updatedAt: date }))); api.listProjectImportFiles.mockResolvedValue([]); api.listProjectGpoBackupStatus.mockResolvedValue({ records: [] }); api.queryUnifiedResultFacets.mockResolvedValue({ total: 2, facets: [] }); const betaHistory = deferred(); api.listProjectAssessmentHistory.mockImplementation((id: string) => id === 'beta' ? betaHistory.promise : Promise.resolve(history(id))); const alphaMetrics = deferred>(); api.getTimeslotResultMetrics.mockImplementation((id: string) => id === 'alpha' ? alphaMetrics.promise : Promise.resolve(totals(40))); const host = document.createElement('div'); document.body.append(host); let root = createRoot(host); const mount = () => undefined}/>; const select = (id: string) => host.querySelector(`[aria-label="Mandant ${id} auswählen"]`)!.click(); const readout = () => host.querySelector('.donut-metric-card[aria-label^="Results All:"]')?.getAttribute('aria-label'); try { await act(async () => root.render(mount())); // One slow client's metadata must not postpone another client's summaries. expect(api.getTimeslotResultMetrics).toHaveBeenCalledExactlyOnceWith('alpha', ['alpha-run'], ['alpha-assessment']); expect(host.querySelector('.datahub-tenant-select')).toBeNull(); await act(async () => betaHistory.resolve(history('beta'))); expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(2); await act(async () => select('alpha')); expect(readout()).toBe('Results All: Loading…'); expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(2); await act(async () => select('beta')); expect(readout()).toBe('Results All: 40, 100%'); await act(async () => alphaMetrics.resolve(totals(20))); expect(readout()).toBe('Results All: 40, 100%'); // Late alpha must not overwrite beta. await act(async () => select('alpha')); expect(readout()).toBe('Results All: 20, 100%'); expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(2); await act(async () => root.unmount()); root = createRoot(host); await act(async () => root.render(mount())); await act(async () => select('alpha')); expect(readout()).toBe('Results All: 20, 100%'); expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(2); revision++; api.getTimeslotResultMetrics.mockImplementation(async (id: string) => totals(id === 'alpha' ? 22 : 44)); await act(async () => globalThis.dispatchEvent(new Event('assessor:datahub-changed'))); expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(4); expect(readout()).toBe('Results All: 22, 100%'); } finally { await act(async () => root.unmount()); } }); const scope = (projectId: string, revision = 1): TimeslotMetricsScope => ({ projectId, revision, scanRunIds: ['b','a'], assessmentIds: ['slot'] }); it('bounds concurrent preloads, prioritizes selection and keys counts by exact revision and scope', async () => { const pending = [deferred>(), deferred>()]; const fetch = vi.fn().mockImplementationOnce(() => pending[0].promise).mockImplementationOnce(() => pending[1].promise).mockResolvedValue(totals(8)); const cache = new DatahubMetricsCache(fetch, 2); cache.prefetch(scope('one')); cache.prefetch(scope('two')); cache.prefetch(scope('three')); cache.prefetch(scope('four')); await Promise.resolve(); expect(fetch).toHaveBeenCalledTimes(2); const chosen = cache.load(scope('four')); pending[0].resolve(totals(2)); await chosen; expect(fetch.mock.calls[2][0].projectId).toBe('four'); pending[1].resolve(totals(4)); await cache.load(scope('three')); await cache.load(scope('five')); expect(cache.peek(scope('one'))).toBeUndefined(); // Bounded completed cache. const count = fetch.mock.calls.length; await cache.load({ ...scope('five'), scanRunIds: ['a','b'] }); expect(fetch).toHaveBeenCalledTimes(count); await cache.load(scope('five', 2)); await cache.load({ ...scope('five', 2), assessmentIds: ['different-slot'] }); expect(fetch).toHaveBeenCalledTimes(count + 2); }); it('does not cache failures or let invalidated in-flight requests restore old values', async () => { const stale = deferred>(); const fetch = vi.fn().mockRejectedValueOnce(new Error('Synthetic unavailable')).mockImplementationOnce(() => stale.promise).mockResolvedValue(totals(10)); const cache = new DatahubMetricsCache(fetch); await expect(cache.load(scope('one'))).rejects.toThrow('Synthetic unavailable'); await Promise.resolve(); const previous = cache.load(scope('one')); cache.invalidate(); await cache.load(scope('one')); stale.resolve(totals(2)); await previous; expect(cache.peek(scope('one'))).toEqual(totals(10)); });