Compare commits

...

10 Commits

Author SHA1 Message Date
2619a27b80 docs: mark portfolio FSD migration tasks complete
Some checks failed
CI / ci (pull_request) Failing after 2m38s
CI / ci (push) Failing after 2m56s
2026-06-20 21:11:54 +03:00
605a4e8d00 docs: update frontend overview for portfolio FSD migration 2026-06-20 21:11:54 +03:00
16f5e1752c refactor(frontend): remove flat components/portfolios 2026-06-20 21:11:30 +03:00
1ef1a3c902 refactor(frontend): switch pages/portfolios to widget imports 2026-06-20 21:11:30 +03:00
cfa890fdbe feat: add portfolio widgets with barrel files (FSD migration) 2026-06-20 21:11:30 +03:00
36f39d2d3f docs: add frontend fsd portfolios plan and tasks 2026-06-20 21:11:30 +03:00
49dcbb4e33 docs: define frontend fsd portfolios spec 2026-06-20 21:11:30 +03:00
b069575fbb refactor(frontend): remove FSD shim files and dead code
Some checks failed
CI / ci (pull_request) Failing after 2m51s
CI / ci (push) Failing after 2m38s
- Break entity shim chain: brokerPositionApi, brokerOperationApi now use shared/api/client directly
- Remove all 43 shim re-export files (api/, hooks/, context/, components/, pages/ flat shims, routes.tsx)
- Remove entire pages/broker/ dead code directory
- Remove api/broker.ts after breaking the shim chain
- Switch auth consumers from AuthProvider (shim) to SessionProvider (FSD)
- Fix api/screener.ts imports to use @/shared/api
- Update frontend hooks.md and routes.md documentation
- 2840 lines removed, 71 lines added
2026-06-20 20:42:44 +03:00
6f9e368126 refactor(frontend): refactoring fsd
Some checks failed
CI / ci (pull_request) Failing after 3m2s
CI / ci (push) Failing after 2m58s
2026-06-20 19:58:49 +03:00
40d7792b9e docs: add specification for frontend FSD market 2026-06-20 19:57:37 +03:00
119 changed files with 1568 additions and 2690 deletions

View File

@ -0,0 +1,72 @@
# ADR-014: Frontend FSD market pages
**Статус:** Accepted
**Дата:** 2026-06-20
**Участники решения:** Product Engineer, Tech Lead
## Context
После `ADR-013` broker-домен уже получил первый рабочий FSD-срез, но market pages (`HomePage`,
`StockPage`, `BondPage`) и связанные UI-блоки всё ещё оставались в исторической технической
структуре `components/`, `hooks/` и плоского `pages/`. Из-за этого frontend оказался в
промежуточном состоянии: часть экранов уже живёт через FSD public API, а публичная документация
всё ещё описывает market-раздел как legacy-only область.
Для следующей итерации нужна управляемая миграция, которая:
- продолжает FSD-переход после broker pilot, не превращая его в big-bang переписывание frontend;
- переносит market pages и связанные UI/query entrypoints в `pages/`, `widgets/` и `entities/`;
- сохраняет совместимость со старыми импортами на время coexistence через тонкие re-export shim;
- оставляет orchestration route params, query composition и loading/not-found состояния в
page-layer, не размывая ответственность widgets.
## Options
### Option A. Полная market FSD migration с coexistence через shims
Перенести `HomePage`, `StockPage`, `BondPage`, `SearchBar`, `PriceChart`, `StockDetails`,
`BondDetails`, `DividendsTable` и `useSearch` в FSD-срезы. Сохранить старые entrypoints
(`components/*`, `hooks/useSearch.ts`, `pages/*.tsx`) как тонкие реэкспорт-шмы на переходный
период. Data orchestration и route-specific логика остаются в `pages/home`, `pages/stock`,
`pages/bond`.
### Option B. Частичный перенос только UI-компонентов
Перенести только `SearchBar`, `PriceChart`, `StockDetails`, `BondDetails` и `DividendsTable` в
`widgets/`, но оставить page entrypoints и `useSearch` в исторических каталогах.
### Option C. Ускоренная миграция без shim-слоя
Сразу заменить все старые import paths на новые FSD entrypoints и удалить legacy-файлы в рамках
одного изменения.
## Decision
Принят Option A: выполнить market-pages миграцию как следующую итерацию после broker pilot и вести
её через coexistence FSD-слоёв и legacy entrypoints.
Решение включает следующие правила:
- `pages/home`, `pages/stock`, `pages/bond` становятся primary route entrypoints;
- `widgets/search-bar`, `widgets/price-chart`, `widgets/stock-details`, `widgets/bond-details`,
`widgets/dividends-table` становятся primary UI entrypoints для market-сценариев;
- `entities/search/model/useSearch.ts` становится primary местом для search query hook;
- legacy `components/*`, `hooks/useSearch.ts` и плоские `pages/*.tsx` сохраняются как тонкие
re-export shim-файлы на переходный период;
- orchestration остаётся в page-layer: `useParams`, вычисление диапазонов дат, вызовы domain query
hooks, а также loading/error/not-found состояния не переносятся в widgets;
- взаимодействие между слоями идёт через public API (`index.ts`), без внешних deep imports в `ui/`
и `model/`.
## Consequences
- Market-раздел получает ту же архитектурную форму, что и broker pilot, и становится вторым
эталонным FSD-срезом frontend.
- Migration risk снижается: coexistence через shims позволяет переключать импортирующие места без
массового удаления legacy entrypoints.
- В проекте временно сохраняется двойная поверхность импортов, поэтому архитектурная дисциплина
по-прежнему поддерживается структурой каталогов и code review.
- Orchestration остаётся ближе к маршрутам, поэтому widgets остаются prop-driven и переиспользуемыми
без знания о `react-router` или query lifecycle.
- Published frontend docs и ADR index должны обновляться синхронно с такой миграцией, иначе
документация начнёт отставать от фактической структуры кода.

