// @vitest-environment jsdom import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AssessorApiClient } from '../../src/api/assessor-api-client'; import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; import type { BsiCatalogState, BsiSearchIndexStatus } from '../../src/compliance/bsi-catalog-management'; import { BsiCatalogLibrary } from '../../src/compliance/ui/BsiCatalogLibrary'; import { useRegulatoryDecisionRevision } from '../../src/compliance/ui/decision-revision'; // Data Hub BSI tile (user decision 2026-10-08): like the MITRE matrix tile, it names the active catalog and // mapping and offers `Update from BSI`; `Use bundled catalog` appears only after an update. Synthetic states. const bundled: BsiCatalogState = { version: 1, origin: 'bundled', catalogVersion: '2026-09-24T07:12:39.131983+00:00', crosswalkVersion: '1.0.1-qa', requirementCount: 901, crosswalkEntryCount: 83, catalogSha256: 'a'.repeat(64), crosswalkSha256: 'b'.repeat(64), missingTargets: ['BER.6.8'] }; const updated: BsiCatalogState = { ...bundled, origin: 'bsi', updatedAt: '2026-10-08T12:00:00.000Z', catalogVersion: '2026-10-01T00:00:00+00:00', crosswalkVersion: '1.0.2', missingTargets: ['BER.6.8', 'GEB.5.4'] }; let host: HTMLDivElement; let root: Root; let revisions: number[]; function Revision() { revisions.push(useRegulatoryDecisionRevision()); return null; } const render = () => act(async () => { root.render(<>); await new Promise(resolve => setTimeout(resolve, 0)); }); const badge = () => host.querySelector('[data-bsi-catalog] .datahub-rack-badge')?.textContent; const button = (label: string) => [...host.querySelectorAll('[data-bsi-catalog] button')].find(item => item.textContent === label); beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); revisions = []; host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); resetEnginePreferencesForTests(); }); describe('Data Hub BSI catalog tile', () => { it('names the bundled catalog and mapping, updates from BSI and reloads the derived BSI views', async () => { vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled); const refresh = vi.spyOn(AssessorApiClient.prototype, 'refreshBsiCatalog').mockResolvedValue({ ...updated, updated: true }); await render(); expect(host.querySelector('[data-bsi-catalog] .datahub-rack-header strong')?.textContent).toBe('BSI GRUNDSCHUTZ++ KERNEL'); expect(badge()).toBe('2026-09-24 · Mapping 1.0.1-qa · Bundled'); expect(button('Use bundled catalog')).toBeUndefined(); const before = revisions.at(-1)!; await act(async () => button('Update from BSI')!.click()); expect(refresh).toHaveBeenCalledTimes(1); expect(badge()).toBe('2026-10-01 · Mapping 1.0.2 · Updated'); expect(host.querySelector('[role="status"]')?.textContent).toBe('✓Updated to catalog 2026-10-01 and mapping 1.0.2. 2 mapping targets are not in the catalog.'); // Client Info and Work reload their derived BSI requirements. expect(revisions.at(-1)).toBe(before + 1); expect(button('Use bundled catalog')).toBeDefined(); }); it('says when the catalog is up to date without reloading other views', async () => { vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled); vi.spyOn(AssessorApiClient.prototype, 'refreshBsiCatalog').mockResolvedValue({ ...bundled, updated: false }); await render(); const before = revisions.at(-1); await act(async () => button('Update from BSI')!.click()); expect(host.querySelector('[role="status"]')?.textContent).toBe('✓The BSI catalog is up to date.'); expect(revisions.at(-1)).toBe(before); }); it('returns to the bundled catalog and shows an update failure without changing the badge', async () => { vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(updated); const reset = vi.spyOn(AssessorApiClient.prototype, 'resetBsiCatalog').mockResolvedValue(bundled); vi.spyOn(AssessorApiClient.prototype, 'refreshBsiCatalog').mockRejectedValue(new Error('BSI could not be reached. Please try again later.')); await render(); await act(async () => button('Update from BSI')!.click()); expect(host.querySelector('[role="alert"]')?.textContent).toBe('BSI could not be reached. Please try again later.'); expect(badge()).toBe('2026-10-01 · Mapping 1.0.2 · Updated'); await act(async () => button('Use bundled catalog')!.click()); expect(reset).toHaveBeenCalledTimes(1); expect(badge()).toBe('2026-09-24 · Mapping 1.0.1-qa · Bundled'); expect(host.querySelector('[role="status"]')?.textContent).toBe('✓The bundled BSI catalog is active again.'); expect(button('Use bundled catalog')).toBeUndefined(); }); it('shows the search index state and builds it from the Data Hub, following its progress', async () => { vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled); const index = (state: BsiSearchIndexStatus['state'], extra: Partial = {}): BsiSearchIndexStatus => ({ version: 1, state, catalogVersion: bundled.catalogVersion, corpusVersion: 'kernel-synthetic', requirementCount: 901, indexedChunks: 0, ...extra }); const status = vi.spyOn(AssessorApiClient.prototype, 'getBsiSearchIndex').mockResolvedValue(index('not-built')); const build = vi.spyOn(AssessorApiClient.prototype, 'buildBsiSearchIndex').mockResolvedValue(index('building', { progress: { completed: 64, total: 1167 } })); const indexLine = () => host.querySelector('[data-bsi-search-index]')?.textContent; await render(); expect(indexLine()).toBe('Search index Not built · the first BSI mapping builds it otherwise'); vi.useFakeTimers(); try { status.mockResolvedValue(index('built', { indexedChunks: 1167 })); await act(async () => button('Build search index')!.click()); expect(build).toHaveBeenCalledWith(false); expect(indexLine()).toBe('Search index Building · 64 of 1,167 chunks embedded'); expect(button('Building search index…')?.disabled).toBe(true); expect(button('Update from BSI')?.disabled).toBe(true); await act(async () => { await vi.advanceTimersByTimeAsync(2_000); }); expect(indexLine()).toBe('Search index Built · 1,167 chunks for 901 requirements'); } finally { vi.useRealTimers(); } // A complete index is rebuilt on request. await act(async () => button('Rebuild search index')!.click()); expect(build).toHaveBeenLastCalledWith(true); }); it('names an outdated index and hides the build action while embedding is not configured', async () => { vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled); const status = vi.spyOn(AssessorApiClient.prototype, 'getBsiSearchIndex').mockResolvedValue({ version: 1, state: 'outdated', catalogVersion: bundled.catalogVersion, corpusVersion: 'kernel-synthetic', requirementCount: 901, indexedChunks: 0 }); await render(); expect(host.querySelector('[data-bsi-search-index]')?.textContent).toBe('Search index Outdated · built for another catalog state'); expect(button('Build search index')).toBeDefined(); status.mockResolvedValue({ version: 1, state: 'unavailable', catalogVersion: bundled.catalogVersion, corpusVersion: 'kernel-synthetic', requirementCount: 901, indexedChunks: 0, disabledReason: 'Embedding or Qdrant is not configured.' }); await act(async () => root.unmount()); root = createRoot(host); await render(); expect(button('Build search index')).toBeUndefined(); expect(host.querySelector('[data-bsi-catalog] .datahub-alert')?.textContent).toBe('Embedding or Qdrant is not configured.'); }); it('renders and requests nothing while the Compliance Engine is off', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled); setEngineEnabled('compliance', false); await render(); expect(host.querySelector('[data-bsi-catalog]')).toBeNull(); expect(request).not.toHaveBeenCalled(); }); it('sits in the Data Hub reference row directly after the ISO corpus tile', () => { const source = readFileSync(join(process.cwd(), 'src/features/project-import/DataHub.tsx'), 'utf8'); expect(source).toContain(' \n \n'); const styles = readFileSync(join(process.cwd(), 'src/styles/datahub-redesign.css'), 'utf8'); expect(styles).toContain('.datahub-reference-row > .bsi-catalog-library { grid-area: 4 / 1 / 5 / -1; }'); }); });