Assessor_2.0/tests/features/db-structure-flow.test.tsx

138 lines
9.6 KiB
TypeScript

// @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 { EngineDataFlow } from '../../src/features/db-structure/EngineDataFlow';
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('selects engine modules, highlights only their directed connections and discloses planned flow', async () => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement('div');
const root = createRoot(container);
await act(async () => root.render(createElement(EngineDataFlow)));
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(9);
expect(container.querySelectorAll('.db-engine-flow__edges > path')).toHaveLength(9);
expect(container.querySelectorAll('.db-engine-flow__edges > path[marker-start]')).toHaveLength(1);
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(3);
expect(container.querySelector('.db-engine-flow__dictionary')?.hasAttribute('open')).toBe(false);
const context = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node')]
.find(button => button.textContent?.startsWith('Context Engine'))!;
await act(async () => context.click());
expect(context.getAttribute('aria-pressed')).toBe('true');
expect(container.querySelector('.db-engine-flow__inspector h4')?.textContent).toBe('Context Engine');
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(3);
expect(container.querySelector('.db-engine-flow__edges > path.is-planned.is-related')).not.toBeNull();
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).toContain('not yet connected in report generation · Planned');
await act(async () => context.click());
expect(container.querySelector('.db-engine-flow__inspector')).toBeNull();
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(0);
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(9);
const dictionary = container.querySelector<HTMLDetailsElement>('.db-engine-flow__dictionary')!;
await act(async () => dictionary.querySelector('summary')!.click());
expect(dictionary.open).toBe(true);
expect(dictionary.querySelectorAll('tbody tr')).toHaveLength(9);
act(() => root.unmount());
});
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 .db-flow__header h3')?.textContent).toBe('Data flow');
expect([...container.querySelectorAll('.db-engine-flow__node.is-engine strong')].map(item => item.textContent))
.toEqual(['Core Engine', 'Context Engine', 'Report Engine']);
const relations = container.querySelector('table[aria-label="Directed engine relationships"]');
expect(relations?.querySelectorAll('tbody tr')).toHaveLength(9);
expect(relations?.textContent).toContain('Core read-only port');
expect(relations?.textContent).toContain('Planned');
expect(relations?.textContent).toContain('not yet connected in report generation');
await act(async () => {
container.querySelector<HTMLButtonElement>('.db-structure-intro-actions button')!.click();
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(container.querySelectorAll('.db-engine-flow__node.is-engine')).toHaveLength(3);
expect(container.querySelectorAll('.db-engine-flow tbody tr')).toHaveLength(9);
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<HTMLButtonElement>('.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<HTMLElement>('.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<HTMLButtonElement>('.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('assigns stored disclosed Result DB rows to their Core-owned materialization step', () => {
const flow = buildDbStructureFlow([table('result_db_rows'), table('result_db_state')]);
expect(flow.steps.map(step => step.id)).toEqual(['result-materialization']);
expect(flow.other).toEqual([]);
});
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([]);
});
});