View File

@ -15,5 +15,6 @@
| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC |
| [ADR-012](ADR-012-frontend-broker-account-aggregation) | Accepted | Агрегация сводки брокерских счетов на frontend |
| [ADR-013](ADR-013-frontend-fsd-broker-pilot) | Accepted | Пилотная FSD-миграция broker-домена |
| [ADR-014](ADR-014-frontend-fsd-market-pages) | Accepted | FSD-миграция market pages и market widgets |
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.

View File

@ -1,13 +1,16 @@
# Компоненты
## Layout (`components/Layout.tsx`)
Published docs ниже перечисляют primary entrypoints. Legacy файлы в `components/` сохранены как
тонкие re-export shim'ы для coexistence со старыми импортами.
## Layout (`app/layouts/AppLayout.tsx`, legacy shim: `components/Layout.tsx`)
Базовый layout с шапкой и `<Outlet />`.
- Шапка: логотип "MoexVibe" (ссылка на `/`) + `SearchBar`
- Основной контент: max-width 1200px, padding 24px
## SearchBar (`components/SearchBar.tsx`)
## SearchBar (`widgets/search-bar/ui/SearchBar.tsx`, public API: `widgets/search-bar`)
Поиск инструментов с debounce (300ms).
@ -16,7 +19,9 @@
- При клике на результат переходит на `/stocks/:secid` или `/bonds/:secid`
- Закрывается при клике вне компонента
## PriceChart (`components/PriceChart.tsx`)
Legacy path `components/SearchBar.tsx` остаётся shim-файлом и не является primary entrypoint.
## PriceChart (`widgets/price-chart/ui/PriceChart.tsx`, public API: `widgets/price-chart`)
График цены на основе `lightweight-charts` v4.
@ -25,16 +30,30 @@
- Цвета: зелёный для роста, красный для падения
- Адаптивная ширина (resize listener)
## StockDetails (`components/StockDetails.tsx`)
Legacy path `components/PriceChart.tsx` остаётся shim-файлом.
## StockDetails (`widgets/stock-details/ui/StockDetails.tsx`, public API: `widgets/stock-details`)
Карточка с информацией об акции.
- Принимает `ShareResponse`
- Отображает: название, тикер, ISIN, цена, изменение (%), open/high/low, объём, капитализация, уровень листинга
## BondDetails (`components/BondDetails.tsx`)
Legacy path `components/StockDetails.tsx` остаётся shim-файлом.
## BondDetails (`widgets/bond-details/ui/BondDetails.tsx`, public API: `widgets/bond-details`)
Карточка с информацией об облигации.
- Принимает `BondResponse`
- Отображает: название, ISIN, цена (в % от номинала), номинал, дата погашения, купон (сумма/%), период купона, НКД, доходность к погашению, дюрация, тип
Legacy path `components/BondDetails.tsx` остаётся shim-файлом.
## DividendsTable (`widgets/dividends-table/ui/DividendsTable.tsx`, public API: `widgets/dividends-table`)
Таблица дивидендов для страницы акции.
- Принимает готовый список дивидендов через props
- Рендерит payout-историю без собственных query-вызовов
- Используется page-layer как prop-driven widget

View File

