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