# Frontend Test Coverage Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add comprehensive frontend unit tests (vitest + RTL + MSW) across all existing modules **Architecture:** MSW intercepts fetch at the network level, so no production code changes are needed. Each test uses `renderWithProviders` (QueryClient + AuthProvider + MemoryRouter wrapper). Fresh QueryClient per test with `retry: false`. **Tech Stack:** vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, jsdom, msw v2 --- ### Task 1: Infrastructure — vitest config + test helpers **Files:** - Create: `apps/frontend/vitest.config.ts` - Create: `apps/frontend/src/test/setup.ts` - Create: `apps/frontend/src/test/server.ts` - Create: `apps/frontend/src/test/handlers.ts` - Create: `apps/frontend/src/test/test-utils.tsx` - Create: `apps/frontend/src/test/factories.ts` - Modify: `apps/frontend/package.json` (add test scripts) - Modify: `package.json` (add root workspace script) - [ ] **Step 1: Create vitest.config.ts** ```ts import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, test: { environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], globals: true, }, }); ``` - [ ] **Step 2: Create test/setup.ts** ```ts import '@testing-library/jest-dom'; import { server } from './server'; beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); Object.defineProperty(window, 'matchMedia', { writable: true, value: (query: string) => ({ matches: false, media: query, onchange: null, addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, }), }); ``` - [ ] **Step 3: Create test/server.ts** ```ts import { setupServer } from 'msw/node'; import { handlers } from './handlers'; export const server = setupServer(...handlers); ``` - [ ] **Step 4: Create test/handlers.ts** ```ts import { http, HttpResponse } from 'msw'; import type { ShareResponse, BondResponse, CandleItem, DividendItem, SearchResultItem, AuthResponse, UserResponse, } from '../api/responses'; const API = '/api/v1'; const mockUser: UserResponse = { id: 1, email: 'user@test.com', name: 'Test User', role: 'user', }; const mockAuth: AuthResponse = { user: mockUser, accessToken: 'mock-access-token', }; const mockShare: ShareResponse = { secid: 'SBER', isin: 'RU0009029540', name: 'Сбер Банк', shortName: 'Сбер', latName: 'Sberbank', listLevel: 1, issueSize: 21586900000, faceValue: 3, faceUnit: 'RUB', type: 'common_share', marketData: { price: 289.5, change: 2.5, changePercent: 0.87, open: 287, high: 291, low: 286.5, volume: 15000000, value: 4350000000, issueCapitalization: 6250000000000, updatedAt: '2024-01-15T10:00:00Z', }, }; const mockBond: BondResponse = { secid: 'SU26238RMFS5', isin: 'RU000A101XU7', name: 'ОФЗ 26238', shortName: 'ОФЗ 26238', latName: null, listLevel: 1, issueSize: 500000000, faceValue: 1000, faceUnit: 'RUB', matDate: '2027-05-15', couponValue: 36.9, couponPercent: 7.5, couponPeriod: 182, nextCoupon: '2024-07-15', accruedInt: 8.45, bondType: 'ОФЗ', bondSubType: 'ОФЗ-ПД', offerDate: null, buybackDate: null, marketData: { price: 98.5, yieldToMaturity: 8.2, duration: 3.5, accruedInt: 8.45, couponValue: 36.9, couponPercent: 7.5, nextCouponDate: '2024-07-15', open: 98.0, high: 99.0, low: 97.5, volume: 1000000, updatedAt: '2024-01-15T10:00:00Z', }, }; const mockCandles: CandleItem[] = [ { open: 280, high: 290, low: 278, close: 289.5, volume: 1000000, value: 280000000, begin: '2024-01-15T10:00:00Z', end: '2024-01-15T18:00:00Z' }, { open: 289, high: 292, low: 285, close: 288, volume: 800000, value: 231200000, begin: '2024-01-16T10:00:00Z', end: '2024-01-16T18:00:00Z' }, ]; const mockDividends: DividendItem[] = [ { registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }, { registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' }, ]; const mockSearchResults: SearchResultItem[] = [ { secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбер', type: 'share', listLevel: 1, currency: 'RUB', price: 289.5 }, { secid: 'VTBR', isin: 'RU000A0JP5V6', shortName: 'ВТБ', type: 'share', listLevel: 1, currency: 'RUB', price: 0.0234 }, ]; export const handlers = [ http.get(`${API}/securities/search`, ({ request }) => { const url = new URL(request.url); const q = url.searchParams.get('q') || ''; if (q.length < 2) return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); const filtered = mockSearchResults.filter(r => r.secid.toLowerCase().includes(q.toLowerCase()) || r.shortName.toLowerCase().includes(q.toLowerCase()), ); return HttpResponse.json({ data: { data: filtered, meta: { fromCache: false, cachedAt: null } } }); }), http.get(`${API}/securities/shares/:secid`, ({ params }) => { const { secid } = params; if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }); return HttpResponse.json({ data: { data: { ...mockShare, secid } as ShareResponse, meta: { fromCache: false, cachedAt: null } } }); }), http.get(`${API}/securities/shares/:secid/candles`, () => HttpResponse.json({ data: { data: mockCandles, meta: { fromCache: false, cachedAt: null } } }), ), http.get(`${API}/securities/shares/:secid/dividends`, () => HttpResponse.json({ data: { data: mockDividends, meta: { fromCache: false, cachedAt: null } } }), ), http.get(`${API}/securities/bonds/:secid`, ({ params }) => { const { secid } = params; if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }); return HttpResponse.json({ data: { data: { ...mockBond, secid } as BondResponse, meta: { fromCache: false, cachedAt: null } } }); }), http.get(`${API}/securities/bonds/:secid/candles`, () => HttpResponse.json({ data: { data: mockCandles, meta: { fromCache: false, cachedAt: null } } }), ), http.get(`${API}/auth/me`, () => HttpResponse.json({ data: { data: mockUser, meta: { fromCache: false, cachedAt: null } } }), ), http.post(`${API}/auth/login`, () => HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }), ), http.post(`${API}/auth/register`, () => HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }), ), http.post(`${API}/auth/refresh`, () => HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }), ), http.post(`${API}/auth/logout`, () => new HttpResponse(null, { status: 204 })), http.patch(`${API}/auth/me`, () => HttpResponse.json({ data: { data: { ...mockUser, name: 'Updated' }, meta: { fromCache: false, cachedAt: null } } }), ), http.get(`${API}/health`, () => HttpResponse.json({ data: { data: { status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }, meta: { fromCache: false, cachedAt: null } } }), ), ]; export { mockUser, mockAuth, mockShare, mockBond, mockCandles, mockDividends, mockSearchResults }; ``` - [ ] **Step 5: Create test/test-utils.tsx** ```tsx import { type ReactElement } from 'react'; import { render, type RenderOptions } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router-dom'; import { AuthProvider } from '../context/AuthContext'; interface CustomRenderOptions extends Omit { queryClient?: QueryClient; route?: string; } function createTestQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false }, }, }); } export function renderWithProviders( ui: ReactElement, { queryClient = createTestQueryClient(), route = '/', ...renderOptions }: CustomRenderOptions = {}, ) { function Wrapper({ children }: { children: React.ReactNode }) { return ( {children} ); } return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), queryClient }; } ``` - [ ] **Step 6: Create test/factories.ts** ```ts import type { ShareResponse, BondResponse, CandleItem, DividendItem, SearchResultItem, UserResponse, AuthResponse } from '../api/responses'; export function createMockShare(overrides: Partial = {}): ShareResponse { return { secid: 'SBER', isin: 'RU0009029540', name: 'Сбер Банк', shortName: 'Сбер', latName: 'Sberbank', listLevel: 1, issueSize: 21586900000, faceValue: 3, faceUnit: 'RUB', type: 'common_share', marketData: { price: 289.5, change: 2.5, changePercent: 0.87, open: 287, high: 291, low: 286.5, volume: 15000000, value: 4350000000, issueCapitalization: 6250000000000, updatedAt: '2024-01-15T10:00:00Z', }, ...overrides, }; } export function createMockBond(overrides: Partial = {}): BondResponse { return { secid: 'SU26238RMFS5', isin: 'RU000A101XU7', name: 'ОФЗ 26238', shortName: 'ОФЗ 26238', latName: null, listLevel: 1, issueSize: 500000000, faceValue: 1000, faceUnit: 'RUB', matDate: '2027-05-15', couponValue: 36.9, couponPercent: 7.5, couponPeriod: 182, nextCoupon: '2024-07-15', accruedInt: 8.45, bondType: 'ОФЗ', bondSubType: 'ОФЗ-ПД', offerDate: null, buybackDate: null, marketData: { price: 98.5, yieldToMaturity: 8.2, duration: 3.5, accruedInt: 8.45, couponValue: 36.9, couponPercent: 7.5, nextCouponDate: '2024-07-15', open: 98.0, high: 99.0, low: 97.5, volume: 1000000, updatedAt: '2024-01-15T10:00:00Z', }, ...overrides, }; } export function createMockUser(overrides: Partial = {}): UserResponse { return { id: 1, email: 'user@test.com', name: 'Test User', role: 'user', ...overrides }; } export function createMockAuth(overrides: Partial = {}): AuthResponse { return { user: createMockUser(), accessToken: 'mock-token', ...overrides }; } export function createMockCandles(count = 2): CandleItem[] { return Array.from({ length: count }, (_, i) => ({ open: 280 + i, high: 290 + i, low: 278 + i, close: 289.5 + i, volume: 1000000, value: 280000000, begin: `2024-01-${15 + i}T10:00:00Z`, end: `2024-01-${15 + i}T18:00:00Z`, })); } export function createMockDividends(): DividendItem[] { return [ { registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }, ]; } export function createMockSearchResults(): SearchResultItem[] { return [ { secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбер', type: 'share', listLevel: 1, currency: 'RUB', price: 289.5 }, { secid: 'VTBR', isin: 'RU000A0JP5V6', shortName: 'ВТБ', type: 'share', listLevel: 1, currency: 'RUB', price: 0.0234 }, ]; } ``` - [ ] **Step 7: Add scripts to apps/frontend/package.json** ```json "test": "vitest run", "test:watch": "vitest" ``` - [ ] **Step 8: Add to root package.json** ```json "test:frontend": "npm run test -w apps/frontend" ``` - [ ] **Step 9: Install dependencies** Run: `npm install -w apps/frontend vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom msw @types/node` - [ ] **Step 10: Verify setup** Run: `npm run test:frontend` Expected: "No test files found, exiting with code 0" (no tests yet, but config works) --- ### Task 2: API client tests **Files:** - Create: `apps/frontend/src/api/client.test.ts` - [ ] **Step 1: Create client.test.ts** ```ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client'; const API = '/api/v1'; beforeEach(() => { setAccessToken(null); }); describe('request', () => { it('makes GET request and returns data', async () => { const result = await request<{ message: string }>('/api/v1/health'); expect(result.data).toEqual({ status: 'ok', timestamp: expect.any(String), uptime: 12345, }); }); it('includes Authorization header when token is set', async () => { setAccessToken('test-token'); let capturedAuth: string | null = null; server.use( http.get(`${API}/test-auth`, ({ request }) => { capturedAuth = request.headers.get('Authorization'); return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); }), ); await request('/api/v1/test-auth'); expect(capturedAuth).toBe('Bearer test-token'); }); it('retries on 401 and succeeds after refresh', async () => { setAccessToken('expired-token'); let attempts = 0; server.use( http.get(`${API}/test-retry`, ({ request }) => { attempts++; const auth = request.headers.get('Authorization'); if (auth === 'Bearer expired-token') { return new HttpResponse(null, { status: 401 }); } return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); }), http.post(`${API}/auth/refresh`, () => HttpResponse.json({ data: { data: { user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, accessToken: 'new-token' }, meta: { fromCache: false, cachedAt: null } } }), ), ); const result = await request<{ ok: boolean }>('/api/v1/test-retry'); expect(attempts).toBe(2); expect(result.data).toEqual({ ok: true }); expect(getAccessToken()).toBe('new-token'); }); it('throws on persistent 401 and clears token', async () => { setAccessToken('expired-token'); let unauthorizedCalled = false; setOnUnauthorized(() => { unauthorizedCalled = true; }); server.use( http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })), http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), ); await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла'); expect(getAccessToken()).toBeNull(); expect(unauthorizedCalled).toBe(true); }); it('throws on non-ok response with status text', async () => { server.use( http.get(`${API}/test-error`, () => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' })), ); await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404'); }); it('sends JSON body for POST requests', async () => { let capturedBody: string | null = null; server.use( http.post(`${API}/test-post`, async ({ request }) => { capturedBody = await request.text(); return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); }), ); await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } }); expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' })); }); it('does not send auth header when skipAuth is true', async () => { setAccessToken('test-token'); let capturedAuth: string | null = null; server.use( http.get(`${API}/test-skip`, ({ request }) => { capturedAuth = request.headers.get('Authorization'); return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); }), ); await request('/api/v1/test-skip', undefined, { skipAuth: true }); expect(capturedAuth).toBeNull(); }); it('sets query params correctly', async () => { let capturedUrl = ''; server.use( http.get(`${API}/test-params`, ({ request }) => { capturedUrl = request.url; return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); }), ); await request('/api/v1/test-params', { q: 'sber', type: 'share' }); expect(capturedUrl).toContain('q=sber'); expect(capturedUrl).toContain('type=share'); }); }); ``` - [ ] **Step 2: Run tests** Run: `npx vitest run apps/frontend/src/api/client.test.ts -w apps/frontend` Expected: all passing --- ### Task 3: Auth API tests **Files:** - Create: `apps/frontend/src/api/auth.test.ts` - [ ] **Step 1: Create auth.test.ts** ```ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { setAccessToken, getAccessToken } from './client'; import { login, register, refresh, logout, getMe, updateProfile } from './auth'; const API = '/api/v1'; beforeEach(() => { setAccessToken(null); }); describe('login', () => { it('returns auth data and sets access token', async () => { const result = await login('user@test.com', 'password'); expect(result.user.email).toBe('user@test.com'); expect(result.accessToken).toBe('mock-access-token'); expect(getAccessToken()).toBe('mock-access-token'); }); it('throws on invalid credentials', async () => { server.use( http.post(`${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' })), ); await expect(login('wrong@test.com', 'wrong')).rejects.toThrow(); }); }); describe('register', () => { it('returns auth data and sets access token', async () => { const result = await register('new@test.com', 'password', 'New User'); expect(result.user.email).toBe('user@test.com'); expect(getAccessToken()).toBe('mock-access-token'); }); }); describe('refresh', () => { it('returns auth data and sets access token', async () => { const result = await refresh(); expect(result.accessToken).toBe('mock-access-token'); expect(getAccessToken()).toBe('mock-access-token'); }); }); describe('logout', () => { it('clears access token', async () => { setAccessToken('test-token'); await logout(); expect(getAccessToken()).toBeNull(); }); }); describe('getMe', () => { it('returns current user', async () => { const result = await getMe(); expect(result.email).toBe('user@test.com'); }); }); describe('updateProfile', () => { it('updates and returns user', async () => { const result = await updateProfile({ name: 'Updated' }); expect(result.name).toBe('Updated'); }); }); ``` - [ ] **Step 2: Run tests** Run: `npx vitest run apps/frontend/src/api/auth.test.ts -w apps/frontend` Expected: all passing --- ### Task 4: AuthContext tests **Files:** - Create: `apps/frontend/src/context/AuthContext.test.tsx` - [ ] **Step 1: Create AuthContext.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { AuthProvider, AuthContext, type AuthContextValue } from './AuthContext'; import { useContext } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const API = '/api/v1'; function renderWithProviders(ui: React.ReactElement) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( {ui} , ); } function TestConsumer() { const ctx = useContext(AuthContext); if (!ctx) return
no context
; return (
{ctx.isAuthenticated ? 'authenticated' : 'anonymous'} {ctx.user?.email ?? ''}
); } describe('AuthContext', () => { it('starts unauthenticated when refresh fails', async () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), ); renderWithProviders( , ); await waitFor(() => { expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'); }); }); it('restores session on mount when refresh succeeds', async () => { renderWithProviders( , ); await waitFor(() => { expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'); expect(screen.getByTestId('email')).toHaveTextContent('user@test.com'); }); }); it('updates state after login', async () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), ); const user = userEvent.setup(); renderWithProviders( , ); await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('anonymous')); await user.click(screen.getByRole('button', { name: 'login' })); await waitFor(() => { expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'); }); }); it('updates state after logout', async () => { const user = userEvent.setup(); renderWithProviders( , ); await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('authenticated')); await user.click(screen.getByRole('button', { name: 'logout' })); await waitFor(() => { expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'); }); }); }); ``` - [ ] **Step 2: Run tests** Run: `npx vitest run apps/frontend/src/context/AuthContext.test.tsx -w apps/frontend` Expected: all passing --- ### Task 5: Hook tests **Files:** - Create: `apps/frontend/src/hooks/useAuth.test.tsx` - Create: `apps/frontend/src/hooks/useSearch.test.tsx` - Create: `apps/frontend/src/hooks/useStock.test.tsx` - Create: `apps/frontend/src/hooks/useStockCandles.test.tsx` - Create: `apps/frontend/src/hooks/useStockDividends.test.tsx` - Create: `apps/frontend/src/hooks/useBond.test.tsx` - Create: `apps/frontend/src/hooks/useBondCandles.test.tsx` - [ ] **Step 1: Create useAuth.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { useAuth } from './useAuth'; import { renderWithProviders } from '../test/test-utils'; describe('useAuth', () => { it('returns auth context values', async () => { const { result } = renderHook(() => useAuth(), { wrapper: ({ children }) => { const { Wrapper } = renderWithProviders(<>{children}); return <>{children}; }}); // Actually use renderWithProviders via wrapper }); it('throws when used outside AuthProvider', () => { const { renderHook: render } = await import('@testing-library/react'); // Will be tested inline }); }); ``` Wait — actually let me write proper, complete tests. Let me re-do this. - [ ] **Step 1: Create useAuth.test.tsx properly** ```tsx import { describe, it, expect, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider } from '../context/AuthContext'; import { useAuth } from './useAuth'; import { type ReactNode } from 'react'; function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return function Wrapper({ children }: { children: ReactNode }) { return ( {children} ); }; } describe('useAuth', () => { it('returns auth context with user after mount', async () => { const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); await waitFor(() => { expect(result.current.isAuthenticated).toBe(true); }); expect(result.current.user?.email).toBe('user@test.com'); expect(result.current.accessToken).toBe('mock-access-token'); }); it('provides login function', async () => { const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); expect(typeof result.current.login).toBe('function'); }); it('provides logout function', async () => { const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); expect(typeof result.current.logout).toBe('function'); }); it('provides register function', async () => { const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); expect(typeof result.current.register).toBe('function'); }); it('throws when used without AuthProvider', () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); expect(() => { renderHook(() => useAuth(), { wrapper: ({ children }) => ( {children} ), }); }).toThrow('useAuth must be used within an AuthProvider'); }); }); ``` - [ ] **Step 2: Create useSearch.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { useSearch } from './useSearch'; import { type ReactNode } from 'react'; const API = '/api/v1'; function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return function Wrapper({ children }: { children: ReactNode }) { return {children}; }; } describe('useSearch', () => { it('does not fetch when query is empty', () => { const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() }); expect(result.current.isFetching).toBe(false); expect(result.current.data).toBeUndefined(); }); it('does not fetch when query is too short', () => { const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() }); expect(result.current.data).toBeUndefined(); }); it('returns search results for valid query', async () => { const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(result.current.data).toBeDefined(); expect(result.current.data?.length).toBeGreaterThan(0); expect(result.current.data?.[0].secid).toBe('SBER'); }); it('returns empty array when no results', async () => { server.use( http.get(`${API}/securities/search`, () => { return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); }), ); const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(result.current.data).toEqual([]); }); it('returns error state on network failure', async () => { server.use( http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })), ); const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() }); await waitFor(() => { expect(result.current.isError).toBe(true); }); }); }); ``` - [ ] **Step 3: Create useStock.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { useStock } from './useStock'; import { type ReactNode } from 'react'; const API = '/api/v1'; function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return function Wrapper({ children }: { children: ReactNode }) { return {children}; }; } describe('useStock', () => { it('returns share data', async () => { const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data?.secid).toBe('SBER'); expect(result.current.data?.shortName).toBe('Сбер'); expect(result.current.data?.marketData.price).toBe(289.5); }); it('returns error for not found', async () => { const { result } = renderHook(() => useStock('NOTFOUND'), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isError).toBe(true)); }); it('starts in loading state', () => { const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }); expect(result.current.isLoading).toBe(true); }); }); ``` - [ ] **Step 4: Create useStockCandles.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { useStockCandles } from './useStockCandles'; import { type ReactNode } from 'react'; const API = '/api/v1'; function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return function Wrapper({ children }: { children: ReactNode }) { return {children}; }; } describe('useStockCandles', () => { it('returns candle data', async () => { const { result } = renderHook( () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'), { wrapper: createWrapper() }, ); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toHaveLength(2); expect(result.current.data?.[0].open).toBe(280); }); it('returns empty array when no candles', async () => { server.use( http.get(`${API}/securities/shares/:secid/candles`, () => { return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); }), ); const { result } = renderHook( () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'), { wrapper: createWrapper() }, ); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual([]); }); }); ``` - [ ] **Step 5: Create useStockDividends.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { useStockDividends } from './useStockDividends'; import { type ReactNode } from 'react'; const API = '/api/v1'; function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return function Wrapper({ children }: { children: ReactNode }) { return {children}; }; } describe('useStockDividends', () => { it('returns dividend data', async () => { const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toHaveLength(2); expect(result.current.data?.[0].value).toBe(35); }); it('returns empty array when no dividends', async () => { server.use( http.get(`${API}/securities/shares/:secid/dividends`, () => { return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); }), ); const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual([]); }); }); ``` - [ ] **Step 6: Create useBond.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { useBond } from './useBond'; import { type ReactNode } from 'react'; const API = '/api/v1'; function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return function Wrapper({ children }: { children: ReactNode }) { return {children}; }; } describe('useBond', () => { it('returns bond data', async () => { const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data?.shortName).toBe('ОФЗ 26238'); expect(result.current.data?.marketData.price).toBe(98.5); }); it('returns error on 404', async () => { const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isError).toBe(true)); }); }); ``` - [ ] **Step 7: Create useBondCandles.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { useBondCandles } from './useBondCandles'; import { type ReactNode } from 'react'; const API = '/api/v1'; function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return function Wrapper({ children }: { children: ReactNode }) { return {children}; }; } describe('useBondCandles', () => { it('returns candle data', async () => { const { result } = renderHook( () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), { wrapper: createWrapper() }, ); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toHaveLength(2); }); it('returns empty array when no candles', async () => { server.use( http.get(`${API}/securities/bonds/:secid/candles`, () => { return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); }), ); const { result } = renderHook( () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), { wrapper: createWrapper() }, ); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual([]); }); }); ``` - [ ] **Step 8: Run all hook tests** Run: `npx vitest run apps/frontend/src/hooks/ -w apps/frontend` Expected: all passing --- ### Task 6: UI Component tests **Files:** - Create: `apps/frontend/src/components/SearchBar.test.tsx` - Create: `apps/frontend/src/components/ProtectedRoute.test.tsx` - Create: `apps/frontend/src/components/StockDetails.test.tsx` - Create: `apps/frontend/src/components/BondDetails.test.tsx` - Create: `apps/frontend/src/components/PriceChart.test.tsx` - Create: `apps/frontend/src/components/Layout.test.tsx` - [ ] **Step 1: Create SearchBar.test.tsx** ```tsx import { describe, it, expect, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../test/test-utils'; describe('SearchBar', () => { it('renders input with placeholder', () => { renderWithProviders(
); // SearchBar is inside Layout, test it within }); }); ``` Actually the SearchBar is rendered through Layout but can be tested standalone. Let me fix: ```tsx import { describe, it, expect } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { SearchBar } from './SearchBar'; import { renderWithProviders } from '../test/test-utils'; const API = '/api/v1'; function renderSearchBar() { return renderWithProviders(); } describe('SearchBar', () => { it('renders search input', () => { renderSearchBar(); expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument(); }); it('shows dropdown on focus', async () => { renderSearchBar(); const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); await userEvent.type(input, 'sber'); await waitFor(() => { expect(screen.getByText('Сбер')).toBeInTheDocument(); }); }); it('shows loading state while fetching', async () => { server.use( http.get(`${API}/securities/search`, () => { return new Promise(() => {}); // never resolves }), ); renderSearchBar(); const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); await userEvent.type(input, 'sber'); await waitFor(() => { expect(screen.getByText('Загрузка...')).toBeInTheDocument(); }); }); it('shows no results message', async () => { server.use( http.get(`${API}/securities/search`, () => { return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); }), ); renderSearchBar(); const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); await userEvent.type(input, 'zzzzz'); await waitFor(() => { expect(screen.getByText('Ничего не найдено')).toBeInTheDocument(); }); }); it('hides dropdown when clicking outside', async () => { renderSearchBar(); const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); await userEvent.type(input, 'sber'); await waitFor(() => { expect(screen.getByText('Сбер')).toBeInTheDocument(); }); await userEvent.click(document.body); await waitFor(() => { expect(screen.queryByText('Сбер')).not.toBeInTheDocument(); }); }); it('navigates to stock page on share result click', async () => { const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); vi.mock('react-router-dom', async (importOriginal) => { const actual = await importOriginal(); return { ...(actual as object), useNavigate: () => mockNavigate }; }); renderSearchBar(); const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); await userEvent.type(input, 'sber'); await waitFor(() => { expect(screen.getByText('Сбер')).toBeInTheDocument(); }); await userEvent.click(screen.getByText('Сбер')); expect(mockNavigate).toHaveBeenCalledWith('/stocks/SBER'); }); }); ``` - [ ] **Step 2: Create ProtectedRoute.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { ProtectedRoute } from './ProtectedRoute'; describe('ProtectedRoute', () => { it('renders children when authenticated', async () => { // Renders through AuthProvider which auto-authenticates via refresh const { renderWithProviders } = await import('../test/test-utils'); renderWithProviders(
Secret
, ); const content = await screen.findByTestId('protected-content'); expect(content).toBeInTheDocument(); }); it('redirects to login when not authenticated', async () => { const { http, HttpResponse } = await import('msw'); const { server } = await import('../test/server'); const API = '/api/v1'; server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), ); const { renderWithProviders } = await import('../test/test-utils'); renderWithProviders(
Secret
, { route: '/profile' }, ); await screen.findByText('Загрузка...'); // After loading, should redirect to login // Use findByText for async redirect check }); }); ``` Actually this imports are messy with dynamic imports. Let me write cleaner: ```tsx import { describe, it, expect } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { ProtectedRoute } from './ProtectedRoute'; import { renderWithProviders } from '../test/test-utils'; const API = '/api/v1'; describe('ProtectedRoute', () => { it('renders children when authenticated', async () => { renderWithProviders(
Secret
, ); expect(await screen.findByTestId('protected-content')).toBeInTheDocument(); }); it('shows loading state initially then redirects when not authenticated', async () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), ); renderWithProviders(
Secret
, { route: '/profile' }, ); expect(screen.getByText('Загрузка...')).toBeInTheDocument(); await waitFor(() => { // After auth resolves, should redirect — we check the protected content is gone expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); }); }); }); ``` - [ ] **Step 3: Create StockDetails.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { StockDetails } from './StockDetails'; import { createMockShare } from '../test/factories'; describe('StockDetails', () => { it('renders stock details', () => { const stock = createMockShare(); render(); expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument(); expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument(); }); it('displays positive change in green', () => { const stock = createMockShare({ marketData: { ...createMockShare().marketData, change: 5, changePercent: 2 } }); render(); const changeEl = screen.getByText('+5.00 (2.00%)'); expect(changeEl).toBeInTheDocument(); expect(changeEl).toHaveStyle({ color: 'var(--color-positive)' }); }); it('displays negative change in red', () => { const stock = createMockShare({ marketData: { ...createMockShare().marketData, change: -3, changePercent: -1 } }); render(); const changeEl = screen.getByText('-3.00 (-1.00%)'); expect(changeEl).toBeInTheDocument(); expect(changeEl).toHaveStyle({ color: 'var(--color-negative)' }); }); it('shows price formatted', () => { const stock = createMockShare(); render(); expect(screen.getByText('289,50')).toBeInTheDocument(); }); it('shows dash for null high', () => { const stock = createMockShare({ marketData: { ...createMockShare().marketData, high: null } }); render(); const rows = screen.getAllByText('—'); expect(rows.length).toBeGreaterThanOrEqual(1); }); it('shows capitalization in billions', () => { const stock = createMockShare(); render(); expect(screen.getByText('6250,00 млрд ₽')).toBeInTheDocument(); }); }); ``` - [ ] **Step 4: Create BondDetails.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { BondDetails } from './BondDetails'; import { createMockBond } from '../test/factories'; describe('BondDetails', () => { it('renders bond details', () => { const bond = createMockBond(); render(); expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument(); expect(screen.getByText('RU000A101XU7')).toBeInTheDocument(); }); it('shows price as percentage', () => { const bond = createMockBond(); render(); expect(screen.getByText('98.50%')).toBeInTheDocument(); }); it('renders maturity date', () => { const bond = createMockBond(); render(); expect(screen.getByText('2027-05-15')).toBeInTheDocument(); }); it('shows coupon with percentage', () => { const bond = createMockBond(); render(); expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument(); }); it('shows coupon without percentage when null', () => { const bond = createMockBond({ marketData: { ...createMockBond().marketData, couponPercent: null } }); render(); expect(screen.getByText('36.9 ₽')).toBeInTheDocument(); }); it('shows dash for missing next coupon date', () => { const bond = createMockBond({ marketData: { ...createMockBond().marketData, nextCouponDate: null } }); render(); const dashes = screen.getAllByText('—'); expect(dashes.length).toBeGreaterThanOrEqual(1); }); it('shows dash for null yieldToMaturity', () => { const bond = createMockBond({ marketData: { ...createMockBond().marketData, yieldToMaturity: null } }); render(); expect(screen.getByText('—')).toBeInTheDocument(); }); it('shows bond type', () => { const bond = createMockBond(); render(); expect(screen.getByText('ОФЗ')).toBeInTheDocument(); }); }); ``` - [ ] **Step 5: Create PriceChart.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { render } from '@testing-library/react'; import { PriceChart } from './PriceChart'; describe('PriceChart', () => { it('renders chart container', () => { const { container } = render(); expect(container.querySelector('div')).toBeInTheDocument(); }); it('renders with candle data', () => { const data = [ { open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' }, ]; const { container } = render(); expect(container.querySelector('div')).toBeInTheDocument(); }); it('accepts custom height', () => { const { container } = render(); expect(container.querySelector('div')).toBeInTheDocument(); }); }); ``` - [ ] **Step 6: Create Layout.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { Layout } from './Layout'; import { renderWithProviders } from '../test/test-utils'; const API = '/api/v1'; describe('Layout', () => { it('renders logo and search bar', async () => { renderWithProviders(); expect(screen.getByText('MoexVibe')).toBeInTheDocument(); expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument(); }); it('shows login link when not authenticated', async () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), ); renderWithProviders(); await waitFor(() => { expect(screen.getByText('Войти')).toBeInTheDocument(); }); }); it('shows user name and logout when authenticated', async () => { renderWithProviders(); await waitFor(() => { expect(screen.getByText('Test User')).toBeInTheDocument(); expect(screen.getByText('Выйти')).toBeInTheDocument(); }); }); it('shows email when user has no name', async () => { server.use( http.post(`${API}/auth/refresh`, () => { return HttpResponse.json({ data: { data: { user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, accessToken: 'mock' }, meta: { fromCache: false, cachedAt: null }, }, }); }), ); renderWithProviders(); await waitFor(() => { expect(screen.getByText('user@test.com')).toBeInTheDocument(); }); }); }); ``` - [ ] **Step 7: Run component tests** Run: `npx vitest run apps/frontend/src/components/ -w apps/frontend` Expected: all passing --- ### Task 7: Page tests **Files:** - Create: `apps/frontend/src/pages/HomePage.test.tsx` - Create: `apps/frontend/src/pages/StockPage.test.tsx` - Create: `apps/frontend/src/pages/BondPage.test.tsx` - Create: `apps/frontend/src/pages/LoginPage.test.tsx` - Create: `apps/frontend/src/pages/RegisterPage.test.tsx` - Create: `apps/frontend/src/pages/ProfilePage.test.tsx` - [ ] **Step 1: Create HomePage.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { HomePage } from './HomePage'; describe('HomePage', () => { it('renders welcome title', () => { render(); expect(screen.getByText('MoexVibe')).toBeInTheDocument(); }); it('renders description', () => { render(); expect(screen.getByText('Анализ акций и облигаций Московской биржи')).toBeInTheDocument(); }); it('renders hint text', () => { render(); expect(screen.getByText(/Введите название или тикер/)).toBeInTheDocument(); }); it('renders data delay notice', () => { render(); expect(screen.getByText(/Данные задерживаются на 15 минут/)).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: Create StockPage.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { StockPage } from './StockPage'; import { renderWithProviders } from '../test/test-utils'; const API = '/api/v1'; describe('StockPage', () => { it('shows loading state', () => { server.use( http.get(`${API}/securities/shares/:secid`, () => new Promise(() => {})), ); renderWithProviders(, { route: '/stocks/SBER' }); expect(screen.getByText('Загрузка...')).toBeInTheDocument(); }); it('renders stock details after loading', async () => { renderWithProviders(, { route: '/stocks/SBER' }); expect(await screen.findByText('Сбер (SBER)')).toBeInTheDocument(); }); it('renders price chart', async () => { renderWithProviders(, { route: '/stocks/SBER' }); expect(await screen.findByText('График цены')).toBeInTheDocument(); }); it('renders dividends section', async () => { renderWithProviders(, { route: '/stocks/SBER' }); expect(await screen.findByText('Дивиденды')).toBeInTheDocument(); expect(await screen.findByText('Дата закрытия реестра')).toBeInTheDocument(); }); it('hides dividends section when empty', async () => { server.use( http.get(`${API}/securities/shares/:secid/dividends`, () => { return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); }), ); renderWithProviders(, { route: '/stocks/SBER' }); await waitFor(() => { expect(screen.queryByText('Дивиденды')).not.toBeInTheDocument(); }); }); it('shows error state for not found', async () => { server.use( http.get(`${API}/securities/shares/:secid`, () => new HttpResponse(null, { status: 404 })), http.get(`${API}/securities/shares/:secid/candles`, () => new HttpResponse(null, { status: 404 })), http.get(`${API}/securities/shares/:secid/dividends`, () => new HttpResponse(null, { status: 404 })), ); renderWithProviders(, { route: '/stocks/NOTFOUND' }); expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument(); }); }); ``` - [ ] **Step 3: Create BondPage.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { screen } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { BondPage } from './BondPage'; import { renderWithProviders } from '../test/test-utils'; const API = '/api/v1'; describe('BondPage', () => { it('shows loading state', () => { server.use( http.get(`${API}/securities/bonds/:secid`, () => new Promise(() => {})), ); renderWithProviders(, { route: '/bonds/SU26238RMFS5' }); expect(screen.getByText('Загрузка...')).toBeInTheDocument(); }); it('renders bond details after loading', async () => { renderWithProviders(, { route: '/bonds/SU26238RMFS5' }); expect(await screen.findByText('ОФЗ 26238')).toBeInTheDocument(); }); it('renders price chart', async () => { renderWithProviders(, { route: '/bonds/SU26238RMFS5' }); expect(await screen.findByText('График цены')).toBeInTheDocument(); }); it('shows error state for not found', async () => { server.use( http.get(`${API}/securities/bonds/:secid`, () => new HttpResponse(null, { status: 404 })), http.get(`${API}/securities/bonds/:secid/candles`, () => new HttpResponse(null, { status: 404 })), ); renderWithProviders(, { route: '/bonds/NOTFOUND' }); expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument(); }); }); ``` - [ ] **Step 4: Create LoginPage.test.tsx** ```tsx import { describe, it, expect, vi } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { LoginPage } from './LoginPage'; import { renderWithProviders } from '../test/test-utils'; const API = '/api/v1'; describe('LoginPage', () => { it('renders login form', () => { // Mock refresh to fail so we don't auto-auth server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), ); renderWithProviders(, { route: '/login' }); expect(screen.getByText('Вход')).toBeInTheDocument(); expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument(); expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); }); it('redirects to home on successful login', async () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), ); const user = userEvent.setup(); renderWithProviders(, { route: '/login' }); await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); await user.type(screen.getByPlaceholderText('••••••••'), 'password'); await user.click(screen.getByRole('button', { name: 'Войти' })); await waitFor(() => { // Login succeeded, should redirect expect(screen.queryByText('Вход')).not.toBeInTheDocument(); }); }); it('shows error on failed login', async () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), http.post(`${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' })), ); const user = userEvent.setup(); renderWithProviders(, { route: '/login' }); await user.type(screen.getByPlaceholderText('email@example.com'), 'bad@test.com'); await user.type(screen.getByPlaceholderText('••••••••'), 'wrong'); await user.click(screen.getByRole('button', { name: 'Войти' })); expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument(); }); it('shows loading state while submitting', async () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), http.post(`${API}/auth/login`, () => new Promise(() => {})), ); const user = userEvent.setup(); renderWithProviders(, { route: '/login' }); await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); await user.type(screen.getByPlaceholderText('••••••••'), 'password'); await user.click(screen.getByRole('button', { name: 'Войти' })); expect(screen.getByText('Вход...')).toBeInTheDocument(); }); }); ``` - [ ] **Step 5: Create RegisterPage.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { RegisterPage } from './RegisterPage'; import { renderWithProviders } from '../test/test-utils'; const API = '/api/v1'; describe('RegisterPage', () => { it('renders registration form', () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), ); renderWithProviders(, { route: '/register' }); expect(screen.getByText('Регистрация')).toBeInTheDocument(); expect(screen.getByPlaceholderText('Иван Иванов')).toBeInTheDocument(); expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument(); }); it('shows password mismatch error', async () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), ); const user = userEvent.setup(); renderWithProviders(, { route: '/register' }); await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password1'); await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password2'); await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); expect(await screen.findByText('Пароли не совпадают')).toBeInTheDocument(); }); it('redirects on successful registration', async () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), ); const user = userEvent.setup(); renderWithProviders(, { route: '/register' }); await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password'); await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password'); await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); await waitFor(() => { expect(screen.queryByText('Регистрация')).not.toBeInTheDocument(); }); }); it('shows error on failed registration', async () => { server.use( http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), http.post(`${API}/auth/register`, () => new HttpResponse(null, { status: 409, statusText: 'Conflict' })), ); const user = userEvent.setup(); renderWithProviders(, { route: '/register' }); await user.type(screen.getByPlaceholderText('email@example.com'), 'existing@test.com'); await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password'); await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password'); await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument(); }); }); ``` - [ ] **Step 6: Create ProfilePage.test.tsx** ```tsx import { describe, it, expect } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; import { ProfilePage } from './ProfilePage'; import { renderWithProviders } from '../test/test-utils'; const API = '/api/v1'; describe('ProfilePage', () => { it('renders user profile', async () => { renderWithProviders( , { route: '/profile' }, ); expect(await screen.findByText('Профиль')).toBeInTheDocument(); expect(await screen.findByText('user@test.com')).toBeInTheDocument(); expect(await screen.findByText('user')).toBeInTheDocument(); }); it('shows user name in input', async () => { renderWithProviders(, { route: '/profile' }); const input = await screen.findByDisplayValue('Test User'); expect(input).toBeInTheDocument(); }); it('updates profile on save', async () => { const user = userEvent.setup(); renderWithProviders(, { route: '/profile' }); const input = await screen.findByDisplayValue('Test User'); await user.clear(input); await user.type(input, 'Updated User'); await user.click(screen.getByRole('button', { name: 'Сохранить' })); expect(await screen.findByText('Профиль обновлён')).toBeInTheDocument(); }); it('shows error message on failed update', async () => { server.use( http.patch(`${API}/auth/me`, () => new HttpResponse(null, { status: 500 })), ); const user = userEvent.setup(); renderWithProviders(, { route: '/profile' }); const input = await screen.findByDisplayValue('Test User'); await user.clear(input); await user.type(input, 'New Name'); await user.click(screen.getByRole('button', { name: 'Сохранить' })); expect(await screen.findByText('Не удалось обновить профиль')).toBeInTheDocument(); }); it('shows saving state', async () => { server.use( http.patch(`${API}/auth/me`, () => new Promise(() => {})), ); const user = userEvent.setup(); renderWithProviders(, { route: '/profile' }); const input = await screen.findByDisplayValue('Test User'); await user.clear(input); await user.type(input, 'New Name'); await user.click(screen.getByRole('button', { name: 'Сохранить' })); expect(await screen.findByText('Сохранение...')).toBeInTheDocument(); }); }); ``` - [ ] **Step 7: Run all page tests** Run: `npx vitest run apps/frontend/src/pages/ -w apps/frontend` Expected: all passing --- ### Task 8: Final verification - [ ] **Step 1: Run all tests** Run: `npm run test:frontend` Expected: all tests passing - [ ] **Step 2: Run format check** Run: `npm run format:check` Expected: no formatting issues