@ -4,13 +4,24 @@
| Хук | Query key | Stale time | Описание |
|---|---|---|---|
| `useSearch(query)` | `['securities', 'search', query]` | 60s | Поиск инструментов (enabled: query ≥ 2 символов) |
| `useSearch(query)` | `['securities', 'search', query]` | 60s | Поиск инструментов (primary: `entities/search/model/useSearch.ts`) |
| `useStock(secid)` | `['stock', secid]` | 900s | Спецификация акции |
| `useStockCandles(secid, interval, from, till)` | `['stockCandles', secid, interval, from, till]` | 3600s | Свечи акции |
| `useStockDividends(secid)` | `['stockDividends', secid]` | 86400s | Дивиденды акции |
| `useBond(secid)` | `['bond', secid]` | 900s | Спецификация облигации |
| `useBondCandles(secid, interval, from, till)` | `['bondCandles', secid, interval, from, till]` | 3600s | Свечи облигации |
## Public API
- 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
```typescript
@ -27,11 +38,9 @@ const queryClient = new QueryClient({
## Паттерн hook
Каждый хук:
1. Вызывает функцию из `api/client.ts`
2. Извлекает `res.data` (ответ MOEX обёрнут в `{ data, meta }`)
3. Типизирован через рукописные типы из `api/responses.ts`
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) {

View File

@ -14,6 +14,9 @@ React SPA, собранная с Vite.
## Структура исходников
Published-структура ниже описывает primary FSD entrypoints. Исторические каталоги `api/`, `context/`,
`hooks/`, `components/` ещё присутствуют в кодовой базе для ещё не мигрированных доменов.
```
apps/frontend/src/
├── main.tsx # Точка входа
@ -31,69 +34,66 @@ apps/frontend/src/
│ └── layouts/
│ ├── AppLayout.tsx # Шапка + <Outlet/>
│ └── index.ts
├── api/
│ ├── auth.ts # re-export shim → entities/session/api
│ ├── client.ts # HTTP-клиент (fetch)
│ ├── portfolio.ts # Portfolio API helpers
│ ├── responses.ts # Типы ответов (ручные)
│ ├── screener.ts # Screener API helpers
│ └── types.ts # Типы из openapi-typescript
├── context/
│ └── AuthContext.tsx # re-export shim → app/providers/SessionProvider
├── hooks/
│ ├── useAuth.ts # re-export shim → entities/session/model
│ ├── usePortfolio.ts
│ ├── usePortfolioAnalytics.ts
│ ├── usePortfolioMutations.ts
│ ├── usePortfolios.ts
│ ├── usePositionMutations.ts
│ ├── useScreener.ts
│ ├── useSearch.ts
│ ├── useStock.ts
│ ├── useStockCandles.ts
│ ├── useStockDividends.ts
│ ├── useBond.ts
│ └── useBondCandles.ts
├── components/
│ ├── Layout.tsx # re-export shim → app/layouts
│ ├── ProtectedRoute.tsx # re-export shim → app/routing
│ ├── SearchBar.tsx
│ ├── PriceChart.tsx
│ ├── StockDetails.tsx
│ ├── BondDetails.tsx
│ └── portfolios/
├── entities/
│ ├── session/ # FSD: auth/session (api, model/context + hook)
│ ├── broker-account/ # FSD: account slice (api, model, ui)
│ ├── broker-position/ # FSD: position slice (api, model)
│ └── broker-operation/ # FSD: operation slice (api, model)
├── widgets/
│ ├── broker-account-card/ # FSD: account card widget
│ ├── broker-accounts-summary/ # FSD: accounts summary widget
│ ├── broker-allocation-chart/ # FSD: allocation chart widget
│ └── broker-operations-table/ # FSD: operations table widget
├── pages/
│ ├── HomePage.tsx
│ ├── StockPage.tsx
│ ├── BondPage.tsx
│ ├── LoginPage.tsx
│ ├── RegisterPage.tsx
│ ├── ProfilePage.tsx
│ ├── portfolios/
│ ├── screener/
├── shared/
│ ├── api/ # shared API client, response types, generated OpenAPI types
│ └── ui/ # shared UI primitives without domain logic
├── entities/ # FSD business entities
│ ├── session/ # Auth/session (api, model)
│ ├── search/ # Market search query slice
│ ├── stock/ # Акции (api, model)
│ ├── bond/ # Облигации (api, model)
│ ├── portfolio/ # Портфели (api, model)
│ ├── broker-account/ # Брокерский счёт (api, model, ui)
│ ├── broker-position/ # Брокерская позиция (api, model)
│ └── broker-operation/ # Брокерская операция (api, model)
├── widgets/ # FSD compositional widgets
│ ├── search-bar/ # Поиск инструментов
│ ├── price-chart/ # График цены
│ ├── stock-details/ # Детальная карточка акции
│ ├── bond-details/ # Детальная карточка облигации
│ ├── dividends-table/ # Таблица дивидендов
│ ├── broker-account-card/ # Карточка брокерского счёта
│ ├── broker-accounts-summary/ # Сводка брокерских счетов
│ ├── broker-allocation-chart/ # График распределения
│ ├── broker-operations-table/ # Таблица операций
│ ├── portfolio-card/ # Карточка портфеля
│ ├── portfolio-form/ # Форма портфеля
│ ├── portfolio-summary/ # Сводка портфеля
│ ├── portfolio-analytics/ # Метрики портфеля
│ ├── share-positions-table/ # Таблица позиций-акций
│ └── bond-positions-table/ # Таблица позиций-облигаций
├── pages/ # FSD page entrypoints
│ ├── home/ # Главная
│ ├── stock/ # Страница акции
│ ├── bond/ # Страница облигации
│ ├── LoginPage.tsx # Вход (не мигрирован)
│ ├── RegisterPage.tsx # Регистрация (не мигрирован)
│ ├── ProfilePage.tsx # Профиль (не мигрирован)
│ ├── portfolios/ # Список и детальная портфеля (не мигрирован)
│ ├── screener/ # Скринер (не мигрирован)
│ ├── broker-accounts/ # FSD: page entrypoint
│ ├── broker-account/ # FSD: page entrypoint
│ ├── broker-positions/ # FSD: page entrypoint
│ └── broker-operations/ # FSD: page entrypoint
├── api/
│ └── screener.ts # Screener API helpers (не мигрирован)
├── hooks/
│ └── useScreener.ts # Screener hook (не мигрирован)
├── components/
│ └── screener/ # Screener UI (не мигрирован)
├── test/
│ ├── factories.ts # Фабрики тестовых данных
│ ├── handlers.ts # MSW handlers
│ └── test-utils.tsx
│ ├── server.ts # MSW server
│ ├── test-utils.tsx # Обёртка рендера
│ ├── setup.ts # Настройка jsdom
│ └── README.md
└── styles.css
```
## FSD-миграция
### app-слой и session-сущность
### app-слой
Создан `app/` слой FSD, в который перенесены инфраструктурные модули:
@ -102,21 +102,50 @@ apps/frontend/src/
- `app/layouts/` — AppLayout (шапка + Outlet)
- `app/App.tsx` — BrowserRouter → AppRoutes
Домен auth переведён в `entities/session/`:
### entities
- `entities/session/api/` — login, register, logout, refresh, getMe, updateProfile
- `entities/session/model/` — SessionContext, useSession
Домены бизнес-сущностей переведены в FSD:
Старые файлы (`api/auth.ts`, `hooks/useAuth.ts`, `context/AuthContext.tsx`, `routes.tsx`, `components/Layout.tsx`, `components/ProtectedRoute.tsx`, `App.tsx`) сохранены как ре-экспорт шмы для обратной совместимости.
| Сущность | api | model | ui |
|----------|-----|-------|----|
| `session` | login, register, refresh, logout, getMe, updateProfile | SessionContext, useSession | — |
| `stock` | getStock, getStockCandles, getStockDividends | useStock, useStockCandles, useStockDividends | — |
| `bond` | getBond, getBondCandles | useBond, useBondCandles | — |
| `portfolio` | CRUD портфелей и позиций, аналитика | usePortfolio, usePortfolios, usePortfolioAnalytics, usePortfolioMutations, usePositionMutations | — |
| `search` | — | useSearch | — |
| `broker-account` | API брокерских счетов | useBrokerAccounts, useBrokerAccountPortfolios | BrokerAccountLayout |
| `broker-position` | API брокерских позиций | useBrokerPositions | — |
| `broker-operation` | API брокерских операций | useBrokerOperations | — |
### broker-домен (пилот)
Каждая сущность имеет barrel-файл `index.ts`, реэкспортирующий публичное API.
Broker-домен переведён в пилотный FSD срез:
### widgets
- `pages/broker-*` — тонкие route entrypoints
- `widgets/broker-*` — screen-level композиция
- `entities/broker-*` — доменные срезы (api, model, ui)
Композиционные блоки UI. Каждый виджет — директория с `index.ts` и `ui/`:
### Остальные домены
- `search-bar` — поиск с автодополнением
- `price-chart` — график цены (lightweight-charts)
- `stock-details`, `bond-details` — детальные карточки бумаг
- `dividends-table` — таблица дивидендов
- `broker-*-*` — UI брокерского домена
- `portfolio-card`, `portfolio-form`, `portfolio-summary`, `portfolio-analytics` — UI портфелей
- `share-positions-table`, `bond-positions-table` — таблицы позиций
`portfolio`, `screener` пока остаются в исторической технической структуре.
### pages
- `pages/home`, `pages/stock`, `pages/bond` — FSD page entrypoints для market pages
- `pages/broker-*` — FSD page entrypoints для брокерского домена
- Временно не мигрированы: `portfolios/`, `screener/`, `LoginPage.tsx`, `RegisterPage.tsx`, `ProfilePage.tsx`
### Не мигрировано
Скринер остаётся в исторической структуре:
- `api/screener.ts` — API-вызовы
- `hooks/useScreener.ts` — логика с URLSearchParams
- `components/screener/` — FilterPanel, FilterPanelBond, FilterPanelShare, ScreenerTable
- `pages/screener/ScreenerPage.tsx` — страница
Страницы аутентификации (`LoginPage`, `RegisterPage`, `ProfilePage`) — плоские, но используют FSD-импорты из `entities/session`.
ESLint-правила на границы импортов FSD пока не введены.

View File

@ -1,20 +1,32 @@
# Маршруты
Определены в `apps/frontend/src/routes.tsx`.
Source of truth для маршрутов: `apps/frontend/src/app/routing/AppRoutes.tsx`.
| Path | Component | Доступ | Описание |
|---|---|---|---|
| `/` | `HomePage` | Public | Главная страница |
| `/stocks/:secid` | `StockPage` | Public | Страница акции |
| `/bonds/:secid` | `BondPage` | Public | Страница облигации |
| `/` | `HomePage` from `pages/home` | Public | Главная страница |
| `/stocks/:secid` | `StockPage` from `pages/stock` | Public | Страница акции |
| `/bonds/:secid` | `BondPage` from `pages/bond` | Public | Страница облигации |
| `/screener` | `ScreenerPage` | Public | Скринер ценных бумаг |
| `/login` | `LoginPage` | Public | Вход |
| `/register` | `RegisterPage` | Public | Регистрация |
| `/profile` | `ProfilePage` | Protected | Профиль текущего пользователя |
| `/portfolios` | `PortfoliosListPage` | Protected | Список портфелей |
| `/portfolios/:id` | `PortfolioDetailPage` | Protected | Детальная страница портфеля |
| `/broker` | `BrokerAccountsPage` from `pages/broker-accounts` | Protected | Список брокерских счетов |
| `/broker/:accountId` | `BrokerAccountLayout` + nested pages | Protected | Детальная область брокерского счёта |
Все страницы обёрнуты в `Layout`, который содержит:
Все page entrypoints живут в FSD-слоях:
- `pages/home`
- `pages/stock`
- `pages/bond`
- `pages/broker-accounts`
- `pages/broker-account`
- `pages/broker-positions`
- `pages/broker-operations`
Все страницы обёрнуты в `AppLayout`, который содержит:
- Шапку с логотипом (ссылка на `/`) и `SearchBar`
- `<main>` с максимальной шириной 1200px
@ -24,7 +36,7 @@
```tsx
<Routes>
<Route element={<Layout />}>
<Route element={<AppLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/stocks/:secid" element={<StockPage />} />
<Route path="/bonds/:secid" element={<BondPage />} />
@ -55,6 +67,27 @@
</ProtectedRoute>
}
/>
<Route
path="/broker"
element={
<ProtectedRoute>
<BrokerAccountsPage />
</ProtectedRoute>
}
/>
<Route
path="/broker/:accountId"
element={
<ProtectedRoute>
<BrokerAccountLayout />
</ProtectedRoute>
}
>
<Route index element={<BrokerAccountOverviewPage />} />
<Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
<Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
<Route path="operations" element={<BrokerOperationsPage />} />
</Route>
</Route>
</Routes>
```

View File

@ -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');
});
});

View File

@ -1,8 +0,0 @@
export {
login,
register,
refresh,
logout,
getMe,
updateProfile,
} from '../entities/session/api/sessionApi';

View File

@ -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),
);
});
});

View File

@ -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<BrokerAccount[]>('/api/v1/broker/accounts');
}
export function getBrokerPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: ApiResponseMeta;
}> {
return request<BrokerPortfolio>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
);
}
export function getBrokerOperations(
accountId: string,
query: BrokerOperationQuery = {},
): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
return request<BrokerOperationsPage>(
`/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<BrokerPositionsPage>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
{
cursor: query.cursor,
limit: query.limit ? String(query.limit) : undefined,
type: query.type,
},
);
}

