test: add 95 frontend unit tests with vitest, RTL, and MSW #4
@ -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'],
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -18,4 +18,3 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { AppRoutes } from './routes';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
68
apps/frontend/src/api/auth.test.ts
Normal file
68
apps/frontend/src/api/auth.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
135
apps/frontend/src/api/client.test.ts
Normal file
135
apps/frontend/src/api/client.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
71
apps/frontend/src/components/BondDetails.test.tsx
Normal file
71
apps/frontend/src/components/BondDetails.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
57
apps/frontend/src/components/Layout.test.tsx
Normal file
57
apps/frontend/src/components/Layout.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
21
apps/frontend/src/components/PriceChart.test.tsx
Normal file
21
apps/frontend/src/components/PriceChart.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
36
apps/frontend/src/components/ProtectedRoute.test.tsx
Normal file
36
apps/frontend/src/components/ProtectedRoute.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
77
apps/frontend/src/components/SearchBar.test.tsx
Normal file
77
apps/frontend/src/components/SearchBar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
63
apps/frontend/src/components/StockDetails.test.tsx
Normal file
63
apps/frontend/src/components/StockDetails.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
73
apps/frontend/src/context/AuthContext.test.tsx
Normal file
73
apps/frontend/src/context/AuthContext.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 {
|
||||
|
||||
63
apps/frontend/src/hooks/useAuth.test.tsx
Normal file
63
apps/frontend/src/hooks/useAuth.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
30
apps/frontend/src/hooks/useBond.test.tsx
Normal file
30
apps/frontend/src/hooks/useBond.test.tsx
Normal 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));
|
||||
});
|
||||
});
|
||||
43
apps/frontend/src/hooks/useBondCandles.test.tsx
Normal file
43
apps/frontend/src/hooks/useBondCandles.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
62
apps/frontend/src/hooks/useSearch.test.tsx
Normal file
62
apps/frontend/src/hooks/useSearch.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
36
apps/frontend/src/hooks/useStock.test.tsx
Normal file
36
apps/frontend/src/hooks/useStock.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
44
apps/frontend/src/hooks/useStockCandles.test.tsx
Normal file
44
apps/frontend/src/hooks/useStockCandles.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
38
apps/frontend/src/hooks/useStockDividends.test.tsx
Normal file
38
apps/frontend/src/hooks/useStockDividends.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
48
apps/frontend/src/pages/BondPage.test.tsx
Normal file
48
apps/frontend/src/pages/BondPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
25
apps/frontend/src/pages/HomePage.test.tsx
Normal file
25
apps/frontend/src/pages/HomePage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
81
apps/frontend/src/pages/LoginPage.test.tsx
Normal file
81
apps/frontend/src/pages/LoginPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
62
apps/frontend/src/pages/ProfilePage.test.tsx
Normal file
62
apps/frontend/src/pages/ProfilePage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
77
apps/frontend/src/pages/RegisterPage.test.tsx
Normal file
77
apps/frontend/src/pages/RegisterPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
73
apps/frontend/src/pages/StockPage.test.tsx
Normal file
73
apps/frontend/src/pages/StockPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
9
apps/frontend/src/test/README.md
Normal file
9
apps/frontend/src/test/README.md
Normal 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.
|
||||
146
apps/frontend/src/test/factories.ts
Normal file
146
apps/frontend/src/test/factories.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
190
apps/frontend/src/test/handlers.ts
Normal file
190
apps/frontend/src/test/handlers.ts
Normal 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 }),
|
||||
),
|
||||
),
|
||||
];
|
||||
4
apps/frontend/src/test/server.ts
Normal file
4
apps/frontend/src/test/server.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
73
apps/frontend/src/test/setup.ts
Normal file
73
apps/frontend/src/test/setup.ts
Normal 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;
|
||||
40
apps/frontend/src/test/test-utils.tsx
Normal file
40
apps/frontend/src/test/test-utils.tsx
Normal 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 };
|
||||
}
|
||||
18
apps/frontend/vitest.config.ts
Normal file
18
apps/frontend/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
1920
docs/superpowers/plans/2026-06-13-frontend-test-coverage.md
Normal file
1920
docs/superpowers/plans/2026-06-13-frontend-test-coverage.md
Normal file
File diff suppressed because it is too large
Load Diff
127
docs/superpowers/specs/2026-06-13-frontend-test-coverage.md
Normal file
127
docs/superpowers/specs/2026-06-13-frontend-test-coverage.md
Normal 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
2906
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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}\"",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user