90 lines
6.2 KiB
TypeScript
90 lines
6.2 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 { 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<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('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([]);
|
|
});
|
|
});
|