View File

@ -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');
});
});

View File

@ -1,8 +0,0 @@
export {
request,
setAccessToken,
getAccessToken,
setOnUnauthorized,
getHealth,
searchSecurities,
} from '../shared/api/client';

View File

@ -1,11 +0,0 @@
export {
getPortfolios,
getPortfolio,
createPortfolio,
updatePortfolio,
deletePortfolio,
addPosition,
updatePosition,
removePosition,
getPortfolioAnalytics,
} from '../entities/portfolio/api/portfolioApi';

View File

@ -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';

View File

@ -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';

View File

@ -1 +0,0 @@
export * from '../shared/api/types';

View File

@ -1,5 +1,5 @@
import { Outlet, Link, useNavigate } from 'react-router-dom';
import { SearchBar } from '@/components/SearchBar';
import { SearchBar } from '@/widgets/search-bar';
import { useSession } from '@/entities/session/model/useSession';
export function AppLayout() {

View File

@ -1,9 +1,9 @@
import { Routes, Route } from 'react-router-dom';
import { AppLayout } from '../layouts/AppLayout';
import { ProtectedRoute } from './ProtectedRoute';
import { HomePage } from '@/pages/HomePage';
import { StockPage } from '@/pages/StockPage';
import { BondPage } from '@/pages/BondPage';
import { HomePage } from '@/pages/home';
import { StockPage } from '@/pages/stock';
import { BondPage } from '@/pages/bond';
import { LoginPage } from '@/pages/LoginPage';
import { RegisterPage } from '@/pages/RegisterPage';
import { ProfilePage } from '@/pages/ProfilePage';

View File

@ -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(<Layout />);
expect(await screen.findByText('MoexVibe')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument();
});
it('shows login link when not authenticated', async () => {
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
renderWithProviders(<Layout />);
await waitFor(() => {
expect(screen.getByText('Войти')).toBeInTheDocument();
});
});
it('shows user name and logout when authenticated', async () => {
renderWithProviders(<Layout />);
await waitFor(() => {
expect(screen.getByText('Test User')).toBeInTheDocument();
expect(screen.getByText('Выйти')).toBeInTheDocument();
});
});
it('shows email when user has no name', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => {
return HttpResponse.json({
data: {
data: {
user: {
id: 1,
email: 'user@test.com',
name: null,
role: 'user',
},
accessToken: 'mock',
},
meta: { fromCache: false, cachedAt: null },
},
});
}),
);
renderWithProviders(<Layout />);
await waitFor(() => {
expect(screen.getByText('user@test.com')).toBeInTheDocument();
});
});
});

