krona-front/src/features/__tests__/accessibilityAndNavigation.test.tsx

305 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import '@testing-library/jest-dom/vitest';
import { RouteGuard } from '../../app/guards/RouteGuard';
import { useDemoStore } from '../../stores/demo';
import { useThemeStore } from '../../stores/theme';
import { Modal } from '../../shared/ui/Modal';
import {
exportDatabaseSnapshot,
importDatabaseSnapshot,
resetDatabase,
putStoreItem,
getAllFromStore,
} from '../../services/storage/indexedDb';
import { metadataService } from '../../services/mock';
describe('Phase F: Accessibility, Security & Navigation Test Suite', () => {
beforeEach(async () => {
await resetDatabase();
useDemoStore.getState().resetDemoControls();
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
});
// ----------------------------------------------------
// 1. RBAC Route Denial: Insufficient permissions -> 403 Forbidden
// ----------------------------------------------------
describe('RBAC Route Denial', () => {
it('denies Cashier role navigating to /finance and renders 403 Forbidden', () => {
useDemoStore.getState().setCurrentRoleId('cashier');
render(
<MemoryRouter initialEntries={['/finance']}>
<Routes>
<Route
path="/finance"
element={
<RouteGuard module="finance" moduleName="Финансы и касса">
<div data-testid="finance-page">Секретные проводки</div>
</RouteGuard>
}
/>
</Routes>
</MemoryRouter>
);
expect(screen.queryByTestId('finance-page')).not.toBeInTheDocument();
expect(screen.getByText(/403/i)).toBeInTheDocument();
expect(screen.getByText(/Доступ ограничен/i)).toBeInTheDocument();
expect(screen.getByText(/Кассир \/ Оператор/i)).toBeInTheDocument();
});
it('denies Cashier role navigating to /settings and renders 403 Forbidden', () => {
useDemoStore.getState().setCurrentRoleId('cashier');
render(
<MemoryRouter initialEntries={['/settings']}>
<Routes>
<Route
path="/settings"
element={
<RouteGuard module="settings" moduleName="Настройки платформы">
<div data-testid="settings-page">Системные настройки</div>
</RouteGuard>
}
/>
</Routes>
</MemoryRouter>
);
expect(screen.queryByTestId('settings-page')).not.toBeInTheDocument();
expect(screen.getByText(/403/i)).toBeInTheDocument();
expect(screen.getByText(/Доступ ограничен/i)).toBeInTheDocument();
});
it('denies Cook role navigating to /crm and renders 403 Forbidden', () => {
useDemoStore.getState().setCurrentRoleId('cook');
render(
<MemoryRouter initialEntries={['/crm']}>
<Routes>
<Route
path="/crm"
element={
<RouteGuard module="crm" moduleName="Клиенты и CRM">
<div data-testid="crm-page">Клиентская база</div>
</RouteGuard>
}
/>
</Routes>
</MemoryRouter>
);
expect(screen.queryByTestId('crm-page')).not.toBeInTheDocument();
expect(screen.getByText(/403/i)).toBeInTheDocument();
expect(screen.getByText(/Повар \/ Шеф-повар/i)).toBeInTheDocument();
});
it('allows Owner role full access to all protected modules', () => {
useDemoStore.getState().setCurrentRoleId('owner');
render(
<MemoryRouter initialEntries={['/finance']}>
<Routes>
<Route
path="/finance"
element={
<RouteGuard module="finance" moduleName="Финансы и касса">
<div data-testid="finance-page">Доступные финансы</div>
</RouteGuard>
}
/>
</Routes>
</MemoryRouter>
);
expect(screen.getByTestId('finance-page')).toBeInTheDocument();
expect(screen.queryByText(/403/)).not.toBeInTheDocument();
});
});
// ----------------------------------------------------
// 2. Smoke Test for all registered routes
// ----------------------------------------------------
describe('Smoke Navigation Test for all Registered Routes', () => {
const allRoutes = [
{ path: '/', module: 'dashboard', name: 'Главная панель' },
{ path: '/operations', module: 'operations', name: 'Операции' },
{ path: '/crm', module: 'crm', name: 'CRM' },
{ path: '/sales', module: 'sales', name: 'Продажи' },
{ path: '/inventory', module: 'inventory', name: 'Склад' },
{ path: '/finance', module: 'finance', name: 'Финансы' },
{ path: '/employees', module: 'employees', name: 'Персонал' },
{ path: '/communications', module: 'communications', name: 'Коммуникации' },
{ path: '/helpdesk', module: 'helpdesk', name: 'Служба поддержки' },
{ path: '/tasks', module: 'tasks', name: 'Задачи' },
{ path: '/cms', module: 'cms', name: 'CMS' },
{ path: '/marketing', module: 'marketing', name: 'Маркетинг' },
{ path: '/analytics', module: 'analytics', name: 'Аналитика' },
{ path: '/automation', module: 'automation', name: 'Автоматизация' },
{ path: '/ai', module: 'ai', name: 'AI Center' },
{ path: '/apps', module: 'apps', name: 'Приложения' },
{ path: '/settings', module: 'settings', name: 'Настройки' },
];
it.each(allRoutes)('mounts and renders $name at $path without crashing', ({ path, module, name }) => {
useDemoStore.getState().setCurrentRoleId('owner');
const { container } = render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path={path}
element={
<RouteGuard module={module} moduleName={name}>
<div data-testid={`page-${module}`}>Элемент страницы: {name}</div>
</RouteGuard>
}
/>
</Routes>
</MemoryRouter>
);
expect(screen.getByTestId(`page-${module}`)).toBeInTheDocument();
expect(screen.getByText(`Элемент страницы: ${name}`)).toBeInTheDocument();
expect(container).toBeInTheDocument();
});
it('resolves custom entity route dynamically', async () => {
await metadataService.createEntity({
code: 'contract',
name: 'Договор',
pluralName: 'Договоры',
category: 'commerce',
isSystem: false,
maturity: 'target_prototype',
primaryFieldKey: 'title',
fields: [],
});
render(
<MemoryRouter initialEntries={['/entities/contract']}>
<Routes>
<Route
path="/entities/:entityCode"
element={<div data-testid="custom-entity-page">Модуль кастомной сущности</div>}
/>
</Routes>
</MemoryRouter>
);
expect(screen.getByTestId('custom-entity-page')).toBeInTheDocument();
});
});
// ----------------------------------------------------
// 3. Modal Focus Trap & Keyboard Navigation
// ----------------------------------------------------
describe('Modal Focus Trap & Keyboard Navigation', () => {
it('sets initial focus to first focusable element when opened', async () => {
render(
<Modal isOpen={true} onClose={vi.fn()} title="Диалог с фокусом">
<input data-testid="input-1" placeholder="Первое поле" />
<button data-testid="button-1">Подтвердить</button>
</Modal>
);
// Verify dialog is rendered with correct ARIA attributes
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveAttribute('aria-modal', 'true');
});
it('invokes onClose callback when Escape key is pressed', () => {
const handleClose = vi.fn();
render(
<Modal isOpen={true} onClose={handleClose} title="Окно подтверждения">
<div>Контент модального окна</div>
</Modal>
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(handleClose).toHaveBeenCalledTimes(1);
});
it('handles close button click via accessible button', () => {
const handleClose = vi.fn();
render(
<Modal isOpen={true} onClose={handleClose} title="Окно закрытия">
<div>Контент</div>
</Modal>
);
const closeBtn = screen.getByLabelText('Закрыть диалоговое окно');
expect(closeBtn).toBeInTheDocument();
fireEvent.click(closeBtn);
expect(handleClose).toHaveBeenCalledTimes(1);
});
});
// ----------------------------------------------------
// 4. Theme Persistence & prefers-reduced-motion
// ----------------------------------------------------
describe('Theme Persistence & Motion Accessibility', () => {
it('persists theme selection across soft-light, light, and dark modes', () => {
const { setTheme } = useThemeStore.getState();
setTheme('soft-light');
expect(localStorage.getItem('krona_theme_mode')).toBe('soft-light');
expect(document.documentElement.getAttribute('data-theme')).toBe('soft-light');
setTheme('light');
expect(localStorage.getItem('krona_theme_mode')).toBe('light');
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
setTheme('dark');
expect(localStorage.getItem('krona_theme_mode')).toBe('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('respects prefers-reduced-motion media query environment', () => {
const mediaQueryList = window.matchMedia('(prefers-reduced-motion: reduce)');
expect(mediaQueryList).toBeDefined();
});
});
// ----------------------------------------------------
// 5. Snapshot Export, Seed Reset & Import Round-Trip
// ----------------------------------------------------
describe('Database Snapshot Round-Trip', () => {
it('performs full export, reset, and restore cycle with data integrity', async () => {
// 1. Seed specific test records
await putStoreItem('records_data', {
id: 'deal_special_001',
entityType: 'deal',
title: 'Сделка с рестораном «Небо»',
amount: { minor: 15000000, currency: 'RUB' },
});
await putStoreItem('settings', { key: 'company_name', value: 'Крона Премиум' });
// 2. Export snapshot
const snapshot = await exportDatabaseSnapshot();
expect(snapshot.stores.records_data.length).toBeGreaterThan(0);
expect(snapshot.stores.settings.length).toBeGreaterThan(0);
expect(snapshot.metadata.totalRecords).toBeGreaterThan(0);
// 3. Reset database
await resetDatabase();
const recordsAfterReset = await getAllFromStore('records_data');
expect(recordsAfterReset).toHaveLength(0);
// 4. Import snapshot back
await importDatabaseSnapshot(snapshot);
// 5. Verify restored data
const recordsRestored = await getAllFromStore<{ id: string; title: string }>('records_data');
expect(recordsRestored.some((r) => r.id === 'deal_special_001')).toBe(true);
const settingsRestored = await getAllFromStore<{ key: string; value: string }>('settings');
expect(settingsRestored.some((s) => s.key === 'company_name' && s.value === 'Крона Премиум')).toBe(true);
});
});
});