diff --git a/apps/docs/docs/frontend/hooks.md b/apps/docs/docs/frontend/hooks.md
index 7aa8348..f09ef4d 100644
--- a/apps/docs/docs/frontend/hooks.md
+++ b/apps/docs/docs/frontend/hooks.md
@@ -11,13 +11,16 @@
| `useBond(secid)` | `['bond', secid]` | 900s | Спецификация облигации |
| `useBondCandles(secid, interval, from, till)` | `['bondCandles', secid, interval, from, till]` | 3600s | Свечи облигации |
-## Primary и legacy paths
+## Public API
-- `useSearch` primary path: `apps/frontend/src/entities/search/model/useSearch.ts`
-- Public API для search slice: `apps/frontend/src/entities/search/index.ts`
-- Legacy compatibility path: `apps/frontend/src/hooks/useSearch.ts` — это shim, который
- реэкспортирует primary hook
-- Market pages используют `entities/search` как source of truth, а не historical hook path
+- search hook: `entities/search/index.ts`
+- stock hooks: `entities/stock/index.ts`
+- bond hooks: `entities/bond/index.ts`
+- portfolio hooks: `entities/portfolio/index.ts`
+- broker-account hooks: `entities/broker-account/index.ts`
+- broker-position hooks: `entities/broker-position/index.ts`
+- broker-operation hooks: `entities/broker-operation/index.ts`
+- session hooks: `entities/session/index.ts`
## Конфигурация Query
@@ -35,19 +38,9 @@ const queryClient = new QueryClient({
## Паттерн hook
-Для historical hooks и FSD hooks общий принцип один и тот же:
-
-1. Хук вызывает ближайший domain/shared API helper
-2. Извлекает `res.data` (ответ MOEX обёрнут в `{ data, meta }`)
-3. Типизируется через актуальные response types из `shared/api/responses.ts`
-
-В legacy-слое helper может приходить из historical `api/*`, а в FSD-срезах — из domain API
-файлов вроде `entities/stock/api/stockApi.ts` или `entities/bond/api/bondApi.ts`.
-
-Для FSD-срезов domain-specific hooks постепенно переезжают ближе к своим сущностям. Для market
-pages это уже сделано для `entities/search`, `entities/stock` и `entities/bond`; для portfolio
-домен уже использует `entities/portfolio`; legacy imports сохраняются только как transitional
-shim-слой там, где миграция ещё не завершена.
+1. Хук вызывает domain API helper из `entities/*/api/` или `shared/api/client`
+2. Извлекает `res.data` (ответ API обёрнут в `{ data, meta }`)
+3. Типизируется через response types из `shared/api/responses.ts`
```typescript
export function useStock(secid: string) {
diff --git a/apps/docs/docs/frontend/routes.md b/apps/docs/docs/frontend/routes.md
index b23113f..fb5fe9d 100644
--- a/apps/docs/docs/frontend/routes.md
+++ b/apps/docs/docs/frontend/routes.md
@@ -16,14 +16,15 @@ Source of truth для маршрутов: `apps/frontend/src/app/routing/AppRou
| `/broker` | `BrokerAccountsPage` from `pages/broker-accounts` | Protected | Список брокерских счетов |
| `/broker/:accountId` | `BrokerAccountLayout` + nested pages | Protected | Детальная область брокерского счёта |
-Market route entrypoints теперь живут в:
+Все page entrypoints живут в FSD-слоях:
-- `apps/frontend/src/pages/home`
-- `apps/frontend/src/pages/stock`
-- `apps/frontend/src/pages/bond`
-
-Legacy `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx` сохранены как shim-файлы,
-но не являются source of truth для новых импортов.
+- `pages/home`
+- `pages/stock`
+- `pages/bond`
+- `pages/broker-accounts`
+- `pages/broker-account`
+- `pages/broker-positions`
+- `pages/broker-operations`
Все страницы обёрнуты в `AppLayout`, который содержит:
diff --git a/apps/frontend/src/api/auth.test.ts b/apps/frontend/src/api/auth.test.ts
deleted file mode 100644
index 571544e..0000000
--- a/apps/frontend/src/api/auth.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-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');
- });
-});
diff --git a/apps/frontend/src/api/auth.ts b/apps/frontend/src/api/auth.ts
deleted file mode 100644
index 3619a97..0000000
--- a/apps/frontend/src/api/auth.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export {
- login,
- register,
- refresh,
- logout,
- getMe,
- updateProfile,
-} from '../entities/session/api/sessionApi';
diff --git a/apps/frontend/src/api/broker.test.ts b/apps/frontend/src/api/broker.test.ts
deleted file mode 100644
index 71bf7b4..0000000
--- a/apps/frontend/src/api/broker.test.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { getBrokerOperations, getBrokerPositions } from './broker';
-
-describe('broker api', () => {
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('serializes operations query parameters', async () => {
- vi.spyOn(globalThis, 'fetch').mockResolvedValue({
- ok: true,
- json: async () => ({
- data: {
- data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
- meta: { fromCache: false, cachedAt: null },
- },
- }),
- } as Response);
-
- await getBrokerOperations('acc-1', { cursor: 'c1', limit: 50 });
-
- expect(fetch).toHaveBeenCalledWith(
- expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'),
- expect.any(Object),
- );
- });
-
- it('serializes operations query parameters including operationTypes', async () => {
- vi.spyOn(globalThis, 'fetch').mockResolvedValue({
- ok: true,
- json: async () => ({
- data: {
- data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
- meta: { fromCache: false, cachedAt: null },
- },
- }),
- } as Response);
-
- await getBrokerOperations('acc-1', {
- cursor: 'c1',
- limit: 10,
- operationTypes: 'OPERATION_TYPE_COUPON',
- });
-
- expect(fetch).toHaveBeenCalledWith(
- expect.stringContaining(
- '/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=10&operationTypes=OPERATION_TYPE_COUPON',
- ),
- expect.any(Object),
- );
- });
-
- it('serializes positions query parameters', async () => {
- vi.spyOn(globalThis, 'fetch').mockResolvedValue({
- ok: true,
- json: async () => ({
- data: {
- data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
- meta: { fromCache: false, cachedAt: null },
- },
- }),
- } as Response);
-
- await getBrokerPositions('acc-1', { cursor: 'pos-1', limit: 5 });
-
- expect(fetch).toHaveBeenCalledWith(
- expect.stringContaining('/api/v1/broker/accounts/acc-1/positions?cursor=pos-1&limit=5'),
- expect.any(Object),
- );
- });
-});
diff --git a/apps/frontend/src/api/broker.ts b/apps/frontend/src/api/broker.ts
deleted file mode 100644
index d9c0816..0000000
--- a/apps/frontend/src/api/broker.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { request } from './client';
-import type {
- ApiResponseMeta,
- BrokerAccount,
- BrokerOperationsPage,
- BrokerPortfolio,
- BrokerPositionsPage,
-} from './responses';
-
-export type BrokerOperationQuery = {
- from?: string;
- to?: string;
- cursor?: string;
- limit?: number;
- instrumentId?: string;
- operationTypes?: string;
- state?: string;
-};
-
-export function getBrokerAccounts(): Promise<{
- data: BrokerAccount[];
- meta: ApiResponseMeta;
-}> {
- return request('/api/v1/broker/accounts');
-}
-
-export function getBrokerPortfolio(accountId: string): Promise<{
- data: BrokerPortfolio;
- meta: ApiResponseMeta;
-}> {
- return request(
- `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
- );
-}
-
-export function getBrokerOperations(
- accountId: string,
- query: BrokerOperationQuery = {},
-): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
- return request(
- `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
- {
- from: query.from,
- to: query.to,
- cursor: query.cursor,
- limit: query.limit ? String(query.limit) : undefined,
- instrumentId: query.instrumentId,
- operationTypes: query.operationTypes,
- state: query.state,
- },
- );
-}
-
-export function getBrokerPositions(
- accountId: string,
- query: { cursor?: string; limit?: number; type?: string } = {},
-): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> {
- return request(
- `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
- {
- cursor: query.cursor,
- limit: query.limit ? String(query.limit) : undefined,
- type: query.type,
- },
- );
-}
diff --git a/apps/frontend/src/api/client.test.ts b/apps/frontend/src/api/client.test.ts
deleted file mode 100644
index cd4dd9d..0000000
--- a/apps/frontend/src/api/client.test.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-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('supports the single API envelope shape documented by Swagger', async () => {
- server.use(
- http.get(`${API}/test-single-envelope`, () =>
- HttpResponse.json({
- data: { ok: true },
- meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
- }),
- ),
- );
-
- const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope');
-
- expect(result).toEqual({
- data: { ok: true },
- meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
- });
- });
-
- 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');
- });
-});
diff --git a/apps/frontend/src/api/client.ts b/apps/frontend/src/api/client.ts
deleted file mode 100644
index 4f7f8b3..0000000
--- a/apps/frontend/src/api/client.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export {
- request,
- setAccessToken,
- getAccessToken,
- setOnUnauthorized,
- getHealth,
- searchSecurities,
-} from '../shared/api/client';
diff --git a/apps/frontend/src/api/portfolio.ts b/apps/frontend/src/api/portfolio.ts
deleted file mode 100644
index b16ddbd..0000000
--- a/apps/frontend/src/api/portfolio.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export {
- getPortfolios,
- getPortfolio,
- createPortfolio,
- updatePortfolio,
- deletePortfolio,
- addPosition,
- updatePosition,
- removePosition,
- getPortfolioAnalytics,
-} from '../entities/portfolio/api/portfolioApi';
diff --git a/apps/frontend/src/api/responses.ts b/apps/frontend/src/api/responses.ts
deleted file mode 100644
index 0ac068e..0000000
--- a/apps/frontend/src/api/responses.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-export type {
- ApiResponseMeta,
- ApiEnvelope,
- StockMarketData,
- ShareResponse,
- DividendItem,
- ShareHistoryItem,
- BondMarketData,
- BondResponse,
- BondHistoryItem,
- CandleItem,
- SearchResultItem,
- HealthResponse,
- UserResponse,
- AuthResponse,
- Portfolio,
- PositionWithPrice,
- PortfolioDetail,
- Position,
- PortfolioSummary,
- AnalyticsResponse,
- ScreenerItem,
- ScreenerResult,
- BrokerMoney,
- BrokerAccount,
- BrokerPosition,
- BrokerPortfolio,
- BrokerOperationCategory,
- BrokerOperation,
- BrokerOperationsPage,
- BrokerPositionsPage,
-} from '../shared/api/responses';
diff --git a/apps/frontend/src/api/screener.ts b/apps/frontend/src/api/screener.ts
index fe80204..c8a9c7c 100644
--- a/apps/frontend/src/api/screener.ts
+++ b/apps/frontend/src/api/screener.ts
@@ -1,5 +1,5 @@
-import { request } from './client';
-import type { ScreenerResult } from './responses';
+import { request } from '@/shared/api/client';
+import type { ScreenerResult } from '@/shared/api/responses';
export interface ScreenerQuery {
type: 'share' | 'bond';
diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts
deleted file mode 100644
index d729c9f..0000000
--- a/apps/frontend/src/api/types.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from '../shared/api/types';
diff --git a/apps/frontend/src/components/BondDetails.test.tsx b/apps/frontend/src/components/BondDetails.test.tsx
deleted file mode 100644
index 3b3d0a7..0000000
--- a/apps/frontend/src/components/BondDetails.test.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { BondDetails } from './BondDetails';
-import { createMockBond } from '../test/factories';
-
-describe('BondDetails', () => {
- it('renders bond details', () => {
- const bond = createMockBond();
- render();
- expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument();
- expect(screen.getAllByText('RU000A101XU7').length).toBe(2);
- });
-
- it('shows price as percentage', () => {
- const bond = createMockBond();
- render();
- expect(screen.getByText('98.50%')).toBeInTheDocument();
- });
-
- it('renders maturity date', () => {
- const bond = createMockBond();
- render();
- expect(screen.getByText('2027-05-15')).toBeInTheDocument();
- });
-
- it('shows coupon with percentage', () => {
- const bond = createMockBond();
- render();
- expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument();
- });
-
- it('shows coupon without percentage when null', () => {
- const bond = createMockBond({
- marketData: {
- ...createMockBond().marketData,
- couponPercent: null,
- },
- });
- render();
- expect(screen.getByText('36.9 ₽')).toBeInTheDocument();
- });
-
- it('shows dash for missing next coupon date', () => {
- const bond = createMockBond({
- marketData: {
- ...createMockBond().marketData,
- nextCouponDate: null,
- },
- });
- render();
- const dashes = screen.getAllByText('—');
- expect(dashes.length).toBeGreaterThanOrEqual(1);
- });
-
- it('shows dash for null yieldToMaturity', () => {
- const bond = createMockBond({
- marketData: {
- ...createMockBond().marketData,
- yieldToMaturity: null,
- },
- });
- render();
- expect(screen.getByText('—')).toBeInTheDocument();
- });
-
- it('shows bond type', () => {
- const bond = createMockBond();
- render();
- expect(screen.getByText('ОФЗ')).toBeInTheDocument();
- });
-});
diff --git a/apps/frontend/src/components/BondDetails.tsx b/apps/frontend/src/components/BondDetails.tsx
deleted file mode 100644
index b1b0d1b..0000000
--- a/apps/frontend/src/components/BondDetails.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { BondDetails } from '@/widgets/bond-details';
diff --git a/apps/frontend/src/components/Layout.test.tsx b/apps/frontend/src/components/Layout.test.tsx
deleted file mode 100644
index 6673eb0..0000000
--- a/apps/frontend/src/components/Layout.test.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
-import { http, HttpResponse } from 'msw';
-import { server } from '../test/server';
-import { Layout } from './Layout';
-import { renderWithProviders } from '../test/test-utils';
-
-const API = '/api/v1';
-
-describe('Layout', () => {
- it('renders logo and search bar', async () => {
- renderWithProviders();
- expect(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();
- await waitFor(() => {
- expect(screen.getByText('Войти')).toBeInTheDocument();
- });
- });
-
- it('shows user name and logout when authenticated', async () => {
- renderWithProviders();
- await waitFor(() => {
- expect(screen.getByText('Test User')).toBeInTheDocument();
- expect(screen.getByText('Выйти')).toBeInTheDocument();
- });
- });
-
- it('shows email when user has no name', async () => {
- server.use(
- http.post(`${API}/auth/refresh`, () => {
- return HttpResponse.json({
- data: {
- data: {
- user: {
- id: 1,
- email: 'user@test.com',
- name: null,
- role: 'user',
- },
- accessToken: 'mock',
- },
- meta: { fromCache: false, cachedAt: null },
- },
- });
- }),
- );
- renderWithProviders();
- await waitFor(() => {
- expect(screen.getByText('user@test.com')).toBeInTheDocument();
- });
- });
-});
diff --git a/apps/frontend/src/components/Layout.tsx b/apps/frontend/src/components/Layout.tsx
deleted file mode 100644
index e66f8a1..0000000
--- a/apps/frontend/src/components/Layout.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { AppLayout as Layout } from '../app/layouts/AppLayout';
diff --git a/apps/frontend/src/components/PriceChart.test.tsx b/apps/frontend/src/components/PriceChart.test.tsx
deleted file mode 100644
index fe00156..0000000
--- a/apps/frontend/src/components/PriceChart.test.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-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();
- expect(container.querySelector('div')).toBeInTheDocument();
- });
-
- it('renders with candle data', () => {
- const data = [{ open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' }];
- const { container } = render();
- expect(container.querySelector('div')).toBeInTheDocument();
- });
-
- it('accepts custom height', () => {
- const { container } = render();
- expect(container.querySelector('div')).toBeInTheDocument();
- });
-});
diff --git a/apps/frontend/src/components/PriceChart.tsx b/apps/frontend/src/components/PriceChart.tsx
deleted file mode 100644
index 866f322..0000000
--- a/apps/frontend/src/components/PriceChart.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { PriceChart } from '@/widgets/price-chart';
diff --git a/apps/frontend/src/components/ProtectedRoute.test.tsx b/apps/frontend/src/components/ProtectedRoute.test.tsx
deleted file mode 100644
index 68238b5..0000000
--- a/apps/frontend/src/components/ProtectedRoute.test.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
-import { http, HttpResponse } from 'msw';
-import { server } from '../test/server';
-import { ProtectedRoute } from './ProtectedRoute';
-import { renderWithProviders } from '../test/test-utils';
-
-const API = '/api/v1';
-
-describe('ProtectedRoute', () => {
- it('renders children when authenticated', async () => {
- renderWithProviders(
-
- Secret
- ,
- );
- expect(await screen.findByTestId('protected-content')).toBeInTheDocument();
- });
-
- it('shows loading state initially then redirects when not authenticated', async () => {
- server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
-
- renderWithProviders(
-
- Secret
- ,
- { route: '/profile' },
- );
-
- expect(screen.getByText('Загрузка...')).toBeInTheDocument();
-
- await waitFor(() => {
- expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
- });
- });
-});
diff --git a/apps/frontend/src/components/ProtectedRoute.tsx b/apps/frontend/src/components/ProtectedRoute.tsx
deleted file mode 100644
index 01fc94a..0000000
--- a/apps/frontend/src/components/ProtectedRoute.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { ProtectedRoute } from '../app/routing/ProtectedRoute';
diff --git a/apps/frontend/src/components/SearchBar.test.tsx b/apps/frontend/src/components/SearchBar.test.tsx
deleted file mode 100644
index 4c6de6f..0000000
--- a/apps/frontend/src/components/SearchBar.test.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-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(
-
-
-
-
- ,
- );
-}
-
-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();
- });
- });
-});
diff --git a/apps/frontend/src/components/SearchBar.tsx b/apps/frontend/src/components/SearchBar.tsx
deleted file mode 100644
index 2809c46..0000000
--- a/apps/frontend/src/components/SearchBar.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { SearchBar } from '@/widgets/search-bar';
diff --git a/apps/frontend/src/components/SkeletonBlock.tsx b/apps/frontend/src/components/SkeletonBlock.tsx
deleted file mode 100644
index c363547..0000000
--- a/apps/frontend/src/components/SkeletonBlock.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { SkeletonBlock } from '../shared/ui/SkeletonBlock';
diff --git a/apps/frontend/src/components/StockDetails.test.tsx b/apps/frontend/src/components/StockDetails.test.tsx
deleted file mode 100644
index 1ca9b82..0000000
--- a/apps/frontend/src/components/StockDetails.test.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { StockDetails } from './StockDetails';
-import { createMockShare } from '../test/factories';
-
-describe('StockDetails', () => {
- it('renders stock details', () => {
- const stock = createMockShare();
- render();
- expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument();
- expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument();
- });
-
- it('displays positive change in green', () => {
- const stock = createMockShare({
- marketData: {
- ...createMockShare().marketData,
- change: 5,
- changePercent: 2,
- },
- });
- render();
- const changeEl = screen.getByText('+5.00 (2.00%)');
- expect(changeEl).toBeInTheDocument();
- });
-
- it('displays negative change in red', () => {
- const stock = createMockShare({
- marketData: {
- ...createMockShare().marketData,
- change: -3,
- changePercent: -1,
- },
- });
- render();
- const changeEl = screen.getByText('-3.00 (-1.00%)');
- expect(changeEl).toBeInTheDocument();
- });
-
- it('shows price formatted', () => {
- const stock = createMockShare();
- render();
- expect(screen.getByText('289,50')).toBeInTheDocument();
- });
-
- it('shows dash for null high', () => {
- const stock = createMockShare({
- marketData: {
- ...createMockShare().marketData,
- high: null,
- },
- });
- render();
- const dashes = screen.getAllByText('—');
- expect(dashes.length).toBeGreaterThanOrEqual(1);
- });
-
- it('shows capitalization in billions', () => {
- const stock = createMockShare();
- render();
- expect(screen.getByText('6250.00 млрд ₽')).toBeInTheDocument();
- });
-});
diff --git a/apps/frontend/src/components/StockDetails.tsx b/apps/frontend/src/components/StockDetails.tsx
deleted file mode 100644
index ccb5cd2..0000000
--- a/apps/frontend/src/components/StockDetails.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { StockDetails } from '@/widgets/stock-details';
diff --git a/apps/frontend/src/components/TableSkeleton.tsx b/apps/frontend/src/components/TableSkeleton.tsx
deleted file mode 100644
index ba8f8ef..0000000
--- a/apps/frontend/src/components/TableSkeleton.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { TableSkeleton } from '../shared/ui/TableSkeleton';
diff --git a/apps/frontend/src/context/AuthContext.test.tsx b/apps/frontend/src/context/AuthContext.test.tsx
deleted file mode 100644
index 88a9580..0000000
--- a/apps/frontend/src/context/AuthContext.test.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-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(
-
- {ui}
- ,
- );
-}
-
-function TestConsumer() {
- const ctx = useContext(AuthContext);
- if (!ctx) return no context
;
- return (
-
- {ctx.isAuthenticated ? 'authenticated' : 'anonymous'}
- {ctx.user?.email ?? ''}
-
-
-
-
-
- );
-}
-
-describe('AuthContext', () => {
- it('starts unauthenticated when refresh fails', async () => {
- server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
- renderWithProviders();
- await waitFor(() => {
- expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
- });
- });
-
- it('restores session on mount when refresh succeeds', async () => {
- renderWithProviders();
- await waitFor(() => {
- expect(screen.getByTestId('auth')).toHaveTextContent('authenticated');
- expect(screen.getByTestId('email')).toHaveTextContent('user@test.com');
- });
- });
-
- it('updates state after login', async () => {
- server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
- const user = userEvent.setup();
- renderWithProviders();
- await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'));
- await user.click(screen.getByRole('button', { name: 'login' }));
- await waitFor(() => {
- expect(screen.getByTestId('auth')).toHaveTextContent('authenticated');
- });
- });
-
- it('updates state after logout', async () => {
- const user = userEvent.setup();
- renderWithProviders();
- await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'));
- await user.click(screen.getByRole('button', { name: 'logout' }));
- await waitFor(() => {
- expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
- });
- });
-});
diff --git a/apps/frontend/src/context/AuthContext.tsx b/apps/frontend/src/context/AuthContext.tsx
deleted file mode 100644
index d8f311a..0000000
--- a/apps/frontend/src/context/AuthContext.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-export {
- SessionContext as AuthContext,
- type SessionContextValue as AuthContextValue,
-} from '@/entities/session/model/sessionContext';
-export { SessionProvider as AuthProvider } from '@/app/providers/SessionProvider';
diff --git a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts
index ec73d7b..7b5a93e 100644
--- a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts
+++ b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts
@@ -1 +1,30 @@
-export { getBrokerOperations, type BrokerOperationQuery } from '../../../api/broker';
+import { request } from '@/shared/api/client';
+import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses';
+
+export type BrokerOperationQuery = {
+ from?: string;
+ to?: string;
+ cursor?: string;
+ limit?: number;
+ instrumentId?: string;
+ operationTypes?: string;
+ state?: string;
+};
+
+export function getBrokerOperations(
+ accountId: string,
+ query: BrokerOperationQuery = {},
+): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
+ return request(
+ `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
+ {
+ from: query.from,
+ to: query.to,
+ cursor: query.cursor,
+ limit: query.limit ? String(query.limit) : undefined,
+ instrumentId: query.instrumentId,
+ operationTypes: query.operationTypes,
+ state: query.state,
+ },
+ );
+}
diff --git a/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts b/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts
index 6d86eb1..485c3dc 100644
--- a/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts
+++ b/apps/frontend/src/entities/broker-position/api/brokerPositionApi.ts
@@ -1,3 +1,16 @@
-import { getBrokerPositions } from '../../../api/broker';
+import { request } from '@/shared/api/client';
+import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses';
-export { getBrokerPositions };
+export function getBrokerPositions(
+ accountId: string,
+ query: { cursor?: string; limit?: number; type?: string } = {},
+): Promise<{ data: BrokerPositionsPage; meta: ApiResponseMeta }> {
+ return request(
+ `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
+ {
+ cursor: query.cursor,
+ limit: query.limit ? String(query.limit) : undefined,
+ type: query.type,
+ },
+ );
+}
diff --git a/apps/frontend/src/entities/session/model/useSession.test.tsx b/apps/frontend/src/entities/session/model/useSession.test.tsx
index c1ca92f..0abf982 100644
--- a/apps/frontend/src/entities/session/model/useSession.test.tsx
+++ b/apps/frontend/src/entities/session/model/useSession.test.tsx
@@ -1,7 +1,7 @@
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 { SessionProvider } from '../../../app/providers/SessionProvider';
import { useSession } from './useSession';
import { type ReactNode } from 'react';
@@ -10,7 +10,7 @@ function createWrapper() {
return function Wrapper({ children }: { children: ReactNode }) {
return (
- {children}
+ {children}
);
};
diff --git a/apps/frontend/src/hooks/useAuth.test.tsx b/apps/frontend/src/hooks/useAuth.test.tsx
deleted file mode 100644
index 1c33e0c..0000000
--- a/apps/frontend/src/hooks/useAuth.test.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-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 (
-
- {children}
-
- );
- };
-}
-
-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 }) => (
- {children}
- ),
- });
- }).toThrow('useSession must be used within a SessionProvider');
- });
-});
diff --git a/apps/frontend/src/hooks/useAuth.ts b/apps/frontend/src/hooks/useAuth.ts
deleted file mode 100644
index a89b4ca..0000000
--- a/apps/frontend/src/hooks/useAuth.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useSession as useAuth } from '../entities/session/model/useSession';
diff --git a/apps/frontend/src/hooks/useBond.ts b/apps/frontend/src/hooks/useBond.ts
deleted file mode 100644
index 37d13b1..0000000
--- a/apps/frontend/src/hooks/useBond.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useBond } from '../entities/bond/model/useBond';
diff --git a/apps/frontend/src/hooks/useBondCandles.ts b/apps/frontend/src/hooks/useBondCandles.ts
deleted file mode 100644
index 9446edc..0000000
--- a/apps/frontend/src/hooks/useBondCandles.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useBondCandles } from '../entities/bond/model/useBondCandles';
diff --git a/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx b/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx
deleted file mode 100644
index 63ae858..0000000
--- a/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { renderHook, waitFor } from '@testing-library/react';
-import { type ReactNode } from 'react';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { getBrokerPortfolio } from '../entities/broker-account/api/brokerAccountApi';
-import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
-import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios';
-
-vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
- getBrokerPortfolio: vi.fn(),
-}));
-
-function createWrapper(queryClient?: QueryClient) {
- const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
-
- return function Wrapper({ children }: { children: ReactNode }) {
- return {children};
- };
-}
-
-function createDeferred() {
- let resolve!: (value: T) => void;
- let reject!: (reason?: unknown) => void;
- const promise = new Promise((res, rej) => {
- resolve = res;
- reject = rej;
- });
-
- return { promise, resolve, reject };
-}
-
-function createAccount(id: string): BrokerAccount {
- return {
- id,
- type: 'brokerage',
- name: id,
- status: 'ACCOUNT_STATUS_OPEN',
- openedAt: null,
- accessLevel: null,
- };
-}
-
-function createPortfolio(id: string): BrokerPortfolio {
- return {
- account: createAccount(id),
- positionCounts: { shares: 1, bonds: 0, etf: 0, other: 0 },
- totals: {
- shares: { currency: 'RUB', units: '0', nano: 0, value: 100 },
- bonds: null,
- etf: null,
- currencies: { currency: 'RUB', units: '0', nano: 0, value: 20 },
- futures: null,
- options: null,
- structuredProducts: null,
- dfa: null,
- portfolio: { currency: 'RUB', units: '0', nano: 0, value: 120 },
- },
- yields: {
- expectedPercent: 3,
- daily: { currency: 'RUB', units: '0', nano: 0, value: 10 },
- dailyPercent: 1,
- },
- cash: [{ currency: 'RUB', units: '0', nano: 0, value: 20 }],
- blockedCash: [],
- asOf: '2026-06-19T10:00:00.000Z',
- };
-}
-
-describe('useBrokerAccountPortfolios', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it('keeps account-to-query mapping regardless of completion order', async () => {
- const first = createDeferred<{
- data: BrokerPortfolio;
- meta: { fromCache: false; cachedAt: null };
- }>();
- const second = createDeferred<{
- data: BrokerPortfolio;
- meta: { fromCache: false; cachedAt: null };
- }>();
-
- vi.mocked(getBrokerPortfolio).mockImplementation((accountId: string) => {
- if (accountId === 'acc-1') {
- return first.promise;
- }
-
- if (accountId === 'acc-2') {
- return second.promise;
- }
-
- throw new Error(`Unexpected account ${accountId}`);
- });
-
- const accounts = [createAccount('acc-1'), createAccount('acc-2')];
- const { result } = renderHook(() => useBrokerAccountPortfolios(accounts), {
- wrapper: createWrapper(),
- });
-
- expect(getBrokerPortfolio).toHaveBeenCalledTimes(2);
- expect(getBrokerPortfolio).toHaveBeenNthCalledWith(1, 'acc-1');
- expect(getBrokerPortfolio).toHaveBeenNthCalledWith(2, 'acc-2');
-
- second.resolve({
- data: createPortfolio('acc-2'),
- meta: { fromCache: false, cachedAt: null },
- });
-
- await waitFor(() => expect(result.current[1].query.data?.account.id).toBe('acc-2'));
- expect(result.current[0].account.id).toBe('acc-1');
- expect(result.current[0].query.data).toBeUndefined();
-
- first.resolve({
- data: createPortfolio('acc-1'),
- meta: { fromCache: false, cachedAt: null },
- });
-
- await waitFor(() => expect(result.current[0].query.data?.account.id).toBe('acc-1'));
- expect(result.current[1].query.data?.account.id).toBe('acc-2');
- });
-
- it('reuses the same cache key as broker account overview page', async () => {
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- const cachedPortfolio = createPortfolio('acc-1');
- queryClient.setQueryData(['broker', 'portfolio', 'acc-1'], cachedPortfolio);
-
- const { result } = renderHook(() => useBrokerAccountPortfolios([createAccount('acc-1')]), {
- wrapper: createWrapper(queryClient),
- });
-
- await waitFor(() => expect(result.current[0].query.data).toBe(cachedPortfolio));
- expect(getBrokerPortfolio).not.toHaveBeenCalled();
- });
-});
diff --git a/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts b/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts
deleted file mode 100644
index 1f6107a..0000000
--- a/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useBrokerAccountPortfolios } from '../entities/broker-account';
diff --git a/apps/frontend/src/hooks/useBrokerAccounts.test.tsx b/apps/frontend/src/hooks/useBrokerAccounts.test.tsx
deleted file mode 100644
index 8cfb766..0000000
--- a/apps/frontend/src/hooks/useBrokerAccounts.test.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { renderHook, waitFor } from '@testing-library/react';
-import { type ReactNode } from 'react';
-import { describe, expect, it, vi } from 'vitest';
-import { getBrokerAccounts } from '../entities/broker-account/api/brokerAccountApi';
-import { useBrokerAccounts } from './useBrokerAccounts';
-
-vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({
- getBrokerAccounts: vi.fn(),
-}));
-
-function createWrapper() {
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
-
- return function Wrapper({ children }: { children: ReactNode }) {
- return {children};
- };
-}
-
-describe('useBrokerAccounts', () => {
- it('returns broker accounts from API', async () => {
- vi.mocked(getBrokerAccounts).mockResolvedValue({
- data: [
- {
- id: 'acc-1',
- type: 'brokerage',
- name: 'Broker',
- status: 'ACCOUNT_STATUS_OPEN',
- openedAt: null,
- accessLevel: null,
- },
- ],
- meta: { fromCache: false, cachedAt: null },
- });
-
- const { result } = renderHook(() => useBrokerAccounts(), { wrapper: createWrapper() });
-
- await waitFor(() => expect(result.current.isSuccess).toBe(true));
- expect(result.current.data?.[0].name).toBe('Broker');
- });
-});
diff --git a/apps/frontend/src/hooks/useBrokerAccounts.ts b/apps/frontend/src/hooks/useBrokerAccounts.ts
deleted file mode 100644
index 438e508..0000000
--- a/apps/frontend/src/hooks/useBrokerAccounts.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useBrokerAccounts } from '../entities/broker-account';
diff --git a/apps/frontend/src/hooks/useBrokerOperations.ts b/apps/frontend/src/hooks/useBrokerOperations.ts
deleted file mode 100644
index 84eb6fb..0000000
--- a/apps/frontend/src/hooks/useBrokerOperations.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useBrokerOperations } from '../entities/broker-operation';
diff --git a/apps/frontend/src/hooks/useBrokerPortfolio.ts b/apps/frontend/src/hooks/useBrokerPortfolio.ts
deleted file mode 100644
index 640ded0..0000000
--- a/apps/frontend/src/hooks/useBrokerPortfolio.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useBrokerPortfolio } from '../entities/broker-account';
diff --git a/apps/frontend/src/hooks/useBrokerPositions.ts b/apps/frontend/src/hooks/useBrokerPositions.ts
deleted file mode 100644
index eb3f4ed..0000000
--- a/apps/frontend/src/hooks/useBrokerPositions.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useBrokerPositions } from '../entities/broker-position';
diff --git a/apps/frontend/src/hooks/usePortfolio.ts b/apps/frontend/src/hooks/usePortfolio.ts
deleted file mode 100644
index 91dac4d..0000000
--- a/apps/frontend/src/hooks/usePortfolio.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { usePortfolio } from '../entities/portfolio/model/usePortfolio';
diff --git a/apps/frontend/src/hooks/usePortfolioAnalytics.ts b/apps/frontend/src/hooks/usePortfolioAnalytics.ts
deleted file mode 100644
index dba65a5..0000000
--- a/apps/frontend/src/hooks/usePortfolioAnalytics.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { usePortfolioAnalytics } from '../entities/portfolio/model/usePortfolioAnalytics';
diff --git a/apps/frontend/src/hooks/usePortfolioMutations.ts b/apps/frontend/src/hooks/usePortfolioMutations.ts
deleted file mode 100644
index 8bfac3d..0000000
--- a/apps/frontend/src/hooks/usePortfolioMutations.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { usePortfolioMutations } from '../entities/portfolio/model/usePortfolioMutations';
diff --git a/apps/frontend/src/hooks/usePortfolios.ts b/apps/frontend/src/hooks/usePortfolios.ts
deleted file mode 100644
index b76b239..0000000
--- a/apps/frontend/src/hooks/usePortfolios.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { usePortfolios } from '../entities/portfolio/model/usePortfolios';
diff --git a/apps/frontend/src/hooks/usePositionMutations.ts b/apps/frontend/src/hooks/usePositionMutations.ts
deleted file mode 100644
index f8289a5..0000000
--- a/apps/frontend/src/hooks/usePositionMutations.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { usePositionMutations } from '../entities/portfolio/model/usePositionMutations';
diff --git a/apps/frontend/src/hooks/useSearch.test.tsx b/apps/frontend/src/hooks/useSearch.test.tsx
deleted file mode 100644
index d136476..0000000
--- a/apps/frontend/src/hooks/useSearch.test.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { renderHook, waitFor } from '@testing-library/react';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { http, HttpResponse } from 'msw';
-import { server } from '../test/server';
-import { useSearch } from './useSearch';
-import { type ReactNode } from 'react';
-
-const API = '/api/v1';
-
-function createWrapper() {
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- return function Wrapper({ children }: { children: ReactNode }) {
- return {children};
- };
-}
-
-describe('useSearch', () => {
- it('does not fetch when query is empty', () => {
- const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() });
- expect(result.current.isFetching).toBe(false);
- expect(result.current.data).toBeUndefined();
- });
-
- it('does not fetch when query is too short', () => {
- const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() });
- expect(result.current.data).toBeUndefined();
- });
-
- it('returns search results for valid query', async () => {
- const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() });
- await waitFor(() => {
- expect(result.current.isSuccess).toBe(true);
- });
- expect(result.current.data).toBeDefined();
- expect(result.current.data?.length).toBeGreaterThan(0);
- expect(result.current.data?.[0].secid).toBe('SBER');
- });
-
- it('returns empty array when no results', async () => {
- server.use(
- http.get(`${API}/securities/search`, () => {
- return HttpResponse.json({
- data: { data: [], meta: { fromCache: false, cachedAt: null } },
- });
- }),
- );
- const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() });
- await waitFor(() => {
- expect(result.current.isSuccess).toBe(true);
- });
- expect(result.current.data).toEqual([]);
- });
-
- it('returns error state on network failure', async () => {
- server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })));
- const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() });
- await waitFor(() => {
- expect(result.current.isError).toBe(true);
- });
- });
-});
diff --git a/apps/frontend/src/hooks/useSearch.ts b/apps/frontend/src/hooks/useSearch.ts
deleted file mode 100644
index 5e3c886..0000000
--- a/apps/frontend/src/hooks/useSearch.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useSearch } from '@/entities/search';
diff --git a/apps/frontend/src/hooks/useStock.ts b/apps/frontend/src/hooks/useStock.ts
deleted file mode 100644
index 11ff4cb..0000000
--- a/apps/frontend/src/hooks/useStock.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useStock } from '../entities/stock/model/useStock';
diff --git a/apps/frontend/src/hooks/useStockCandles.ts b/apps/frontend/src/hooks/useStockCandles.ts
deleted file mode 100644
index 644306c..0000000
--- a/apps/frontend/src/hooks/useStockCandles.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useStockCandles } from '../entities/stock/model/useStockCandles';
diff --git a/apps/frontend/src/hooks/useStockDividends.ts b/apps/frontend/src/hooks/useStockDividends.ts
deleted file mode 100644
index 563ae92..0000000
--- a/apps/frontend/src/hooks/useStockDividends.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useStockDividends } from '../entities/stock/model/useStockDividends';
diff --git a/apps/frontend/src/pages/BondPage.test.tsx b/apps/frontend/src/pages/BondPage.test.tsx
deleted file mode 100644
index 4435ce1..0000000
--- a/apps/frontend/src/pages/BondPage.test.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-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 '@/pages/bond';
-import { renderWithProviders } from '../test/test-utils';
-
-const API = '/api/v1';
-
-function renderBondPage(secid = 'SU26238RMFS5') {
- return renderWithProviders(
-
- } />
- ,
- { 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();
- });
-});
diff --git a/apps/frontend/src/pages/BondPage.tsx b/apps/frontend/src/pages/BondPage.tsx
deleted file mode 100644
index 4b20ac7..0000000
--- a/apps/frontend/src/pages/BondPage.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { BondPage } from '@/pages/bond';
diff --git a/apps/frontend/src/pages/HomePage.test.tsx b/apps/frontend/src/pages/HomePage.test.tsx
deleted file mode 100644
index 6964234..0000000
--- a/apps/frontend/src/pages/HomePage.test.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { HomePage } from '@/pages/home';
-
-describe('HomePage', () => {
- it('renders welcome title', () => {
- render();
- expect(screen.getByText('MoexVibe')).toBeInTheDocument();
- });
-
- it('renders description', () => {
- render();
- expect(screen.getByText('Анализ акций и облигаций Московской биржи')).toBeInTheDocument();
- });
-
- it('renders hint text', () => {
- render();
- expect(screen.getByText(/Введите название или тикер/)).toBeInTheDocument();
- });
-
- it('renders data delay notice', () => {
- render();
- expect(screen.getByText(/Данные задерживаются на 15 минут/)).toBeInTheDocument();
- });
-});
diff --git a/apps/frontend/src/pages/HomePage.tsx b/apps/frontend/src/pages/HomePage.tsx
deleted file mode 100644
index 2de953a..0000000
--- a/apps/frontend/src/pages/HomePage.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { HomePage } from '@/pages/home';
diff --git a/apps/frontend/src/pages/StockPage.test.tsx b/apps/frontend/src/pages/StockPage.test.tsx
deleted file mode 100644
index 63b4fc3..0000000
--- a/apps/frontend/src/pages/StockPage.test.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-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 '@/pages/stock';
-import { renderWithProviders } from '../test/test-utils';
-
-const API = '/api/v1';
-
-function renderStockPage(secid = 'SBER') {
- return renderWithProviders(
-
- } />
- ,
- { 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();
- });
-});
diff --git a/apps/frontend/src/pages/StockPage.tsx b/apps/frontend/src/pages/StockPage.tsx
deleted file mode 100644
index 5e65071..0000000
--- a/apps/frontend/src/pages/StockPage.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { StockPage } from '@/pages/stock';
diff --git a/apps/frontend/src/pages/broker/BrokerAccountCard.tsx b/apps/frontend/src/pages/broker/BrokerAccountCard.tsx
deleted file mode 100644
index 540f6ee..0000000
--- a/apps/frontend/src/pages/broker/BrokerAccountCard.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { BrokerAccountCard } from '../../widgets/broker-account-card';
diff --git a/apps/frontend/src/pages/broker/BrokerAccountLayout.tsx b/apps/frontend/src/pages/broker/BrokerAccountLayout.tsx
deleted file mode 100644
index f82f39b..0000000
--- a/apps/frontend/src/pages/broker/BrokerAccountLayout.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-export {
- BrokerAccountLayout,
- useBrokerAccountContext,
- type BrokerAccountContext,
-} from '../../entities/broker-account/ui/BrokerAccountLayout';
diff --git a/apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx
deleted file mode 100644
index a4aeaaf..0000000
--- a/apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { BrokerAccountOverviewPage } from '../broker-account';
diff --git a/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx b/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx
deleted file mode 100644
index 221a8f2..0000000
--- a/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx
+++ /dev/null
@@ -1,292 +0,0 @@
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { render, screen, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { type ReactElement } from 'react';
-import { MemoryRouter } from 'react-router-dom';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
-import * as brokerAccountEntity from '../../entities/broker-account';
-import { BrokerAccountsPage } from './BrokerAccountsPage';
-
-function renderPage(ui: ReactElement) {
- const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
-
- return render(
-
- {ui}
- ,
- );
-}
-
-function createAccount(
- account: Partial & Pick,
-): BrokerAccount {
- return {
- id: account.id,
- name: account.name,
- type: account.type ?? 'brokerage',
- status: 'ACCOUNT_STATUS_OPEN',
- openedAt: account.openedAt ?? '2022-06-16T00:00:00.000Z',
- accessLevel: null,
- };
-}
-
-function createPortfolio(
- account: BrokerAccount,
- overrides: Partial = {},
-): BrokerPortfolio {
- return {
- account,
- positionCounts: { shares: 4, bonds: 2, etf: 1, other: 0 },
- totals: {
- shares: { currency: 'RUB', units: '0', nano: 0, value: 600 },
- bonds: { currency: 'RUB', units: '0', nano: 0, value: 300 },
- etf: { currency: 'RUB', units: '0', nano: 0, value: 100 },
- currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 },
- futures: null,
- options: null,
- structuredProducts: null,
- dfa: null,
- portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_000 },
- },
- yields: {
- expectedPercent: 8,
- daily: { currency: 'RUB', units: '0', nano: 0, value: 100 },
- dailyPercent: 11.11,
- },
- cash: [{ currency: 'RUB', units: '0', nano: 0, value: 200 }],
- blockedCash: [],
- asOf: '2026-06-19T10:00:00.000Z',
- ...overrides,
- };
-}
-
-function createQueryState(overrides: Record = {}) {
- return {
- data: undefined,
- isLoading: false,
- isFetching: false,
- isPending: false,
- isError: false,
- error: null,
- refetch: vi.fn(),
- ...overrides,
- };
-}
-
-describe('BrokerAccountsPage', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it('renders heading, aggregate summary, daily result and two linked cards', () => {
- const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
- const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
-
- vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
- data: [broker, iis],
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
- {
- account: broker,
- query: createQueryState({ data: createPortfolio(broker) }),
- },
- {
- account: iis,
- query: createQueryState({
- data: createPortfolio(iis, {
- totals: {
- shares: { currency: 'RUB', units: '0', nano: 0, value: 800 },
- bonds: { currency: 'RUB', units: '0', nano: 0, value: 500 },
- etf: { currency: 'RUB', units: '0', nano: 0, value: 200 },
- currencies: { currency: 'RUB', units: '0', nano: 0, value: 100 },
- futures: null,
- options: null,
- structuredProducts: null,
- dfa: null,
- portfolio: { currency: 'RUB', units: '0', nano: 0, value: 1_600 },
- },
- yields: {
- expectedPercent: 12,
- daily: { currency: 'RUB', units: '0', nano: 0, value: 140 },
- dailyPercent: 9.59,
- },
- cash: [{ currency: 'RUB', units: '0', nano: 0, value: 300 }],
- }),
- }),
- },
- ] as any);
-
- renderPage();
-
- expect(screen.getByRole('heading', { level: 1, name: 'Брокерские счета' })).toBeInTheDocument();
- expect(screen.getByText(/2[\s\u00a0]?600(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
- expect(screen.getByText(/\+?240(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
- expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute(
- 'href',
- '/broker/acc-1',
- );
- expect(screen.getByRole('link', { name: /ИИС капитал/i })).toHaveAttribute(
- 'href',
- '/broker/acc-2',
- );
- });
-
- it('shows human labels and opened date without exposing technical fields', () => {
- const broker = createAccount({ id: 'account one', name: 'Основной счёт' });
- const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
-
- vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
- data: [broker, iis],
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
- { account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
- { account: iis, query: createQueryState({ data: createPortfolio(iis) }) },
- ] as any);
-
- renderPage();
-
- expect(screen.getByText('Брокерский счёт')).toBeInTheDocument();
- expect(screen.getByText('ИИС')).toBeInTheDocument();
- expect(screen.getAllByText(/16\.06\.2022/)).toHaveLength(2);
- expect(screen.queryByText('ACCOUNT_STATUS_OPEN')).not.toBeInTheDocument();
- expect(screen.queryByText('account one')).not.toBeInTheDocument();
- expect(screen.getByRole('link', { name: /Основной счёт/i })).toHaveAttribute(
- 'href',
- '/broker/account%20one',
- );
- });
-
- it('shows page skeleton while accounts are loading', () => {
- vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
- data: undefined,
- isLoading: true,
- isFetching: true,
- error: null,
- } as any);
-
- const { container } = renderPage();
-
- expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
- });
-
- it('renders an empty state when there are no accounts', () => {
- vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
- data: [],
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([] as any);
-
- renderPage();
-
- expect(
- screen.getByText(/После подключения T-Bank здесь появятся брокерские счета и ИИС/),
- ).toBeInTheDocument();
- });
-
- it('marks the summary as partial when one account portfolio is unavailable', () => {
- const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
- const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
-
- vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
- data: [broker, iis],
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
- { account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
- {
- account: iis,
- query: createQueryState({ isError: true, error: new Error('boom') }),
- },
- ] as any);
-
- renderPage();
-
- expect(screen.getByText('Доступно по 1 из 2 счетов')).toBeInTheDocument();
- });
-
- it('shows a local alert and retries only the failed account', async () => {
- const user = userEvent.setup();
- const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' });
- const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' });
- const refetch = vi.fn();
-
- vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
- data: [broker, iis],
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
- { account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
- {
- account: iis,
- query: createQueryState({ isError: true, error: new Error('boom'), refetch }),
- },
- ] as any);
-
- renderPage();
-
- const alert = screen.getByRole('alert');
- expect(alert).toHaveTextContent('Не удалось загрузить данные счёта');
- await user.click(
- within(alert.closest('.broker-account-card')!).getByRole('button', { name: 'Повторить' }),
- );
- expect(refetch).toHaveBeenCalled();
- });
-
- it('keeps currencies separate in the overview summary', () => {
- const broker = createAccount({ id: 'acc-1', name: 'Рублёвый счёт' });
- const usd = createAccount({ id: 'acc-2', name: 'Долларовый счёт' });
-
- vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({
- data: [broker, usd],
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([
- { account: broker, query: createQueryState({ data: createPortfolio(broker) }) },
- {
- account: usd,
- query: createQueryState({
- data: createPortfolio(usd, {
- totals: {
- shares: { currency: 'USD', units: '0', nano: 0, value: 300 },
- bonds: { currency: 'USD', units: '0', nano: 0, value: 100 },
- etf: null,
- currencies: { currency: 'USD', units: '0', nano: 0, value: 100 },
- futures: null,
- options: null,
- structuredProducts: null,
- dfa: null,
- portfolio: { currency: 'USD', units: '0', nano: 0, value: 500 },
- },
- yields: {
- expectedPercent: 4,
- daily: { currency: 'USD', units: '0', nano: 0, value: 20 },
- dailyPercent: 4.16,
- },
- cash: [{ currency: 'USD', units: '0', nano: 0, value: 25 }],
- }),
- }),
- },
- ] as any);
-
- renderPage();
-
- const summary = screen.getByRole('region', { name: 'Общая сводка по счетам' });
- expect(within(summary).getByText(/1[\s\u00a0]?000(?:,00)?[\s\u00a0]?₽/)).toBeInTheDocument();
- expect(within(summary).getByText(/500(?:,00)?[\s\u00a0]?\$/)).toBeInTheDocument();
- });
-});
diff --git a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx
deleted file mode 100644
index 2e85647..0000000
--- a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { BrokerAccountsPage } from '../broker-accounts';
diff --git a/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx b/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx
deleted file mode 100644
index 61efc23..0000000
--- a/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { BrokerAccountsSummary } from '../../widgets/broker-accounts-summary';
diff --git a/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx b/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx
deleted file mode 100644
index 11df303..0000000
--- a/apps/frontend/src/pages/broker/BrokerAllocationBar.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { BrokerAllocationBar } from '../../widgets/broker-allocation-chart';
diff --git a/apps/frontend/src/pages/broker/BrokerAllocationChart.tsx b/apps/frontend/src/pages/broker/BrokerAllocationChart.tsx
deleted file mode 100644
index 59fd817..0000000
--- a/apps/frontend/src/pages/broker/BrokerAllocationChart.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { BrokerAllocationChart } from '../../widgets/broker-allocation-chart';
diff --git a/apps/frontend/src/pages/broker/BrokerOperationsPage.tsx b/apps/frontend/src/pages/broker/BrokerOperationsPage.tsx
deleted file mode 100644
index 47eae58..0000000
--- a/apps/frontend/src/pages/broker/BrokerOperationsPage.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { BrokerOperationsPage } from '../broker-operations';
diff --git a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
deleted file mode 100644
index ae5a988..0000000
--- a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { BrokerOperationsTable } from '../../widgets/broker-operations-table';
diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx
deleted file mode 100644
index 797037c..0000000
--- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx
+++ /dev/null
@@ -1,1173 +0,0 @@
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { render, screen, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { type ReactElement } from 'react';
-import { MemoryRouter, Route, Routes } from 'react-router-dom';
-import { describe, expect, it, vi } from 'vitest';
-import * as operationsHook from '../../entities/broker-operation';
-import * as brokerAccountsHook from '../../hooks/useBrokerAccounts';
-import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios';
-import * as portfolioHook from '../../entities/broker-account/model/useBrokerPortfolio';
-import type { BrokerAccount, BrokerPortfolio, BrokerPosition } from '@/shared/api/responses';
-import * as positionsHook from '../../entities/broker-position';
-import { AppRoutes } from '../../routes';
-import { renderWithProviders } from '../../test/test-utils';
-import {
- BrokerAccountLayout,
- useBrokerAccountContext,
-} from '../../entities/broker-account/ui/BrokerAccountLayout';
-
-import { BrokerAccountOverviewPage } from '../broker-account';
-import { BrokerPositionsPage } from '../broker-positions';
-import { BrokerOperationsPage } from '../broker-operations';
-
-function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
- const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
-
- return render(
-
- {ui}
- ,
- );
-}
-
-function createPosition(input: Partial): BrokerPosition {
- return {
- figi: null,
- instrumentUid: null,
- positionUid: null,
- ticker: null,
- classCode: null,
- instrumentType: null,
- name: null,
- quantity: null,
- blockedLots: null,
- currentPrice: null,
- currentValue: null,
- averagePositionPrice: null,
- expectedYieldPercent: null,
- dailyYield: null,
- ...input,
- };
-}
-
-function createOverviewPortfolio(overrides: Partial = {}): BrokerPortfolio {
- return {
- account: {
- id: 'acc-1',
- type: 'brokerage',
- name: 'Основной брокерский счёт',
- status: 'ACCOUNT_STATUS_OPEN',
- openedAt: null,
- accessLevel: null,
- },
- positionCounts: { shares: 14, bonds: 8, etf: 2, other: 1 },
- totals: {
- shares: { currency: 'RUB', units: '750000', nano: 0, value: 750_000 },
- bonds: { currency: 'RUB', units: '300000', nano: 0, value: 300_000 },
- etf: { currency: 'RUB', units: '50000', nano: 0, value: 50_000 },
- currencies: { currency: 'RUB', units: '100000', nano: 0, value: 100_000 },
- futures: null,
- options: null,
- structuredProducts: null,
- dfa: null,
- portfolio: { currency: 'RUB', units: '1250000', nano: 0, value: 1_250_000 },
- },
- yields: {
- expectedPercent: 12.4,
- daily: { currency: 'RUB', units: '1500', nano: 0, value: 1_500 },
- dailyPercent: 0.12,
- },
- cash: [
- { currency: 'RUB', units: '100000', nano: 0, value: 100_000 },
- { currency: 'USD', units: '250', nano: 0, value: 250 },
- ],
- blockedCash: [],
- asOf: '2026-06-19T00:00:00.000Z',
- ...overrides,
- };
-}
-
-function createBrokerAccount(
- overrides: Partial & Pick,
-): BrokerAccount {
- return {
- id: overrides.id,
- name: overrides.name,
- type: overrides.type ?? 'brokerage',
- status: overrides.status ?? 'ACCOUNT_STATUS_OPEN',
- openedAt: overrides.openedAt ?? null,
- accessLevel: overrides.accessLevel ?? null,
- };
-}
-
-function mockOverviewOperations(overrides: Record = {}) {
- return vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
- data: {
- accountId: 'acc-1',
- items: [],
- nextCursor: null,
- hasNext: false,
- asOf: '2026-06-19T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- ...overrides,
- } as any);
-}
-
-function renderOverview() {
- return renderWithClient(
-
- }>
- } />
-
- ,
- ['/broker/acc-1'],
- );
-}
-
-/** Spy on useBrokerPositions and return only positions matching query.type . */
-function mockUseBrokerPositions(...positions: BrokerPosition[]) {
- return vi.spyOn(positionsHook, 'useBrokerPositions').mockImplementation((_accountId, query) => {
- const type = query.type?.toLowerCase();
- const filtered = type ? positions.filter((p) => p.instrumentType?.toLowerCase() === type) : [];
- return {
- data: {
- accountId: 'acc-1',
- items: filtered,
- nextCursor: null,
- hasNext: false,
- asOf: '2026-06-17T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- } as any;
- });
-}
-
-function BrokerAccountContextProbe({ expectedPortfolio }: { expectedPortfolio: unknown }) {
- const { accountId, portfolio } = useBrokerAccountContext();
-
- return (
-
-
Account context: {accountId}
-
- {portfolio === expectedPortfolio ? 'Same portfolio query' : 'Different portfolio query'}
-
-
- );
-}
-
-describe('Broker pages', () => {
- it('renders broker routes through fsd entrypoints', async () => {
- const account = createBrokerAccount({ id: 'acc-1', name: 'Основной брокерский счёт' });
-
- vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({
- data: [account],
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([
- {
- account,
- query: {
- data: createOverviewPortfolio({ account }),
- isLoading: false,
- isFetching: false,
- isPending: false,
- error: null,
- refetch: vi.fn(),
- },
- },
- ] as any);
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: createOverviewPortfolio({ account }),
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
- data: {
- accountId: 'acc-1',
- items: [],
- nextCursor: null,
- hasNext: false,
- asOf: '2026-06-19T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations();
-
- renderWithProviders(, { route: '/broker' });
-
- expect(
- await screen.findByRole('heading', { level: 1, name: /брокерские счета/i }),
- ).toBeInTheDocument();
- });
-
- it('renders account section navigation with the current nested route', () => {
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: {
- account: {
- id: 'acc-1',
- type: 'brokerage',
- name: 'Broker',
- status: 'ACCOUNT_STATUS_OPEN',
- openedAt: null,
- accessLevel: null,
- },
- totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } },
- yields: { expectedPercent: 5, daily: null, dailyPercent: null },
- cash: [],
- blockedCash: [],
- asOf: '2026-06-17T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
-
- renderWithClient(
-
- }>
- Содержимое облигаций
} />
-
- ,
- ['/broker/acc-1/bonds'],
- );
-
- expect(screen.getByRole('heading', { level: 1, name: 'Broker' })).toBeInTheDocument();
- const navigation = screen.getByRole('navigation', { name: 'Разделы брокерского счёта' });
- expect(within(navigation).getByRole('link', { name: 'Обзор' })).toHaveAttribute(
- 'href',
- '/broker/acc-1',
- );
- expect(within(navigation).getByRole('link', { name: 'Акции' })).toHaveAttribute(
- 'href',
- '/broker/acc-1/shares',
- );
- expect(within(navigation).getByRole('link', { name: 'Облигации' })).toHaveAttribute(
- 'href',
- '/broker/acc-1/bonds',
- );
- expect(within(navigation).getByRole('link', { name: 'Операции' })).toHaveAttribute(
- 'href',
- '/broker/acc-1/operations',
- );
- const activeLink = within(navigation).getByRole('link', { name: 'Облигации' });
- expect(activeLink).toHaveAttribute('aria-current', 'page');
- expect(activeLink).toHaveClass('is-active');
- expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument();
- expect(screen.queryByRole('main')).not.toBeInTheDocument();
- });
-
- it('passes the decoded account and exact portfolio query through outlet context', () => {
- const portfolioResult = {
- data: {
- account: {
- id: 'account one',
- type: 'brokerage',
- name: 'Encoded account',
- status: 'ACCOUNT_STATUS_OPEN',
- openedAt: null,
- accessLevel: null,
- },
- totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } },
- yields: { expectedPercent: 5, daily: null, dailyPercent: null },
- cash: [],
- blockedCash: [],
- asOf: '2026-06-17T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- } as any;
- const portfolioSpy = vi
- .spyOn(portfolioHook, 'useBrokerPortfolio')
- .mockReturnValue(portfolioResult);
-
- renderWithClient(
-
- }>
- }
- />
-
- ,
- ['/broker/account%20one/bonds'],
- );
-
- expect(screen.getByText('Account context: account one')).toBeInTheDocument();
- expect(screen.getByText('Same portfolio query')).toBeInTheDocument();
- expect(portfolioSpy).toHaveBeenCalledWith('account one');
-
- const navigation = screen.getByRole('navigation', { name: 'Разделы брокерского счёта' });
- expect(within(navigation).getByRole('link', { name: 'Обзор' })).toHaveAttribute(
- 'href',
- '/broker/account%20one',
- );
- expect(within(navigation).getByRole('link', { name: 'Облигации' })).toHaveAttribute(
- 'href',
- '/broker/account%20one/bonds',
- );
- });
-
- it('keeps account navigation and nested content visible when the portfolio is unavailable', () => {
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: undefined,
- isLoading: false,
- isFetching: false,
- error: new Error('Portfolio unavailable'),
- } as any);
-
- renderWithClient(
-
- }>
- Содержимое облигаций} />
-
- ,
- ['/broker/acc-1/bonds'],
- );
-
- expect(screen.getByRole('heading', { level: 1, name: 'Брокерский счёт' })).toBeInTheDocument();
- expect(
- screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
- ).toBeInTheDocument();
- expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument();
- });
-
- it('renders the broker account overview with allocation, asset links and recent operations', () => {
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: createOverviewPortfolio(),
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- const operationsSpy = mockOverviewOperations({
- data: {
- accountId: 'acc-1',
- items: [
- {
- cursor: 'recent-1',
- accountId: 'acc-1',
- id: 'recent-1',
- parentOperationId: null,
- date: '2026-06-18T10:00:00.000Z',
- category: 'income',
- type: 'OPERATION_TYPE_COUPON',
- description: 'Coupon',
- name: 'Купон ОФЗ',
- state: 'OPERATION_STATE_EXECUTED',
- instrumentUid: 'bond-uid',
- figi: null,
- ticker: 'SU26238RMFS5',
- classCode: 'TQOB',
- instrumentType: 'bond',
- payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
- price: null,
- commission: null,
- yield: null,
- accruedInt: null,
- quantity: 2,
- quantityDone: 2,
- },
- ],
- nextCursor: null,
- hasNext: false,
- asOf: '2026-06-19T00:00:00.000Z',
- },
- });
-
- renderOverview();
-
- expect(screen.getByText(/1[\s\u00a0]?250[\s\u00a0]?000/)).toBeInTheDocument();
- expect(screen.getByRole('link', { name: /Акции.*14 позиций/i })).toHaveAttribute(
- 'href',
- '/broker/acc-1/shares',
- );
- expect(screen.getByRole('link', { name: /Облигации.*8 выпусков/i })).toHaveAttribute(
- 'href',
- '/broker/acc-1/bonds',
- );
- const allocationChart = screen.getByRole('img', {
- name: 'Структура брокерского портфеля',
- });
- expect(allocationChart).toBeInTheDocument();
- expect(screen.getByTitle('Структура брокерского портфеля')).toBeInTheDocument();
- const circumference = 2 * Math.PI * 44;
- const allocationArcs = allocationChart.querySelectorAll('circle');
- expect(allocationArcs[0]).toHaveAttribute(
- 'stroke-dasharray',
- `${circumference * 0.6} ${circumference - circumference * 0.6}`,
- );
- expect(allocationArcs[1]).toHaveAttribute('stroke-dashoffset', `${-circumference * 0.6}`);
- expect(screen.getByText(/Акции:.*750[\s\u00a0]?000.*60\.0%/)).toBeInTheDocument();
- expect(document.querySelector('.broker-allocation__swatch')).toHaveAttribute(
- 'aria-hidden',
- 'true',
- );
- expect(screen.getByRole('link', { name: 'Вся история' })).toHaveAttribute(
- 'href',
- '/broker/acc-1/operations',
- );
- expect(operationsSpy).toHaveBeenCalledWith('acc-1', { limit: 5 });
- expect(screen.queryByRole('heading', { name: 'Позиции' })).not.toBeInTheDocument();
- expect(screen.queryByRole('columnheader', { name: 'Количество' })).not.toBeInTheDocument();
- });
-
- it.each([
- [1, '1 позиция', '1 выпуск'],
- [2, '2 позиции', '2 выпуска'],
- [5, '5 позиций', '5 выпусков'],
- ])('uses Russian asset count plurals for %i', (count, sharesText, bondsText) => {
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: createOverviewPortfolio({
- positionCounts: { shares: count, bonds: count, etf: 0, other: 0 },
- }),
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations();
-
- renderOverview();
-
- expect(
- screen.getByRole('link', { name: new RegExp(`Акции.*${sharesText}`) }),
- ).toBeInTheDocument();
- expect(
- screen.getByRole('link', { name: new RegExp(`Облигации.*${bondsText}`) }),
- ).toBeInTheDocument();
- });
-
- it('renders an overview skeleton while the portfolio is loading', () => {
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: undefined,
- isLoading: true,
- isFetching: true,
- error: null,
- } as any);
- mockOverviewOperations();
-
- const { container } = renderOverview();
-
- expect(container.querySelector('.broker-overview')).toBeInTheDocument();
- expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
- expect(
- screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
- ).toBeInTheDocument();
- });
-
- it('keeps account navigation visible when the overview portfolio fails', () => {
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: undefined,
- isLoading: false,
- isFetching: false,
- error: new Error('portfolio failed'),
- } as any);
- mockOverviewOperations();
-
- renderOverview();
-
- expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить сводку счёта');
- expect(
- screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
- ).toBeInTheDocument();
- });
-
- it('keeps the overview summary and navigation when recent operations fail', () => {
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: createOverviewPortfolio(),
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations({ data: undefined, error: new Error('operations failed') });
-
- renderOverview();
-
- expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить последние операции');
- expect(screen.getByText(/1[\s\u00a0]?250[\s\u00a0]?000/)).toBeInTheDocument();
- expect(
- screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
- ).toBeInTheDocument();
- });
-
- it('renders the overview recent-operations empty state and history link', () => {
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: createOverviewPortfolio(),
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations();
-
- renderOverview();
-
- expect(screen.getByText('Операций с начала текущего года нет')).toBeInTheDocument();
- expect(screen.getByRole('link', { name: 'Вся история' })).toHaveAttribute(
- 'href',
- '/broker/acc-1/operations',
- );
- });
-
- it('renders negative allocation values as text instead of chart sectors', () => {
- const portfolio = createOverviewPortfolio();
- portfolio.totals.bonds = { currency: 'RUB', units: '-10000', nano: 0, value: -10_000 };
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: portfolio,
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations();
-
- renderOverview();
-
- const negativeList = screen.getByRole('list', {
- name: 'Отрицательные значения распределения',
- });
- expect(
- within(negativeList).getByText(/Облигации: отрицательное значение.*10[\s\u00a0]?000/),
- ).toBeInTheDocument();
- });
-
- it('renders an empty allocation state when the portfolio total has no allocation data', () => {
- const portfolio = createOverviewPortfolio();
- portfolio.totals.portfolio = { currency: 'RUB', units: '0', nano: 0, value: 0 };
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: portfolio,
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations();
-
- renderOverview();
-
- expect(screen.getByText('Нет данных для распределения')).toBeInTheDocument();
- });
-
- it('bounds visual allocation arcs when textual percentages exceed 100%', () => {
- const portfolio = createOverviewPortfolio();
- portfolio.totals.portfolio = { currency: 'RUB', units: '100', nano: 0, value: 100 };
- portfolio.totals.shares = { currency: 'RUB', units: '120', nano: 0, value: 120 };
- portfolio.totals.bonds = { currency: 'RUB', units: '-20', nano: 0, value: -20 };
- portfolio.totals.etf = null;
- portfolio.totals.currencies = null;
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: portfolio,
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations();
-
- renderOverview();
-
- const chart = screen.getByRole('img', { name: 'Структура брокерского портфеля' });
- const circumference = 2 * Math.PI * 44;
- const dashLengths = Array.from(chart.querySelectorAll('circle')).map((circle) => {
- const parts = circle
- .getAttribute('stroke-dasharray')!
- .split(' ')
- .map((part) => Number(part));
- expect(parts.every((part) => Number.isFinite(part) && part >= 0)).toBe(true);
- expect(Math.abs(Number(circle.getAttribute('stroke-dashoffset')))).toBeLessThanOrEqual(
- circumference,
- );
- return parts[0];
- });
- expect(dashLengths.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(circumference);
- expect(screen.getByText(/Акции:.*120.*120\.0%/)).toBeInTheDocument();
- });
-
- it('uses the portfolio currency in the allocation legend', () => {
- const portfolio = createOverviewPortfolio();
- portfolio.totals.portfolio!.currency = 'USD';
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: portfolio,
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations();
-
- renderOverview();
-
- const sharesLegend = screen.getByText(/Акции:.*60\.0%/);
- expect(sharesLegend).toHaveTextContent('$');
- expect(sharesLegend).not.toHaveTextContent('₽');
- });
-
- it('falls back to an available asset currency when the portfolio currency is absent', () => {
- const portfolio = createOverviewPortfolio();
- for (const total of Object.values(portfolio.totals)) {
- if (total) total.currency = 'USD';
- }
- portfolio.totals.portfolio!.currency = '';
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: portfolio,
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations();
-
- renderOverview();
-
- const sharesLegend = screen.getByText(/Акции:.*60\.0%/);
- expect(sharesLegend).toHaveTextContent('$');
- expect(sharesLegend).not.toHaveTextContent('₽');
- });
-
- it('distinguishes unavailable allocation percentages from a genuine zero', () => {
- const unavailable = createOverviewPortfolio();
- unavailable.totals.shares = null;
- unavailable.totals.bonds = { currency: 'RUB', units: '0', nano: 0, value: 0 };
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: unavailable,
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations();
-
- renderOverview();
-
- const shares = screen.getByRole('link', { name: /Акции.*14 позиций/ });
- const bonds = screen.getByRole('link', { name: /Облигации.*8 выпусков/ });
- expect(within(shares).getAllByText('—')).toHaveLength(2);
- expect(within(bonds).getByText('0.0%')).toBeInTheDocument();
- });
-
- it.each([null, 0, -10])(
- 'shows an unavailable card percentage for portfolio total %s',
- (portfolioTotal) => {
- const portfolio = createOverviewPortfolio();
- portfolio.totals.portfolio =
- portfolioTotal === null
- ? null
- : { currency: 'RUB', units: String(portfolioTotal), nano: 0, value: portfolioTotal };
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: portfolio,
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations();
-
- renderOverview();
-
- const shares = screen.getByRole('link', { name: /Акции.*14 позиций/ });
- expect(within(shares).getByText('—')).toBeInTheDocument();
- expect(within(shares).queryByText('0.0%')).not.toBeInTheDocument();
- },
- );
-
- it('renders duplicate cash currencies without duplicate React keys', () => {
- const portfolio = createOverviewPortfolio();
- portfolio.cash = [
- { currency: 'RUB', units: '100', nano: 0, value: 100 },
- { currency: 'RUB', units: '200', nano: 0, value: 200 },
- ];
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: portfolio,
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations();
- const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
-
- renderOverview();
-
- expect(screen.getAllByText('RUB')).toHaveLength(2);
- expect(consoleError.mock.calls.flat().join(' ')).not.toContain(
- 'Encountered two children with the same key',
- );
- consoleError.mockRestore();
- });
-
- it('marks the recent operations table busy while retaining its rows', () => {
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: createOverviewPortfolio(),
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- mockOverviewOperations({
- data: {
- accountId: 'acc-1',
- items: [
- {
- cursor: 'recent-busy',
- accountId: 'acc-1',
- id: 'recent-busy',
- parentOperationId: null,
- date: '2026-06-18T10:00:00.000Z',
- category: 'income',
- type: 'OPERATION_TYPE_COUPON',
- description: 'Coupon',
- name: 'Купон ОФЗ',
- state: 'OPERATION_STATE_EXECUTED',
- instrumentUid: 'bond-uid',
- figi: null,
- ticker: 'SU26238RMFS5',
- classCode: 'TQOB',
- instrumentType: 'bond',
- payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
- price: null,
- commission: null,
- yield: null,
- accruedInt: null,
- quantity: 2,
- quantityDone: 2,
- },
- ],
- nextCursor: null,
- hasNext: false,
- asOf: '2026-06-19T00:00:00.000Z',
- },
- isFetching: true,
- });
-
- renderOverview();
-
- const operations = screen
- .getByRole('heading', { name: 'Последние операции' })
- .closest('section')!;
- expect(operations).toHaveAttribute('aria-busy', 'true');
- expect(screen.getByRole('status')).toHaveTextContent('Обновление операций…');
- expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
- });
-
- describe('Broker positions page', () => {
- function renderPositionsPage(initialEntry = '/broker/acc-1/shares') {
- return renderWithClient(
-
- }>
- } />
- } />
-
- ,
- [initialEntry],
- );
- }
-
- function mockPositionsPortfolio() {
- vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: {
- account: {
- id: 'acc-1',
- type: 'brokerage',
- name: 'Broker',
- status: 'ACCOUNT_STATUS_OPEN',
- openedAt: null,
- accessLevel: null,
- },
- totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } },
- yields: { expectedPercent: 5, daily: null, dailyPercent: null },
- cash: [],
- blockedCash: [],
- asOf: '2026-06-19T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- }
-
- it('renders share positions with correct query and instrument links', () => {
- mockPositionsPortfolio();
- const positionsSpy = mockUseBrokerPositions(
- createPosition({
- instrumentUid: 'share-uid',
- ticker: 'SBER',
- classCode: 'TQBR',
- instrumentType: 'share',
- name: 'Sberbank',
- quantity: 10,
- currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
- currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
- }),
- createPosition({
- instrumentUid: 'bond-uid',
- ticker: 'SU26238RMFS5',
- classCode: 'TQOB',
- instrumentType: 'bond',
- name: 'ОФЗ 26238',
- quantity: 2,
- currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
- currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
- }),
- );
-
- renderPositionsPage('/broker/acc-1/shares');
-
- expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
- type: 'share',
- limit: 10,
- cursor: undefined,
- });
- expect(screen.getByRole('heading', { name: 'Акции' })).toBeInTheDocument();
- expect(screen.getByRole('link', { name: 'SBER' })).toHaveAttribute('href', '/stocks/SBER');
- expect(screen.queryByText('SU26238RMFS5')).not.toBeInTheDocument();
- });
-
- it('renders bond positions with correct query and instrument links', () => {
- mockPositionsPortfolio();
- const positionsSpy = mockUseBrokerPositions(
- createPosition({
- instrumentUid: 'share-uid',
- ticker: 'SBER',
- instrumentType: 'share',
- name: 'Sberbank',
- }),
- createPosition({
- instrumentUid: 'bond-uid',
- ticker: 'SU26238RMFS5',
- classCode: 'TQOB',
- instrumentType: 'bond',
- name: 'ОФЗ 26238',
- }),
- );
-
- renderPositionsPage('/broker/acc-1/bonds');
-
- expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
- type: 'bond',
- limit: 10,
- cursor: undefined,
- });
- expect(screen.getByRole('heading', { name: 'Облигации' })).toBeInTheDocument();
- expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
- 'href',
- '/bonds/SU26238RMFS5',
- );
- expect(screen.queryByText('SBER')).not.toBeInTheDocument();
- });
-
- it('navigates positions forward and backward by cursor', async () => {
- const user = userEvent.setup();
- mockPositionsPortfolio();
- const positionsSpy = vi
- .spyOn(positionsHook, 'useBrokerPositions')
- .mockImplementation((_accountId, query) => {
- const items =
- query.cursor === 'cursor-page-2'
- ? [
- createPosition({
- instrumentUid: 'share-2',
- ticker: 'GAZP',
- instrumentType: 'share',
- name: 'Gazprom',
- quantity: 5,
- currentValue: { currency: 'RUB', units: '5000', nano: 0, value: 5000 },
- }),
- ]
- : [
- createPosition({
- instrumentUid: 'share-1',
- ticker: 'SBER',
- instrumentType: 'share',
- name: 'Sberbank',
- quantity: 10,
- currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
- }),
- ];
- return {
- data: {
- accountId: 'acc-1',
- items,
- nextCursor: query.cursor ? null : 'cursor-page-2',
- hasNext: !query.cursor,
- asOf: '2026-06-19T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- } as any;
- });
-
- renderPositionsPage('/broker/acc-1/shares');
-
- const section = screen.getByRole('heading', { name: 'Акции' }).closest('section')!;
- const withinSection = within(section);
- expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
- type: 'share',
- limit: 10,
- cursor: undefined,
- });
- expect(withinSection.getByText('SBER')).toBeInTheDocument();
-
- const nextButton = withinSection.getByRole('button', { name: 'Следующая страница' });
- await user.click(nextButton);
-
- expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
- type: 'share',
- limit: 10,
- cursor: 'cursor-page-2',
- });
- expect(withinSection.getByText('GAZP')).toBeInTheDocument();
- expect(withinSection.queryByText('SBER')).not.toBeInTheDocument();
-
- const prevButton = withinSection.getByRole('button', { name: 'Предыдущая страница' });
- await user.click(prevButton);
-
- expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
- type: 'share',
- limit: 10,
- cursor: undefined,
- });
- expect(withinSection.getByText('SBER')).toBeInTheDocument();
- });
-
- it('shows an empty message when there are no positions of the given type', () => {
- mockPositionsPortfolio();
- mockUseBrokerPositions();
-
- renderPositionsPage('/broker/acc-1/shares');
-
- expect(screen.getByText('На счёте нет акций')).toBeInTheDocument();
- });
-
- it('shows a skeleton while positions are loading', () => {
- mockPositionsPortfolio();
- vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
- data: undefined,
- isLoading: true,
- isFetching: true,
- error: null,
- } as any);
-
- const { container } = renderPositionsPage('/broker/acc-1/shares');
-
- expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
- expect(
- screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
- ).toBeInTheDocument();
- });
-
- it('keeps account navigation visible when positions fail to load', () => {
- mockPositionsPortfolio();
- vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
- data: undefined,
- isLoading: false,
- isFetching: false,
- error: new Error('positions failed'),
- } as any);
-
- renderPositionsPage('/broker/acc-1/shares');
-
- expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить акции');
- expect(
- screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
- ).toBeInTheDocument();
- });
- });
-
- describe('Broker operations page', () => {
- function renderOperationsPage(initialEntry = '/broker/acc-1/operations') {
- return renderWithClient(
-
- }>
- } />
-
- ,
- [initialEntry],
- );
- }
-
- function mockOperationsPortfolio() {
- return vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
- data: {
- account: {
- id: 'acc-1',
- type: 'brokerage',
- name: 'Broker',
- status: 'ACCOUNT_STATUS_OPEN',
- openedAt: null,
- accessLevel: null,
- },
- totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } },
- yields: { expectedPercent: 5, daily: null, dailyPercent: null },
- cash: [],
- blockedCash: [],
- asOf: '2026-06-19T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
- }
-
- it('reads the operation type filter from the URL and requests the correct API query', () => {
- mockOperationsPortfolio();
- const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
- (_accountId, _query) =>
- ({
- data: {
- accountId: 'acc-1',
- items: [],
- nextCursor: null,
- hasNext: false,
- asOf: '2026-06-19T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- }) as any,
- );
-
- renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON');
-
- expect(screen.getByRole('combobox', { name: 'Тип операции' })).toHaveValue(
- 'OPERATION_TYPE_COUPON',
- );
- expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
- limit: 10,
- cursor: undefined,
- operationTypes: 'OPERATION_TYPE_COUPON',
- });
- });
-
- it('resets cursor when the filter changes and updates the URL', async () => {
- const user = userEvent.setup();
- mockOperationsPortfolio();
- const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
- (_accountId, query) =>
- ({
- data: {
- accountId: 'acc-1',
- items: query.cursor
- ? []
- : [
- {
- cursor: 'op-1',
- accountId: 'acc-1',
- id: 'op-1',
- parentOperationId: null,
- date: '2026-06-19T10:00:00.000Z',
- category: 'income',
- type: 'OPERATION_TYPE_COUPON',
- description: 'Coupon',
- state: 'OPERATION_STATE_EXECUTED',
- instrumentUid: null,
- figi: null,
- ticker: null,
- classCode: null,
- instrumentType: null,
- payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
- price: null,
- commission: null,
- yield: null,
- accruedInt: null,
- quantity: null,
- quantityDone: null,
- },
- ],
- nextCursor: query.cursor ? null : 'cursor-page-2',
- hasNext: !query.cursor,
- asOf: '2026-06-19T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- }) as any,
- );
-
- renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON');
-
- const section = screen.getByRole('heading', { name: 'Операции' }).closest('section')!;
- const withinSection = within(section);
- const nextButton = withinSection.getByRole('button', { name: 'Следующая страница' });
- await user.click(nextButton);
-
- expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
- limit: 10,
- cursor: 'cursor-page-2',
- operationTypes: 'OPERATION_TYPE_COUPON',
- });
-
- const select = screen.getByRole('combobox', { name: 'Тип операции' });
- await user.selectOptions(select, 'OPERATION_TYPE_TAX');
-
- expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
- limit: 10,
- cursor: undefined,
- operationTypes: 'OPERATION_TYPE_TAX',
- });
- expect(withinSection.getByText('1')).toBeInTheDocument();
- });
-
- it('removes the type parameter and query when selecting Все операции', async () => {
- const user = userEvent.setup();
- mockOperationsPortfolio();
- const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
- data: {
- accountId: 'acc-1',
- items: [],
- nextCursor: null,
- hasNext: false,
- asOf: '2026-06-19T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
-
- renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON');
-
- const select = screen.getByRole('combobox', { name: 'Тип операции' });
- await user.selectOptions(select, '');
-
- expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
- });
-
- it('treats an invalid URL type as Все операции', () => {
- mockOperationsPortfolio();
- vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
- data: {
- accountId: 'acc-1',
- items: [],
- nextCursor: null,
- hasNext: false,
- asOf: '2026-06-19T00:00:00.000Z',
- },
- isLoading: false,
- isFetching: false,
- error: null,
- } as any);
-
- renderOperationsPage('/broker/acc-1/operations?type=INVALID_TYPE');
-
- expect(screen.getByRole('combobox', { name: 'Тип операции' })).toHaveValue('');
- });
-
- it('keeps account navigation and filter visible when operations fail to load', () => {
- mockOperationsPortfolio();
- vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
- data: undefined,
- isLoading: false,
- isFetching: false,
- error: new Error('operations failed'),
- } as any);
-
- renderOperationsPage('/broker/acc-1/operations');
-
- expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить историю операций');
- expect(
- screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
- ).toBeInTheDocument();
- expect(screen.getByRole('combobox', { name: 'Тип операции' })).toBeInTheDocument();
- });
- });
-});
diff --git a/apps/frontend/src/pages/broker/BrokerPositionsPage.tsx b/apps/frontend/src/pages/broker/BrokerPositionsPage.tsx
deleted file mode 100644
index 4c45f43..0000000
--- a/apps/frontend/src/pages/broker/BrokerPositionsPage.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { BrokerPositionsPage } from '../broker-positions';
diff --git a/apps/frontend/src/pages/broker/brokerAccountsOverview.ts b/apps/frontend/src/pages/broker/brokerAccountsOverview.ts
deleted file mode 100644
index e41724b..0000000
--- a/apps/frontend/src/pages/broker/brokerAccountsOverview.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-export {
- aggregateBrokerAccounts,
- brokerAccountTypeLabel,
- formatBrokerCurrencyValue,
- formatBrokerDate,
- formatBrokerMoney,
- formatBrokerPercent,
- formatBrokerSignedCurrencyValue,
- formatBrokerSignedPercent,
- type BrokerAccountsAggregate,
- type BrokerCurrencyAllocationSummary,
- type BrokerCurrencyCashSummary,
- type BrokerCurrencyPortfolioSummary,
-} from '../../entities/broker-account/model/brokerAccountsOverview';
diff --git a/apps/frontend/src/pages/broker/brokerAllocation.ts b/apps/frontend/src/pages/broker/brokerAllocation.ts
deleted file mode 100644
index 3c5822b..0000000
--- a/apps/frontend/src/pages/broker/brokerAllocation.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export {
- buildBrokerAllocation,
- type BrokerAllocationItem,
- type BrokerAllocationKey,
-} from '../../entities/broker-position';
diff --git a/apps/frontend/src/pages/broker/brokerDisplay.ts b/apps/frontend/src/pages/broker/brokerDisplay.ts
deleted file mode 100644
index 52b4515..0000000
--- a/apps/frontend/src/pages/broker/brokerDisplay.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export {
- BROKER_OPERATION_TYPE_OPTIONS,
- getBrokerInstrumentPath,
- getBrokerOperationImpact,
- getBrokerOperationTypeLabel,
- getBrokerPositionGroup,
- isBrokerOperationType,
- type BrokerOperationImpact,
- type BrokerPositionGroup,
-} from '../../entities/broker-position';
diff --git a/apps/frontend/src/routes.tsx b/apps/frontend/src/routes.tsx
deleted file mode 100644
index 7df1046..0000000
--- a/apps/frontend/src/routes.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { AppRoutes } from './app/routing/AppRoutes';
diff --git a/apps/frontend/src/test/test-utils.tsx b/apps/frontend/src/test/test-utils.tsx
index 4169df8..971b7da 100644
--- a/apps/frontend/src/test/test-utils.tsx
+++ b/apps/frontend/src/test/test-utils.tsx
@@ -2,7 +2,7 @@ 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';
+import { SessionProvider } from '../app/providers/SessionProvider';
interface CustomRenderOptions extends Omit {
queryClient?: QueryClient;
@@ -33,7 +33,7 @@ export function renderWithProviders(
initialEntries={[route]}
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
- {children}
+ {children}
);
diff --git a/docs/features/frontend-fsd-cleanup/plan.md b/docs/features/frontend-fsd-cleanup/plan.md
new file mode 100644
index 0000000..1c39b01
--- /dev/null
+++ b/docs/features/frontend-fsd-cleanup/plan.md
@@ -0,0 +1,105 @@
+# Frontend FSD Cleanup — план
+
+## Общий подход
+
+7 последовательных шагов, каждый шаг верифицируется `npm test -w apps/frontend`.
+После шага 1 (разрыв цепочки) удаляем файлы. После шагов 3-6 обновляем документацию.
+
+## Шаг 1. Разорвать цепочку entity shims к `api/broker.ts`
+
+- `entities/broker-position/api/brokerPositionApi.ts` — переписать на прямой вызов `shared/api/client`
+- `entities/broker-operation/api/brokerOperationApi.ts` — переписать на прямой вызов `shared/api/client`
+
+Оба файла делают то же, что `entities/broker-account/api/brokerAccountApi.ts` уже делает:
+импортируют `apiClient` из `shared/api/client` и типы из `shared/api/responses`.
+
+После этого шага `api/broker.ts` перестаёт быть нужен.
+
+## Шаг 2. Переключить потребителей shim на FSD-импорты
+
+Три группы потребителей:
+
+1. `pages/broker/BrokerPages.test.tsx` — импортирует моки `useBrokerAccounts` и `useBrokerAccountPortfolios` из `../../hooks/`. Переключить на импорт из `entities/broker-account` (через `vi.mock(entities/broker-account)`). Сами файлы shim-хуков будут удалены на шаге 3, но пока их тесты нужно переключить.
+
+2. `pages/broker/BrokerAccountsPage.test.tsx` — импортирует `BrokerAccountsPage` из `./BrokerAccountsPage` (который shim в `pages/broker/`). Переключить на `pages/broker-accounts`. После миграции тест будет жить при FSD-странице.
+
+3. Три файла импортируют `AuthProvider` из `context/AuthContext`:
+ - `test/test-utils.tsx`
+ - `hooks/useAuth.test.tsx`
+ - `entities/session/model/useSession.test.tsx`
+
+ Заменить на импорт `SessionProvider` из `app/providers/SessionProvider`. `AuthProvider` — это просто alias.
+
+## Шаг 3. Удалить shim-файлы
+
+### api/ (кроме api/screener.ts)
+- `api/auth.ts` → re-export из `entities/session/api/sessionApi`
+- `api/client.ts` → re-export из `shared/api/client`
+- `api/portfolio.ts` → re-export из `entities/portfolio/api/portfolioApi`
+- `api/responses.ts` → re-export из `shared/api/responses`
+- `api/types.ts` → re-export из `shared/api/types`
+- `api/broker.ts` → больше не нужен (шаг 1)
+- `api/broker.test.ts` → мёртвый код
+
+### hooks/ (кроме hooks/useScreener.ts)
+Все 17 файлов — однострочные re-export.
+
+### context/
+- `context/AuthContext.tsx` — re-export
+- `context/AuthContext.test.tsx` — тест shim
+
+### components/ (кроме components/screener/ и components/portfolios/)
+- `components/Layout.tsx` → `app/layouts/AppLayout`
+- `components/ProtectedRoute.tsx` → `app/routing/ProtectedRoute`
+- `components/SearchBar.tsx` → `widgets/search-bar`
+- `components/PriceChart.tsx` → `widgets/price-chart`
+- `components/StockDetails.tsx` → `widgets/stock-details`
+- `components/BondDetails.tsx` → `widgets/bond-details`
+- `components/SkeletonBlock.tsx` → `shared/ui/SkeletonBlock`
+- `components/TableSkeleton.tsx` → `shared/ui/TableSkeleton`
+- Соответствующие `.test.tsx` файлы
+
+### pages/ flat shims
+- `pages/HomePage.tsx` → `pages/home`
+- `pages/StockPage.tsx` → `pages/stock`
+- `pages/BondPage.tsx` → `pages/bond`
+- Соответствующие `.test.tsx` файлы
+
+### pages/broker/ (весь каталог)
+13 файлов — все shims.
+
+### Корень src/
+- `routes.tsx` → re-export из `app/routing/AppRoutes`
+
+## Шаг 4. Удалить тесты, привязанные к shim-файлам
+
+После удаления shim-файлов их тесты тоже удаляются:
+- `api/auth.test.ts`, `api/client.test.ts`, `api/broker.test.ts`
+- `hooks/useAuth.test.tsx`, `hooks/useBrokerAccountPortfolios.test.tsx`,
+ `hooks/useBrokerAccounts.test.tsx`, `hooks/useSearch.test.tsx`
+- `context/AuthContext.test.tsx`
+- `components/BondDetails.test.tsx`, `components/Layout.test.tsx`,
+ `components/PriceChart.test.tsx`, `components/ProtectedRoute.test.tsx`,
+ `components/SearchBar.test.tsx`, `components/StockDetails.test.tsx`
+- `pages/HomePage.test.tsx`, `pages/StockPage.test.tsx`, `pages/BondPage.test.tsx`
+- `pages/broker/BrokerPages.test.tsx`, `pages/broker/BrokerAccountsPage.test.tsx`
+
+Функциональность этих тестов уже покрыта тестами внутри FSD-слоёв.
+
+## Шаг 5. Удалить `api/broker.ts`
+
+После шага 1 (разрыв цепочки) entity API прокси больше не ссылаются на `api/broker.ts`.
+Осталось только удалить сам файл.
+
+## Шаг 6. Обновить документацию
+
+- `apps/docs/docs/frontend/hooks.md` — убрать секции про legacy shim-пути
+- `apps/docs/docs/frontend/routes.md` — убрать упоминания shim-файлов
+
+## Шаг 7. Финальная верификация
+
+- `npm test -w apps/frontend` — PASS
+- `npm run lint -w apps/frontend` — PASS
+- `npm run build -w apps/frontend` — PASS
+- `npm run build -w apps/docs` — PASS
+- Deep import verification: `rg -n "@/api/|@/hooks/|@/context/|@/components/(?!screener|portfolios)" apps/frontend/src` — no matches
diff --git a/docs/features/frontend-fsd-cleanup/spec.md b/docs/features/frontend-fsd-cleanup/spec.md
new file mode 100644
index 0000000..1997fcd
--- /dev/null
+++ b/docs/features/frontend-fsd-cleanup/spec.md
@@ -0,0 +1,84 @@
+# Frontend FSD Cleanup
+
+Дата: 2026-06-20
+Статус: спецификация
+
+## Контекст
+
+FSD-миграция фронтенда выполнена в 5 фаз:
+
+1. Broker pilot — entities broker-account/broker-position/broker-operation + widgets + pages
+2. Shared layer — shared/api, shared/ui
+3. Entities migration — stock, bond, portfolio, session, search
+4. App + auth — providers, routing, layouts
+5. Market pages — home/stock/bond pages + widgets
+
+После завершения миграции остались shim-файлы — однострочные re-export из старых путей в новые
+FSD-точки входа — а также мёртвый код в `pages/broker/` и старая цепочка entity API-прокси,
+которая идёт через `api/broker.ts` вместо прямого вызова `shared/api/client`.
+
+## Цель
+
+Удалить shim-файлы и мёртвый код, разорвав последние цепочки legacy-импортов, чтобы
+FSD-структура стала единственной архитектурой фронтенда без дублирующихся точек входа.
+
+## Область изменений
+
+### Удаляемые shim-файлы (re-export only)
+
+- `api/` — `auth.ts`, `client.ts`, `portfolio.ts`, `responses.ts`, `types.ts`, `broker.test.ts`
+- `hooks/` — все файлы, кроме `useScreener.ts`
+- `context/` — `AuthContext.tsx`, `AuthContext.test.tsx`
+- `components/` — все файлы, кроме `screener/` и `portfolios/`
+- `pages/broker/` — все 13 файлов
+- `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx`
+- `routes.tsx`
+- Тесты, привязанные к удаляемым shim-файлам
+
+### Переписываемые entity API (разрыв цепочки к `api/broker.ts`)
+
+- `entities/broker-position/api/brokerPositionApi.ts` — прямой вызов `shared/api/client`
+- `entities/broker-operation/api/brokerOperationApi.ts` — прямой вызов `shared/api/client`
+
+### Удаляемый мёртвый код
+
+- `api/broker.ts` — после разрыва цепочки перестаёт быть нужен
+
+### Обновляемые потребители (переключение с shim на FSD-импорты)
+
+- `pages/broker/BrokerPages.test.tsx` — импорты из `../../hooks/` → `entities/broker-*`
+- `pages/broker/BrokerAccountsPage.test.tsx` — импорт shim → `pages/broker-accounts`
+- `test/test-utils.tsx`, `hooks/useAuth.test.tsx`, `entities/session/model/useSession.test.tsx` — `AuthProvider` из `context/AuthContext` → `SessionProvider` из `app/providers/SessionProvider`
+
+### Обновляемая документация
+
+- `apps/docs/docs/frontend/hooks.md` — убрать legacy shim-пути
+- `apps/docs/docs/frontend/routes.md` — убрать shim-файлы
+
+## Ограничения
+
+- Изменения ограничены frontend-пакетом.
+- Не мигрируется screener (живой код, не shim) — остаётся в текущей структуре.
+- Не мигрируется portfolio pages (живой код, не shim) — остаются в текущей структуре.
+- Не мигрируется LoginPage/RegisterPage/ProfilePage (живой код, не shim).
+- Не меняется поведение UI, API-контракты, роутинг.
+- Не вводятся ESLint import boundaries.
+
+## Acceptance Criteria
+
+- `api/` содержит только `screener.ts` (живой код)
+- `hooks/` содержит только `useScreener.ts` (живой код)
+- `context/` удалён полностью
+- `components/` содержит только `screener/` и `portfolios/` (живой код)
+- `pages/broker/` удалён полностью
+- `pages/HomePage.tsx`, `StockPage.tsx`, `BondPage.tsx` удалены (живут в `pages/home/`, `pages/stock/`, `pages/bond/`)
+- `routes.tsx` удалён (живёт в `app/routing/AppRoutes.tsx`)
+- `api/broker.ts` удалён
+- `entities/broker-position/api/brokerPositionApi.ts` и `entities/broker-operation/api/brokerOperationApi.ts` импортируют напрямую из `shared/api/client`
+- Все тестовые файлы, привязанные к удалённым shim-файлам, удалены
+- Все потребители переключены на FSD-импорты
+- `npm test -w apps/frontend` — PASS
+- `npm run lint -w apps/frontend` — PASS
+- `npm run build -w apps/frontend` — PASS
+- `npm run build -w apps/docs` — PASS
+- Документация обновлена
diff --git a/docs/features/frontend-fsd-cleanup/tasks.md b/docs/features/frontend-fsd-cleanup/tasks.md
new file mode 100644
index 0000000..a27bf4a
--- /dev/null
+++ b/docs/features/frontend-fsd-cleanup/tasks.md
@@ -0,0 +1,68 @@
+# Frontend FSD Cleanup — задачи
+
+Статус: выполнено
+
+## 1. Разорвать цепочку entity shims
+
+- [x] Переписать `entities/broker-position/api/brokerPositionApi.ts` на прямой вызов `shared/api/client`
+- [x] Переписать `entities/broker-operation/api/brokerOperationApi.ts` на прямой вызов `shared/api/client`
+- [x] Проверить `npm test -w apps/frontend`
+
+## 2. Переключить потребителей shim на FSD-импорты
+
+- [x] Удалены вместе с `pages/broker/` (BrokerPages.test.tsx, BrokerAccountsPage.test.tsx)
+- [x] Обновить `test/test-utils.tsx` — `AuthProvider` → `SessionProvider` из `app/providers/SessionProvider`
+- [x] Обновить `hooks/useAuth.test.tsx` — `AuthProvider` → `SessionProvider` (удалён на шаге 4)
+- [x] Обновить `entities/session/model/useSession.test.tsx` — `AuthProvider` → `SessionProvider`
+- [x] Проверить `npm test -w apps/frontend`
+
+## 3. Удалить shim-файлы
+
+### api/
+- [x] Удалить `api/auth.ts`, `api/client.ts`, `api/portfolio.ts`, `api/responses.ts`, `api/types.ts`
+- [x] Удалить `api/broker.test.ts`
+
+### hooks/
+- [x] Удалить все файлы кроме `useScreener.ts`
+
+### context/
+- [x] Удалить `context/AuthContext.tsx`
+- [x] Удалить `context/AuthContext.test.tsx`
+
+### components/
+- [x] Удалить все flat shims
+
+### pages/ flat shims
+- [x] Удалить `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx`
+
+### pages/broker/
+- [x] Удалить весь каталог `pages/broker/`
+
+### Корень src/
+- [x] Удалить `routes.tsx`
+
+- [x] Проверить `npm test -w apps/frontend`
+
+## 4. Удалить тесты, привязанные к shim-файлам
+
+- [x] Удалены 17 тестовых файлов
+- [x] Исправлен `api/screener.ts` — импорты из `./client` → `@/shared/api/client`
+
+## 5. Удалить api/broker.ts
+
+- [x] Удалить `api/broker.ts`
+- [x] Проверить `npm test -w apps/frontend`
+
+## 6. Обновить документацию
+
+- [x] Обновить `apps/docs/docs/frontend/hooks.md`
+- [x] Обновить `apps/docs/docs/frontend/routes.md`
+- [x] Проверить `npm run build -w apps/docs`
+
+## 7. Финальная верификация
+
+- [x] `npm test -w apps/frontend` — PASS (23 files, 112 tests)
+- [x] `npm run lint -w apps/frontend` — PASS
+- [x] `npm run build -w apps/frontend` — PASS
+- [x] `npm run build -w apps/docs` — PASS
+- [x] Legacy import check — no matches