View File

@ -1 +0,0 @@
export { AppLayout as Layout } from '../app/layouts/AppLayout';

View File

@ -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(
<ProtectedRoute>
<div data-testid="protected-content">Secret</div>
</ProtectedRoute>,
);
expect(await screen.findByTestId('protected-content')).toBeInTheDocument();
});
it('shows loading state initially then redirects when not authenticated', async () => {
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
renderWithProviders(
<ProtectedRoute>
<div data-testid="protected-content">Secret</div>
</ProtectedRoute>,
{ route: '/profile' },
);
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
});
});
});

View File

@ -1 +0,0 @@
export { ProtectedRoute } from '../app/routing/ProtectedRoute';

View File

@ -1 +0,0 @@
export { SkeletonBlock } from '../shared/ui/SkeletonBlock';

View File

@ -1 +0,0 @@
export { TableSkeleton } from '../shared/ui/TableSkeleton';

View File

@ -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(
<QueryClientProvider client={queryClient}>
<AuthProvider>{ui}</AuthProvider>
</QueryClientProvider>,
);
}
function TestConsumer() {
const ctx = useContext(AuthContext);
if (!ctx) return <div>no context</div>;
return (
<div>
<span data-testid="auth">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span>
<span data-testid="email">{ctx.user?.email ?? ''}</span>
<button onClick={() => ctx.login('a@b.com', 'p')}>login</button>
<button onClick={() => ctx.register('a@b.com', 'p')}>register</button>
<button onClick={() => ctx.logout()}>logout</button>
<button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button>
</div>
);
}
describe('AuthContext', () => {
it('starts unauthenticated when refresh fails', async () => {
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
renderWithProviders(<TestConsumer />);
await waitFor(() => {
expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
});
});
it('restores session on mount when refresh succeeds', async () => {
renderWithProviders(<TestConsumer />);
await waitFor(() => {
expect(screen.getByTestId('auth')).toHaveTextContent('authenticated');
expect(screen.getByTestId('email')).toHaveTextContent('user@test.com');
});
});
it('updates state after login', async () => {
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
const user = userEvent.setup();
renderWithProviders(<TestConsumer />);
await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'));
await user.click(screen.getByRole('button', { name: 'login' }));
await waitFor(() => {
expect(screen.getByTestId('auth')).toHaveTextContent('authenticated');
});
});
it('updates state after logout', async () => {
const user = userEvent.setup();
renderWithProviders(<TestConsumer />);
await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'));
await user.click(screen.getByRole('button', { name: 'logout' }));
await waitFor(() => {
expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
});
});
});

View File

@ -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';

View File

@ -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<BrokerOperationsPage>(
`/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,
},
);
}

View File

@ -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<BrokerPositionsPage>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/positions`,
{
cursor: query.cursor,
limit: query.limit ? String(query.limit) : undefined,
type: query.type,
},
);
}

View File

@ -0,0 +1 @@
export { useSearch } from './model/useSearch';

View File

