// @vitest-environment jsdom import { act, createElement } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AssessorApiClient } from '../../src/api/assessor-api-client'; import type { DbStructureTable } from '../../src/domain/db-structure'; import { DbStructurePanel } from '../../src/features/db-structure/DbStructurePanel'; import { buildDbStructureFlow, dbStructureNeighbours, DB_FLOW_STEPS } from '../../src/features/db-structure/db-structure-flow'; const table = (name: string, references: string[] = [], rows: number | null = null): DbStructureTable => ({ name: `assessor_${name}`, layer: 'other', approximateRowCount: rows, columns: [{ name: 'id', dataType: 'text', nullable: false }], foreignKeys: references.map(target => ({ column: `${target}_id`, referencedTable: `assessor_${target}`, referencedColumn: 'id' })) }); describe('DB Structure data flow', () => { afterEach(() => vi.restoreAllMocks()); it('assigns each table to exactly one step and never hides an unassigned table', () => { const assigned = DB_FLOW_STEPS.flatMap(step => step.tables); expect(new Set(assigned).size).toBe(assigned.length); const flow = buildDbStructureFlow([table('projects'), table('gpo_backup_instances', ['projects']), table('synthetic_new_table')]); expect(flow.steps.map(step => step.id)).toEqual(['foundation', 'gpo-inventory']); expect(flow.other.map(item => item.name)).toEqual(['assessor_synthetic_new_table']); }); it('lists outgoing and incoming relations of the selected table', () => { const tables = [table('projects'), table('assessments', ['projects']), table('reviews', ['assessments', 'projects'])]; expect(dbStructureNeighbours(tables, 'assessor_assessments')).toEqual({ outgoing: ['assessor_projects'], incoming: ['assessor_reviews'] }); }); it('opens the selected flow table in a popup and returns to the unchanged flow on close', async () => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; vi.spyOn(AssessorApiClient.prototype, 'getDatabaseStructure').mockResolvedValue({ generatedAt: '2026-09-25T10:00:00.000Z', persistence: 'postgresql', tables: [table('projects', [], 0), table('import_artifacts', ['projects'], 12), table('scan_runs', ['import_artifacts']), table('gpo_backup_instances', ['import_artifacts']), table('microsoft_security_baselines'), table('reviews', ['projects'])] }); const container = document.createElement('div'); const root = createRoot(container); await act(async () => { root.render(createElement(DbStructurePanel, { backendStatus: 'connected' })); await new Promise(resolve => setTimeout(resolve, 0)); }); expect([...container.querySelectorAll('.db-flow__lane-title')].map(item => item.textContent)).toEqual(['Scanner reports', 'GPO backups', 'Reference data']); expect(container.querySelector('.db-structure-intro-copy')).toBeNull(); expect(container.querySelector('.db-flow__header p')).toBeNull(); expect(container.querySelector('.db-flow__header h3')?.textContent).toBe('Data flow'); expect(container.querySelector('.db-structure-intro-actions button')?.textContent).toBe('Refresh schema'); expect(container.querySelector('.db-flow__lane.is-reference')?.textContent).toContain('Input to GPO step'); const chips = [...container.querySelectorAll('.db-flow__table')]; const flowStepCount = container.querySelectorAll('.db-flow__step').length; // A zero estimate is not shown as a row count. expect(chips.find(chip => chip.textContent?.startsWith('projects'))?.textContent).toBe('projects'); expect(chips.find(chip => chip.textContent?.startsWith('import_artifacts'))?.textContent).toBe('import_artifacts~12'); await act(async () => chips.find(chip => chip.textContent?.startsWith('import_artifacts'))!.click()); expect(container.querySelectorAll('.db-flow__table[aria-pressed="true"]')).toHaveLength(1); expect([...new Set([...container.querySelectorAll('.db-flow__table[data-related]')] .map(chip => `${chip.textContent}:${chip.getAttribute('data-related')}`))]) .toEqual(['projects:outgoing', 'scan_runs:incoming', 'gpo_backup_instances:incoming']); expect(container.querySelector('.db-flow__detail')).toBeNull(); const popup = document.querySelector('.floating-data-window[role="dialog"]'); expect(popup?.textContent).toContain('assessor_import_artifacts'); expect(popup?.querySelector('[aria-label="Columns in assessor_import_artifacts"]')).not.toBeNull(); expect(popup?.textContent).toContain('Referenced by'); await act(async () => [...popup!.querySelectorAll('.db-flow__links button')] .find(button => button.textContent === 'scan_runs')!.click()); expect(popup?.querySelector('h2')?.textContent).toBe('assessor_scan_runs'); await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); expect(document.querySelector('.floating-data-window')).toBeNull(); expect(container.querySelectorAll('.db-flow__table[aria-pressed="true"]')).toHaveLength(0); expect(container.querySelectorAll('.db-flow__step')).toHaveLength(flowStepCount); // The layered table dictionary stays available below the flow. expect(container.querySelector('details.db-structure-dictionary summary')?.textContent).toBe('Table dictionary by layer'); act(() => root.unmount()); }); }); describe('DB Structure read views', () => { it('shows the Result DB as a read view over tables owned by the processing steps', async () => { const { buildDbStructureViews } = await import('../../src/features/db-structure/db-structure-flow'); const views = buildDbStructureViews([table('finding_occurrences'), table('policy_evaluation_results'), table('reviews')]); expect(views.map(view => [view.title, view.resolved.map(item => item.name)])).toEqual([ ['Result DB', ['assessor_finding_occurrences', 'assessor_policy_evaluation_results']] ]); // Owning steps stay unique: a read view never claims a table. expect(buildDbStructureFlow([table('finding_occurrences')]).other).toEqual([]); }); });