test: add 95 frontend unit tests with vitest, RTL, and MSW #4

Merged
ksv741 merged 1 commits from feature/test-coverage into main 2026-06-14 08:29:59 +03:00
45 changed files with 6879 additions and 98 deletions

View File

@ -1,14 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: "prisma/schema.prisma",
schema: 'prisma/schema.prisma',
migrations: {
path: "prisma/migrations",
path: 'prisma/migrations',
},
datasource: {
url: process.env["DATABASE_URL"],
url: process.env['DATABASE_URL'],
},
});

View File

@ -101,9 +101,7 @@ describe('AuthService', () => {
it('should register a user without name', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.create).mockResolvedValue(
mockPrismaUser({ name: null, id: 2 }) as any,
);
vi.mocked(prisma.user.create).mockResolvedValue(mockPrismaUser({ name: null, id: 2 }) as any);
const result = await service.register({ email: testUser.email, password: testUser.password });

View File

@ -100,7 +100,12 @@ export class AuthService {
return this.sanitizeUser(user);
}
private async generateTokens(user: { id: number; email: string; name: string | null; role: string }) {
private async generateTokens(user: {
id: number;
email: string;
name: string | null;
role: string;
}) {
const accessPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
const accessToken = await this.jwtService.signAsync(accessPayload, {
secret: this.configService.get('app.auth.jwtSecret'),

View File

@ -18,4 +18,3 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
await this.$disconnect();
}
}

View File

@ -7,22 +7,31 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts"
"codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.20.0",
"lightweight-charts": "^4.1.0",
"openapi-fetch": "^0.9.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.20.0",
"@tanstack/react-query": "^5.20.0",
"openapi-fetch": "^0.9.0",
"lightweight-charts": "^4.1.0"
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.9.3",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.0",
"jsdom": "^29.1.1",
"msw": "^2.14.6",
"openapi-typescript": "^7.0.0",
"typescript": "^5.3.0",
"vite": "^5.4.0",
"openapi-typescript": "^7.0.0"
"vitest": "^4.1.8"
}
}

View File

@ -3,7 +3,7 @@ import { AppRoutes } from './routes';
export default function App() {
return (
<BrowserRouter>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<AppRoutes />
</BrowserRouter>
);

View File

@ -0,0 +1,68 @@
import { describe, it, expect, 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');
});
});

View File

@ -2,41 +2,38 @@ import { request, setAccessToken } from './client';
import type { AuthResponse, UserResponse } from './responses';
export async function login(email: string, password: string) {
const result = await request<AuthResponse>(
'/api/v1/auth/login',
undefined,
{ method: 'POST', body: { email, password }, skipAuth: true },
);
const result = await request<AuthResponse>('/api/v1/auth/login', undefined, {
method: 'POST',
body: { email, password },
skipAuth: true,
});
setAccessToken(result.data.accessToken);
return result.data;
}
export async function register(email: string, password: string, name?: string) {
const result = await request<AuthResponse>(
'/api/v1/auth/register',
undefined,
{ method: 'POST', body: { email, password, name }, skipAuth: true },
);
const result = await request<AuthResponse>('/api/v1/auth/register', undefined, {
method: 'POST',
body: { email, password, name },
skipAuth: true,
});
setAccessToken(result.data.accessToken);
return result.data;
}
export async function refresh() {
const result = await request<AuthResponse>(
'/api/v1/auth/refresh',
undefined,
{ method: 'POST', skipAuth: true },
);
const result = await request<AuthResponse>('/api/v1/auth/refresh', undefined, {
method: 'POST',
skipAuth: true,
});
setAccessToken(result.data.accessToken);
return result.data;
}
export async function logout() {
const result = await request<{ message: string }>(
'/api/v1/auth/logout',
undefined,
{ method: 'POST' },
);
const result = await request<{ message: string }>('/api/v1/auth/logout', undefined, {
method: 'POST',
});
setAccessToken(null);
return result.data;
}
@ -47,10 +44,9 @@ export async function getMe() {
}
export async function updateProfile(data: { name?: string }) {
const result = await request<UserResponse>(
'/api/v1/auth/me',
undefined,
{ method: 'PATCH', body: data },
);
const result = await request<UserResponse>('/api/v1/auth/me', undefined, {
method: 'PATCH',
body: data,
});
return result.data;
}

View File

@ -0,0 +1,135 @@
import { describe, it, expect, 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<{ status: string; timestamp: string; uptime: number }>(
'/api/v1/health',
);
expect(result.data.status).toBe('ok');
});
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');
});
});