@ -2,14 +2,15 @@ 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';
import { server } from '@/test/server';
import { useSearch } from '@/entities/search';
const API = '/api/v1';
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
@ -18,20 +19,24 @@ function createWrapper() {
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');
@ -39,22 +44,27 @@ describe('useSearch', () => {
it('returns empty array when no results', async () => {
server.use(
http.get(`${API}/securities/search`, () => {
return HttpResponse.json({
http.get(`${API}/securities/search`, () =>
HttpResponse.json({
data: { data: [], meta: { fromCache: false, cachedAt: null } },
});
}),
}),
),
);
const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('returns error state on network failure', async () => {
server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })));
const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});

View File

@ -7,6 +7,7 @@ export function useSearch(query: string) {
queryKey: ['securities', 'search', query],
queryFn: async () => {
const res = await searchSecurities(query);
return res.data;
},
enabled: query.length >= 2,

View File

@ -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 (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
<SessionProvider>{children}</SessionProvider>
</QueryClientProvider>
);
};

View File

@ -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 (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
}
describe('useAuth', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('returns auth context with user after mount', async () => {
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
});
expect(result.current.user?.email).toBe('user@test.com');
expect(result.current.accessToken).toBe('mock-access-token');
});
it('provides login function', async () => {
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
expect(typeof result.current.login).toBe('function');
});
it('provides logout function', async () => {
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
expect(typeof result.current.logout).toBe('function');
});
it('provides register function', async () => {
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
expect(typeof result.current.register).toBe('function');
});
it('throws when used without AuthProvider', () => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
vi.spyOn(console, 'error').mockImplementation(() => {});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
expect(() => {
renderHook(() => useAuth(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
}).toThrow('useSession must be used within a SessionProvider');
});
});

View File

@ -1 +0,0 @@
export { useSession as useAuth } from '../entities/session/model/useSession';

View File

@ -1 +0,0 @@
export { useBond } from '../entities/bond/model/useBond';

View File

@ -1 +0,0 @@
export { useBondCandles } from '../entities/bond/model/useBondCandles';

View File

@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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();
});
});

View File

@ -1 +0,0 @@
export { useBrokerAccountPortfolios } from '../entities/broker-account';

View File

@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
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');
});
});

View File

@ -1 +0,0 @@
export { useBrokerAccounts } from '../entities/broker-account';

View File

@ -1 +0,0 @@
export { useBrokerOperations } from '../entities/broker-operation';

View File

@ -1 +0,0 @@
export { useBrokerPortfolio } from '../entities/broker-account';

View File

@ -1 +0,0 @@
export { useBrokerPositions } from '../entities/broker-position';

View File

@ -1 +0,0 @@
export { usePortfolio } from '../entities/portfolio/model/usePortfolio';

View File

@ -1 +0,0 @@
export { usePortfolioAnalytics } from '../entities/portfolio/model/usePortfolioAnalytics';

View File

@ -1 +0,0 @@
export { usePortfolioMutations } from '../entities/portfolio/model/usePortfolioMutations';

View File

@ -1 +0,0 @@
export { usePortfolios } from '../entities/portfolio/model/usePortfolios';

View File

@ -1 +0,0 @@
export { usePositionMutations } from '../entities/portfolio/model/usePositionMutations';

View File

@ -1 +0,0 @@
export { useStock } from '../entities/stock/model/useStock';

View File

@ -1 +0,0 @@
export { useStockCandles } from '../entities/stock/model/useStockCandles';

View File

@ -1 +0,0 @@
export { useStockDividends } from '../entities/stock/model/useStockDividends';

View File

@ -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 './BondPage';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
function renderBondPage(secid = 'SU26238RMFS5') {
return renderWithProviders(
<Routes>
<Route path="/bonds/:secid" element={<BondPage />} />
</Routes>,
{ route: `/bonds/${secid}` },
);
}
describe('BondPage', () => {
it('shows loading state', () => {
server.use(http.get(`${API}/securities/bonds/:secid`, () => new Promise(() => {})));
renderBondPage();
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
});
it('renders bond details after loading', async () => {
renderBondPage();
expect(await screen.findByText('ОФЗ 26238')).toBeInTheDocument();
});
it('renders price chart', async () => {
renderBondPage();
expect(await screen.findByText('График цены')).toBeInTheDocument();
});
it('shows error state for not found', async () => {
server.use(
http.get(`${API}/securities/bonds/:secid`, () => new HttpResponse(null, { status: 404 })),
http.get(
`${API}/securities/bonds/:secid/candles`,
() => new HttpResponse(null, { status: 404 }),
),
);
renderBondPage('NOTFOUND');
expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument();
});
});

View File

@ -1,25 +0,0 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { HomePage } from './HomePage';
describe('HomePage', () => {
it('renders welcome title', () => {
render(<HomePage />);
expect(screen.getByText('MoexVibe')).toBeInTheDocument();
});
it('renders description', () => {
render(<HomePage />);
expect(screen.getByText('Анализ акций и облигаций Московской биржи')).toBeInTheDocument();
});
it('renders hint text', () => {
render(<HomePage />);
expect(screen.getByText(/Введите название или тикер/)).toBeInTheDocument();
});
it('renders data delay notice', () => {
render(<HomePage />);
expect(screen.getByText(/Данные задерживаются на 15 минут/)).toBeInTheDocument();
});
});

View File

