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