View File

@ -91,7 +91,8 @@ export async function request<T>(
fetchOptions.method = options.method;
}
if (options?.body !== undefined) {
fetchOptions.body = options.body instanceof FormData ? options.body : JSON.stringify(options.body);
fetchOptions.body =
options.body instanceof FormData ? options.body : JSON.stringify(options.body);
}
let res = await fetch(url.toString(), fetchOptions);

View File

@ -0,0 +1,71 @@
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.getAllByText('RU000A101XU7').length).toBe(2);
});
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();
});
});

View File

@ -0,0 +1,57 @@
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(await screen.findByText('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();
});
});
});

View File

@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { PriceChart } from './PriceChart';
describe('PriceChart', () => {
it('renders chart container with empty data', () => {
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();
});
});

View File

@ -0,0 +1,36 @@
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(() => {
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,77 @@
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { server } from '../test/server';
import { SearchBar } from './SearchBar';
const API = '/api/v1';
function renderSearchBar() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<SearchBar />
</MemoryRouter>
</QueryClientProvider>,
);
}
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`, () => new Promise(() => {})));
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();
});
});
});

View File

@ -0,0 +1,63 @@
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();
});
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();
});
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 dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(1);
});
it('shows capitalization in billions', () => {
const stock = createMockShare();
render(<StockDetails stock={stock} />);
expect(screen.getByText('6250.00 млрд ₽')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { useContext } from 'react';
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 { AuthContext, AuthProvider } from './AuthContext';
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}>
<AuthProvider>{ui}</AuthProvider>
</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(<TestConsumer />);
await waitFor(() => {
expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
});
});
it('restores session on mount when refresh succeeds', async () => {
renderWithProviders(<TestConsumer />);
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(<TestConsumer />);
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(<TestConsumer />);
await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'));
await user.click(screen.getByRole('button', { name: 'logout' }));
await waitFor(() => {
expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
});
});
});

View File

@ -32,15 +32,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAccessTokenState(null);
}, []);
const login = useCallback(async (email: string, password: string) => {
const result = await authApi.login(email, password);
updateSession(result);
}, [updateSession]);
const login = useCallback(
async (email: string, password: string) => {
const result = await authApi.login(email, password);
updateSession(result);
},
[updateSession],
);
const register = useCallback(async (email: string, password: string, name?: string) => {
const result = await authApi.register(email, password, name);
updateSession(result);
}, [updateSession]);
const register = useCallback(
async (email: string, password: string, name?: string) => {
const result = await authApi.register(email, password, name);
updateSession(result);
},
[updateSession],
);
const logout = useCallback(async () => {
try {

View File

@ -0,0 +1,63 @@
import { describe, it, expect } 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', () => {
afterEach(() => {
vi.restoreAllMocks();
});
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', () => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
vi.spyOn(console, 'error').mockImplementation(() => {});
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');
});
});

View File

@ -0,0 +1,30 @@
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));
});
});

View File

@ -0,0 +1,43 @@
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([]);
});
});

View File

@ -0,0 +1,62 @@
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);
});
});
});

View File

@ -0,0 +1,36 @@
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);
});
});

View File

@ -0,0 +1,44 @@
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([]);
});
});

View File

@ -0,0 +1,38 @@
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([]);
});
});

View File

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { BondPage } from './BondPage';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
function renderBondPage(secid = 'SU26238RMFS5') {
return renderWithProviders(
<Routes>
<Route path="/bonds/:secid" element={<BondPage />} />
</Routes>,
{ route: `/bonds/${secid}` },
);
}
describe('BondPage', () => {
it('shows loading state', () => {
server.use(http.get(`${API}/securities/bonds/:secid`, () => new Promise(() => {})));
renderBondPage();
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
});
it('renders bond details after loading', async () => {
renderBondPage();
expect(await screen.findByText('ОФЗ 26238')).toBeInTheDocument();
});
it('renders price chart', async () => {
renderBondPage();
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 }),
),
);
renderBondPage('NOTFOUND');
expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,25 @@
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();
});
});

View File

@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
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';
async function waitForAuth() {
await screen.findByText('Войти');
}
describe('LoginPage', () => {
it('renders login form', async () => {
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
renderWithProviders(<LoginPage />, { route: '/login' });
expect(await screen.findByText('Вход')).toBeInTheDocument();
expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument();
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
});
it('redirects on successful login', async () => {
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
const user = userEvent.setup();
renderWithProviders(
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<div>Home Page</div>} />
</Routes>,
{ route: '/login' },
);
await screen.findByText('Вход');
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 screen.findByText('Home Page');
});
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 screen.findByText('Вход');
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 screen.findByText('Вход');
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(await screen.findByText('Вход...')).toBeInTheDocument();
});
});

View File

@ -31,33 +31,45 @@ export function LoginPage() {
<div style={{ maxWidth: 400, margin: '60px auto' }}>
<h1 style={{ marginBottom: 24, fontSize: 24, fontWeight: 700 }}>Вход</h1>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && (
<div style={{ color: 'var(--color-negative)', fontSize: 14 }}>{error}</div>
)}
{error && <div style={{ color: 'var(--color-negative)', fontSize: 14 }}>{error}</div>}
<div>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
<label
style={{
display: 'block',
marginBottom: 4,
fontSize: 14,
color: 'var(--color-text-secondary)',
}}
>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={inputStyle}
placeholder="email@example.com"
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={inputStyle}
placeholder="email@example.com"
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
<label
style={{
display: 'block',
marginBottom: 4,
fontSize: 14,
color: 'var(--color-text-secondary)',
}}
>
Пароль
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={inputStyle}
placeholder="••••••••"
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={inputStyle}
placeholder="••••••••"
/>
</div>
<button type="submit" disabled={loading} style={buttonStyle}>
@ -65,9 +77,12 @@ export function LoginPage() {
</button>
<p style={{ textAlign: 'center', fontSize: 14, color: 'var(--color-text-secondary)' }}>
Нет аккаунта?{' '}
<Link to={`/register${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} style={{ color: 'var(--color-primary)' }}>
Зарегистрироваться
</Link>
<Link
to={`/register${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`}
style={{ color: 'var(--color-primary)' }}
>
Зарегистрироваться
</Link>
</p>
</form>
</div>