@ -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 './StockPage';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
function renderStockPage(secid = 'SBER') {
return renderWithProviders(
<Routes>
<Route path="/stocks/:secid" element={<StockPage />} />
</Routes>,
{ route: `/stocks/${secid}` },
);
}
describe('StockPage', () => {
it('shows loading state', () => {
server.use(http.get(`${API}/securities/shares/:secid`, () => new Promise(() => {})));
renderStockPage();
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
});
it('renders stock details after loading', async () => {
renderStockPage();
expect(await screen.findByText('Сбер (SBER)')).toBeInTheDocument();
expect(await screen.findByText('Сбер Банк · RU0009029540')).toBeInTheDocument();
});
it('renders price chart', async () => {
renderStockPage();
expect(await screen.findByText('График цены')).toBeInTheDocument();
});
it('renders dividends section', async () => {
renderStockPage();
expect(await screen.findByText('Дивиденды')).toBeInTheDocument();
expect(await screen.findByText('Дата закрытия реестра')).toBeInTheDocument();
});
it('hides dividends section when empty', async () => {
server.use(
http.get(`${API}/securities/shares/:secid/dividends`, () => {
return HttpResponse.json({
data: { data: [], meta: { fromCache: false, cachedAt: null } },
});
}),
);
renderStockPage();
await waitFor(() => {
expect(screen.queryByText('Дивиденды')).not.toBeInTheDocument();
});
});
it('shows error state for not found', async () => {
server.use(
http.get(`${API}/securities/shares/:secid`, () => new HttpResponse(null, { status: 404 })),
http.get(
`${API}/securities/shares/:secid/candles`,
() => new HttpResponse(null, { status: 404 }),
),
http.get(
`${API}/securities/shares/:secid/dividends`,
() => new HttpResponse(null, { status: 404 }),
),
);
renderStockPage('NOTFOUND');
expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument();
});
});

View File

