1921 lines
64 KiB
Markdown
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.

# 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<RenderOptions, 'wrapper'> {
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 (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>
<AuthProvider>
{children}
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
);
}
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> = {}): 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> = {}): 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> = {}): UserResponse {
return { id: 1, email: 'user@test.com', name: 'Test User', role: 'user', ...overrides };
}
export function createMockAuth(overrides: Partial<AuthResponse> = {}): 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(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
);
}
function TestConsumer() {
const ctx = useContext(AuthContext);
if (!ctx) return <div>no context</div>;
return (
<div>
<span data-testid="auth">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span>
<span data-testid="email">{ctx.user?.email ?? ''}</span>
<button onClick={() => ctx.login('a@b.com', 'p')}>login</button>
<button onClick={() => ctx.register('a@b.com', 'p')}>register</button>
<button onClick={() => ctx.logout()}>logout</button>
<button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button>
</div>
);
}
describe('AuthContext', () => {
it('starts unauthenticated when refresh fails', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
renderWithProviders(
<AuthProvider>
<TestConsumer />
</AuthProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
});
});
it('restores session on mount when refresh succeeds', async () => {
renderWithProviders(
<AuthProvider>
<TestConsumer />
</AuthProvider>,
);
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(
<AuthProvider>
<TestConsumer />
</AuthProvider>,
);
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(
<AuthProvider>
<TestConsumer />
</AuthProvider>,
);
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 (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
}
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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
}).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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
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(<div />); // 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(<SearchBar />);
}
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(
<ProtectedRoute>
<div data-testid="protected-content">Secret</div>
</ProtectedRoute>,
);
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(
<ProtectedRoute>
<div data-testid="protected-content">Secret</div>
</ProtectedRoute>,
{ 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(
<ProtectedRoute>
<div data-testid="protected-content">Secret</div>
</ProtectedRoute>,
);
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(
<ProtectedRoute>
<div data-testid="protected-content">Secret</div>
</ProtectedRoute>,
{ 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(<StockDetails stock={stock} />);
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(<StockDetails stock={stock} />);
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(<StockDetails stock={stock} />);
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(<StockDetails stock={stock} />);
expect(screen.getByText('289,50')).toBeInTheDocument();
});
it('shows dash for null high', () => {
const stock = createMockShare({ marketData: { ...createMockShare().marketData, high: null } });
render(<StockDetails stock={stock} />);
const rows = screen.getAllByText('—');
expect(rows.length).toBeGreaterThanOrEqual(1);
});
it('shows capitalization in billions', () => {
const stock = createMockShare();
render(<StockDetails stock={stock} />);
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(<BondDetails bond={bond} />);
expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument();
expect(screen.getByText('RU000A101XU7')).toBeInTheDocument();
});
it('shows price as percentage', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('98.50%')).toBeInTheDocument();
});
it('renders maturity date', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('2027-05-15')).toBeInTheDocument();
});
it('shows coupon with percentage', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument();
});
it('shows coupon without percentage when null', () => {
const bond = createMockBond({ marketData: { ...createMockBond().marketData, couponPercent: null } });
render(<BondDetails bond={bond} />);
expect(screen.getByText('36.9 ₽')).toBeInTheDocument();
});
it('shows dash for missing next coupon date', () => {
const bond = createMockBond({ marketData: { ...createMockBond().marketData, nextCouponDate: null } });
render(<BondDetails bond={bond} />);
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(1);
});
it('shows dash for null yieldToMaturity', () => {
const bond = createMockBond({ marketData: { ...createMockBond().marketData, yieldToMaturity: null } });
render(<BondDetails bond={bond} />);
expect(screen.getByText('—')).toBeInTheDocument();
});
it('shows bond type', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
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(<PriceChart data={[]} />);
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(<PriceChart data={data} />);
expect(container.querySelector('div')).toBeInTheDocument();
});
it('accepts custom height', () => {
const { container } = render(<PriceChart data={[]} height={600} />);
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(<Layout />);
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(<Layout />);
await waitFor(() => {
expect(screen.getByText('Войти')).toBeInTheDocument();
});
});
it('shows user name and logout when authenticated', async () => {
renderWithProviders(<Layout />);
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(<Layout />);
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(<HomePage />);
expect(screen.getByText('MoexVibe')).toBeInTheDocument();
});
it('renders description', () => {
render(<HomePage />);
expect(screen.getByText('Анализ акций и облигаций Московской биржи')).toBeInTheDocument();
});
it('renders hint text', () => {
render(<HomePage />);
expect(screen.getByText(/Введите название или тикер/)).toBeInTheDocument();
});
it('renders data delay notice', () => {
render(<HomePage />);
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(<StockPage />, { route: '/stocks/SBER' });
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
});
it('renders stock details after loading', async () => {
renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
expect(await screen.findByText('Сбер (SBER)')).toBeInTheDocument();
});
it('renders price chart', async () => {
renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
expect(await screen.findByText('График цены')).toBeInTheDocument();
});
it('renders dividends section', async () => {
renderWithProviders(<StockPage />, { 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(<StockPage />, { 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(<StockPage />, { 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(<BondPage />, { route: '/bonds/SU26238RMFS5' });
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
});
it('renders bond details after loading', async () => {
renderWithProviders(<BondPage />, { route: '/bonds/SU26238RMFS5' });
expect(await screen.findByText('ОФЗ 26238')).toBeInTheDocument();
});
it('renders price chart', async () => {
renderWithProviders(<BondPage />, { 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(<BondPage />, { 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(<LoginPage />, { 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(<LoginPage />, { 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(<LoginPage />, { 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(<LoginPage />, { 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(<RegisterPage />, { 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(<RegisterPage />, { 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(<RegisterPage />, { 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(<RegisterPage />, { 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(
<ProfilePage />,
{ 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(<ProfilePage />, { route: '/profile' });
const input = await screen.findByDisplayValue('Test User');
expect(input).toBeInTheDocument();
});
it('updates profile on save', async () => {
const user = userEvent.setup();
renderWithProviders(<ProfilePage />, { 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(<ProfilePage />, { 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(<ProfilePage />, { 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