View File

@ -0,0 +1,62 @@
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();
});
});

View File

@ -36,7 +36,7 @@ export function ProfilePage() {
>
<div style={{ marginBottom: 16 }}>
<span style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>Почта</span>
<p style={{ fontSize: 16, fontWeight: 500 }}>{user.email}</p>
<p style={{ fontSize: 16, fontWeight: 500 }}>{user.email}</p>
</div>
<div style={{ marginBottom: 16 }}>
<span style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>Роль</span>
@ -53,8 +53,8 @@ export function ProfilePage() {
}}
>
Имя
</label>
<input
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
style={{
@ -69,7 +69,15 @@ export function ProfilePage() {
/>
</div>
{message && (
<div style={{ fontSize: 14, color: message === 'Профиль обновлён' ? 'var(--color-positive)' : 'var(--color-negative)' }}>
<div
style={{
fontSize: 14,
color:
message === 'Профиль обновлён'
? 'var(--color-positive)'
: 'var(--color-negative)',
}}
>
{message}
</div>
)}

View File

@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
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', async () => {
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
renderWithProviders(<RegisterPage />, { route: '/register' });
expect(await screen.findByText('Регистрация')).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 screen.findByText('Регистрация');
await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com');
await user.type(screen.getByPlaceholderText('Минимум 6 символов'), '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(
<Routes>
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<div>Home Page</div>} />
</Routes>,
{ route: '/register' },
);
await screen.findByText('Регистрация');
await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com');
await user.type(screen.getByPlaceholderText('Минимум 6 символов'), 'password');
await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password');
await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' }));
await screen.findByText('Home Page');
});
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 screen.findByText('Регистрация');
await user.type(screen.getByPlaceholderText('email@example.com'), 'existing@test.com');
await user.type(screen.getByPlaceholderText('Минимум 6 символов'), 'password');
await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password');
await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' }));
expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument();
});
});

View File

