305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|