@ -1,65 +0,0 @@
import { useParams } from 'react-router-dom';
import { useStock, useStockCandles, useStockDividends } from '../entities/stock';
import { StockDetails } from '../components/StockDetails';
import { PriceChart } from '../components/PriceChart';
export function StockPage() {
const { secid } = useParams<{ secid: string }>();
const { data: stock, isLoading, error } = useStock(secid!);
const till = new Date().toISOString().split('T')[0];
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const { data: candles } = useStockCandles(secid!, '24h', from, till);
const { data: dividends } = useStockDividends(secid!);
if (isLoading) return <div>Загрузка...</div>;
if (error || !stock) return <div>Инструмент не найден</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<StockDetails stock={stock} />
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>График цены</h3>
<PriceChart data={candles ?? []} />
</div>
{dividends && dividends.length > 0 && (
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #eee' }}>
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
</tr>
</thead>
<tbody>
{dividends.map((d, i) => (
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{d.registryCloseDate}</td>
<td style={{ textAlign: 'right', padding: 8 }}>
{d.value.toFixed(2)} {d.currency}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export { BondPage } from './ui/BondPage';

View File

@ -1,7 +1,7 @@
import { useParams } from 'react-router-dom';
import { useBond, useBondCandles } from '../entities/bond';
import { BondDetails } from '../components/BondDetails';
import { PriceChart } from '../components/PriceChart';
import { useBond, useBondCandles } from '@/entities/bond';
import { BondDetails } from '@/widgets/bond-details';
import { PriceChart } from '@/widgets/price-chart';
export function BondPage() {
const { secid } = useParams<{ secid: string }>();

View File

@ -1 +0,0 @@
export { BrokerAccountCard } from '../../widgets/broker-account-card';

View File

@ -1,5 +0,0 @@
export {
BrokerAccountLayout,
useBrokerAccountContext,
type BrokerAccountContext,
} from '../../entities/broker-account/ui/BrokerAccountLayout';

View File

@ -1 +0,0 @@
export { BrokerAccountOverviewPage } from '../broker-account';

View File

@ -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(
<QueryClientProvider client={client}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
function createAccount(
account: Partial<BrokerAccount> & Pick<BrokerAccount, 'id' | 'name'>,
): 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> = {},
): 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<string, unknown> = {}) {
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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(<BrokerAccountsPage />);
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();
});
});

View File

@ -1 +0,0 @@
export { BrokerAccountsPage } from '../broker-accounts';

View File

@ -1 +0,0 @@
export { BrokerAccountsSummary } from '../../widgets/broker-accounts-summary';

View File

@ -1 +0,0 @@
export { BrokerAllocationBar } from '../../widgets/broker-allocation-chart';

View File

@ -1 +0,0 @@
export { BrokerAllocationChart } from '../../widgets/broker-allocation-chart';

View File

@ -1 +0,0 @@
export { BrokerOperationsPage } from '../broker-operations';

View File

@ -1 +0,0 @@
export { BrokerOperationsTable } from '../../widgets/broker-operations-table';

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
export { BrokerPositionsPage } from '../broker-positions';

View File

@ -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';

View File

@ -1,5 +0,0 @@
export {
buildBrokerAllocation,
type BrokerAllocationItem,
type BrokerAllocationKey,
} from '../../entities/broker-position';

View File

@ -1,10 +0,0 @@
export {
BROKER_OPERATION_TYPE_OPTIONS,
getBrokerInstrumentPath,
getBrokerOperationImpact,
getBrokerOperationTypeLabel,
getBrokerPositionGroup,
isBrokerOperationType,
type BrokerOperationImpact,
type BrokerPositionGroup,
} from '../../entities/broker-position';

View File

@ -0,0 +1 @@
export { HomePage } from './ui/HomePage';

View File

@ -5,11 +5,11 @@ import {
usePortfolioMutations,
usePositionMutations,
} from '../../entities/portfolio';
import { PortfolioForm } from '../../components/portfolios/PortfolioForm';
import { PortfolioSummary } from '../../components/portfolios/PortfolioSummary';
import { AnalyticsSummary } from '../../components/portfolios/AnalyticsSummary';
import { SharePositionTable } from '../../components/portfolios/SharePositionTable';
import { BondPositionTable } from '../../components/portfolios/BondPositionTable';
import { PortfolioForm } from '../../widgets/portfolio-form';
import { PortfolioSummary } from '../../widgets/portfolio-summary';
import { AnalyticsSummary } from '../../widgets/portfolio-analytics';
import { SharePositionTable } from '../../widgets/share-positions-table';
import { BondPositionTable } from '../../widgets/bond-positions-table';
export function PortfolioDetailPage() {
const { id } = useParams<{ id: string }>();

View File

@ -1,7 +1,7 @@
import { useState } from 'react';
import { usePortfolios, usePortfolioMutations } from '../../entities/portfolio';
import { PortfolioCard } from '../../components/portfolios/PortfolioCard';
import { PortfolioForm } from '../../components/portfolios/PortfolioForm';
import { PortfolioCard } from '../../widgets/portfolio-card';
import { PortfolioForm } from '../../widgets/portfolio-form';
export function PortfoliosListPage() {
const { data: portfolios, isLoading, error } = usePortfolios();

View File

@ -0,0 +1 @@
export { StockPage } from './ui/StockPage';

View File

@ -0,0 +1,37 @@
import { useParams } from 'react-router-dom';
import { useStock, useStockCandles, useStockDividends } from '@/entities/stock';
import { StockDetails } from '@/widgets/stock-details';
import { PriceChart } from '@/widgets/price-chart';
import { DividendsTable } from '@/widgets/dividends-table';
export function StockPage() {
const { secid } = useParams<{ secid: string }>();
const { data: stock, isLoading, error } = useStock(secid!);
const till = new Date().toISOString().split('T')[0];
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const { data: candles } = useStockCandles(secid!, '24h', from, till);
const { data: dividends } = useStockDividends(secid!);
if (isLoading) return <div>Загрузка...</div>;
if (error || !stock) return <div>Инструмент не найден</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<StockDetails stock={stock} />
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>График цены</h3>
<PriceChart data={candles ?? []} />
</div>
{dividends && dividends.length > 0 && <DividendsTable dividends={dividends} />}
</div>
);
}

View File

@ -1 +0,0 @@
export { AppRoutes } from './app/routing/AppRoutes';

View File

@ -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<RenderOptions, 'wrapper'> {
queryClient?: QueryClient;
@ -33,7 +33,7 @@ export function renderWithProviders(
initialEntries={[route]}
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
<AuthProvider>{children}</AuthProvider>
<SessionProvider>{children}</SessionProvider>
</MemoryRouter>
</QueryClientProvider>
);

View File

@ -0,0 +1 @@
export { BondDetails } from './ui/BondDetails';

View File

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BondDetails } from './BondDetails';
import { createMockBond } from '../test/factories';
import { createMockBond } from '@/test/factories';
describe('BondDetails', () => {
it('renders bond details', () => {
@ -63,9 +63,62 @@ describe('BondDetails', () => {
expect(screen.getByText('—')).toBeInTheDocument();
});
it('preserves unit suffixes for missing values', () => {
const bond = createMockBond({
marketData: {
...createMockBond().marketData,
price: null,
couponValue: null,
couponPercent: 7.5,
accruedInt: null,
},
});
render(<BondDetails bond={bond} />);
expect(screen.getByText('—%')).toBeInTheDocument();
expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument();
expect(screen.getByText('— ₽')).toBeInTheDocument();
});
it('shows bond type', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('ОФЗ')).toBeInTheDocument();
});
it('preserves percent suffix when price is missing', () => {
const bond = createMockBond({
marketData: {
...createMockBond().marketData,
price: null,
},
});
render(<BondDetails bond={bond} />);
expect(screen.getByText('—%')).toBeInTheDocument();
});
it('preserves currency and percentage formatting when coupon value is missing', () => {
const bond = createMockBond({
marketData: {
...createMockBond().marketData,
couponValue: null,
},
});
render(<BondDetails bond={bond} />);
expect(screen.getByText('Купон')).toBeInTheDocument();
expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument();
});
it('preserves currency suffix when accrued interest is missing', () => {
const bond = createMockBond({
marketData: {
...createMockBond().marketData,
accruedInt: null,
},
});
render(<BondDetails bond={bond} />);
expect(screen.getByText('НКД')).toBeInTheDocument();
expect(screen.getByText('— ₽')).toBeInTheDocument();
});
});

View File

@ -0,0 +1 @@
export { BondPositionTable } from './ui/BondPositionTable';

View File

@ -0,0 +1 @@
export { DividendsTable } from './ui/DividendsTable';

View File

@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { DividendsTable } from './DividendsTable';
import { createMockDividends } from '@/test/factories';
describe('DividendsTable', () => {
it('renders title, date column and formatted amount with currency', () => {
render(<DividendsTable dividends={createMockDividends()} />);
expect(screen.getByText('Дивиденды')).toBeInTheDocument();
expect(screen.getByText('Дата закрытия реестра')).toBeInTheDocument();
expect(screen.getByText('2024-07-10')).toBeInTheDocument();
expect(screen.getByText('35.00 RUB')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,38 @@
import type { DividendItem } from '@/shared/api/responses';
interface DividendsTableProps {
dividends: DividendItem[];
}
export function DividendsTable({ dividends }: DividendsTableProps) {
return (
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #eee' }}>
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
</tr>
</thead>
<tbody>
{dividends.map((d, i) => (
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{d.registryCloseDate}</td>
<td style={{ textAlign: 'right', padding: 8 }}>
{d.value.toFixed(2)} {d.currency}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1 @@
export { AnalyticsSummary } from './ui/AnalyticsSummary';

View File

@ -0,0 +1 @@
export { PortfolioCard } from './ui/PortfolioCard';

View File

@ -0,0 +1 @@
export { PortfolioForm } from './ui/PortfolioForm';

View File

@ -0,0 +1 @@
export { PortfolioSummary } from './ui/PortfolioSummary';

View File

@ -0,0 +1 @@
export { PriceChart } from './ui/PriceChart';

Some files were not shown because too many files have changed in this diff Show More