@ -39,11 +39,16 @@ export function RegisterPage() {
<div style={{ maxWidth: 400, margin: '60px auto' }}>
<h1 style={{ marginBottom: 24, fontSize: 24, fontWeight: 700 }}>Регистрация</h1>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && (
<div style={{ color: 'var(--color-negative)', fontSize: 14 }}>{error}</div>
)}
{error && <div style={{ color: 'var(--color-negative)', fontSize: 14 }}>{error}</div>}
<div>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
<label
style={{
display: 'block',
marginBottom: 4,
fontSize: 14,
color: 'var(--color-text-secondary)',
}}
>
Имя (необязательно)
</label>
<input
@ -55,20 +60,34 @@ export function RegisterPage() {
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
<label
style={{
display: 'block',
marginBottom: 4,
fontSize: 14,
color: 'var(--color-text-secondary)',
}}
>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={inputStyle}
placeholder="email@example.com"
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={inputStyle}
placeholder="email@example.com"
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
<label
style={{
display: 'block',
marginBottom: 4,
fontSize: 14,
color: 'var(--color-text-secondary)',
}}
>
Пароль
</label>
<input
@ -82,7 +101,14 @@ export function RegisterPage() {
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, color: 'var(--color-text-secondary)' }}>
<label
style={{
display: 'block',
marginBottom: 4,
fontSize: 14,
color: 'var(--color-text-secondary)',
}}
>
Подтверждение пароля
</label>
<input
@ -99,9 +125,12 @@ export function RegisterPage() {
</button>
<p style={{ textAlign: 'center', fontSize: 14, color: 'var(--color-text-secondary)' }}>
Уже есть аккаунт?{' '}
<Link to={`/login${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} style={{ color: 'var(--color-primary)' }}>
Войти
</Link>
<Link
to={`/login${redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`}
style={{ color: 'var(--color-primary)' }}
>
Войти
</Link>
</p>
</form>
</div>

View File

@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { StockPage } from './StockPage';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
function renderStockPage(secid = 'SBER') {
return renderWithProviders(
<Routes>
<Route path="/stocks/:secid" element={<StockPage />} />
</Routes>,
{ route: `/stocks/${secid}` },
);
}
describe('StockPage', () => {
it('shows loading state', () => {
server.use(http.get(`${API}/securities/shares/:secid`, () => new Promise(() => {})));
renderStockPage();
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
});
it('renders stock details after loading', async () => {
renderStockPage();
expect(await screen.findByText('Сбер (SBER)')).toBeInTheDocument();
expect(await screen.findByText('Сбер Банк · RU0009029540')).toBeInTheDocument();
});
it('renders price chart', async () => {
renderStockPage();
expect(await screen.findByText('График цены')).toBeInTheDocument();
});
it('renders dividends section', async () => {
renderStockPage();
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 } },
});
}),
);
renderStockPage();
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 }),
),
);
renderStockPage('NOTFOUND');
expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,9 @@
# Tests
## Common issues
### Auth loading screen
Components rendered through `renderWithProviders` are wrapped in AuthProvider which shows
a loading screen until `POST /api/v1/auth/refresh` resolves.
- Use `await screen.findByText()` to wait for auth to complete.
- For components that don't need auth, render with just QueryClientProvider + MemoryRouter.

View File

@ -0,0 +1,146 @@
import type {
ShareResponse,
BondResponse,
BondMarketData,
StockMarketData,
CandleItem,
DividendItem,
SearchResultItem,
UserResponse,
AuthResponse,
} from '../api/responses';
export function createMockMarketData(overrides: Partial<StockMarketData> = {}): StockMarketData {
return {
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 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: createMockMarketData(),
...overrides,
};
}
export function createMockBondMarketData(overrides: Partial<BondMarketData> = {}): BondMarketData {
return {
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 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: createMockBondMarketData(),
...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,
},
];
}

View File

@ -0,0 +1,190 @@
import { http, HttpResponse } from 'msw';
import type { ShareResponse, BondResponse, CandleItem, SearchResultItem } from '../api/responses';
const API = '/api/v1';
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 = [
{ 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,
},
];
const userResponse = {
id: 1,
email: 'user@test.com',
name: 'Test User',
role: 'user',
};
const authResponse = {
user: userResponse,
accessToken: 'mock-access-token',
};
const envelope = (data: unknown) => ({
data: { data, meta: { fromCache: false, cachedAt: null } },
});
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(envelope([]));
}
const filtered = mockSearchResults.filter(
(r) =>
r.secid.toLowerCase().includes(q.toLowerCase()) ||
r.shortName.toLowerCase().includes(q.toLowerCase()),
);
return HttpResponse.json(envelope(filtered));
}),
http.get(`${API}/securities/shares/:secid`, ({ params }) => {
const { secid } = params;
if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 });
return HttpResponse.json(envelope({ ...mockShare, secid } as ShareResponse));
}),
http.get(`${API}/securities/shares/:secid/candles`, () =>
HttpResponse.json(envelope(mockCandles)),
),
http.get(`${API}/securities/shares/:secid/dividends`, () =>
HttpResponse.json(envelope(mockDividends)),
),
http.get(`${API}/securities/bonds/:secid`, ({ params }) => {
const { secid } = params;
if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 });
return HttpResponse.json(envelope({ ...mockBond, secid } as BondResponse));
}),
http.get(`${API}/securities/bonds/:secid/candles`, () =>
HttpResponse.json(envelope(mockCandles)),
),
http.get(`${API}/auth/me`, () => HttpResponse.json(envelope(userResponse))),
http.post(`${API}/auth/login`, () => HttpResponse.json(envelope(authResponse))),
http.post(`${API}/auth/register`, () => HttpResponse.json(envelope(authResponse))),
http.post(`${API}/auth/refresh`, () => HttpResponse.json(envelope(authResponse))),
http.post(`${API}/auth/logout`, () => HttpResponse.json(envelope({ message: 'Logged out' }))),
http.patch(`${API}/auth/me`, () =>
HttpResponse.json(envelope({ ...userResponse, name: 'Updated' })),
),
http.get(`${API}/health`, () =>
HttpResponse.json(
envelope({ status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }),
),
),
];

View File

@ -0,0 +1,4 @@
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);

View File

@ -0,0 +1,73 @@
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,
}),
});
// Prevent lightweight-charts animation frame callbacks in jsdom
window.requestAnimationFrame = () => 0;
window.cancelAnimationFrame = () => {};
// Mock canvas 2D context to prevent jsdom "Not implemented" warnings from lightweight-charts
HTMLCanvasElement.prototype.getContext = ((contextId: string) => {
if (contextId === '2d') {
return {
canvas: {} as HTMLCanvasElement,
fillRect: () => {},
clearRect: () => {},
getImageData: () => ({ data: [], width: 0, height: 0 }),
putImageData: () => {},
createImageData: () => ({ width: 0, height: 0, data: [] }),
setTransform: () => {},
drawImage: () => {},
save: () => {},
fillText: () => {},
restore: () => {},
beginPath: () => {},
moveTo: () => {},
lineTo: () => {},
closePath: () => {},
stroke: () => {},
translate: () => {},
scale: () => {},
rotate: () => {},
arc: () => {},
fill: () => {},
measureText: () => ({ width: 0 }),
transform: () => {},
rect: () => {},
clip: () => {},
createLinearGradient: () => ({ addColorStop: () => {} }),
createRadialGradient: () => ({ addColorStop: () => {} }),
createPattern: () => ({ setTransform: () => {} }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
lineCap: 'butt' as CanvasLineCap,
lineJoin: 'miter' as CanvasLineJoin,
globalAlpha: 1,
globalCompositeOperation: 'source-over' as GlobalCompositeOperation,
shadowBlur: 0,
shadowColor: '',
shadowOffsetX: 0,
shadowOffsetY: 0,
} as unknown as CanvasRenderingContext2D;
}
return null;
}) as typeof HTMLCanvasElement.prototype.getContext;

View File

@ -0,0 +1,40 @@
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]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<AuthProvider>{children}</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
);
}
return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), queryClient };
}

View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
logLevel: 'error',
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,127 @@
# Frontend Unit Test Coverage
## Stack
- **vitest** — test runner
- **@testing-library/react** + **@testing-library/jest-dom** + **@testing-library/user-event** — component testing
- **jsdom** — DOM environment
- **msw** (v2) — network-level mocking via `setupServer`
## Project structure (new files)
```
apps/frontend/
├── vitest.config.ts
└── src/
└── test/
├── setup.ts # jest-dom matchers, matchMedia mock
├── server.ts # MSW setupServer instance
├── handlers.ts # Default MSW handlers for all endpoints
├── test-utils.tsx # renderWithProviders wrapper
└── factories.ts # Mock data factories (share, bond, candle, user)
```
## Conventions
| Rule | Value |
|---|---|
| Location | `ComponentName.test.tsx` alongside `ComponentName.tsx` |
| Pattern | AAA (Arrange -> Act -> Assert) |
| Describe nesting | `describe('Component')` -> `describe('when ...')` |
| Snapshots | None |
| Mocks | MSW for API; `vi.fn()` for callbacks only |
| QueryClient | Fresh instance per test via `renderWithProviders` with `retry: false` |
## renderWithProviders
Custom wrapper that composes:
```tsx
function renderWithProviders(ui, { authState, queryClient, route } = {})
```
- `QueryClientProvider` with fresh client (defaultOptions: `queries: { retry: false }`)
- `AuthProvider` with optional override of initial state
- `MemoryRouter` with optional initial route
- Returns `{ ...render(ui), queryClient }`
## MSW handlers (default)
All API endpoints return 200 with valid mock data by default. Handlers:
- `GET /api/v1/auth/me` -> user
- `POST /api/v1/auth/login` -> tokens
- `POST /api/v1/auth/register` -> tokens
- `POST /api/v1/auth/refresh` -> tokens
- `POST /api/v1/auth/logout` -> 204
- `PATCH /api/v1/auth/me` -> updated user
- `GET /api/v1/shares/:secid` -> share
- `GET /api/v1/shares/:secid/candles` -> candles
- `GET /api/v1/shares/:secid/dividends` -> dividends
- `GET /api/v1/bonds/:secid` -> bond
- `GET /api/v1/bonds/:secid/candles` -> candles
- `GET /api/v1/securities/search` -> search results
- `GET /api/v1/health` -> ok
Errors are tested by overriding handlers per-test via `server.use()`.
## Implementation order (5 phases)
### Phase 1: Infrastructure + API client
**Files to create:**
- `vitest.config.ts`
- `src/test/setup.ts`
- `src/test/server.ts`
- `src/test/handlers.ts`
- `src/test/test-utils.tsx`
- `src/test/factories.ts`
**Tests:**
- `api/client.test.ts` — basic get/post, 401 -> refresh -> retry, persistent 401 -> throws
- `api/auth.test.ts` — login, register, refresh, logout, getMe, updateProfile
### Phase 2: Auth + Hooks
- `context/AuthContext.test.tsx``login`/`register`/`logout`/`updateProfile` update context state
- `hooks/useAuth.test.tsx` — returns context values, throws outside provider
- `hooks/useSearch.test.ts` — empty query, results found, no results, error
- `hooks/useStock.test.ts` — data loaded, loading, error
- `hooks/useStockCandles.test.ts` — data loaded, empty array, error
- `hooks/useStockDividends.test.ts` — data loaded, no dividends, error
- `hooks/useBond.test.ts` — data loaded, loading, error
- `hooks/useBondCandles.test.ts` — data loaded, empty array, error
### Phase 3: UI Components
- `components/SearchBar.test.tsx` — typing triggers search, dropdown shows results, clicking result navigates, empty input hides dropdown, loading state
- `components/ProtectedRoute.test.tsx` — authenticated -> renders children; unauthenticated -> redirects to `/login?redirect=`
- `components/StockDetails.test.tsx` — renders all fields, missing optional fields, null/undefined data
- `components/BondDetails.test.tsx` — renders all fields, missing optional fields, null/undefined data
- `components/PriceChart.test.tsx` — renders chart with candle data, empty data array, error state
- `components/Layout.test.tsx` — renders search bar, auth state toggles login/profile link, logout button works on profile
### Phase 4: Pages
- `pages/LoginPage.test.tsx` — renders form, validation errors, successful login redirects, server error displayed
- `pages/RegisterPage.test.tsx` — renders form, validation errors, successful register redirects, password mismatch
- `pages/HomePage.test.tsx` — renders welcome content and navigation links
- `pages/StockPage.test.tsx` — loading skeleton, renders all sections (details, chart, dividends), error state, not found
- `pages/BondPage.test.tsx` — loading skeleton, renders all sections (details, chart), error state, not found
- `pages/ProfilePage.test.tsx` — loads user data, renders form, successful update, validation error, not authenticated -> redirect
### Phase 5: Scripts & wiring
- Add `"test": "vitest run"` and `"test:watch": "vitest"` to `apps/frontend/package.json`
- Add `"test:frontend": "npm run test -w apps/frontend"` to root `package.json`
- Verify: `npm run test:frontend` passes
## Testing rules
1. **No `waitFor` for queries** — use `findBy*` / `findAllBy*` which already await
2. **`userEvent` over `fireEvent`** — simulates real interactions
3. **MSW handlers reset in `afterEach`**`server.resetHandlers()`
4. **`server.close()` in `afterAll`**
5. **No snapshot tests**
6. **Factory data only includes required fields** — tests explicitly add optional fields when needed
7. **Each `describe` tests one concern** — no 200-line tests

2906
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"build:backend": "npm run build -w apps/backend",
"build:frontend": "npm run build -w apps/frontend",
"test:backend": "npm run test -w apps/backend",
"test:frontend": "npm run test -w apps/frontend",
"lint": "npm run lint -w apps/backend",
"format": "prettier --write \"**/*.{ts,tsx}\"",
"format:check": "prettier --check \"**/*.{ts,tsx}\"",