Assessor_2.0/tests/features/datahub-metrics-prefetch.te...

107 lines
7.2 KiB
TypeScript

// @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(), 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; 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<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(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.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<ProjectAssessmentHistoryResponse>();
api.listProjectAssessmentHistory.mockImplementation((id: string) => id === 'beta' ? betaHistory.promise : Promise.resolve(history(id)));
const alphaMetrics = deferred<ReturnType<typeof totals>>();
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 = () => <DataHub backendStatus="connected" onProjectSelected={() => undefined}/>;
const select = (id: string) => host.querySelector<HTMLButtonElement>(`[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<ReturnType<typeof totals>>(), deferred<ReturnType<typeof totals>>()];
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<ReturnType<typeof totals>>();
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));
});