Шаг 2.5 плана production-готовности: - страница уведомлений (фильтр, догрузка, прочтение) + ссылки из dropdown/мобильного меню - общие хелперы notification-utils (дедупликация dropdown) - skip-link + id=content на main - axe smoke-тесты (0 critical/serious), исправлены 2 нарушения в новой странице - 3 новых теста (107/107), i18n отложен по решению пользователя
173 lines
6.5 KiB
TypeScript
173 lines
6.5 KiB
TypeScript
// @vitest-environment jsdom
|
||
/**
|
||
* Базовый axe-проход по ключевым компонентам: форма входа, онбординг-чеклист,
|
||
* страница уведомлений. Проверяем отсутствие нарушений уровня critical/serious.
|
||
* Это smoke-задел, а не полный аудит всего приложения.
|
||
*/
|
||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||
import { render, cleanup } from '@testing-library/react';
|
||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||
import React from 'react';
|
||
import axe from 'axe-core';
|
||
|
||
// jsdom не имеет ResizeObserver, а Radix-контролы (Checkbox и др.) его требуют
|
||
if (typeof globalThis.ResizeObserver === 'undefined') {
|
||
globalThis.ResizeObserver = class {
|
||
observe() {}
|
||
unobserve() {}
|
||
disconnect() {}
|
||
} as unknown as typeof ResizeObserver;
|
||
}
|
||
|
||
// --- Моки хуков: тестируем разметку без сети и auth ---
|
||
|
||
// Текущий «пользователь» для useAuth — переключается между тестами
|
||
const mockAuthState = vi.hoisted(() => ({
|
||
user: null as null | Record<string, unknown>,
|
||
}));
|
||
|
||
vi.mock('@/hooks/useAuth', () => ({
|
||
useAuth: () => ({
|
||
user: mockAuthState.user,
|
||
login: vi.fn(),
|
||
isLoading: false,
|
||
isExpiredOffline: false,
|
||
}),
|
||
}));
|
||
|
||
vi.mock('@/hooks/use-toast', () => ({
|
||
useToast: () => ({ toast: vi.fn() }),
|
||
toast: vi.fn(),
|
||
}));
|
||
|
||
// Layout в странице уведомлений заменяем на passthrough —
|
||
// сайдбар с SSE/офлайн-контекстами в jsdom не нужен
|
||
vi.mock('@/components/Layout', () => ({
|
||
default: ({ children }: { children: React.ReactNode }) =>
|
||
React.createElement('div', null, children),
|
||
}));
|
||
|
||
const FIXTURE_NOTIFICATIONS = [
|
||
{
|
||
id: 1,
|
||
taskId: 10,
|
||
type: 'task.assigned',
|
||
title: 'Вас назначили ответственным',
|
||
message: 'Задача «Проверить отчёт» назначена вам',
|
||
isRead: false,
|
||
createdAt: '2026-03-01T10:00:00.000Z',
|
||
task: { id: 10, title: 'Проверить отчёт', formId: 5 },
|
||
},
|
||
{
|
||
id: 2,
|
||
taskId: 11,
|
||
type: 'task_mention',
|
||
title: 'Вас упомянули',
|
||
isRead: true,
|
||
createdAt: '2026-02-28T09:00:00.000Z',
|
||
task: { id: 11, title: 'Согласовать договор', formId: 5 },
|
||
taskMessage: {
|
||
id: 100,
|
||
message: 'Посмотрите, пожалуйста',
|
||
author: { firstName: 'Иван', lastName: 'Петров' },
|
||
},
|
||
},
|
||
];
|
||
|
||
vi.mock('@/hooks/useNotifications', () => ({
|
||
CHAT_EVENT_TYPES: ['task.mentioned', 'task.comment.created', 'task.comment.replied', 'task_mention', 'task_reply'],
|
||
useNotifications: () => ({
|
||
allNotifications: FIXTURE_NOTIFICATIONS,
|
||
chatNotifications: [],
|
||
systemNotifications: FIXTURE_NOTIFICATIONS,
|
||
unreadChatCount: 0,
|
||
unreadSystemCount: 1,
|
||
isLoading: false,
|
||
sseConnected: true,
|
||
}),
|
||
}));
|
||
|
||
// Импорты компонентов — после vi.mock (hoisting учтён)
|
||
import Login from '../client/src/pages/Login';
|
||
import { OnboardingChecklist } from '../client/src/components/OnboardingChecklist';
|
||
import NotificationsPage from '../client/src/pages/NotificationsPage';
|
||
|
||
afterEach(cleanup);
|
||
|
||
function makeQueryClient() {
|
||
return new QueryClient({
|
||
defaultOptions: {
|
||
queries: {
|
||
retry: false,
|
||
staleTime: Infinity,
|
||
// Заглушка queryFn: тесты работают на предзагруженном кэше, сеть не нужна
|
||
queryFn: () => Promise.resolve({}),
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
async function runAxe(container: HTMLElement) {
|
||
const results = await axe.run(container, {
|
||
// color-contrast в jsdom не вычисляется (нет реальных стилей/canvas) — исключаем,
|
||
// чтобы не ловить ложные срабатывания; контраст проверяется скинами дизайн-системы
|
||
rules: { 'color-contrast': { enabled: false } },
|
||
});
|
||
return results.violations.filter(
|
||
v => v.impact === 'critical' || v.impact === 'serious'
|
||
);
|
||
}
|
||
|
||
describe('a11y smoke (axe)', () => {
|
||
it('Login: форма входа без критичных нарушений', async () => {
|
||
const { container } = render(<Login />);
|
||
// Защита от вакуумного прохода: форма действительно отрендерилась
|
||
expect(container.querySelector('[data-testid="input-email"]')).toBeTruthy();
|
||
const violations = await runAxe(container);
|
||
expect(
|
||
violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`)
|
||
).toEqual([]);
|
||
});
|
||
|
||
it('OnboardingChecklist: чеклист без критичных нарушений', async () => {
|
||
mockAuthState.user = {
|
||
id: 1,
|
||
firstName: 'Тест',
|
||
lastName: 'Тестов',
|
||
organization: { id: 1 },
|
||
};
|
||
const queryClient = makeQueryClient();
|
||
// Данные предзагружены — компонент рендерит шаги без сетевых запросов
|
||
queryClient.setQueryData(['/api/forms'], { success: true, forms: [{}] });
|
||
queryClient.setQueryData(['/api/users/list'], { success: true, users: [{}, {}] });
|
||
queryClient.setQueryData(['/api/tasks', 'onboarding-first'], { success: true, tasks: [] });
|
||
|
||
const { container } = render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<OnboardingChecklist />
|
||
</QueryClientProvider>
|
||
);
|
||
// Чеклист действительно виден (не скрыт по loading/dismissed)
|
||
expect(container.textContent).toContain('Начало работы');
|
||
const violations = await runAxe(container);
|
||
expect(
|
||
violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`)
|
||
).toEqual([]);
|
||
});
|
||
|
||
it('NotificationsPage: центр уведомлений без критичных нарушений', async () => {
|
||
const queryClient = makeQueryClient();
|
||
const { container } = render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<NotificationsPage />
|
||
</QueryClientProvider>
|
||
);
|
||
// Список действительно отрендерился
|
||
expect(container.querySelector('[data-testid="notification-1"]')).toBeTruthy();
|
||
const violations = await runAxe(container);
|
||
expect(
|
||
violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`)
|
||
).toEqual([]);
|
||
});
|
||
});
|