Compare commits

..

No commits in common. "a13b5145f70c8bf3c0ef0173447625d781b0dbd3" and "462212c95eecc42ccdc0161091baa140046b4f3c" have entirely different histories.

242 changed files with 4834 additions and 6989 deletions

View File

@ -26,6 +26,9 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Format check
run: npm run format:check
- name: Test backend - name: Test backend
run: npm run test:backend run: npm run test:backend

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": true
}

View File

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class ApiResponseMeta { export class ApiResponseMeta {
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
cachedAt: string | null; cachedAt: string | null;
@ApiProperty() @ApiProperty()

View File

@ -1,32 +1,26 @@
import { Controller, Get, Param, Query } from '@nestjs/common'; import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { BondsService } from './bonds.service'; import { BondsService } from './bonds.service';
import { BondEnvelopeDto, BondMarketDataEnvelopeDto, BondHistoryEnvelopeDto } from './dto/bonds-envelope.dto';
@ApiTags('Bonds') @ApiTags('Bonds')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities/bonds') @Controller('securities/bonds')
export class BondsController { export class BondsController {
constructor(private readonly bondsService: BondsService) {} constructor(private readonly bondsService: BondsService) {}
@Get(':secid') @Get(':secid')
@ApiOperation({ summary: 'Получить спецификацию облигации' }) @ApiOperation({ summary: 'Получить спецификацию облигации' })
@ApiOkResponse({ type: BondEnvelopeDto })
async getBond(@Param('secid') secid: string) { async getBond(@Param('secid') secid: string) {
return this.bondsService.getBond(secid); return this.bondsService.getBond(secid);
} }
@Get(':secid/marketdata') @Get(':secid/marketdata')
@ApiOperation({ summary: 'Получить рыночные данные облигации' }) @ApiOperation({ summary: 'Получить рыночные данные облигации' })
@ApiOkResponse({ type: BondMarketDataEnvelopeDto })
async getMarketData(@Param('secid') secid: string) { async getMarketData(@Param('secid') secid: string) {
return this.bondsService.getMarketData(secid); return this.bondsService.getMarketData(secid);
} }
@Get(':secid/history') @Get(':secid/history')
@ApiOperation({ summary: 'Получить дневную историю торгов облигации' }) @ApiOperation({ summary: 'Получить дневную историю торгов облигации' })
@ApiOkResponse({ type: BondHistoryEnvelopeDto })
async getHistory( async getHistory(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query('from') from: string, @Query('from') from: string,

View File

@ -1,28 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { BondMarketDataDto, BondResponseDto } from './bond-response.dto';
import { BondHistoryItemDto } from './history-item.dto';
export class BondEnvelopeDto {
@ApiProperty({ type: BondResponseDto })
data!: BondResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BondMarketDataEnvelopeDto {
@ApiProperty({ type: BondMarketDataDto })
data!: BondMarketDataDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BondHistoryEnvelopeDto {
@ApiProperty({ type: [BondHistoryItemDto] })
data!: BondHistoryItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,15 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BondHistoryItemDto {
@ApiProperty({ example: '2026-06-01' })
date!: string;
@ApiProperty({ example: 100.45 })
closePrice!: number;
@ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 })
yieldClose!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 })
duration!: number | null;
}

View File

@ -1,19 +1,15 @@
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common'; import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { CandlesService } from './candles.service'; import { CandlesService } from './candles.service';
import { CandlesQueryDto } from './dto/candles-query.dto'; import { CandlesQueryDto } from './dto/candles-query.dto';
import { CandleEnvelopeDto } from './dto/candles-envelope.dto';
@ApiTags('Candles') @ApiTags('Candles')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities') @Controller('securities')
export class CandlesController { export class CandlesController {
constructor(private readonly candlesService: CandlesService) {} constructor(private readonly candlesService: CandlesService) {}
@Get('shares/:secid/candles') @Get('shares/:secid/candles')
@ApiOperation({ summary: 'Получить свечи акции' }) @ApiOperation({ summary: 'Получить свечи акции' })
@ApiOkResponse({ type: CandleEnvelopeDto })
async getShareCandles( async getShareCandles(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query(ValidationPipe) query: CandlesQueryDto, @Query(ValidationPipe) query: CandlesQueryDto,
@ -23,7 +19,6 @@ export class CandlesController {
@Get('bonds/:secid/candles') @Get('bonds/:secid/candles')
@ApiOperation({ summary: 'Получить свечи облигации' }) @ApiOperation({ summary: 'Получить свечи облигации' })
@ApiOkResponse({ type: CandleEnvelopeDto })
async getBondCandles( async getBondCandles(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query(ValidationPipe) query: CandlesQueryDto, @Query(ValidationPipe) query: CandlesQueryDto,

View File

@ -1,27 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class CandleItemDto {
@ApiProperty({ example: 321.3 })
open!: number;
@ApiProperty({ example: 322.66 })
high!: number;
@ApiProperty({ example: 321.2 })
low!: number;
@ApiProperty({ example: 322.35 })
close!: number;
@ApiProperty({ example: 1925163 })
volume!: number;
@ApiProperty({ example: 620184479 })
value!: number;
@ApiProperty({ example: '2026-06-01T10:00:00' })
begin!: string;
@ApiProperty({ example: '2026-06-01T10:59:00' })
end!: string;
}

View File

@ -1,11 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { CandleItemDto } from './candle-item.dto';
export class CandleEnvelopeDto {
@ApiProperty({ type: [CandleItemDto] })
data!: CandleItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,11 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { HealthResponseDto } from './health-response.dto';
export class HealthEnvelopeDto {
@ApiProperty({ type: HealthResponseDto })
data!: HealthResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class HealthResponseDto {
@ApiProperty({ example: 'ok' })
status!: string;
@ApiProperty({ example: '2026-06-23T06:00:00.000Z' })
timestamp!: string;
@ApiProperty({ example: 12345 })
uptime!: number;
}

View File

@ -1,17 +1,13 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import { HealthEnvelopeDto } from './dto/health-envelope.dto';
@ApiTags('Health') @ApiTags('Health')
@ApiExtraModels(ApiResponseMeta)
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
@Get() @Get()
@Public() @Public()
@ApiOperation({ summary: 'Проверка состояния сервиса' }) @ApiOperation({ summary: 'Проверка состояния сервиса' })
@ApiOkResponse({ type: HealthEnvelopeDto })
check() { check() {
return { return {
status: 'ok', status: 'ok',

View File

@ -1,33 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
export class SearchResultItemDto {
@ApiProperty({ example: 'SBER' })
secid!: string;
@ApiProperty({ example: 'RU0009029540' })
isin!: string;
@ApiProperty({ example: 'Сбербанк' })
shortName!: string;
@ApiProperty({ enum: ['share', 'bond'] })
type!: 'share' | 'bond';
@ApiProperty({ example: 1 })
listLevel!: number;
@ApiPropertyOptional({ type: String, nullable: true, example: 'RUB' })
currency!: string | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 })
price!: number | null;
}
export class SearchEnvelopeDto {
@ApiProperty({ type: [SearchResultItemDto] })
data!: SearchResultItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,15 +1,12 @@
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service'; import { ScreenerService } from './screener.service';
import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
import { ScreenerQueryDto } from './dto/screener-query.dto'; import { ScreenerQueryDto } from './dto/screener-query.dto';
import { ScreenerResponseDto } from './dto/screener-response.dto'; import { ScreenerResponseDto } from './dto/screener-response.dto';
import { SearchEnvelopeDto } from './dto/search-response.dto';
@ApiTags('Securities') @ApiTags('Securities')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities') @Controller('securities')
export class SecuritiesController { export class SecuritiesController {
constructor( constructor(
@ -19,7 +16,6 @@ export class SecuritiesController {
@Get('search') @Get('search')
@ApiOperation({ summary: 'Поиск по инструментам' }) @ApiOperation({ summary: 'Поиск по инструментам' })
@ApiOkResponse({ type: SearchEnvelopeDto })
async search(@Query(ValidationPipe) query: SearchQueryDto) { async search(@Query(ValidationPipe) query: SearchQueryDto) {
const results = await this.securitiesService.search( const results = await this.securitiesService.search(
query.q, query.q,

View File

@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class DividendItemDto {
@ApiProperty({ example: '2026-05-15' })
registryCloseDate!: string;
@ApiProperty({ example: 33.47 })
value!: number;
@ApiProperty({ example: 'RUB' })
currency!: string;
}

View File

@ -1,24 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class HistoryItemDto {
@ApiProperty({ example: '2026-06-01' })
date!: string;
@ApiProperty({ example: 321.3 })
open!: number;
@ApiProperty({ example: 322.66 })
high!: number;
@ApiProperty({ example: 321.2 })
low!: number;
@ApiProperty({ example: 322.35 })
close!: number;
@ApiProperty({ example: 1925163 })
volume!: number;
@ApiProperty({ example: 620184479 })
value!: number;
}

View File

@ -1,38 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { ShareResponseDto } from './share-response.dto';
import { ShareMarketDataResponseDto } from './share-marketdata-response.dto';
import { HistoryItemDto } from './history-item.dto';
import { DividendItemDto } from './dividend-item.dto';
export class ShareEnvelopeDto {
@ApiProperty({ type: ShareResponseDto })
data!: ShareResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class ShareMarketDataEnvelopeDto {
@ApiProperty({ type: ShareMarketDataResponseDto })
data!: ShareMarketDataResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class DividendsEnvelopeDto {
@ApiProperty({ type: [DividendItemDto] })
data!: DividendItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class ShareHistoryEnvelopeDto {
@ApiProperty({ type: [HistoryItemDto] })
data!: HistoryItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,23 +1,14 @@
import { Controller, Get, Param, Query } from '@nestjs/common'; import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { SharesService } from './shares.service'; import { SharesService } from './shares.service';
import {
ShareEnvelopeDto,
ShareMarketDataEnvelopeDto,
DividendsEnvelopeDto,
ShareHistoryEnvelopeDto,
} from './dto/shares-envelope.dto';
@ApiTags('Shares') @ApiTags('Shares')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities/shares') @Controller('securities/shares')
export class SharesController { export class SharesController {
constructor(private readonly sharesService: SharesService) {} constructor(private readonly sharesService: SharesService) {}
@Get(':secid') @Get(':secid')
@ApiOperation({ summary: 'Получить спецификацию акции' }) @ApiOperation({ summary: 'Получить спецификацию акции' })
@ApiOkResponse({ type: ShareEnvelopeDto })
async getShare(@Param('secid') secid: string) { async getShare(@Param('secid') secid: string) {
const share = await this.sharesService.getShare(secid); const share = await this.sharesService.getShare(secid);
return { data: share, meta: { cachedAt: null, fromCache: false } }; return { data: share, meta: { cachedAt: null, fromCache: false } };
@ -25,21 +16,18 @@ export class SharesController {
@Get(':secid/marketdata') @Get(':secid/marketdata')
@ApiOperation({ summary: 'Получить рыночные данные акции' }) @ApiOperation({ summary: 'Получить рыночные данные акции' })
@ApiOkResponse({ type: ShareMarketDataEnvelopeDto })
async getMarketData(@Param('secid') secid: string) { async getMarketData(@Param('secid') secid: string) {
return this.sharesService.getMarketData(secid); return this.sharesService.getMarketData(secid);
} }
@Get(':secid/dividends') @Get(':secid/dividends')
@ApiOperation({ summary: 'Получить дивиденды' }) @ApiOperation({ summary: 'Получить дивиденды' })
@ApiOkResponse({ type: DividendsEnvelopeDto })
async getDividends(@Param('secid') secid: string) { async getDividends(@Param('secid') secid: string) {
return this.sharesService.getDividends(secid); return this.sharesService.getDividends(secid);
} }
@Get(':secid/history') @Get(':secid/history')
@ApiOperation({ summary: 'Получить дневную историю торгов акции' }) @ApiOperation({ summary: 'Получить дневную историю торгов акции' })
@ApiOkResponse({ type: ShareHistoryEnvelopeDto })
async getHistory( async getHistory(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query('from') from: string, @Query('from') from: string,

View File

@ -21,37 +21,37 @@ export class BrokerEventItemDto {
@ApiProperty() @ApiProperty()
eventDate!: string; eventDate!: string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
paymentDate!: string | null; paymentDate!: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
ticker!: string | null; ticker!: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
name!: string | null; name!: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
instrumentUid!: string | null; instrumentUid!: string | null;
@ApiProperty({ enum: instrumentTypes }) @ApiProperty({ enum: instrumentTypes })
instrumentType!: string; instrumentType!: string;
@ApiProperty({ type: Number, nullable: true }) @ApiProperty({ nullable: true })
quantitySnapshot!: number | null; quantitySnapshot!: number | null;
@ApiProperty({ type: Number, nullable: true }) @ApiProperty({ nullable: true })
payoutPerUnit!: number | null; payoutPerUnit!: number | null;
@ApiProperty({ type: Number, nullable: true }) @ApiProperty({ nullable: true })
estimatedAmount!: number | null; estimatedAmount!: number | null;
@ApiProperty({ type: Number, nullable: true }) @ApiProperty({ nullable: true })
actualAmount!: number | null; actualAmount!: number | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
currency!: string | null; currency!: string | null;
@ApiProperty({ type: String, nullable: true, enum: ['current_position'] }) @ApiProperty({ nullable: true })
estimateMode!: 'current_position' | null; estimateMode!: 'current_position' | null;
} }
@ -59,7 +59,7 @@ export class BrokerEventsSummaryDto {
@ApiProperty({ minimum: 0 }) @ApiProperty({ minimum: 0 })
eventCount!: number; eventCount!: number;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
nearestEventDate!: string | null; nearestEventDate!: string | null;
@ApiProperty() @ApiProperty()

View File

@ -1,67 +0,0 @@
# ADR-017: Biome как единый инструмент линтинга и форматирования
**Дата:** 2026-06-23
**Статус:** Принято
**Автор:** AI Agent (codex/frontend-infrastructure-tooling)
## Контекст
Проект использует **ESLint v8** + **Prettier** для линтинга и форматирования. ESLint 8 устарел: ESLint 9 имеет полностью изменённый конфиг, миграция потребует переписывания `.eslintrc`. Оба инструмента написаны на JavaScript и работают медленно на больших кодовых базах.
Появились современные Rust-альтернативы, объединяющие линтинг и форматирование в одном CLI со значительным приростом производительности.
## Рассмотренные варианты
### Biome v2.5
- Linter + Formatter в одном CLI
- 500+ правил (ESLint + TypeScript ESLint + others)
- 97% совместимость форматтера с Prettier
- ~35x быстрее Prettier, ~35x быстрее ESLint
- Поддержка: JS, TS, JSX, TSX, JSON, HTML, CSS, GraphQL
- Стабильный LTS, продакшн у AWS, Google, Vercel
- Встроенная миграция: `biome migrate eslint`
### Oxlint + Oxfmt (Oxc Project)
- Linter отдельно (800+ правил, 50-100x быстрее ESLint)
- Formatter отдельно (Oxfmt, beta, 3x быстрее Biome, 30x быстрее Prettier)
- Type-aware linting через tsgo
- Два отдельных инструмента с разными конфигами
- Oxfmt в статусе beta
### Оставить ESLint + Prettier
- Знакомый стек
- Медленная производительность
- Необходимость мигрировать на ESLint 9 в любом случае
- Два набора конфигов
## Решение
Перейти на **Biome** как единый инструмент для линтинга и форматирования.
Причины:
1. **Один инструмент вместо двух** — меньше конфигов, один CI-степ, одна команда
2. **Production-ready** — стабильный LTS, используется крупными компаниями
3. **Плавная миграция**`biome migrate eslint` переносит правила автоматически
4. **Производительность** — ~35x быстрее как линтинг, так и форматирование
5. **Встроенный import sorting** — замена `eslint-plugin-import`
### Ограничения
FSD-правила из `@conarti/eslint-plugin-feature-sliced` не имеют аналога в Biome. Если не удаётся портировать через plugin system — сохраняется минимальный `.eslintrc.cjs` только для FSD layer boundaries.
## Последствия
### Положительные
- Единый конфиг `biome.json` вместо `.eslintrc.cjs` + `.prettierrc`
- Ускорение CI (lint + format за один проход)
- Автоматический import sorting
- Меньше зависимостей в `package.json`
### Риски
- FSD-правила могут не портироваться — потребуется костыль с минимальным ESLint
- Biome может не поддерживать какое-то редкое правило ESLint — потребуется адаптация
- Команде нужно привыкнуть к новому CLI
## Связанные документы
- `docs/research/frontend-infrastructure-tooling/biome-vs-oxlint.md`
- `docs/features/frontend-infrastructure-tooling/plan.md` (Phase 2)

View File

@ -1,77 +0,0 @@
# ADR-018: TanStack Router как основной роутер
**Дата:** 2026-06-23
**Статус:** Принято
**Автор:** AI Agent (codex/frontend-infrastructure-tooling)
## Контекст
Проект использует **react-router-dom v6** для клиентской маршрутизации. Текущая реализация:
- Все страницы импортируются статически в `AppRoutes.tsx`
- Нет lazy loading (code splitting) — каждая навигация грузит весь бандл
- Параметры роутов (`useParams`) и search params (`useSearchParams`) не типизированы
- Нет встроенной валидации search params
- Проект уже использует TanStack Query — потенциальная синергия с TanStack Router
Требуется:
- Route-level code splitting для оптимизации бандла
- Типобезопасность параметров и search params
- Интеграция с существующим TanStack Query (prefetching через loaders)
## Рассмотренные варианты
### react-router-dom v6 + React.lazy
- Минимальные изменения — обернуть каждый импорт в `React.lazy` + `<Suspense>`
- Не решает проблему типизации
- Нет prefetching / loaders
- React.lazy boilerplate на каждый роут
### TanStack Router
- Полная типобезопасность через генерацию RouteTree
- Search params с Zod-схемами
- Code splitting built-in — каждый роут ленивый по умолчанию
- Loaders для prefetching + интеграция с TanStack Query
- Pending/Error/NotFound boundaries на уровне роута
- Размер: ~3-4KB gzip (меньше react-router-dom)
- Требует переписывания всех роутов и навигации
## Решение
Мигрировать на **TanStack Router** (code-first approach).
Причины:
1. **Типобезопасность** — RouteTree generation исключает опечатки в путях и невалидные search params
2. **Code splitting без boilerplate** — built-in lazy, не нужен `React.lazy`
3. **Синергия с TanStack Query** — уже используется в проекте; loaders дают prefetching данных до рендера компонента
4. **Zod** — уже используется для валидации форм; Router использует Zod для search params
5. **Search params** — типизированная валидация вместо строковых `useSearchParams`
6. **Меньший размер** — 3-4KB vs 8KB react-router-dom
> **Примечание о реализации:** Изначально планировался file-based подход с `@tanstack/router-plugin`, но плагин не генерировал `routeTree.gen.ts` корректно на текущей версии Vite/плагина. Принято решение использовать code-first подход — все маршруты определяются вручную в `routeTree.tsx` через `createRootRoute` + `createRoute`. Это даёт тот же функционал (типобезопасность, guard'ы, код-сплиттинг) без зависимости от Vite-плагина.
## Последствия
### Положительные
- Каждая страница — отдельный chunk, грузится по требованию
- Search params валидируются Zod-схемами (screener, broker-operations)
- Loaders предзагружают данные, уменьшая время до первого контента
- Guard'ы (ProtectedRoute) реализуются через `beforeLoad`, единый подход
### Риски
- Переписывание всех роутов, компонентов навигации (`Link`, `useNavigate`) и тестов
- `MemoryRouter` в тестах заменяется на `createMemoryHistory` + `RouterProvider` из TanStack Router
- Файловая структура роутов меняется — `src/app/routing/` с code-first определением в `routeTree.tsx`
- TanStack Router не экспортирует `useSearchParams` — потребовалась обёртка `useSearchParamsCompat`
- `useNavigate` использует объектный синтаксис: `navigate({ to: '...' })` вместо строкового `navigate('...')`
- Learning curve для команды
### Миграция
- Все маршруты определены в одном `routeTree.tsx` (code-first)
- Старый `AppRoutes.tsx` удалён после прохождения тестов
- `react-router-dom` удалён из зависимостей после верификации
- Все 125 тестов проходят, сборка зелёная
## Связанные документы
- `docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md`
- `docs/features/frontend-infrastructure-tooling/plan.md` (Phase 6)

View File

@ -1,64 +0,0 @@
# ADR-019: Унификация API-слоя: ky + codegen как единый источник типов
**Дата:** 2026-06-23
**Статус:** Принято
**Автор:** AI Agent (codex/frontend-infrastructure-tooling)
## Контекст
В проекте сложилась ситуация расхождения между принятыми ADR и фактической реализацией:
- **ADR-005** решил использовать `openapi-typescript` + `openapi-fetch` для кодогенерации типов
- **ADR-015** решил использовать `ky` как HTTP-клиент
- Фактически: используется кастомный `client.ts` с нативным fetch, создан `kyClient.ts` (но не используется), рукописный `responses.ts` дублирует сгенерированный `types.ts`
Проблемы:
- Дублирование типов — рукописные расходятся с codegen
- Два HTTP-клиента (один мёртвый) — путаница
- fetch-реализация без удобных интерцепторов (retry, timeout, hooks)
## Решение
### 1. ky как единый HTTP-клиент
- Активировать `kyClient.ts` с доработанными hooks (normalizeEnvelope в afterResponse)
- Перевести все entity-API файлы с `request()` на `kyApi`
- Удалить `shared/api/client.ts`
- ADR-015 исполняется полностью
### 2. OpenAPI codegen как единый источник типов
- Удалить рукописный `shared/api/responses.ts`
- Все entity-API файлы используют типы из сгенерированного `shared/api/types.ts`
- Типы пишутся только через `npm run codegen`
- ADR-005 приводится к фактическому исполнению (без `openapi-fetch`, с кастомным клиентом)
### 3. MSW Browser Mode (расширение существующей инфраструктуры)
- MSW уже используется в тестах (`shared/lib/test/server.ts`)
- Добавить browser entry (`setupWorker`) для dev-режима
- Включается переменной `VITE_API_MOCK=true`
- Переиспользует существующие handlers
### 4. Валидация переменных окружения
- Zod-схема для `VITE_*` переменных
- Валидация при старте приложения
## Последствия
### Положительные
- Единый HTTP-клиент с интерцепторами (auth, retry, normalize)
- Нет дублирования типов — все через codegen
- Возможность разрабатывать UI без бэкенда (MSW browser)
- Раннее обнаружение ошибок конфигурации (env validation)
### Риски
- Миграция entity API требует регрессионного тестирования каждого модуля
- MSW browser handlers могут отличаться от реального API — нужно синхронизировать
- При изменении OpenAPI spec нужно запускать codegen вручную
## Связанные документы
- ADR-005: OpenAPI codegen через openapi-typescript
- ADR-015: Модернизация инфраструктуры фронтенда
- `docs/features/frontend-infrastructure-tooling/plan.md` (Phase 1, 3, 4, 5)

View File

@ -18,8 +18,5 @@
| [ADR-014](ADR-014-frontend-fsd-market-pages) | Accepted | FSD-миграция market pages и market widgets | | [ADR-014](ADR-014-frontend-fsd-market-pages) | Accepted | FSD-миграция market pages и market widgets |
| [ADR-015](ADR-015-frontend-libraries-modernization) | — | Модернизация инфраструктуры фронтенда | | [ADR-015](ADR-015-frontend-libraries-modernization) | — | Модернизация инфраструктуры фронтенда |
| [ADR-016](ADR-016-design-system) | Accepted | Дизайн-система — гибридный подход | | [ADR-016](ADR-016-design-system) | Accepted | Дизайн-система — гибридный подход |
| [ADR-017](ADR-017-biome-linter-formatter) | Accepted | Biome как единый инструмент линтинга и форматирования |
| [ADR-018](ADR-018-tanstack-router) | Accepted | TanStack Router как основной роутер |
| [ADR-019](ADR-019-api-layer-unification) | Accepted | Унификация API-слоя: ky + codegen как единый источник типов |
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе. Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.

View File

@ -5,14 +5,21 @@ module.exports = {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
parserOptions: { parserOptions: {
sourceType: 'module', sourceType: 'module',
ecmaFeatures: { jsx: true },
}, },
plugins: ['@conarti/feature-sliced', 'import'], plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import', '@conarti/feature-sliced'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
],
root: true, root: true,
env: { env: {
browser: true, browser: true,
es2020: true, es2020: true,
}, },
settings: { settings: {
react: { version: 'detect' },
'import/resolver': { 'import/resolver': {
typescript: { typescript: {
alwaysTryTypes: true, alwaysTryTypes: true,
@ -22,31 +29,63 @@ module.exports = {
}, },
ignorePatterns: ['.eslintrc.cjs', 'vite.config.ts', 'vitest.config.ts', 'dist/'], ignorePatterns: ['.eslintrc.cjs', 'vite.config.ts', 'vitest.config.ts', 'dist/'],
rules: { rules: {
'no-restricted-imports': ['warn', {
paths: [{
name: '@mui/material',
importNames: [
// DS-covered: import from @moex-vibe/design-system
'Typography', 'Button', 'TextField', 'Select', 'Checkbox',
'Paper', 'Chip', 'Badge', 'Alert', 'Dialog', 'Skeleton',
'CircularProgress', 'Link', 'IconButton',
'Table', 'TableBody', 'TableCell', 'TableContainer',
'TableHead', 'TableRow', 'TableSortLabel',
'TablePagination', 'Pagination',
],
message: 'Import from @moex-vibe/design-system instead, or use Box/Stack/Grid for layout.',
}],
}],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'react/react-in-jsx-scope': 'off',
// FSD layer boundaries (from @conarti/eslint-plugin-feature-sliced) // FSD layer boundaries (from @conarti/eslint-plugin-feature-sliced)
// layers-slices: catches cross-layer violations (e.g., shared→entities)
'@conarti/feature-sliced/layers-slices': ['error', { '@conarti/feature-sliced/layers-slices': ['error', {
// allow test files and test utilities to import from any layer for mocking
ignoreInFilesPatterns: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', '**/test/**'], ignoreInFilesPatterns: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', '**/test/**'],
}], }],
// absolute-relative: false positives with @/ alias convention — disabled
'@conarti/feature-sliced/absolute-relative': 'off',
// public-api: too strict for app/ and test internals — disabled
'@conarti/feature-sliced/public-api': 'off',
// FSD layer boundaries (from import/no-restricted-paths) // FSD layer boundaries (from import/no-restricted-paths)
// NOTE: `from` = what's being imported, `target` = the file doing the import
'import/no-restricted-paths': [ 'import/no-restricted-paths': [
'error', 'error',
{ {
zones: [ zones: [
// shared/ cannot import from entities/, features/, widgets/, pages/, app/
{ from: `${src}/entities`, target: `${src}/shared` }, { from: `${src}/entities`, target: `${src}/shared` },
{ from: `${src}/features`, target: `${src}/shared` }, { from: `${src}/features`, target: `${src}/shared` },
{ from: `${src}/widgets`, target: `${src}/shared` }, { from: `${src}/widgets`, target: `${src}/shared` },
{ from: `${src}/pages`, target: `${src}/shared` }, { from: `${src}/pages`, target: `${src}/shared` },
{ from: `${src}/app`, target: `${src}/shared` }, { from: `${src}/app`, target: `${src}/shared` },
// entities/ cannot import from features/, widgets/, pages/, app/
{ from: `${src}/features`, target: `${src}/entities` }, { from: `${src}/features`, target: `${src}/entities` },
{ from: `${src}/widgets`, target: `${src}/entities` }, { from: `${src}/widgets`, target: `${src}/entities` },
{ from: `${src}/pages`, target: `${src}/entities` }, { from: `${src}/pages`, target: `${src}/entities` },
{ from: `${src}/app`, target: `${src}/entities` }, { from: `${src}/app`, target: `${src}/entities` },
// features/ cannot import from widgets/, pages/, app/
{ from: `${src}/widgets`, target: `${src}/features` }, { from: `${src}/widgets`, target: `${src}/features` },
{ from: `${src}/pages`, target: `${src}/features` }, { from: `${src}/pages`, target: `${src}/features` },
{ from: `${src}/app`, target: `${src}/features` }, { from: `${src}/app`, target: `${src}/features` },
// widgets/ cannot import from pages/, app/
{ from: `${src}/pages`, target: `${src}/widgets` }, { from: `${src}/pages`, target: `${src}/widgets` },
{ from: `${src}/app`, target: `${src}/widgets` }, { from: `${src}/app`, target: `${src}/widgets` },
// pages/ cannot import from app/
{ from: `${src}/app`, target: `${src}/pages` }, { from: `${src}/app`, target: `${src}/pages` },
], ],
}, },
], ],

View File

@ -1,4 +0,0 @@
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)

View File

@ -1,11 +0,0 @@
export const mockUser = {
id: 1,
email: 'user@test.com',
name: 'Test User',
role: 'user',
}
export const mockAuthResponse = {
user: mockUser,
accessToken: 'mock-access-token',
}

View File

@ -1,40 +0,0 @@
export const mockBond = {
secid: 'SU26238RMFS5',
isin: 'RU000A101XU7',
name: 'ОФЗ 26238',
shortName: 'ОФЗ 26238',
latName: null,
listLevel: 1,
issueSize: 500000000,
faceValue: 1000,
faceUnit: 'RUB',
matDate: '2027-05-15',
couponValue: 36.9,
couponPercent: 7.5,
couponPeriod: 182,
nextCoupon: '2024-07-15',
accruedInt: 8.45,
bondType: 'ОФЗ',
bondSubType: 'ОФЗ-ПД',
offerDate: null,
buybackDate: null,
marketData: {
price: 98.5,
yieldToMaturity: 8.2,
duration: 3.5,
accruedInt: 8.45,
couponValue: 36.9,
couponPercent: 7.5,
nextCouponDate: '2024-07-15',
open: 98.0,
high: 99.0,
low: 97.5,
volume: 1000000,
updatedAt: '2024-01-15T10:00:00Z',
},
}
export const mockBondHistory = [
{ date: '2024-01-15', closePrice: 98.5, yieldClose: 8.2, duration: 3.5 },
{ date: '2024-01-14', closePrice: 98.2, yieldClose: 8.3, duration: 3.5 },
]

View File

@ -1,189 +0,0 @@
export const mockBrokerAccounts = [
{
id: '2084014113',
type: 'brokerage',
name: 'Т-Инвестиции',
status: 'open',
openedAt: null,
accessLevel: null,
},
]
export const mockBrokerPortfolio = {
account: mockBrokerAccounts[0],
positionCounts: { shares: 3, bonds: 2, etf: 0, other: 0 },
totals: {
shares: { currency: 'RUB', units: '240000', nano: 500000000, value: 240000.5 },
bonds: { currency: 'RUB', units: '45000', nano: 0, value: 45000 },
etf: null,
currencies: null,
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: { currency: 'RUB', units: '285000', nano: 500000000, value: 285000.5 },
},
yields: {
expectedPercent: null,
daily: { currency: 'RUB', units: '1200', nano: 0, value: 1200 },
dailyPercent: null,
},
cash: [{ currency: 'RUB', units: '15000', nano: 0, value: 15000 }],
blockedCash: [],
asOf: '2024-06-01T10:00:00Z',
}
export const mockBrokerPositions = [
{
figi: null,
instrumentUid: null,
positionUid: null,
ticker: 'SBER',
classCode: null,
instrumentType: 'share',
name: 'Сбер Банк',
quantity: { currency: '', units: '100', nano: 0, value: 100 },
blockedLots: null,
currentPrice: { currency: 'RUB', units: '289', nano: 500000000, value: 289.5 },
currentValue: { currency: 'RUB', units: '28950', nano: 0, value: 28950 },
averagePositionPrice: null,
expectedYieldPercent: null,
dailyYield: null,
},
{
figi: null,
instrumentUid: null,
positionUid: null,
ticker: 'VTBR',
classCode: null,
instrumentType: 'share',
name: 'ВТБ',
quantity: { currency: '', units: '5000', nano: 0, value: 5000 },
blockedLots: null,
currentPrice: { currency: 'RUB', units: '0', nano: 23400000, value: 0.0234 },
currentValue: { currency: 'RUB', units: '117', nano: 0, value: 117 },
averagePositionPrice: null,
expectedYieldPercent: null,
dailyYield: null,
},
{
figi: null,
instrumentUid: null,
positionUid: null,
ticker: 'SU26238RMFS5',
classCode: null,
instrumentType: 'bond',
name: 'ОФЗ 26238',
quantity: { currency: '', units: '10', nano: 0, value: 10 },
blockedLots: null,
currentPrice: { currency: 'RUB', units: '985', nano: 0, value: 985 },
currentValue: { currency: 'RUB', units: '9850', nano: 0, value: 9850 },
averagePositionPrice: null,
expectedYieldPercent: null,
dailyYield: null,
},
]
export const mockBrokerOperations = [
{
cursor: null,
accountId: '2084014113',
id: 'op-1',
parentOperationId: null,
date: '2024-06-01T10:00:00Z',
type: 'buy',
category: 'trade',
description: null,
name: 'Покупка SBER',
state: 'executed',
instrumentUid: null,
figi: null,
ticker: 'SBER',
classCode: null,
instrumentType: 'share',
payment: { currency: 'RUB', units: '-27500', nano: 0, value: -27500 },
price: { currency: 'RUB', units: '275', nano: 0, value: 275 },
commission: { currency: 'RUB', units: '-55', nano: 0, value: -55 },
yield: null,
accruedInt: null,
quantity: { currency: '', units: '100', nano: 0, value: 100 },
quantityDone: { currency: '', units: '100', nano: 0, value: 100 },
},
{
cursor: null,
accountId: '2084014113',
id: 'op-2',
parentOperationId: null,
date: '2024-05-20T14:30:00Z',
type: 'dividend',
category: 'income',
description: null,
name: 'Дивиденды Сбер',
state: 'executed',
instrumentUid: null,
figi: null,
ticker: 'SBER',
classCode: null,
instrumentType: 'share',
payment: { currency: 'RUB', units: '3500', nano: 0, value: 3500 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
]
export const mockBrokerEvents = [
{
id: 'ev-1',
type: 'dividend',
source: 'forecast',
category: 'cashflow',
eventDate: '2024-07-10',
paymentDate: null,
ticker: 'SBER',
name: 'Сбер Банк',
instrumentUid: 'uid-sber',
instrumentType: 'share',
quantitySnapshot: 100,
payoutPerUnit: 35,
estimatedAmount: 3500,
actualAmount: null,
currency: 'RUB',
estimateMode: 'current_position',
},
{
id: 'ev-2',
type: 'coupon',
source: 'forecast',
category: 'cashflow',
eventDate: '2024-07-15',
paymentDate: null,
ticker: 'SU26238RMFS5',
name: 'ОФЗ 26238',
instrumentUid: 'uid-bond',
instrumentType: 'bond',
quantitySnapshot: 10,
payoutPerUnit: 36.9,
estimatedAmount: 369,
actualAmount: null,
currency: 'RUB',
estimateMode: 'current_position',
},
]
export const mockEventsSummary = {
eventCount: 2,
nearestEventDate: '2024-07-10',
totalEstimatedCashflow: 3869,
actualCashflow: 0,
forecastEstimatedCashflow: 3869,
dividendsTotal: 3500,
couponsTotal: 369,
principalRepaymentTotal: 0,
actualDividendsTotal: 0,
actualCouponsTotal: 0,
actualPrincipalRepaymentTotal: 0,
}

View File

@ -1,22 +0,0 @@
export const mockCandles = [
{
open: 280,
high: 290,
low: 278,
close: 289.5,
volume: 1000000,
value: 280000000,
begin: '2024-01-15T10:00:00Z',
end: '2024-01-15T18:00:00Z',
},
{
open: 289,
high: 292,
low: 285,
close: 288,
volume: 800000,
value: 231200000,
begin: '2024-01-16T10:00:00Z',
end: '2024-01-16T18:00:00Z',
},
]

View File

@ -1,15 +0,0 @@
export { mockAuthResponse, mockUser } from './auth'
export { mockBond, mockBondHistory } from './bonds'
export {
mockBrokerAccounts,
mockBrokerEvents,
mockBrokerOperations,
mockBrokerPortfolio,
mockBrokerPositions,
mockEventsSummary,
} from './broker'
export { mockCandles } from './candles'
export { mockAnalytics, mockPortfolioDetail, mockPortfolios, mockPositions } from './portfolios'
export { mockScreenerItems } from './screener'
export { mockSearchResults } from './search'
export { mockDividends, mockShare, mockShareHistory } from './shares'

View File

@ -1,138 +0,0 @@
export const mockPortfolios = [
{
id: 1,
name: 'Основной портфель',
currency: 'RUB',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-06-01T00:00:00Z',
totalValue: 285000,
positionCount: 5,
shareCount: 3,
bondCount: 2,
description: null,
},
{
id: 2,
name: 'ИИС',
currency: 'RUB',
createdAt: '2024-03-15T00:00:00Z',
updatedAt: '2024-06-10T00:00:00Z',
totalValue: 150000,
positionCount: 3,
shareCount: 2,
bondCount: 1,
description: 'Индивидуальный инвестиционный счёт',
},
]
export const mockPositions = [
{
id: 1,
secid: 'SBER',
shortName: 'Сбер',
type: 'share',
quantity: 100,
buyPrice: 275,
currentPrice: 289.5,
totalCost: 27500,
currentValue: 28950,
weightPercent: 10.2,
pnl: 1450,
pnlPercent: 5.27,
dividendIncome: 3500,
totalReturn: 4950,
totalReturnPercent: 18,
change: 14.5,
changePercent: 5.27,
notes: null,
tags: ['DIVIDEND', 'GROWTH'],
},
{
id: 2,
secid: 'VTBR',
shortName: 'ВТБ',
type: 'share',
quantity: 5000,
buyPrice: 0.021,
currentPrice: 0.0234,
totalCost: 105,
currentValue: 117,
weightPercent: 0.04,
pnl: 12,
pnlPercent: 11.43,
dividendIncome: 0,
totalReturn: 12,
totalReturnPercent: 11.43,
change: 0.0024,
changePercent: 11.43,
notes: null,
tags: ['SPECULATIVE'],
},
{
id: 3,
secid: 'SU26238RMFS5',
shortName: 'ОФЗ 26238',
type: 'bond',
quantity: 10,
buyPrice: 97,
currentPrice: 98.5,
totalCost: 9700,
currentValue: 9850,
weightPercent: 3.5,
pnl: 150,
pnlPercent: 1.55,
dividendIncome: 0,
totalReturn: 150,
totalReturnPercent: 1.55,
change: 1.5,
changePercent: 1.55,
yieldToMaturity: 8.2,
duration: 3.5,
couponValue: 36.9,
couponPercent: 7.5,
nextCouponDate: '2024-07-15',
matDate: '2027-05-15',
accruedInt: 8.45,
bid: 98.3,
offer: 98.6,
couponPeriod: 182,
bondType: 'ОФЗ',
},
]
export const mockPortfolioDetail = {
id: 1,
name: 'Основной портфель',
currency: 'RUB',
description: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-06-01T00:00:00Z',
positions: mockPositions,
totalValue: 285000,
analytics: {
totalInvested: 250000,
totalValue: 285000,
totalPnl: 35000,
totalPnlPercent: 14,
totalDividends: 5000,
totalReturn: 40000,
totalReturnPercent: 16,
positionCount: 5,
weightedYield: null,
},
}
export const mockAnalytics = {
positions: mockPositions,
summary: {
totalInvested: 250000,
totalValue: 285000,
totalPnl: 35000,
totalPnlPercent: 14,
totalDividends: 5000,
totalReturn: 40000,
totalReturnPercent: 16,
positionCount: 5,
weightedYield: 7.5,
},
}

View File

@ -1,38 +0,0 @@
export const mockScreenerItems = [
{
secid: 'SBER',
shortName: 'Сбер',
isin: 'RU0009029540',
type: 'share',
price: 289.5,
change: 2.5,
changePercent: 0.87,
volume: 15000000,
listLevel: 1,
capitalization: 6250000000000,
},
{
secid: 'VTBR',
shortName: 'ВТБ',
isin: 'RU000A0JP5V6',
type: 'share',
price: 0.0234,
change: 0.0002,
changePercent: 0.86,
volume: 50000000,
listLevel: 1,
capitalization: 30000000000,
},
{
secid: 'GAZP',
shortName: 'Газпром',
isin: 'RU0007661625',
type: 'share',
price: 198.5,
change: -1.5,
changePercent: -0.75,
volume: 8000000,
listLevel: 1,
capitalization: 4700000000000,
},
]

View File

@ -1,20 +0,0 @@
export const mockSearchResults = [
{
secid: 'SBER',
isin: 'RU0009029540',
shortName: 'Сбер',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: 289.5,
},
{
secid: 'VTBR',
isin: 'RU000A0JP5V6',
shortName: 'ВТБ',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: 0.0234,
},
]

View File

@ -1,50 +0,0 @@
export const mockShare = {
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбер Банк',
shortName: 'Сбер',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586900000,
faceValue: 3,
faceUnit: 'RUB',
type: 'common_share',
marketData: {
price: 289.5,
change: 2.5,
changePercent: 0.87,
open: 287,
high: 291,
low: 286.5,
volume: 15000000,
value: 4350000000,
issueCapitalization: 6250000000000,
updatedAt: '2024-01-15T10:00:00Z',
},
}
export const mockDividends = [
{ registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' },
{ registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' },
]
export const mockShareHistory = [
{
date: '2024-01-15',
open: 287,
high: 291,
low: 286.5,
close: 289.5,
volume: 15000000,
value: 4350000000,
},
{
date: '2024-01-14',
open: 285,
high: 288,
low: 284,
close: 287,
volume: 12000000,
value: 3440000000,
},
]

View File

@ -1,151 +0,0 @@
import { HttpResponse, http } from 'msw'
import {
mockAnalytics,
mockAuthResponse,
mockBond,
mockBondHistory,
mockBrokerAccounts,
mockBrokerEvents,
mockBrokerOperations,
mockBrokerPortfolio,
mockBrokerPositions,
mockCandles,
mockDividends,
mockEventsSummary,
mockPortfolioDetail,
mockPortfolios,
mockPositions,
mockScreenerItems,
mockSearchResults,
mockShare,
mockShareHistory,
mockUser,
} from './data'
import { envelope } from './utils'
const API = '/api/v1'
export const handlers = [
http.get(`${API}/health`, () =>
HttpResponse.json(
envelope({ status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }),
),
),
http.get(`${API}/auth/me`, () => HttpResponse.json(envelope(mockUser))),
http.patch(`${API}/auth/me`, () => HttpResponse.json(envelope({ ...mockUser, name: 'Updated' }))),
http.post(`${API}/auth/login`, () => HttpResponse.json(envelope(mockAuthResponse))),
http.post(`${API}/auth/register`, () => HttpResponse.json(envelope(mockAuthResponse))),
http.post(`${API}/auth/refresh`, () => HttpResponse.json(envelope(mockAuthResponse))),
http.post(`${API}/auth/logout`, () => HttpResponse.json(envelope({ message: 'Logged out' }))),
http.get(`${API}/securities/search`, ({ request }) => {
const url = new URL(request.url)
const q = url.searchParams.get('q') || ''
if (q.length < 2) return HttpResponse.json(envelope([]))
const filtered = mockSearchResults.filter(
(r) =>
r.secid.toLowerCase().includes(q.toLowerCase()) ||
r.shortName.toLowerCase().includes(q.toLowerCase()),
)
return HttpResponse.json(envelope(filtered))
}),
http.get(`${API}/securities/shares/:secid`, ({ params }) => {
const { secid } = params
if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 })
return HttpResponse.json(envelope({ ...mockShare, secid }))
}),
http.get(`${API}/securities/shares/:secid/marketdata`, () =>
HttpResponse.json(envelope(mockShare.marketData)),
),
http.get(`${API}/securities/shares/:secid/dividends`, () =>
HttpResponse.json(envelope(mockDividends)),
),
http.get(`${API}/securities/shares/:secid/history`, () =>
HttpResponse.json(envelope(mockShareHistory)),
),
http.get(`${API}/securities/shares/:secid/candles`, () =>
HttpResponse.json(envelope(mockCandles)),
),
http.get(`${API}/securities/bonds/:secid`, ({ params }) => {
const { secid } = params
if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 })
return HttpResponse.json(envelope({ ...mockBond, secid }))
}),
http.get(`${API}/securities/bonds/:secid/marketdata`, () =>
HttpResponse.json(envelope(mockBond.marketData)),
),
http.get(`${API}/securities/bonds/:secid/history`, () =>
HttpResponse.json(envelope(mockBondHistory)),
),
http.get(`${API}/securities/bonds/:secid/candles`, () =>
HttpResponse.json(envelope(mockCandles)),
),
http.get(`${API}/securities/screener`, () =>
HttpResponse.json(
envelope({
items: mockScreenerItems,
total: mockScreenerItems.length,
page: 1,
pageSize: 20,
totalPages: 1,
}),
),
),
http.get(`${API}/portfolios`, () => HttpResponse.json(envelope(mockPortfolios))),
http.post(`${API}/portfolios`, () => HttpResponse.json(envelope(mockPortfolios[0]))),
http.get(`${API}/portfolios/:id`, () => HttpResponse.json(envelope(mockPortfolioDetail))),
http.patch(`${API}/portfolios/:id`, () => HttpResponse.json(envelope(mockPortfolios[0]))),
http.delete(`${API}/portfolios/:id`, () => HttpResponse.json(envelope(null))),
http.post(`${API}/portfolios/:id/positions`, () => HttpResponse.json(envelope(mockPositions[0]))),
http.patch(`${API}/portfolios/:id/positions/:positionId`, () =>
HttpResponse.json(envelope(mockPositions[0])),
),
http.delete(`${API}/portfolios/:id/positions/:positionId`, () =>
HttpResponse.json(envelope(null)),
),
http.get(`${API}/portfolios/:id/analytics`, () => HttpResponse.json(envelope(mockAnalytics))),
http.get(`${API}/broker/accounts`, () => HttpResponse.json(envelope(mockBrokerAccounts))),
http.get(`${API}/broker/accounts/:accountId/portfolio`, () =>
HttpResponse.json(envelope(mockBrokerPortfolio)),
),
http.get(`${API}/broker/accounts/:accountId/positions`, () =>
HttpResponse.json(
envelope({
accountId: '2084014113',
items: mockBrokerPositions,
nextCursor: null,
hasNext: false,
asOf: '2024-06-01T10:00:00Z',
}),
),
),
http.get(`${API}/broker/accounts/:accountId/operations`, () =>
HttpResponse.json(
envelope({
accountId: '2084014113',
items: mockBrokerOperations,
nextCursor: null,
hasNext: false,
asOf: '2024-06-01T10:00:00Z',
}),
),
),
http.get(`${API}/broker/accounts/:accountId/events`, () =>
HttpResponse.json(
envelope({
items: mockBrokerEvents,
summary: mockEventsSummary,
asOf: '2024-06-01T10:00:00Z',
}),
),
),
http.post(`${API}/broker/accounts/:accountId/operations/sync`, () =>
HttpResponse.json(envelope({ upserted: 5 })),
),
]

View File

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

View File

@ -1,5 +0,0 @@
export function envelope(data: unknown) {
return {
data: { data, meta: { fromCache: false, cachedAt: null } },
}
}

View File

@ -5,14 +5,10 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "tsc -b && vite build",
"typecheck": "tsc -b",
"preview": "vite preview", "preview": "vite preview",
"codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/shared/api/types.ts", "codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts",
"lint": "biome check src/", "lint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:fix": "biome check --write src/",
"format": "biome format --write src/",
"format:check": "biome format src/",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
}, },
@ -20,12 +16,11 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@hookform/resolvers": "^3.10.0",
"@moex-vibe/design-system": "*", "@moex-vibe/design-system": "*",
"@hookform/resolvers": "^3.10.0",
"@mui/icons-material": "^6.5.0", "@mui/icons-material": "^6.5.0",
"@mui/material": "^6.5.0", "@mui/material": "^6.5.0",
"@tanstack/react-query": "^5.20.0", "@tanstack/react-query": "^5.20.0",
"@tanstack/react-router": "^1.170.16",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
@ -36,25 +31,27 @@
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-is": "^18.3.1", "react-is": "^18.3.1",
"react-router-dom": "^6.20.0",
"zod": "^4.4.3", "zod": "^4.4.3",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.5.0",
"@conarti/eslint-plugin-feature-sliced": "^1.0.5", "@conarti/eslint-plugin-feature-sliced": "^1.0.5",
"@tanstack/router-devtools": "^1.167.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0", "@typescript-eslint/parser": "^7.0.0",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
"eslint": "^8.0.0", "eslint": "^8.0.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5", "eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"msw": "^2.14.6", "msw": "^2.14.6",
"openapi-typescript": "^7.0.0", "openapi-typescript": "^7.0.0",

View File

@ -1,349 +0,0 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.14.6'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View File

@ -1,6 +1,10 @@
import { RouterProvider } from '@tanstack/react-router' import { BrowserRouter } from 'react-router-dom';
import { router } from './routing/router' import { AppRoutes } from './routing/AppRoutes';
export default function App() { export default function App() {
return <RouterProvider router={router} /> return (
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<AppRoutes />
</BrowserRouter>
);
} }

View File

@ -1 +1 @@
export { default as App } from './App' export { default as App } from './App';

View File

@ -1,14 +1,14 @@
import { Link, Outlet, useNavigate } from '@tanstack/react-router' import { Outlet, Link, useNavigate } from 'react-router-dom';
import { useSession } from '@/entities/session' import { SearchBar } from '@/widgets/search-bar';
import { SearchBar } from '@/widgets/search-bar' import { useSession } from '@/entities/session';
export function AppLayout() { export function AppLayout() {
const { isAuthenticated, user, logout } = useSession() const { isAuthenticated, user, logout } = useSession();
const navigate = useNavigate() const navigate = useNavigate();
async function handleLogout() { async function handleLogout() {
await logout() await logout();
navigate('/') navigate('/');
} }
return ( return (
@ -138,5 +138,5 @@ export function AppLayout() {
<Outlet /> <Outlet />
</main> </main>
</div> </div>
) );
} }

View File

@ -1 +1 @@
export { AppLayout } from './AppLayout' export { AppLayout } from './AppLayout';

View File

@ -1,11 +1,11 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { type ReactNode } from 'react';
import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '@fontsource/inter/400.css' import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css' import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css' import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css' import '@fontsource/inter/700.css';
import { MoexVibeThemeProvider } from '@moex-vibe/design-system/theme' import { MoexVibeThemeProvider } from '@moex-vibe/design-system/theme';
import { SessionProvider } from './SessionProvider' import { SessionProvider } from './SessionProvider';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -15,7 +15,7 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, },
}, },
}) });
export function AppProviders({ children }: { children: ReactNode }) { export function AppProviders({ children }: { children: ReactNode }) {
return ( return (
@ -24,5 +24,5 @@ export function AppProviders({ children }: { children: ReactNode }) {
<SessionProvider>{children}</SessionProvider> <SessionProvider>{children}</SessionProvider>
</QueryClientProvider> </QueryClientProvider>
</MoexVibeThemeProvider> </MoexVibeThemeProvider>
) );
} }

View File

@ -1,27 +1,27 @@
import { server } from '@mocks/server' import { describe, it, expect } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useContext } from 'react';
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw' import { http, HttpResponse } from 'msw';
import { useContext } from 'react' import { server } from '@/shared/lib/test/server';
import { describe, expect, it } from 'vitest' import { SessionContext } from '@/entities/session';
import { SessionContext } from '@/entities/session' import { SessionProvider } from './SessionProvider';
import { SessionProvider } from './SessionProvider' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const API = '/api/v1' const API = '/api/v1';
function renderWithProviders(ui: React.ReactElement) { function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<SessionProvider>{ui}</SessionProvider> <SessionProvider>{ui}</SessionProvider>
</QueryClientProvider>, </QueryClientProvider>,
) );
} }
function TestConsumer() { function TestConsumer() {
const ctx = useContext(SessionContext) const ctx = useContext(SessionContext);
if (!ctx) return <div>no context</div> if (!ctx) return <div>no context</div>;
return ( return (
<div> <div>
<span data-testid="session">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span> <span data-testid="session">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span>
@ -31,44 +31,44 @@ function TestConsumer() {
<button onClick={() => ctx.logout()}>logout</button> <button onClick={() => ctx.logout()}>logout</button>
<button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button> <button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button>
</div> </div>
) );
} }
describe('SessionProvider', () => { describe('SessionProvider', () => {
it('starts unauthenticated when refresh fails', async () => { it('starts unauthenticated when refresh fails', async () => {
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))) server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
renderWithProviders(<TestConsumer />) renderWithProviders(<TestConsumer />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('session')).toHaveTextContent('anonymous') expect(screen.getByTestId('session')).toHaveTextContent('anonymous');
}) });
}) });
it('restores session on mount when refresh succeeds', async () => { it('restores session on mount when refresh succeeds', async () => {
renderWithProviders(<TestConsumer />) renderWithProviders(<TestConsumer />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('session')).toHaveTextContent('authenticated') expect(screen.getByTestId('session')).toHaveTextContent('authenticated');
expect(screen.getByTestId('email')).toHaveTextContent('user@test.com') expect(screen.getByTestId('email')).toHaveTextContent('user@test.com');
}) });
}) });
it('updates state after login', async () => { it('updates state after login', async () => {
server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 }))) server.use(http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })));
const user = userEvent.setup() const user = userEvent.setup();
renderWithProviders(<TestConsumer />) renderWithProviders(<TestConsumer />);
await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('anonymous')) await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('anonymous'));
await user.click(screen.getByRole('button', { name: 'login' })) await user.click(screen.getByRole('button', { name: 'login' }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('session')).toHaveTextContent('authenticated') expect(screen.getByTestId('session')).toHaveTextContent('authenticated');
}) });
}) });
it('updates state after logout', async () => { it('updates state after logout', async () => {
const user = userEvent.setup() const user = userEvent.setup();
renderWithProviders(<TestConsumer />) renderWithProviders(<TestConsumer />);
await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('authenticated')) await waitFor(() => expect(screen.getByTestId('session')).toHaveTextContent('authenticated'));
await user.click(screen.getByRole('button', { name: 'logout' })) await user.click(screen.getByRole('button', { name: 'logout' }));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('session')).toHaveTextContent('anonymous') expect(screen.getByTestId('session')).toHaveTextContent('anonymous');
}) });
}) });
}) });

View File

@ -1,99 +1,97 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react' import { useState, useEffect, useCallback, type ReactNode } from 'react';
import * as sessionApi from '@/entities/session' import * as sessionApi from '@/entities/session';
import { SessionContext, type SessionContextValue, useSessionStore } from '@/entities/session' import { SessionContext, type SessionContextValue } from '@/entities/session';
import { configureAuth } from '@/shared/api/client';
import { import {
setOnUnauthorized,
getAccessToken, getAccessToken,
handleUnauthorized, handleUnauthorized,
setOnUnauthorized, } from '@/entities/session/api/tokenManager';
} from '@/entities/session/api/tokenManager' import type { UserResponse } from '@/shared/api/responses';
import type { UserResponse } from '@/shared/api'
import { configureKyAuth } from '@/shared/api/kyClient'
export function SessionProvider({ children }: { children: ReactNode }) { export function SessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserResponse | null>(null) const [user, setUser] = useState<UserResponse | null>(null);
const [accessToken, setAccessTokenState] = useState<string | null>(null) const [accessToken, setAccessTokenState] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true);
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false);
const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => { const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => {
setUser(authData.user) setUser(authData.user);
setAccessTokenState(authData.accessToken) setAccessTokenState(authData.accessToken);
useSessionStore.getState().setSession(authData) }, []);
}, [])
const clearSession = useCallback(() => { const clearSession = useCallback(() => {
setUser(null) setUser(null);
setAccessTokenState(null) setAccessTokenState(null);
useSessionStore.getState().clearSession() }, []);
}, [])
const login = useCallback( const login = useCallback(
async (email: string, password: string) => { async (email: string, password: string) => {
const result = await sessionApi.login(email, password) const result = await sessionApi.login(email, password);
updateSession(result) updateSession(result);
}, },
[updateSession], [updateSession],
) );
const register = useCallback( const register = useCallback(
async (email: string, password: string, name?: string) => { async (email: string, password: string, name?: string) => {
const result = await sessionApi.register(email, password, name) const result = await sessionApi.register(email, password, name);
updateSession(result) updateSession(result);
}, },
[updateSession], [updateSession],
) );
const logout = useCallback(async () => { const logout = useCallback(async () => {
try { try {
await sessionApi.logout() await sessionApi.logout();
} catch { } catch {
// ignore network errors on logout // ignore network errors on logout
} }
clearSession() clearSession();
}, [clearSession]) }, [clearSession]);
const updateProfileFn = useCallback(async (data: { name?: string }) => { const updateProfileFn = useCallback(async (data: { name?: string }) => {
const result = await sessionApi.updateProfile(data) const result = await sessionApi.updateProfile(data);
setUser(result) setUser(result);
}, []) }, []);
// Try to restore session on mount // Try to restore session on mount
useEffect(() => { useEffect(() => {
let mounted = true let mounted = true;
async function init() { async function init() {
try { try {
const result = await sessionApi.refresh() const result = await sessionApi.refresh();
if (mounted) { if (mounted) {
updateSession(result) updateSession(result);
} }
} catch { } catch {
// No valid session // No valid session
} finally { } finally {
if (mounted) { if (mounted) {
setIsLoading(false) setIsLoading(false);
setInitialized(true) setInitialized(true);
} }
} }
} }
init() init();
return () => { return () => {
mounted = false mounted = false;
} };
}, [updateSession]) }, [updateSession]);
// Wire up auth config and auto-logout on unauthorized // Wire up auth config and auto-logout on unauthorized
useEffect(() => { useEffect(() => {
configureKyAuth({ configureAuth({
getAccessToken, getAccessToken,
handleUnauthorized, handleUnauthorized,
}) });
setOnUnauthorized(() => { setOnUnauthorized(() => {
clearSession() clearSession();
}) });
}, [clearSession]) }, [clearSession]);
if (!initialized && isLoading) { if (!initialized && isLoading) {
return ( return (
@ -108,7 +106,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
> >
Загрузка... Загрузка...
</div> </div>
) );
} }
const value: SessionContextValue = { const value: SessionContextValue = {
@ -120,7 +118,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
register, register,
logout, logout,
updateProfile: updateProfileFn, updateProfile: updateProfileFn,
} };
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider> return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
} }

View File

@ -1,2 +1,2 @@
export { AppProviders } from './AppProviders' export { SessionProvider } from './SessionProvider';
export { SessionProvider } from './SessionProvider' export { AppProviders } from './AppProviders';

View File

@ -0,0 +1,78 @@
import { Routes, Route } from 'react-router-dom';
import { AppLayout } from '../layouts/AppLayout';
import { ProtectedRoute } from './ProtectedRoute';
import { HomePage } from '@/pages/home';
import { StockPage } from '@/pages/stock';
import { BondPage } from '@/pages/bond';
import { LoginPage } from '@/pages/login';
import { RegisterPage } from '@/pages/register';
import { ProfilePage } from '@/pages/profile';
import { PortfoliosListPage, PortfolioDetailPage } from '@/pages/portfolios';
import { ScreenerPage } from '@/pages/screener';
import { BrokerAccountsPage } from '@/pages/broker-accounts';
import { BrokerAccountLayout } from '@/widgets/broker-account-layout';
import { BrokerAccountOverviewPage } from '@/pages/broker-account';
import { BrokerEventsPage } from '@/pages/broker-events';
import { BrokerPositionsPage } from '@/pages/broker-positions';
import { BrokerOperationsPage } from '@/pages/broker-operations';
export function AppRoutes() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/stocks/:secid" element={<StockPage />} />
<Route path="/bonds/:secid" element={<BondPage />} />
<Route path="/screener" element={<ScreenerPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
<Route
path="/portfolios"
element={
<ProtectedRoute>
<PortfoliosListPage />
</ProtectedRoute>
}
/>
<Route
path="/portfolios/:id"
element={
<ProtectedRoute>
<PortfolioDetailPage />
</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 path="events" element={<BrokerEventsPage />} />
</Route>
</Route>
</Routes>
);
}

View File

@ -0,0 +1,29 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useSession } from '@/entities/session';
import type { ReactNode } from 'react';
export function ProtectedRoute({ children }: { children: ReactNode }) {
const { isAuthenticated, isLoading } = useSession();
const location = useLocation();
if (isLoading) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: 40,
color: 'var(--color-text-secondary)',
}}
>
Загрузка...
</div>
);
}
if (!isAuthenticated) {
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
}
return <>{children}</>;
}

View File

@ -1 +1,2 @@
export { router } from './routeTree' export { AppRoutes } from './AppRoutes';
export { ProtectedRoute } from './ProtectedRoute';

View File

@ -1,169 +0,0 @@
import {
createRootRoute,
createRoute,
createRouter,
Outlet,
redirect,
} from '@tanstack/react-router'
import { useSessionStore } from '@/entities/session'
import { BondPage } from '@/pages/bond'
import { BrokerAccountOverviewPage } from '@/pages/broker-account'
import { BrokerAccountsPage } from '@/pages/broker-accounts'
import { BrokerEventsPage } from '@/pages/broker-events'
import { BrokerOperationsPage } from '@/pages/broker-operations'
import { BrokerPositionsPage } from '@/pages/broker-positions'
import { HomePage } from '@/pages/home'
import { LoginPage } from '@/pages/login'
import { PortfolioDetailPage, PortfoliosListPage } from '@/pages/portfolios'
import { ProfilePage } from '@/pages/profile'
import { RegisterPage } from '@/pages/register'
import { ScreenerPage } from '@/pages/screener'
import { StockPage } from '@/pages/stock'
import { BrokerAccountLayout } from '@/widgets/broker-account-layout'
import { AppLayout } from '../layouts/AppLayout'
function requireAuth() {
if (!useSessionStore.getState().isAuthenticated) {
throw redirect({ to: '/login' })
}
}
const rootRoute = createRootRoute({
component: () => <AppLayout />,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: HomePage,
})
const stockRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/stocks/$secid',
component: StockPage,
})
const bondRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/bonds/$secid',
component: BondPage,
})
const screenerRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/screener',
component: ScreenerPage,
})
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
component: LoginPage,
})
const registerRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/register',
component: RegisterPage,
})
const profileRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/profile',
beforeLoad: requireAuth,
component: ProfilePage,
})
const portfoliosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/portfolios',
beforeLoad: requireAuth,
component: PortfoliosListPage,
})
const portfolioDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/portfolios/$id',
beforeLoad: requireAuth,
component: PortfolioDetailPage,
})
const brokerRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/broker',
beforeLoad: requireAuth,
component: BrokerAccountsPage,
})
const brokerAccountRoot = createRoute({
getParentRoute: () => rootRoute,
path: '/broker/$accountId',
beforeLoad: requireAuth,
component: () => (
<BrokerAccountLayout>
<Outlet />
</BrokerAccountLayout>
),
})
const brokerAccountIndexRoute = createRoute({
getParentRoute: () => brokerAccountRoot,
path: '/',
component: BrokerAccountOverviewPage,
})
const brokerSharesRoute = createRoute({
getParentRoute: () => brokerAccountRoot,
path: '/shares',
component: () => <BrokerPositionsPage type="share" title="Акции" />,
})
const brokerBondsRoute = createRoute({
getParentRoute: () => brokerAccountRoot,
path: '/bonds',
component: () => <BrokerPositionsPage type="bond" title="Облигации" />,
})
const brokerOperationsRoute = createRoute({
getParentRoute: () => brokerAccountRoot,
path: '/operations',
component: BrokerOperationsPage,
})
const brokerEventsRoute = createRoute({
getParentRoute: () => brokerAccountRoot,
path: '/events',
component: BrokerEventsPage,
})
const routeTree = rootRoute.addChildren([
indexRoute,
stockRoute,
bondRoute,
screenerRoute,
loginRoute,
registerRoute,
profileRoute,
portfoliosRoute,
portfolioDetailRoute,
brokerRoute,
brokerAccountRoot.addChildren([
brokerAccountIndexRoute,
brokerSharesRoute,
brokerBondsRoute,
brokerOperationsRoute,
brokerEventsRoute,
]),
])
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

View File

@ -1 +0,0 @@
export { router } from './routeTree.tsx'

View File

@ -1,20 +1,22 @@
import { request } from '@/shared/api/client';
import type { import type {
ApiResponseMeta, ApiResponseMeta,
BondHistoryItem,
BondMarketData,
BondResponse, BondResponse,
BondMarketData,
BondHistoryItem,
CandleItem, CandleItem,
} from '@/shared/api' } from '@/shared/api/responses';
import { request } from '@/shared/api/kyClient'
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> { export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`) return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`);
} }
export function getBondMarketData( export function getBondMarketData(
secid: string, secid: string,
): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> { ): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
return request<BondMarketData>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`) return request<BondMarketData>(
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`,
);
} }
export function getBondHistory( export function getBondHistory(
@ -25,7 +27,7 @@ export function getBondHistory(
return request<BondHistoryItem[]>( return request<BondHistoryItem[]>(
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`, `/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`,
{ from, till }, { from, till },
) );
} }
export function getBondCandles( export function getBondCandles(
@ -38,5 +40,5 @@ export function getBondCandles(
interval, interval,
from, from,
till, till,
}) });
} }

View File

@ -1,3 +1,3 @@
export { getBond, getBondCandles, getBondHistory, getBondMarketData } from './api/bondApi' export { useBond } from './model/useBond';
export { useBond } from './model/useBond' export { useBondCandles } from './model/useBondCandles';
export { useBondCandles } from './model/useBondCandles' export { getBond, getBondMarketData, getBondHistory, getBondCandles } from './api/bondApi';

View File

@ -1,26 +1,26 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react' import { renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, expect, it } from 'vitest' import { useBond } from './useBond';
import { useBond } from './useBond' import { type ReactNode } from 'react';
function createWrapper() { function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) { return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
} };
} }
describe('useBond', () => { describe('useBond', () => {
it('returns bond data', async () => { it('returns bond data', async () => {
const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() }) const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true)) await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.shortName).toBe('ОФЗ 26238') expect(result.current.data?.shortName).toBe('ОФЗ 26238');
expect(result.current.data?.marketData.price).toBe(98.5) expect(result.current.data?.marketData.price).toBe(98.5);
}) });
it('returns error on 404', async () => { it('returns error on 404', async () => {
const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() }) const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isError).toBe(true)) await waitFor(() => expect(result.current.isError).toBe(true));
}) });
}) });

View File

@ -1,14 +1,14 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import type { BondResponse } from '@/shared/api' import { getBond } from '../api/bondApi';
import { getBond } from '../api/bondApi' import type { BondResponse } from '@/shared/api/responses';
export function useBond(secid: string) { export function useBond(secid: string) {
return useQuery<BondResponse>({ return useQuery<BondResponse>({
queryKey: ['bond', secid], queryKey: ['bond', secid],
queryFn: async () => { queryFn: async () => {
const res = await getBond(secid) const res = await getBond(secid);
return res.data return res.data;
}, },
staleTime: 900_000, staleTime: 900_000,
}) });
} }

View File

@ -1,18 +1,18 @@
import { server } from '@mocks/server' import { describe, it, expect } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook, waitFor } from '@testing-library/react';
import { renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { HttpResponse, http } from 'msw' import { http, HttpResponse } from 'msw';
import type { ReactNode } from 'react' import { server } from '@/shared/lib/test/server';
import { describe, expect, it } from 'vitest' import { useBondCandles } from './useBondCandles';
import { useBondCandles } from './useBondCandles' import { type ReactNode } from 'react';
const API = '/api/v1' const API = '/api/v1';
function createWrapper() { function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) { return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
} };
} }
describe('useBondCandles', () => { describe('useBondCandles', () => {
@ -20,24 +20,24 @@ describe('useBondCandles', () => {
const { result } = renderHook( const { result } = renderHook(
() => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'),
{ wrapper: createWrapper() }, { wrapper: createWrapper() },
) );
await waitFor(() => expect(result.current.isSuccess).toBe(true)) await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(2) expect(result.current.data).toHaveLength(2);
}) });
it('returns empty array when no candles', async () => { it('returns empty array when no candles', async () => {
server.use( server.use(
http.get(`${API}/securities/bonds/:secid/candles`, () => { http.get(`${API}/securities/bonds/:secid/candles`, () => {
return HttpResponse.json({ return HttpResponse.json({
data: { data: [], meta: { fromCache: false, cachedAt: null } }, data: { data: [], meta: { fromCache: false, cachedAt: null } },
}) });
}), }),
) );
const { result } = renderHook( const { result } = renderHook(
() => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'),
{ wrapper: createWrapper() }, { wrapper: createWrapper() },
) );
await waitFor(() => expect(result.current.isSuccess).toBe(true)) await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]) expect(result.current.data).toEqual([]);
}) });
}) });

View File

@ -1,14 +1,14 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import type { CandleItem } from '@/shared/api' import { getBondCandles } from '../api/bondApi';
import { getBondCandles } from '../api/bondApi' import type { CandleItem } from '@/shared/api/responses';
export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
return useQuery<CandleItem[]>({ return useQuery<CandleItem[]>({
queryKey: ['bondCandles', secid, interval, from, till], queryKey: ['bondCandles', secid, interval, from, till],
queryFn: async () => { queryFn: async () => {
const res = await getBondCandles(secid, interval, from, till) const res = await getBondCandles(secid, interval, from, till);
return res.data return res.data;
}, },
staleTime: 3600_000, staleTime: 3600_000,
}) });
} }

View File

@ -1,28 +1,28 @@
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api' import { request } from '@/shared/api/client';
import { request } from '@/shared/api/kyClient' import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
export type BrokerOperationQuery = { export type BrokerOperationQuery = {
from?: string from?: string;
to?: string to?: string;
cursor?: string cursor?: string;
limit?: number limit?: number;
instrumentId?: string instrumentId?: string;
operationTypes?: string operationTypes?: string;
state?: string state?: string;
} };
export function getBrokerAccounts(): Promise<{ export function getBrokerAccounts(): Promise<{
data: BrokerAccount[] data: BrokerAccount[];
meta: ApiResponseMeta meta: ApiResponseMeta;
}> { }> {
return request<BrokerAccount[]>('/api/v1/broker/accounts') return request<BrokerAccount[]>('/api/v1/broker/accounts');
} }
export function getBrokerPortfolio(accountId: string): Promise<{ export function getBrokerPortfolio(accountId: string): Promise<{
data: BrokerPortfolio data: BrokerPortfolio;
meta: ApiResponseMeta meta: ApiResponseMeta;
}> { }> {
return request<BrokerPortfolio>( return request<BrokerPortfolio>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`, `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
) );
} }

View File

@ -1,12 +1,12 @@
export { export { useBrokerAccounts } from './model/useBrokerAccounts';
type BrokerOperationQuery, export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios';
getBrokerAccounts, export { useBrokerPortfolio } from './model/useBrokerPortfolio';
getBrokerPortfolio,
} from './api/brokerAccountApi'
export { export {
aggregateBrokerAccounts, aggregateBrokerAccounts,
type BrokerAccountsAggregate, type BrokerAccountsAggregate,
} from './model/brokerAccountsOverview' } from './model/brokerAccountsOverview';
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios' export {
export { useBrokerAccounts } from './model/useBrokerAccounts' getBrokerAccounts,
export { useBrokerPortfolio } from './model/useBrokerPortfolio' getBrokerPortfolio,
type BrokerOperationQuery,
} from './api/brokerAccountApi';

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest';
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api/responses';
import { aggregateBrokerAccounts } from '../model/brokerAccountsOverview' import { aggregateBrokerAccounts } from '../model/brokerAccountsOverview';
function portfolio( function portfolio(
id: string, id: string,
@ -38,7 +38,7 @@ function portfolio(
cash: [{ currency, units: '0', nano: 0, value: cash }], cash: [{ currency, units: '0', nano: 0, value: cash }],
blockedCash: [], blockedCash: [],
asOf: '2026-06-19T10:00:00.000Z', asOf: '2026-06-19T10:00:00.000Z',
} };
} }
describe('aggregateBrokerAccounts', () => { describe('aggregateBrokerAccounts', () => {
@ -46,7 +46,7 @@ describe('aggregateBrokerAccounts', () => {
const result = aggregateBrokerAccounts([ const result = aggregateBrokerAccounts([
portfolio('a', 'RUB', 1_100, 100, 200), portfolio('a', 'RUB', 1_100, 100, 200),
portfolio('b', 'RUB', 2_200, 200, 300), portfolio('b', 'RUB', 2_200, 200, 300),
]) ]);
expect(result.portfolios).toEqual([ expect(result.portfolios).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -56,44 +56,44 @@ describe('aggregateBrokerAccounts', () => {
dailyPercent: 10, dailyPercent: 10,
allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 }, allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 },
}), }),
]) ]);
expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }]) expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }]);
}) });
it('keeps different currencies separate', () => { it('keeps different currencies separate', () => {
const result = aggregateBrokerAccounts([ const result = aggregateBrokerAccounts([
portfolio('rub', 'RUB', 1_100, 100, 200), portfolio('rub', 'RUB', 1_100, 100, 200),
portfolio('usd', 'USD', 550, 50, 25), portfolio('usd', 'USD', 550, 50, 25),
]) ]);
expect(result.portfolios.map(({ currency, total }) => ({ currency, total }))).toEqual([ expect(result.portfolios.map(({ currency, total }) => ({ currency, total }))).toEqual([
{ currency: 'RUB', total: 1_100 }, { currency: 'RUB', total: 1_100 },
{ currency: 'USD', total: 550 }, { currency: 'USD', total: 550 },
]) ]);
}) });
it('does not expose a daily percent when one account lacks daily data', () => { it('does not expose a daily percent when one account lacks daily data', () => {
const result = aggregateBrokerAccounts([ const result = aggregateBrokerAccounts([
portfolio('a', 'RUB', 1_100, 100, 200), portfolio('a', 'RUB', 1_100, 100, 200),
portfolio('b', 'RUB', 2_000, null, 300), portfolio('b', 'RUB', 2_000, null, 300),
]) ]);
expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null }) expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null });
}) });
it('does not expose a daily percent when start of day is non-positive', () => { it('does not expose a daily percent when start of day is non-positive', () => {
const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)]) const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)]);
expect(result.portfolios[0]).toMatchObject({ daily: 100, dailyPercent: null }) expect(result.portfolios[0]).toMatchObject({ daily: 100, dailyPercent: null });
}) });
it('clamps negative residual other allocation to zero', () => { it('clamps negative residual other allocation to zero', () => {
const overAllocated = portfolio('a', 'RUB', 1_000, 50, 100) const overAllocated = portfolio('a', 'RUB', 1_000, 50, 100);
overAllocated.totals.shares!.value = 700 overAllocated.totals.shares!.value = 700;
overAllocated.totals.bonds!.value = 400 overAllocated.totals.bonds!.value = 400;
overAllocated.totals.currencies!.value = 100 overAllocated.totals.currencies!.value = 100;
const result = aggregateBrokerAccounts([overAllocated]) const result = aggregateBrokerAccounts([overAllocated]);
expect(result.portfolios[0].allocation).toEqual({ expect(result.portfolios[0].allocation).toEqual({
shares: 700, shares: 700,
@ -101,27 +101,27 @@ describe('aggregateBrokerAccounts', () => {
etf: 0, etf: 0,
cash: 100, cash: 100,
other: 0, other: 0,
}) });
}) });
it('returns empty summaries for empty or unsupported portfolios', () => { it('returns empty summaries for empty or unsupported portfolios', () => {
const missingTotal = portfolio('a', 'RUB', 1_000, 50, 100) const missingTotal = portfolio('a', 'RUB', 1_000, 50, 100);
missingTotal.totals.portfolio = null missingTotal.totals.portfolio = null;
expect(aggregateBrokerAccounts([])).toEqual({ portfolios: [], cash: [] }) expect(aggregateBrokerAccounts([])).toEqual({ portfolios: [], cash: [] });
expect(aggregateBrokerAccounts([missingTotal])).toEqual({ expect(aggregateBrokerAccounts([missingTotal])).toEqual({
portfolios: [], portfolios: [],
cash: [{ currency: 'RUB', value: 100 }], cash: [{ currency: 'RUB', value: 100 }],
}) });
}) });
it('groups cash separately by currency', () => { it('groups cash separately by currency', () => {
const mixedCash = portfolio('a', 'RUB', 1_000, 50, 100) const mixedCash = portfolio('a', 'RUB', 1_000, 50, 100);
mixedCash.cash.push({ currency: 'USD', units: '0', nano: 0, value: 25 }) mixedCash.cash.push({ currency: 'USD', units: '0', nano: 0, value: 25 });
expect(aggregateBrokerAccounts([mixedCash]).cash).toEqual([ expect(aggregateBrokerAccounts([mixedCash]).cash).toEqual([
{ currency: 'RUB', value: 100 }, { currency: 'RUB', value: 100 },
{ currency: 'USD', value: 25 }, { currency: 'USD', value: 25 },
]) ]);
}) });
}) });

View File

@ -1,74 +1,74 @@
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api' import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
export interface BrokerCurrencyAllocationSummary { export interface BrokerCurrencyAllocationSummary {
shares: number shares: number;
bonds: number bonds: number;
etf: number etf: number;
cash: number cash: number;
other: number other: number;
} }
export interface BrokerCurrencyPortfolioSummary { export interface BrokerCurrencyPortfolioSummary {
currency: string currency: string;
total: number total: number;
daily: number | null daily: number | null;
dailyPercent: number | null dailyPercent: number | null;
allocation: BrokerCurrencyAllocationSummary allocation: BrokerCurrencyAllocationSummary;
} }
export interface BrokerCurrencyCashSummary { export interface BrokerCurrencyCashSummary {
currency: string currency: string;
value: number value: number;
} }
export interface BrokerAccountsAggregate { export interface BrokerAccountsAggregate {
portfolios: BrokerCurrencyPortfolioSummary[] portfolios: BrokerCurrencyPortfolioSummary[];
cash: BrokerCurrencyCashSummary[] cash: BrokerCurrencyCashSummary[];
} }
interface MutableCurrencySummary { interface MutableCurrencySummary {
currency: string currency: string;
total: number total: number;
daily: number | null daily: number | null;
dailyComparable: boolean dailyComparable: boolean;
allocation: BrokerCurrencyAllocationSummary allocation: BrokerCurrencyAllocationSummary;
} }
function moneyValue(money: BrokerMoney | null | undefined): number { function moneyValue(money: BrokerMoney | null | undefined): number {
return money?.value ?? 0 return money?.value ?? 0;
} }
export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string { export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
return type === 'iis' ? 'ИИС' : 'Брокерский счёт' return type === 'iis' ? 'ИИС' : 'Брокерский счёт';
} }
export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate { export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate {
const portfolioSummaries = new Map<string, MutableCurrencySummary>() const portfolioSummaries = new Map<string, MutableCurrencySummary>();
const cashSummaries = new Map<string, BrokerCurrencyCashSummary>() const cashSummaries = new Map<string, BrokerCurrencyCashSummary>();
for (const portfolio of portfolios) { for (const portfolio of portfolios) {
for (const cash of portfolio.cash) { for (const cash of portfolio.cash) {
if (!cash.currency) { if (!cash.currency) {
continue continue;
} }
const existingCash = cashSummaries.get(cash.currency) const existingCash = cashSummaries.get(cash.currency);
if (existingCash) { if (existingCash) {
existingCash.value += cash.value existingCash.value += cash.value;
} else { } else {
cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value }) cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value });
} }
} }
const totalMoney = portfolio.totals.portfolio const totalMoney = portfolio.totals.portfolio;
const currency = totalMoney?.currency const currency = totalMoney?.currency;
if (!totalMoney || !currency) { if (!totalMoney || !currency) {
continue continue;
} }
const existingSummary = portfolioSummaries.get(currency) const existingSummary = portfolioSummaries.get(currency);
const summary = const summary =
existingSummary ?? existingSummary ??
({ ({
@ -77,43 +77,45 @@ export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAc
daily: 0, daily: 0,
dailyComparable: true, dailyComparable: true,
allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 }, allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 },
} satisfies MutableCurrencySummary) } satisfies MutableCurrencySummary);
const total = totalMoney.value const total = totalMoney.value;
const shares = moneyValue(portfolio.totals.shares) const shares = moneyValue(portfolio.totals.shares);
const bonds = moneyValue(portfolio.totals.bonds) const bonds = moneyValue(portfolio.totals.bonds);
const etf = moneyValue(portfolio.totals.etf) const etf = moneyValue(portfolio.totals.etf);
const cash = moneyValue(portfolio.totals.currencies) const cash = moneyValue(portfolio.totals.currencies);
const other = Math.max(0, total - shares - bonds - etf - cash) const other = Math.max(0, total - shares - bonds - etf - cash);
summary.total += total summary.total += total;
summary.allocation.shares += shares summary.allocation.shares += shares;
summary.allocation.bonds += bonds summary.allocation.bonds += bonds;
summary.allocation.etf += etf summary.allocation.etf += etf;
summary.allocation.cash += cash summary.allocation.cash += cash;
summary.allocation.other += other summary.allocation.other += other;
const dailyMoney = portfolio.yields.daily const dailyMoney = portfolio.yields.daily;
const comparableDaily = dailyMoney && dailyMoney.currency === currency const comparableDaily = dailyMoney && dailyMoney.currency === currency;
if (!comparableDaily) { if (!comparableDaily) {
summary.daily = null summary.daily = null;
summary.dailyComparable = false summary.dailyComparable = false;
} else if (summary.dailyComparable) { } else if (summary.dailyComparable) {
summary.daily = (summary.daily ?? 0) + dailyMoney.value summary.daily = (summary.daily ?? 0) + dailyMoney.value;
} }
if (!existingSummary) { if (!existingSummary) {
portfolioSummaries.set(currency, summary) portfolioSummaries.set(currency, summary);
} }
} }
return { return {
portfolios: Array.from(portfolioSummaries.values()).map((summary) => { portfolios: Array.from(portfolioSummaries.values()).map((summary) => {
const daily = summary.dailyComparable ? summary.daily : null const daily = summary.dailyComparable ? summary.daily : null;
const startOfDay = daily === null ? null : summary.total - daily const startOfDay = daily === null ? null : summary.total - daily;
const dailyPercent = const dailyPercent =
daily === null || startOfDay === null || startOfDay <= 0 ? null : (daily / startOfDay) * 100 daily === null || startOfDay === null || startOfDay <= 0
? null
: (daily / startOfDay) * 100;
return { return {
currency: summary.currency, currency: summary.currency,
@ -121,8 +123,8 @@ export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAc
daily, daily,
dailyPercent, dailyPercent,
allocation: summary.allocation, allocation: summary.allocation,
} };
}), }),
cash: Array.from(cashSummaries.values()), cash: Array.from(cashSummaries.values()),
} };
} }

View File

@ -1,6 +1,6 @@
import { useQueries } from '@tanstack/react-query' import { useQueries } from '@tanstack/react-query';
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api' import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses';
import { getBrokerPortfolio } from '../api/brokerAccountApi' import { getBrokerPortfolio } from '../api/brokerAccountApi';
export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) { export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {
const queries = useQueries({ const queries = useQueries({
@ -11,7 +11,7 @@ export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {
retry: 2, retry: 2,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
})), })),
}) });
return accounts.map((account, index) => ({ account, query: queries[index] })) return accounts.map((account, index) => ({ account, query: queries[index] }));
} }

View File

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import type { BrokerAccount } from '@/shared/api' import type { BrokerAccount } from '@/shared/api/responses';
import { getBrokerAccounts } from '../api/brokerAccountApi' import { getBrokerAccounts } from '../api/brokerAccountApi';
export function useBrokerAccounts() { export function useBrokerAccounts() {
return useQuery<BrokerAccount[]>({ return useQuery<BrokerAccount[]>({
@ -9,5 +9,5 @@ export function useBrokerAccounts() {
staleTime: 3_600_000, staleTime: 3_600_000,
retry: 2, retry: 2,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}) });
} }

View File

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api/responses';
import { getBrokerPortfolio } from '../api/brokerAccountApi' import { getBrokerPortfolio } from '../api/brokerAccountApi';
export function useBrokerPortfolio(accountId: string | undefined) { export function useBrokerPortfolio(accountId: string | undefined) {
return useQuery<BrokerPortfolio>({ return useQuery<BrokerPortfolio>({
@ -10,5 +10,5 @@ export function useBrokerPortfolio(accountId: string | undefined) {
staleTime: 60_000, staleTime: 60_000,
retry: 2, retry: 2,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}) });
} }

View File

@ -1,11 +1,11 @@
import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api' import { request } from '@/shared/api/client';
import { request } from '@/shared/api/kyClient' import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses';
export type BrokerEventsQuery = { export type BrokerEventsQuery = {
from: string from: string;
to: string to: string;
types?: string types?: string;
} };
export function getBrokerEvents( export function getBrokerEvents(
accountId: string, accountId: string,
@ -18,5 +18,5 @@ export function getBrokerEvents(
to: query.to, to: query.to,
types: query.types, types: query.types,
}, },
) );
} }

View File

@ -1,2 +1,2 @@
export { type BrokerEventsQuery, getBrokerEvents } from './api/brokerEventApi' export { getBrokerEvents, type BrokerEventsQuery } from './api/brokerEventApi';
export { useBrokerEvents } from './model/useBrokerEvents' export { useBrokerEvents } from './model/useBrokerEvents';

View File

@ -1,20 +1,20 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react' import { renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react' import { type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getBrokerEvents } from '../api/brokerEventApi' import { getBrokerEvents } from '../api/brokerEventApi';
import { useBrokerEvents } from './useBrokerEvents' import { useBrokerEvents } from './useBrokerEvents';
vi.mock('../api/brokerEventApi', () => ({ vi.mock('../api/brokerEventApi', () => ({
getBrokerEvents: vi.fn(), getBrokerEvents: vi.fn(),
})) }));
function createWrapper(queryClient?: QueryClient) { function createWrapper(queryClient?: QueryClient) {
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }) const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) { return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider> return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
} };
} }
const mockEventsData = { const mockEventsData = {
@ -53,55 +53,55 @@ const mockEventsData = {
currency: 'RUB' as const, currency: 'RUB' as const,
}, },
], ],
} };
const query = { from: '2026-06-22', to: '2026-06-29' } const query = { from: '2026-06-22', to: '2026-06-29' };
describe('useBrokerEvents', () => { describe('useBrokerEvents', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks();
}) });
it('returns events data from API', async () => { it('returns events data from API', async () => {
vi.mocked(getBrokerEvents).mockResolvedValue({ vi.mocked(getBrokerEvents).mockResolvedValue({
data: mockEventsData, data: mockEventsData,
meta: { fromCache: false, cachedAt: null }, meta: { fromCache: false, cachedAt: null },
}) });
const { result } = renderHook(() => useBrokerEvents('acc-1', query), { const { result } = renderHook(() => useBrokerEvents('acc-1', query), {
wrapper: createWrapper(), wrapper: createWrapper(),
}) });
await waitFor(() => expect(result.current.isSuccess).toBe(true)) await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.summary.eventCount).toBe(3) expect(result.current.data?.summary.eventCount).toBe(3);
expect(getBrokerEvents).toHaveBeenCalledWith('acc-1', query) expect(getBrokerEvents).toHaveBeenCalledWith('acc-1', query);
}) });
it('reuses cache when query key matches', async () => { it('reuses cache when query key matches', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData( queryClient.setQueryData(
['broker', 'events', 'acc-1', '2026-06-22', '2026-06-29', 'dividend,coupon'], ['broker', 'events', 'acc-1', '2026-06-22', '2026-06-29', 'dividend,coupon'],
mockEventsData, mockEventsData,
) );
const { result } = renderHook( const { result } = renderHook(
() => useBrokerEvents('acc-1', { ...query, types: 'dividend,coupon' }), () => useBrokerEvents('acc-1', { ...query, types: 'dividend,coupon' }),
{ {
wrapper: createWrapper(queryClient), wrapper: createWrapper(queryClient),
}, },
) );
await waitFor(() => expect(result.current.data).toBe(mockEventsData)) await waitFor(() => expect(result.current.data).toBe(mockEventsData));
expect(getBrokerEvents).not.toHaveBeenCalled() expect(getBrokerEvents).not.toHaveBeenCalled();
}) });
it('is not enabled when accountId is undefined', async () => { it('is not enabled when accountId is undefined', async () => {
const { result } = renderHook(() => useBrokerEvents(undefined, query), { const { result } = renderHook(() => useBrokerEvents(undefined, query), {
wrapper: createWrapper(), wrapper: createWrapper(),
}) });
expect(result.current.isPending).toBe(true) expect(result.current.isPending).toBe(true);
expect(getBrokerEvents).not.toHaveBeenCalled() expect(getBrokerEvents).not.toHaveBeenCalled();
}) });
}) });

View File

@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import type { BrokerEventsData } from '@/shared/api' import type { BrokerEventsData } from '@/shared/api/responses';
import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi' import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi';
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) { export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
const { from, to, types } = query const { from, to, types } = query;
return useQuery<BrokerEventsData>({ return useQuery<BrokerEventsData>({
queryKey: ['broker', 'events', accountId, from, to, types], queryKey: ['broker', 'events', accountId, from, to, types],
enabled: Boolean(accountId), enabled: Boolean(accountId),
@ -11,5 +11,5 @@ export function useBrokerEvents(accountId: string | undefined, query: BrokerEven
staleTime: 300_000, staleTime: 300_000,
retry: 2, retry: 2,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}) });
} }

View File

@ -1,15 +1,15 @@
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api' import { request } from '@/shared/api/client';
import { request } from '@/shared/api/kyClient' import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses';
export type BrokerOperationQuery = { export type BrokerOperationQuery = {
from?: string from?: string;
to?: string to?: string;
cursor?: string cursor?: string;
limit?: number limit?: number;
instrumentId?: string instrumentId?: string;
operationTypes?: string operationTypes?: string;
state?: string state?: string;
} };
export function getBrokerOperations( export function getBrokerOperations(
accountId: string, accountId: string,
@ -26,5 +26,5 @@ export function getBrokerOperations(
operationTypes: query.operationTypes, operationTypes: query.operationTypes,
state: query.state, state: query.state,
}, },
) );
} }

View File

@ -1,9 +1,9 @@
export { type BrokerOperationQuery, getBrokerOperations } from './api/brokerOperationApi' export { getBrokerOperations, type BrokerOperationQuery } from './api/brokerOperationApi';
export { export {
BROKER_OPERATION_TYPE_OPTIONS, BROKER_OPERATION_TYPE_OPTIONS,
type BrokerOperationImpact,
getBrokerOperationImpact, getBrokerOperationImpact,
getBrokerOperationTypeLabel, getBrokerOperationTypeLabel,
isBrokerOperationType, isBrokerOperationType,
} from './model/operationFilters' type BrokerOperationImpact,
export { useBrokerOperations } from './model/useBrokerOperations' } from './model/operationFilters';
export { useBrokerOperations } from './model/useBrokerOperations';

View File

@ -1,17 +1,17 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest';
import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from '../model/operationFilters' import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from '../model/operationFilters';
describe('operationFilters', () => { describe('operationFilters', () => {
it('accepts only declared broker operation types', () => { it('accepts only declared broker operation types', () => {
expect(isBrokerOperationType('OPERATION_TYPE_BUY')).toBe(true) expect(isBrokerOperationType('OPERATION_TYPE_BUY')).toBe(true);
expect(isBrokerOperationType('unexpected')).toBe(false) expect(isBrokerOperationType('unexpected')).toBe(false);
}) });
it('keeps operation type option values unique and labels sorted for the filter', () => { it('keeps operation type option values unique and labels sorted for the filter', () => {
const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value) const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value);
const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label) const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label);
expect(new Set(values).size).toBe(values.length) expect(new Set(values).size).toBe(values.length);
expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru'))) expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru')));
}) });
}) });

View File

@ -1,6 +1,6 @@
import type { BrokerOperation } from '@/shared/api' import type { BrokerOperation } from '@/shared/api/responses';
export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown' export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown';
const TRADE_TYPES = new Set([ const TRADE_TYPES = new Set([
'OPERATION_TYPE_BUY', 'OPERATION_TYPE_BUY',
@ -11,14 +11,14 @@ const TRADE_TYPES = new Set([
'OPERATION_TYPE_SELL_MARGIN', 'OPERATION_TYPE_SELL_MARGIN',
'OPERATION_TYPE_DELIVERY_BUY', 'OPERATION_TYPE_DELIVERY_BUY',
'OPERATION_TYPE_DELIVERY_SELL', 'OPERATION_TYPE_DELIVERY_SELL',
]) ]);
const BOND_REPAYMENT_TYPES = new Set([ const BOND_REPAYMENT_TYPES = new Set([
'OPERATION_TYPE_BOND_REPAYMENT', 'OPERATION_TYPE_BOND_REPAYMENT',
'OPERATION_TYPE_BOND_REPAYMENT_FULL', 'OPERATION_TYPE_BOND_REPAYMENT_FULL',
]) ]);
const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']) const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']);
const TAX_TYPES = new Set([ const TAX_TYPES = new Set([
'OPERATION_TYPE_TAX', 'OPERATION_TYPE_TAX',
@ -26,35 +26,35 @@ const TAX_TYPES = new Set([
'OPERATION_TYPE_DIVIDEND_TAX', 'OPERATION_TYPE_DIVIDEND_TAX',
'OPERATION_TYPE_TAX_CORRECTION', 'OPERATION_TYPE_TAX_CORRECTION',
'OPERATION_TYPE_TAX_CORRECTION_COUPON', 'OPERATION_TYPE_TAX_CORRECTION_COUPON',
]) ]);
const FEE_TYPES = new Set([ const FEE_TYPES = new Set([
'OPERATION_TYPE_BROKER_FEE', 'OPERATION_TYPE_BROKER_FEE',
'OPERATION_TYPE_SERVICE_FEE', 'OPERATION_TYPE_SERVICE_FEE',
'OPERATION_TYPE_MARGIN_FEE', 'OPERATION_TYPE_MARGIN_FEE',
'OPERATION_TYPE_SUCCESS_FEE', 'OPERATION_TYPE_SUCCESS_FEE',
]) ]);
const TRANSFER_INPUT_TYPES = new Set([ const TRANSFER_INPUT_TYPES = new Set([
'OPERATION_TYPE_INPUT', 'OPERATION_TYPE_INPUT',
'OPERATION_TYPE_INPUT_SWIFT', 'OPERATION_TYPE_INPUT_SWIFT',
'OPERATION_TYPE_INPUT_ACQUIRING', 'OPERATION_TYPE_INPUT_ACQUIRING',
'OPERATION_TYPE_INP_MULTI', 'OPERATION_TYPE_INP_MULTI',
]) ]);
const TRANSFER_OUTPUT_TYPES = new Set([ const TRANSFER_OUTPUT_TYPES = new Set([
'OPERATION_TYPE_OUTPUT', 'OPERATION_TYPE_OUTPUT',
'OPERATION_TYPE_OUTPUT_SWIFT', 'OPERATION_TYPE_OUTPUT_SWIFT',
'OPERATION_TYPE_OUTPUT_ACQUIRING', 'OPERATION_TYPE_OUTPUT_ACQUIRING',
'OPERATION_TYPE_OUT_MULTI', 'OPERATION_TYPE_OUT_MULTI',
]) ]);
const SECURITY_TRANSFER_TYPES = new Set([ const SECURITY_TRANSFER_TYPES = new Set([
'OPERATION_TYPE_INPUT_SECURITIES', 'OPERATION_TYPE_INPUT_SECURITIES',
'OPERATION_TYPE_OUTPUT_SECURITIES', 'OPERATION_TYPE_OUTPUT_SECURITIES',
'OPERATION_TYPE_TRANS_IIS_BS', 'OPERATION_TYPE_TRANS_IIS_BS',
'OPERATION_TYPE_TRANS_BS_BS', 'OPERATION_TYPE_TRANS_BS_BS',
]) ]);
const OPERATION_TYPE_LABELS: Record<string, string> = { const OPERATION_TYPE_LABELS: Record<string, string> = {
OPERATION_TYPE_BUY: 'Покупка', OPERATION_TYPE_BUY: 'Покупка',
@ -82,7 +82,7 @@ const OPERATION_TYPE_LABELS: Record<string, string> = {
OPERATION_TYPE_OUTPUT: 'Вывод средств', OPERATION_TYPE_OUTPUT: 'Вывод средств',
OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг', OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг',
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг', OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
} };
export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray< export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray<
Readonly<{ value: string; label: string }> Readonly<{ value: string; label: string }>
@ -90,25 +90,25 @@ export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray<
Object.entries(OPERATION_TYPE_LABELS) Object.entries(OPERATION_TYPE_LABELS)
.map(([value, label]) => Object.freeze({ value, label })) .map(([value, label]) => Object.freeze({ value, label }))
.sort((left, right) => left.label.localeCompare(right.label, 'ru')), .sort((left, right) => left.label.localeCompare(right.label, 'ru')),
) );
const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value)) const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value));
export function isBrokerOperationType(value: string | null): value is string { export function isBrokerOperationType(value: string | null): value is string {
return value !== null && BROKER_OPERATION_TYPES.has(value) return value !== null && BROKER_OPERATION_TYPES.has(value);
} }
export function getBrokerOperationTypeLabel( export function getBrokerOperationTypeLabel(
operation: Pick<BrokerOperation, 'type' | 'description'>, operation: Pick<BrokerOperation, 'type' | 'description'>,
): string { ): string {
const knownLabel = OPERATION_TYPE_LABELS[operation.type] const knownLabel = OPERATION_TYPE_LABELS[operation.type];
if (knownLabel) return knownLabel if (knownLabel) return knownLabel;
if (operation.description) return operation.description if (operation.description) return operation.description;
return operation.type return operation.type
.replace(/^OPERATION_TYPE_/, '') .replace(/^OPERATION_TYPE_/, '')
.replace(/_/g, ' ') .replace(/_/g, ' ')
.toLowerCase() .toLowerCase();
} }
export function getBrokerOperationImpact( export function getBrokerOperationImpact(
@ -119,15 +119,15 @@ export function getBrokerOperationImpact(
BOND_REPAYMENT_TYPES.has(operation.type) || BOND_REPAYMENT_TYPES.has(operation.type) ||
SECURITY_TRANSFER_TYPES.has(operation.type) SECURITY_TRANSFER_TYPES.has(operation.type)
) { ) {
return 'neutral' return 'neutral';
} }
if (INCOME_TYPES.has(operation.type)) return 'adds' if (INCOME_TYPES.has(operation.type)) return 'adds';
if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces' if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces';
if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds' if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds';
if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces' if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces';
if (operation.category === 'tax' || operation.category === 'fee') return 'reduces' if (operation.category === 'tax' || operation.category === 'fee') return 'reduces';
if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds' if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds';
return 'unknown' return 'unknown';
} }

View File

@ -1,26 +1,26 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react' import { renderHook, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react' import { type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getBrokerOperations } from '../api/brokerOperationApi' import { getBrokerOperations } from '../api/brokerOperationApi';
import { useBrokerOperations } from '../model/useBrokerOperations' import { useBrokerOperations } from '../model/useBrokerOperations';
vi.mock('../api/brokerOperationApi', () => ({ vi.mock('../api/brokerOperationApi', () => ({
getBrokerOperations: vi.fn(), getBrokerOperations: vi.fn(),
})) }));
function createWrapper(queryClient?: QueryClient) { function createWrapper(queryClient?: QueryClient) {
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }) const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) { return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider> return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
} };
} }
describe('useBrokerOperations', () => { describe('useBrokerOperations', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks();
}) });
it('returns operations page data from API', async () => { it('returns operations page data from API', async () => {
vi.mocked(getBrokerOperations).mockResolvedValue({ vi.mocked(getBrokerOperations).mockResolvedValue({
@ -32,34 +32,34 @@ describe('useBrokerOperations', () => {
asOf: '2026-06-19T00:00:00.000Z', asOf: '2026-06-19T00:00:00.000Z',
}, },
meta: { fromCache: false, cachedAt: null }, meta: { fromCache: false, cachedAt: null },
}) });
const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), { const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), {
wrapper: createWrapper(), wrapper: createWrapper(),
}) });
await waitFor(() => expect(result.current.isSuccess).toBe(true)) await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.accountId).toBe('acc-1') expect(result.current.data?.accountId).toBe('acc-1');
expect(getBrokerOperations).toHaveBeenCalledWith('acc-1', { limit: 5 }) expect(getBrokerOperations).toHaveBeenCalledWith('acc-1', { limit: 5 });
}) });
it('reuses the broker operations cache key across the account overview and full history pages', async () => { it('reuses the broker operations cache key across the account overview and full history pages', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const cachedPage = { const cachedPage = {
accountId: 'acc-1', accountId: 'acc-1',
items: [], items: [],
nextCursor: null, nextCursor: null,
hasNext: false, hasNext: false,
asOf: '2026-06-19T00:00:00.000Z', asOf: '2026-06-19T00:00:00.000Z',
} };
queryClient.setQueryData(['broker', 'operations', 'acc-1', { limit: 5 }], cachedPage) queryClient.setQueryData(['broker', 'operations', 'acc-1', { limit: 5 }], cachedPage);
const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), { const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), {
wrapper: createWrapper(queryClient), wrapper: createWrapper(queryClient),
}) });
await waitFor(() => expect(result.current.data).toBe(cachedPage)) await waitFor(() => expect(result.current.data).toBe(cachedPage));
expect(getBrokerOperations).not.toHaveBeenCalled() expect(getBrokerOperations).not.toHaveBeenCalled();
}) });
}) });

View File

@ -1,6 +1,6 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query' import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { BrokerOperationsPage } from '@/shared/api' import type { BrokerOperationsPage } from '@/shared/api/responses';
import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOperationApi' import { getBrokerOperations, type BrokerOperationQuery } from '../api/brokerOperationApi';
export function useBrokerOperations( export function useBrokerOperations(
accountId: string | undefined, accountId: string | undefined,
@ -14,5 +14,5 @@ export function useBrokerOperations(
retry: 2, retry: 2,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}) });
} }

View File

@ -1,5 +1,5 @@
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api' import { request } from '@/shared/api/client';
import { request } from '@/shared/api/kyClient' import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses';
export function getBrokerPositions( export function getBrokerPositions(
accountId: string, accountId: string,
@ -12,5 +12,5 @@ export function getBrokerPositions(
limit: query.limit ? String(query.limit) : undefined, limit: query.limit ? String(query.limit) : undefined,
type: query.type, type: query.type,
}, },
) );
} }

View File

@ -1,12 +1,12 @@
export { getBrokerPositions } from './api/brokerPositionApi' export { getBrokerPositions } from './api/brokerPositionApi';
export { export {
buildBrokerAllocation,
type BrokerAllocationItem, type BrokerAllocationItem,
type BrokerAllocationKey, type BrokerAllocationKey,
buildBrokerAllocation, } from './model/brokerAllocation';
} from './model/brokerAllocation'
export { export {
type BrokerPositionGroup,
getBrokerInstrumentPath, getBrokerInstrumentPath,
getBrokerPositionGroup, getBrokerPositionGroup,
} from './model/brokerDisplay' type BrokerPositionGroup,
export { useBrokerPositions } from './model/useBrokerPositions' } from './model/brokerDisplay';
export { useBrokerPositions } from './model/useBrokerPositions';

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest';
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api' import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
import { buildBrokerAllocation } from './brokerAllocation' import { buildBrokerAllocation } from './brokerAllocation';
function money(value: number): BrokerMoney { function money(value: number): BrokerMoney {
return { return {
@ -8,16 +8,16 @@ function money(value: number): BrokerMoney {
units: String(Math.trunc(value)), units: String(Math.trunc(value)),
nano: 0, nano: 0,
value, value,
} };
} }
function portfolio( function portfolio(
values: Partial<Record<'shares' | 'bonds' | 'etf' | 'currencies' | 'portfolio', number | null>>, values: Partial<Record<'shares' | 'bonds' | 'etf' | 'currencies' | 'portfolio', number | null>>,
): BrokerPortfolio { ): BrokerPortfolio {
const total = (key: keyof typeof values): BrokerMoney | null => { const total = (key: keyof typeof values): BrokerMoney | null => {
const value = values[key] const value = values[key];
return value == null ? null : money(value) return value == null ? null : money(value);
} };
return { return {
account: { account: {
@ -53,7 +53,7 @@ function portfolio(
cash: [], cash: [],
blockedCash: [], blockedCash: [],
asOf: '2025-01-01T00:00:00.000Z', asOf: '2025-01-01T00:00:00.000Z',
} };
} }
describe('buildBrokerAllocation', () => { describe('buildBrokerAllocation', () => {
@ -72,71 +72,73 @@ describe('buildBrokerAllocation', () => {
{ key: 'other', label: 'Прочие', value: 50, percent: 5, color: '#aeb6c5' }, { key: 'other', label: 'Прочие', value: 50, percent: 5, color: '#aeb6c5' },
], ],
negative: [], negative: [],
}) });
}) });
it('omits zero-value sectors', () => { it('omits zero-value sectors', () => {
const result = buildBrokerAllocation( const result = buildBrokerAllocation(
portfolio({ shares: 600, bonds: 0, etf: null, currencies: 400, portfolio: 1000 }), portfolio({ shares: 600, bonds: 0, etf: null, currencies: 400, portfolio: 1000 }),
) );
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'cash']) expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'cash']);
expect(result.negative).toEqual([]) expect(result.negative).toEqual([]);
}) });
it('reports a negative residual outside the sectors', () => { it('reports a negative residual outside the sectors', () => {
const result = buildBrokerAllocation( const result = buildBrokerAllocation(
portfolio({ shares: 700, bonds: 300, etf: 100, currencies: 50, portfolio: 1000 }), portfolio({ shares: 700, bonds: 300, etf: 100, currencies: 50, portfolio: 1000 }),
) );
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds', 'etf', 'cash']) expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds', 'etf', 'cash']);
expect(result.negative).toEqual([ expect(result.negative).toEqual([
{ key: 'other', label: 'Прочие', value: -150, color: '#aeb6c5' }, { key: 'other', label: 'Прочие', value: -150, color: '#aeb6c5' },
]) ]);
}) });
it('ignores a tiny negative residual caused by decimal arithmetic', () => { it('ignores a tiny negative residual caused by decimal arithmetic', () => {
const result = buildBrokerAllocation(portfolio({ shares: 0.1, bonds: 0.2, portfolio: 0.3 })) const result = buildBrokerAllocation(portfolio({ shares: 0.1, bonds: 0.2, portfolio: 0.3 }));
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds']) expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds']);
expect(result.negative).toEqual([]) expect(result.negative).toEqual([]);
}) });
it('ignores a tiny positive residual caused by decimal arithmetic', () => { it('ignores a tiny positive residual caused by decimal arithmetic', () => {
const result = buildBrokerAllocation(portfolio({ shares: 0.3, portfolio: 0.30000000000000004 })) const result = buildBrokerAllocation(
portfolio({ shares: 0.3, portfolio: 0.30000000000000004 }),
);
expect(result.sectors.map(({ key }) => key)).toEqual(['shares']) expect(result.sectors.map(({ key }) => key)).toEqual(['shares']);
expect(result.negative).toEqual([]) expect(result.negative).toEqual([]);
}) });
it('returns no allocation for missing or nonpositive portfolio totals', () => { it('returns no allocation for missing or nonpositive portfolio totals', () => {
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: null }))).toEqual({ expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: null }))).toEqual({
total: 0, total: 0,
sectors: [], sectors: [],
negative: [], negative: [],
}) });
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: 0 }))).toEqual({ expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: 0 }))).toEqual({
total: 0, total: 0,
sectors: [], sectors: [],
negative: [], negative: [],
}) });
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: -10 }))).toEqual({ expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: -10 }))).toEqual({
total: -10, total: -10,
sectors: [], sectors: [],
negative: [], negative: [],
}) });
}) });
it('preserves named negative components when the portfolio total is nonpositive', () => { it('preserves named negative components when the portfolio total is nonpositive', () => {
expect(buildBrokerAllocation(portfolio({ shares: 100, bonds: -20, portfolio: 0 }))).toEqual({ expect(buildBrokerAllocation(portfolio({ shares: 100, bonds: -20, portfolio: 0 }))).toEqual({
total: 0, total: 0,
sectors: [], sectors: [],
negative: [{ key: 'bonds', label: 'Облигации', value: -20, color: '#e5a33c' }], negative: [{ key: 'bonds', label: 'Облигации', value: -20, color: '#e5a33c' }],
}) });
expect(buildBrokerAllocation(portfolio({ currencies: -30, etf: 5, portfolio: -10 }))).toEqual({ expect(buildBrokerAllocation(portfolio({ currencies: -30, etf: 5, portfolio: -10 }))).toEqual({
total: -10, total: -10,
sectors: [], sectors: [],
negative: [{ key: 'cash', label: 'Деньги', value: -30, color: '#7b63cf' }], negative: [{ key: 'cash', label: 'Деньги', value: -30, color: '#7b63cf' }],
}) });
}) });
}) });

View File

@ -1,16 +1,16 @@
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api/responses';
export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other' export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other';
export interface BrokerAllocationItem { export interface BrokerAllocationItem {
key: BrokerAllocationKey key: BrokerAllocationKey;
label: string label: string;
value: number value: number;
percent: number percent: number;
color: string color: string;
} }
type BrokerNegativeAllocationItem = Omit<BrokerAllocationItem, 'percent'> type BrokerNegativeAllocationItem = Omit<BrokerAllocationItem, 'percent'>;
const ALLOCATION_CONFIG: Array<Pick<BrokerAllocationItem, 'key' | 'label' | 'color'>> = [ const ALLOCATION_CONFIG: Array<Pick<BrokerAllocationItem, 'key' | 'label' | 'color'>> = [
{ key: 'shares', label: 'Акции', color: '#4969f5' }, { key: 'shares', label: 'Акции', color: '#4969f5' },
@ -18,40 +18,40 @@ const ALLOCATION_CONFIG: Array<Pick<BrokerAllocationItem, 'key' | 'label' | 'col
{ key: 'etf', label: 'ETF/фонды', color: '#62b889' }, { key: 'etf', label: 'ETF/фонды', color: '#62b889' },
{ key: 'cash', label: 'Деньги', color: '#7b63cf' }, { key: 'cash', label: 'Деньги', color: '#7b63cf' },
{ key: 'other', label: 'Прочие', color: '#aeb6c5' }, { key: 'other', label: 'Прочие', color: '#aeb6c5' },
] ];
export function buildBrokerAllocation(portfolio: BrokerPortfolio): { export function buildBrokerAllocation(portfolio: BrokerPortfolio): {
total: number total: number;
sectors: BrokerAllocationItem[] sectors: BrokerAllocationItem[];
negative: BrokerNegativeAllocationItem[] negative: BrokerNegativeAllocationItem[];
} { } {
const total = portfolio.totals.portfolio?.value ?? 0 const total = portfolio.totals.portfolio?.value ?? 0;
const shares = portfolio.totals.shares?.value ?? 0 const shares = portfolio.totals.shares?.value ?? 0;
const bonds = portfolio.totals.bonds?.value ?? 0 const bonds = portfolio.totals.bonds?.value ?? 0;
const etf = portfolio.totals.etf?.value ?? 0 const etf = portfolio.totals.etf?.value ?? 0;
const cash = portfolio.totals.currencies?.value ?? 0 const cash = portfolio.totals.currencies?.value ?? 0;
const namedValues: Record<Exclude<BrokerAllocationKey, 'other'>, number> = { const namedValues: Record<Exclude<BrokerAllocationKey, 'other'>, number> = {
shares, shares,
bonds, bonds,
etf, etf,
cash, cash,
} };
if (total <= 0) { if (total <= 0) {
const negative = ALLOCATION_CONFIG.filter( const negative = ALLOCATION_CONFIG.filter(
( (
item, item,
): item is (typeof ALLOCATION_CONFIG)[number] & { ): item is (typeof ALLOCATION_CONFIG)[number] & {
key: Exclude<BrokerAllocationKey, 'other'> key: Exclude<BrokerAllocationKey, 'other'>;
} => item.key !== 'other', } => item.key !== 'other',
) )
.filter((item) => namedValues[item.key] < 0) .filter((item) => namedValues[item.key] < 0)
.map((item) => ({ ...item, value: namedValues[item.key] })) .map((item) => ({ ...item, value: namedValues[item.key] }));
return { total, sectors: [], negative } return { total, sectors: [], negative };
} }
const mappedTotal = shares + bonds + etf + cash const mappedTotal = shares + bonds + etf + cash;
const residual = total - mappedTotal const residual = total - mappedTotal;
const residualTolerance = const residualTolerance =
Number.EPSILON * Number.EPSILON *
Math.max( Math.max(
@ -59,27 +59,27 @@ export function buildBrokerAllocation(portfolio: BrokerPortfolio): {
Math.abs(total), Math.abs(total),
Math.abs(shares) + Math.abs(bonds) + Math.abs(etf) + Math.abs(cash), Math.abs(shares) + Math.abs(bonds) + Math.abs(etf) + Math.abs(cash),
) * ) *
8 8;
const values: Record<BrokerAllocationKey, number> = { const values: Record<BrokerAllocationKey, number> = {
shares, shares,
bonds, bonds,
etf, etf,
cash, cash,
other: Math.abs(residual) <= residualTolerance ? 0 : residual, other: Math.abs(residual) <= residualTolerance ? 0 : residual,
} };
const sectors: BrokerAllocationItem[] = [] const sectors: BrokerAllocationItem[] = [];
const negative: BrokerNegativeAllocationItem[] = [] const negative: BrokerNegativeAllocationItem[] = [];
for (const item of ALLOCATION_CONFIG) { for (const item of ALLOCATION_CONFIG) {
const value = values[item.key] const value = values[item.key];
if (value > 0) { if (value > 0) {
sectors.push({ ...item, value, percent: (value / total) * 100 }) sectors.push({ ...item, value, percent: (value / total) * 100 });
} else if (value < 0) { } else if (value < 0) {
negative.push({ ...item, value }) negative.push({ ...item, value });
} }
} }
return { total, sectors, negative } return { total, sectors, negative };
} }

View File

@ -1,12 +1,12 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest';
import type { BrokerOperation, BrokerPosition } from '@/shared/api/responses';
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay';
import { import {
BROKER_OPERATION_TYPE_OPTIONS, BROKER_OPERATION_TYPE_OPTIONS,
getBrokerOperationImpact, getBrokerOperationImpact,
getBrokerOperationTypeLabel, getBrokerOperationTypeLabel,
isBrokerOperationType, isBrokerOperationType,
} from '@/entities/broker-operation' } from '@/entities/broker-operation';
import type { BrokerOperation, BrokerPosition } from '@/shared/api'
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'
function position(input: Partial<BrokerPosition>): BrokerPosition { function position(input: Partial<BrokerPosition>): BrokerPosition {
return { return {
@ -25,7 +25,7 @@ function position(input: Partial<BrokerPosition>): BrokerPosition {
expectedYieldPercent: null, expectedYieldPercent: null,
dailyYield: null, dailyYield: null,
...input, ...input,
} };
} }
function operation(input: Partial<BrokerOperation>): BrokerOperation { function operation(input: Partial<BrokerOperation>): BrokerOperation {
@ -53,70 +53,70 @@ function operation(input: Partial<BrokerOperation>): BrokerOperation {
quantity: null, quantity: null,
quantityDone: null, quantityDone: null,
...input, ...input,
} };
} }
describe('broker display helpers', () => { describe('broker display helpers', () => {
it('groups positions by instrument type', () => { it('groups positions by instrument type', () => {
expect(getBrokerPositionGroup(position({ instrumentType: 'share' }))).toBe('shares') expect(getBrokerPositionGroup(position({ instrumentType: 'share' }))).toBe('shares');
expect(getBrokerPositionGroup(position({ instrumentType: 'bond' }))).toBe('bonds') expect(getBrokerPositionGroup(position({ instrumentType: 'bond' }))).toBe('bonds');
expect(getBrokerPositionGroup(position({ instrumentType: 'etf' }))).toBe('other') expect(getBrokerPositionGroup(position({ instrumentType: 'etf' }))).toBe('other');
expect(getBrokerPositionGroup(position({ instrumentType: null }))).toBe('other') expect(getBrokerPositionGroup(position({ instrumentType: null }))).toBe('other');
}) });
it('builds stock and bond routes from instrument metadata', () => { it('builds stock and bond routes from instrument metadata', () => {
expect( expect(
getBrokerInstrumentPath({ ticker: 'sber', instrumentType: 'share', classCode: 'TQBR' }), getBrokerInstrumentPath({ ticker: 'sber', instrumentType: 'share', classCode: 'TQBR' }),
).toBe('/stocks/SBER') ).toBe('/stocks/SBER');
expect( expect(
getBrokerInstrumentPath({ getBrokerInstrumentPath({
ticker: 'SU26238RMFS5', ticker: 'SU26238RMFS5',
instrumentType: 'bond', instrumentType: 'bond',
classCode: 'TQOB', classCode: 'TQOB',
}), }),
).toBe('/bonds/SU26238RMFS5') ).toBe('/bonds/SU26238RMFS5');
expect( expect(
getBrokerInstrumentPath({ ticker: null, instrumentType: 'share', classCode: 'TQBR' }), getBrokerInstrumentPath({ ticker: null, instrumentType: 'share', classCode: 'TQBR' }),
).toBeNull() ).toBeNull();
expect( expect(
getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQTF' }), getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQTF' }),
).toBeNull() ).toBeNull();
}) });
it('uses class code fallback when instrument type is missing', () => { it('uses class code fallback when instrument type is missing', () => {
expect( expect(
getBrokerInstrumentPath({ ticker: 'SBER', instrumentType: null, classCode: 'TQBR' }), getBrokerInstrumentPath({ ticker: 'SBER', instrumentType: null, classCode: 'TQBR' }),
).toBe('/stocks/SBER') ).toBe('/stocks/SBER');
expect( expect(
getBrokerInstrumentPath({ ticker: 'RU000A0JX0J2', instrumentType: null, classCode: 'TQOB' }), getBrokerInstrumentPath({ ticker: 'RU000A0JX0J2', instrumentType: null, classCode: 'TQOB' }),
).toBe('/bonds/RU000A0JX0J2') ).toBe('/bonds/RU000A0JX0J2');
}) });
it('does not let class code override a known unsupported or conflicting instrument type', () => { it('does not let class code override a known unsupported or conflicting instrument type', () => {
expect( expect(
getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQBR' }), getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQBR' }),
).toBeNull() ).toBeNull();
expect( expect(
getBrokerInstrumentPath({ getBrokerInstrumentPath({
ticker: 'SU26238RMFS5', ticker: 'SU26238RMFS5',
instrumentType: 'bond', instrumentType: 'bond',
classCode: 'TQBR', classCode: 'TQBR',
}), }),
).toBe('/bonds/SU26238RMFS5') ).toBe('/bonds/SU26238RMFS5');
}) });
it('maps operation enum values to Russian labels', () => { it('maps operation enum values to Russian labels', () => {
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_COUPON' }))).toBe( expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_COUPON' }))).toBe(
'Выплата купона', 'Выплата купона',
) );
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_TAX' }))).toBe('Налог') expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_TAX' }))).toBe('Налог');
expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_BUY' }))).toBe('Покупка') expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_BUY' }))).toBe('Покупка');
expect( expect(
getBrokerOperationTypeLabel( getBrokerOperationTypeLabel(
operation({ type: 'OPERATION_TYPE_UNKNOWN_VALUE', description: 'Custom' }), operation({ type: 'OPERATION_TYPE_UNKNOWN_VALUE', description: 'Custom' }),
), ),
).toBe('Custom') ).toBe('Custom');
}) });
it('exposes independently selectable known operation types', () => { it('exposes independently selectable known operation types', () => {
expect(BROKER_OPERATION_TYPE_OPTIONS).toEqual( expect(BROKER_OPERATION_TYPE_OPTIONS).toEqual(
@ -126,28 +126,28 @@ describe('broker display helpers', () => {
{ value: 'OPERATION_TYPE_BOND_TAX', label: 'Налог по облигациям' }, { value: 'OPERATION_TYPE_BOND_TAX', label: 'Налог по облигациям' },
{ value: 'OPERATION_TYPE_DIVIDEND_TAX', label: 'Налог на дивиденды' }, { value: 'OPERATION_TYPE_DIVIDEND_TAX', label: 'Налог на дивиденды' },
]), ]),
) );
}) });
it('keeps operation type option values unique and labels in Russian order', () => { it('keeps operation type option values unique and labels in Russian order', () => {
const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value) const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value);
const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label) const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label);
expect(new Set(values).size).toBe(values.length) expect(new Set(values).size).toBe(values.length);
expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru'))) expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru')));
}) });
it('keeps operation type options immutable at runtime', () => { it('keeps operation type options immutable at runtime', () => {
expect(Object.isFrozen(BROKER_OPERATION_TYPE_OPTIONS)).toBe(true) expect(Object.isFrozen(BROKER_OPERATION_TYPE_OPTIONS)).toBe(true);
expect(BROKER_OPERATION_TYPE_OPTIONS.every((option) => Object.isFrozen(option))).toBe(true) expect(BROKER_OPERATION_TYPE_OPTIONS.every((option) => Object.isFrozen(option))).toBe(true);
}) });
it('validates only exact known operation type values', () => { it('validates only exact known operation type values', () => {
expect(isBrokerOperationType('OPERATION_TYPE_COUPON')).toBe(true) expect(isBrokerOperationType('OPERATION_TYPE_COUPON')).toBe(true);
expect(isBrokerOperationType('operation_type_coupon')).toBe(false) expect(isBrokerOperationType('operation_type_coupon')).toBe(false);
expect(isBrokerOperationType('OPERATION_TYPE_UNKNOWN')).toBe(false) expect(isBrokerOperationType('OPERATION_TYPE_UNKNOWN')).toBe(false);
expect(isBrokerOperationType(null)).toBe(false) expect(isBrokerOperationType(null)).toBe(false);
}) });
it('classifies operations by portfolio impact', () => { it('classifies operations by portfolio impact', () => {
expect( expect(
@ -158,7 +158,7 @@ describe('broker display helpers', () => {
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
}), }),
), ),
).toBe('adds') ).toBe('adds');
expect( expect(
getBrokerOperationImpact( getBrokerOperationImpact(
operation({ operation({
@ -167,7 +167,7 @@ describe('broker display helpers', () => {
payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 }, payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 },
}), }),
), ),
).toBe('reduces') ).toBe('reduces');
expect( expect(
getBrokerOperationImpact( getBrokerOperationImpact(
operation({ operation({
@ -176,11 +176,11 @@ describe('broker display helpers', () => {
payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
}), }),
), ),
).toBe('neutral') ).toBe('neutral');
expect(getBrokerOperationImpact(operation({ type: 'OPERATION_TYPE_UNSPECIFIED' }))).toBe( expect(getBrokerOperationImpact(operation({ type: 'OPERATION_TYPE_UNSPECIFIED' }))).toBe(
'unknown', 'unknown',
) );
}) });
it('keeps unknown operation types unclear even when they have non-zero payments', () => { it('keeps unknown operation types unclear even when they have non-zero payments', () => {
expect( expect(
@ -191,7 +191,7 @@ describe('broker display helpers', () => {
payment: { currency: 'RUB', units: '100', nano: 0, value: 100 }, payment: { currency: 'RUB', units: '100', nano: 0, value: 100 },
}), }),
), ),
).toBe('unknown') ).toBe('unknown');
expect( expect(
getBrokerOperationImpact( getBrokerOperationImpact(
operation({ operation({
@ -200,8 +200,8 @@ describe('broker display helpers', () => {
payment: { currency: 'RUB', units: '-100', nano: 0, value: -100 }, payment: { currency: 'RUB', units: '-100', nano: 0, value: -100 },
}), }),
), ),
).toBe('unknown') ).toBe('unknown');
}) });
it('classifies known income operation types as additions even with weak metadata', () => { it('classifies known income operation types as additions even with weak metadata', () => {
expect( expect(
@ -212,7 +212,7 @@ describe('broker display helpers', () => {
payment: null, payment: null,
}), }),
), ),
).toBe('adds') ).toBe('adds');
expect( expect(
getBrokerOperationImpact( getBrokerOperationImpact(
operation({ operation({
@ -221,6 +221,6 @@ describe('broker display helpers', () => {
payment: null, payment: null,
}), }),
), ),
).toBe('adds') ).toBe('adds');
}) });
}) });

View File

@ -1,46 +1,46 @@
import type { BrokerPosition } from '@/shared/api' import type { BrokerPosition } from '@/shared/api/responses';
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other' export type BrokerPositionGroup = 'shares' | 'bonds' | 'other';
type BrokerInstrumentLinkInput = { type BrokerInstrumentLinkInput = {
ticker: string | null ticker: string | null;
instrumentType: string | null instrumentType: string | null;
classCode: string | null classCode: string | null;
} };
const STOCK_CLASS_CODES = new Set(['TQBR']) const STOCK_CLASS_CODES = new Set(['TQBR']);
const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']) const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']);
export function getBrokerPositionGroup( export function getBrokerPositionGroup(
position: Pick<BrokerPosition, 'instrumentType'>, position: Pick<BrokerPosition, 'instrumentType'>,
): BrokerPositionGroup { ): BrokerPositionGroup {
const instrumentType = position.instrumentType?.toLowerCase() const instrumentType = position.instrumentType?.toLowerCase();
if (instrumentType === 'share') return 'shares' if (instrumentType === 'share') return 'shares';
if (instrumentType === 'bond') return 'bonds' if (instrumentType === 'bond') return 'bonds';
return 'other' return 'other';
} }
export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null { export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null {
const ticker = input.ticker?.trim().toUpperCase() const ticker = input.ticker?.trim().toUpperCase();
if (!ticker) return null if (!ticker) return null;
const instrumentType = input.instrumentType?.toLowerCase() const instrumentType = input.instrumentType?.toLowerCase();
const classCode = input.classCode?.toUpperCase() ?? null const classCode = input.classCode?.toUpperCase() ?? null;
if (instrumentType === 'share') { if (instrumentType === 'share') {
return `/stocks/${encodeURIComponent(ticker)}` return `/stocks/${encodeURIComponent(ticker)}`;
} }
if (instrumentType === 'bond') { if (instrumentType === 'bond') {
return `/bonds/${encodeURIComponent(ticker)}` return `/bonds/${encodeURIComponent(ticker)}`;
} }
if (instrumentType) return null if (instrumentType) return null;
if (classCode && STOCK_CLASS_CODES.has(classCode)) return `/stocks/${encodeURIComponent(ticker)}` if (classCode && STOCK_CLASS_CODES.has(classCode)) return `/stocks/${encodeURIComponent(ticker)}`;
if (classCode && BOND_CLASS_CODES.has(classCode)) return `/bonds/${encodeURIComponent(ticker)}` if (classCode && BOND_CLASS_CODES.has(classCode)) return `/bonds/${encodeURIComponent(ticker)}`;
return null return null;
} }

View File

@ -1,6 +1,6 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query' import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { BrokerPositionsPage } from '@/shared/api' import type { BrokerPositionsPage } from '@/shared/api/responses';
import { getBrokerPositions } from '../api/brokerPositionApi' import { getBrokerPositions } from '../api/brokerPositionApi';
export function useBrokerPositions( export function useBrokerPositions(
accountId: string | undefined, accountId: string | undefined,
@ -14,5 +14,5 @@ export function useBrokerPositions(
retry: 2, retry: 2,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}) });
} }

View File

@ -1,28 +1,33 @@
import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from '@/shared/api' import { request } from '@/shared/api/client';
import { request } from '@/shared/api/kyClient' import type {
AnalyticsResponse,
Portfolio,
PortfolioDetail,
Position,
} from '@/shared/api/responses';
export function getPortfolios(): Promise<{ export function getPortfolios(): Promise<{
data: Portfolio[] data: Portfolio[];
meta: { cachedAt: string | null; fromCache: boolean } meta: { cachedAt: string | null; fromCache: boolean };
}> { }> {
return request<Portfolio[]>('/api/v1/portfolios') return request<Portfolio[]>('/api/v1/portfolios');
} }
export function getPortfolio( export function getPortfolio(
id: number, id: number,
): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> { ): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<PortfolioDetail>(`/api/v1/portfolios/${id}`) return request<PortfolioDetail>(`/api/v1/portfolios/${id}`);
} }
export function createPortfolio(data: { export function createPortfolio(data: {
name: string name: string;
description?: string description?: string;
currency?: string currency?: string;
}): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> { }): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<Portfolio>('/api/v1/portfolios', undefined, { return request<Portfolio>('/api/v1/portfolios', undefined, {
method: 'POST', method: 'POST',
body: data, body: data,
}) });
} }
export function updatePortfolio( export function updatePortfolio(
@ -32,7 +37,7 @@ export function updatePortfolio(
return request<Portfolio>(`/api/v1/portfolios/${id}`, undefined, { return request<Portfolio>(`/api/v1/portfolios/${id}`, undefined, {
method: 'PATCH', method: 'PATCH',
body: data, body: data,
}) });
} }
export function deletePortfolio( export function deletePortfolio(
@ -40,41 +45,41 @@ export function deletePortfolio(
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> { ): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<null>(`/api/v1/portfolios/${id}`, undefined, { return request<null>(`/api/v1/portfolios/${id}`, undefined, {
method: 'DELETE', method: 'DELETE',
}) });
} }
export function addPosition( export function addPosition(
portfolioId: number, portfolioId: number,
data: { data: {
secid: string secid: string;
quantity: number quantity: number;
buyPrice?: number buyPrice?: number;
buyDate?: string buyDate?: string;
notes?: string notes?: string;
tags?: string[] tags?: string[];
}, },
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { ): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, { return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, {
method: 'POST', method: 'POST',
body: data, body: data,
}) });
} }
export function updatePosition( export function updatePosition(
portfolioId: number, portfolioId: number,
positionId: number, positionId: number,
data: { data: {
quantity?: number quantity?: number;
buyPrice?: number buyPrice?: number;
buyDate?: string buyDate?: string;
notes?: string notes?: string;
tags?: string[] tags?: string[];
}, },
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> { ): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
method: 'PATCH', method: 'PATCH',
body: data, body: data,
}) });
} }
export function removePosition( export function removePosition(
@ -83,11 +88,11 @@ export function removePosition(
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> { ): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<null>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { return request<null>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
method: 'DELETE', method: 'DELETE',
}) });
} }
export function getPortfolioAnalytics( export function getPortfolioAnalytics(
portfolioId: number, portfolioId: number,
): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> { ): Promise<{ data: AnalyticsResponse; meta: { cachedAt: string | null; fromCache: boolean } }> {
return request<AnalyticsResponse>(`/api/v1/portfolios/${portfolioId}/analytics`) return request<AnalyticsResponse>(`/api/v1/portfolios/${portfolioId}/analytics`);
} }

View File

@ -1,16 +1,16 @@
export { usePortfolio } from './model/usePortfolio';
export { usePortfolios } from './model/usePortfolios';
export { usePortfolioAnalytics } from './model/usePortfolioAnalytics';
export { usePortfolioMutations } from './model/usePortfolioMutations';
export { usePositionMutations } from './model/usePositionMutations';
export { export {
addPosition,
createPortfolio,
deletePortfolio,
getPortfolio,
getPortfolioAnalytics,
getPortfolios, getPortfolios,
removePosition, getPortfolio,
createPortfolio,
updatePortfolio, updatePortfolio,
deletePortfolio,
addPosition,
updatePosition, updatePosition,
} from './api/portfolioApi' removePosition,
export { usePortfolio } from './model/usePortfolio' getPortfolioAnalytics,
export { usePortfolioAnalytics } from './model/usePortfolioAnalytics' } from './api/portfolioApi';
export { usePortfolioMutations } from './model/usePortfolioMutations'
export { usePortfolios } from './model/usePortfolios'
export { usePositionMutations } from './model/usePositionMutations'

View File

@ -1,17 +1,17 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import type { PortfolioDetail } from '@/shared/api' import { getPortfolio } from '../api/portfolioApi';
import { getPortfolio } from '../api/portfolioApi' import type { PortfolioDetail } from '@/shared/api/responses';
export function usePortfolio(id: number) { export function usePortfolio(id: number) {
return useQuery<PortfolioDetail>({ return useQuery<PortfolioDetail>({
queryKey: ['portfolio', id], queryKey: ['portfolio', id],
queryFn: async () => { queryFn: async () => {
const res = await getPortfolio(id) const res = await getPortfolio(id);
return res.data return res.data;
}, },
staleTime: 900_000, staleTime: 900_000,
retry: 2, retry: 2,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
enabled: !!id, enabled: !!id,
}) });
} }

View File

@ -1,17 +1,17 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import type { AnalyticsResponse } from '@/shared/api' import { getPortfolioAnalytics } from '../api/portfolioApi';
import { getPortfolioAnalytics } from '../api/portfolioApi' import type { AnalyticsResponse } from '@/shared/api/responses';
export function usePortfolioAnalytics(portfolioId: number) { export function usePortfolioAnalytics(portfolioId: number) {
return useQuery<AnalyticsResponse>({ return useQuery<AnalyticsResponse>({
queryKey: ['portfolio', portfolioId, 'analytics'], queryKey: ['portfolio', portfolioId, 'analytics'],
queryFn: async () => { queryFn: async () => {
const res = await getPortfolioAnalytics(portfolioId) const res = await getPortfolioAnalytics(portfolioId);
return res.data return res.data;
}, },
staleTime: 900_000, staleTime: 900_000,
retry: 2, retry: 2,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
enabled: !!portfolioId, enabled: !!portfolioId,
}) });
} }

View File

@ -1,45 +1,45 @@
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router' import { createPortfolio, updatePortfolio, deletePortfolio } from '../api/portfolioApi';
import { createPortfolio, deletePortfolio, updatePortfolio } from '../api/portfolioApi' import { useNavigate } from 'react-router-dom';
export function usePortfolioMutations() { export function usePortfolioMutations() {
const queryClient = useQueryClient() const queryClient = useQueryClient();
const navigate = useNavigate() const navigate = useNavigate();
const create = useMutation({ const create = useMutation({
mutationFn: (data: { name: string; description?: string; currency?: string }) => mutationFn: (data: { name: string; description?: string; currency?: string }) =>
createPortfolio(data), createPortfolio(data),
onSuccess: (res) => { onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['portfolios'] }) queryClient.invalidateQueries({ queryKey: ['portfolios'] });
navigate({ to: `/portfolios/${res.data.id}` }) navigate(`/portfolios/${res.data.id}`);
}, },
}) });
const update = useMutation({ const update = useMutation({
mutationFn: ({ mutationFn: ({
id, id,
data, data,
}: { }: {
id: number id: number;
data: { data: {
name?: string name?: string;
description?: string description?: string;
currency?: string currency?: string;
} };
}) => updatePortfolio(id, data), }) => updatePortfolio(id, data),
onSuccess: (_, { id }) => { onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['portfolios'] }) queryClient.invalidateQueries({ queryKey: ['portfolios'] });
queryClient.invalidateQueries({ queryKey: ['portfolio', id] }) queryClient.invalidateQueries({ queryKey: ['portfolio', id] });
}, },
}) });
const remove = useMutation({ const remove = useMutation({
mutationFn: (id: number) => deletePortfolio(id), mutationFn: (id: number) => deletePortfolio(id),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portfolios'] }) queryClient.invalidateQueries({ queryKey: ['portfolios'] });
navigate({ to: '/portfolios' }) navigate('/portfolios');
}, },
}) });
return { create, update, remove } return { create, update, remove };
} }

View File

@ -1,16 +1,16 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import type { Portfolio } from '@/shared/api' import { getPortfolios } from '../api/portfolioApi';
import { getPortfolios } from '../api/portfolioApi' import type { Portfolio } from '@/shared/api/responses';
export function usePortfolios() { export function usePortfolios() {
return useQuery<Portfolio[]>({ return useQuery<Portfolio[]>({
queryKey: ['portfolios'], queryKey: ['portfolios'],
queryFn: async () => { queryFn: async () => {
const res = await getPortfolios() const res = await getPortfolios();
return res.data return res.data;
}, },
staleTime: 900_000, staleTime: 900_000,
retry: 2, retry: 2,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}) });
} }

View File

@ -1,46 +1,46 @@
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { PortfolioDetail } from '@/shared/api' import { addPosition, updatePosition, removePosition } from '../api/portfolioApi';
import { addPosition, removePosition, updatePosition } from '../api/portfolioApi' import type { PortfolioDetail } from '@/shared/api/responses';
export function usePositionMutations(portfolioId: number) { export function usePositionMutations(portfolioId: number) {
const queryClient = useQueryClient() const queryClient = useQueryClient();
const add = useMutation({ const add = useMutation({
mutationFn: (data: { mutationFn: (data: {
secid: string secid: string;
quantity: number quantity: number;
buyPrice?: number buyPrice?: number;
buyDate?: string buyDate?: string;
notes?: string notes?: string;
tags?: string[] tags?: string[];
}) => addPosition(portfolioId, data), }) => addPosition(portfolioId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }) queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
}, },
}) });
const update = useMutation({ const update = useMutation({
mutationFn: ({ mutationFn: ({
positionId, positionId,
data, data,
}: { }: {
positionId: number positionId: number;
data: { data: {
quantity?: number quantity?: number;
buyPrice?: number buyPrice?: number;
buyDate?: string buyDate?: string;
notes?: string notes?: string;
tags?: string[] tags?: string[];
} };
}) => updatePosition(portfolioId, positionId, data), }) => updatePosition(portfolioId, positionId, data),
onMutate: async ({ positionId, data }) => { onMutate: async ({ positionId, data }) => {
await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] }) await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] });
const previous = queryClient.getQueryData<{ data: PortfolioDetail }>([ const previous = queryClient.getQueryData<{ data: PortfolioDetail }>([
'portfolio', 'portfolio',
portfolioId, portfolioId,
]) ]);
queryClient.setQueryData(['portfolio', portfolioId], (old: any) => { queryClient.setQueryData(['portfolio', portfolioId], (old: any) => {
if (!old) return old if (!old) return old;
return { return {
...old, ...old,
positions: old.positions.map((p: any) => positions: old.positions.map((p: any) =>
@ -53,26 +53,26 @@ export function usePositionMutations(portfolioId: number) {
} }
: p, : p,
), ),
} };
}) });
return { previous } return { previous };
}, },
onError: (_err, _vars, context) => { onError: (_err, _vars, context) => {
if (context?.previous) { if (context?.previous) {
queryClient.setQueryData(['portfolio', portfolioId], context.previous) queryClient.setQueryData(['portfolio', portfolioId], context.previous);
} }
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }) queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
}, },
}) });
const remove = useMutation({ const remove = useMutation({
mutationFn: (positionId: number) => removePosition(portfolioId, positionId), mutationFn: (positionId: number) => removePosition(portfolioId, positionId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }) queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
}, },
}) });
return { add, update, remove } return { add, update, remove };
} }

View File

@ -1,10 +1,10 @@
import type { SearchResultItem } from '@/shared/api' import { request } from '@/shared/api/client';
import { request } from '@/shared/api/kyClient' import type { SearchResultItem } from '@/shared/api/responses';
export function searchSecurities(q: string, type: 'all' | 'share' | 'bond' = 'all', limit = 20) { export function searchSecurities(q: string, type: 'all' | 'share' | 'bond' = 'all', limit = 20) {
return request<SearchResultItem[]>('/api/v1/securities/search', { return request<SearchResultItem[]>('/api/v1/securities/search', {
q, q,
type, type,
limit: String(limit), limit: String(limit),
}) });
} }

View File

@ -1,2 +1,2 @@
export { searchSecurities } from './api/searchApi' export { useSearch } from './model/useSearch';
export { useSearch } from './model/useSearch' export { searchSecurities } from './api/searchApi';

View File

@ -1,46 +1,46 @@
import { server } from '@mocks/server' import { describe, it, expect } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook, waitFor } from '@testing-library/react';
import { renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { HttpResponse, http } from 'msw' import { http, HttpResponse } from 'msw';
import type { ReactNode } from 'react' import { type ReactNode } from 'react';
import { describe, expect, it } from 'vitest' import { server } from '@/shared/lib/test/server';
import { useSearch } from '@/entities/search' import { useSearch } from '@/entities/search';
const API = '/api/v1' const API = '/api/v1';
function createWrapper() { function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) { return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
} };
} }
describe('useSearch', () => { describe('useSearch', () => {
it('does not fetch when query is empty', () => { it('does not fetch when query is empty', () => {
const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() }) const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() });
expect(result.current.isFetching).toBe(false) expect(result.current.isFetching).toBe(false);
expect(result.current.data).toBeUndefined() expect(result.current.data).toBeUndefined();
}) });
it('does not fetch when query is too short', () => { it('does not fetch when query is too short', () => {
const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() }) const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() });
expect(result.current.data).toBeUndefined() expect(result.current.data).toBeUndefined();
}) });
it('returns search results for valid query', async () => { it('returns search results for valid query', async () => {
const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() }) const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() });
await waitFor(() => { await waitFor(() => {
expect(result.current.isSuccess).toBe(true) expect(result.current.isSuccess).toBe(true);
}) });
expect(result.current.data).toBeDefined() expect(result.current.data).toBeDefined();
expect(result.current.data?.length).toBeGreaterThan(0) expect(result.current.data?.length).toBeGreaterThan(0);
expect(result.current.data?.[0].secid).toBe('SBER') expect(result.current.data?.[0].secid).toBe('SBER');
}) });
it('returns empty array when no results', async () => { it('returns empty array when no results', async () => {
server.use( server.use(
@ -49,24 +49,24 @@ describe('useSearch', () => {
data: { data: [], meta: { fromCache: false, cachedAt: null } }, data: { data: [], meta: { fromCache: false, cachedAt: null } },
}), }),
), ),
) );
const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() }) const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() });
await waitFor(() => { await waitFor(() => {
expect(result.current.isSuccess).toBe(true) expect(result.current.isSuccess).toBe(true);
}) });
expect(result.current.data).toEqual([]) expect(result.current.data).toEqual([]);
}) });
it('returns error state on network failure', async () => { it('returns error state on network failure', async () => {
server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 }))) server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })));
const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() }) const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() });
await waitFor(() => { await waitFor(() => {
expect(result.current.isError).toBe(true) expect(result.current.isError).toBe(true);
}) });
}) });
}) });

View File

@ -1,16 +1,16 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import type { SearchResultItem } from '@/shared/api' import { searchSecurities } from '../api/searchApi';
import { searchSecurities } from '../api/searchApi' import type { SearchResultItem } from '@/shared/api/responses';
export function useSearch(query: string) { export function useSearch(query: string) {
return useQuery<SearchResultItem[]>({ return useQuery<SearchResultItem[]>({
queryKey: ['securities', 'search', query], queryKey: ['securities', 'search', query],
queryFn: async () => { queryFn: async () => {
const res = await searchSecurities(query) const res = await searchSecurities(query);
return res.data return res.data;
}, },
enabled: query.length >= 2, enabled: query.length >= 2,
staleTime: 60_000, staleTime: 60_000,
}) });
} }

View File

@ -1,22 +1,22 @@
import { server } from '@mocks/server' import { describe, it, expect, beforeEach } from 'vitest';
import { HttpResponse, http } from 'msw' import { http, HttpResponse } from 'msw';
import { beforeEach, describe, expect, it } from 'vitest' import { server } from '@/shared/lib/test/server';
import { getMe, login, logout, refresh, register, updateProfile } from './sessionApi' import { setAccessToken, getAccessToken } from './tokenManager';
import { getAccessToken, setAccessToken } from './tokenManager' import { login, register, refresh, logout, getMe, updateProfile } from './sessionApi';
const API = '/api/v1' const API = '/api/v1';
beforeEach(() => { beforeEach(() => {
setAccessToken(null) setAccessToken(null);
}) });
describe('login', () => { describe('login', () => {
it('returns auth data and sets access token', async () => { it('returns auth data and sets access token', async () => {
const result = await login('user@test.com', 'password') const result = await login('user@test.com', 'password');
expect(result.user.email).toBe('user@test.com') expect(result.user.email).toBe('user@test.com');
expect(result.accessToken).toBe('mock-access-token') expect(result.accessToken).toBe('mock-access-token');
expect(getAccessToken()).toBe('mock-access-token') expect(getAccessToken()).toBe('mock-access-token');
}) });
it('throws on invalid credentials', async () => { it('throws on invalid credentials', async () => {
server.use( server.use(
@ -24,45 +24,45 @@ describe('login', () => {
`${API}/auth/login`, `${API}/auth/login`,
() => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }), () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }),
), ),
) );
await expect(login('wrong@test.com', 'wrong')).rejects.toThrow() await expect(login('wrong@test.com', 'wrong')).rejects.toThrow();
}) });
}) });
describe('register', () => { describe('register', () => {
it('returns auth data and sets access token', async () => { it('returns auth data and sets access token', async () => {
const result = await register('new@test.com', 'password', 'New User') const result = await register('new@test.com', 'password', 'New User');
expect(result.user.email).toBe('user@test.com') expect(result.user.email).toBe('user@test.com');
expect(getAccessToken()).toBe('mock-access-token') expect(getAccessToken()).toBe('mock-access-token');
}) });
}) });
describe('refresh', () => { describe('refresh', () => {
it('returns auth data and sets access token', async () => { it('returns auth data and sets access token', async () => {
const result = await refresh() const result = await refresh();
expect(result.accessToken).toBe('mock-access-token') expect(result.accessToken).toBe('mock-access-token');
expect(getAccessToken()).toBe('mock-access-token') expect(getAccessToken()).toBe('mock-access-token');
}) });
}) });
describe('logout', () => { describe('logout', () => {
it('clears access token', async () => { it('clears access token', async () => {
setAccessToken('test-token') setAccessToken('test-token');
await logout() await logout();
expect(getAccessToken()).toBeNull() expect(getAccessToken()).toBeNull();
}) });
}) });
describe('getMe', () => { describe('getMe', () => {
it('returns current user', async () => { it('returns current user', async () => {
const result = await getMe() const result = await getMe();
expect(result.email).toBe('user@test.com') expect(result.email).toBe('user@test.com');
}) });
}) });
describe('updateProfile', () => { describe('updateProfile', () => {
it('updates and returns user', async () => { it('updates and returns user', async () => {
const result = await updateProfile({ name: 'Updated' }) const result = await updateProfile({ name: 'Updated' });
expect(result.name).toBe('Updated') expect(result.name).toBe('Updated');
}) });
}) });

View File

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

View File

@ -1,21 +1,21 @@
import type { AuthResponse } from '@/shared/api' import type { AuthResponse } from '@/shared/api/responses';
import { normalizeEnvelope } from '@/shared/api/kyClient' import { normalizeEnvelope } from '@/shared/api/client';
let accessToken: string | null = null let accessToken: string | null = null;
let onUnauthorized: (() => void) | null = null let onUnauthorized: (() => void) | null = null;
let isRefreshing = false let isRefreshing = false;
let refreshPromise: Promise<boolean> | null = null let refreshPromise: Promise<boolean> | null = null;
export function setAccessToken(token: string | null) { export function setAccessToken(token: string | null) {
accessToken = token accessToken = token;
} }
export function getAccessToken(): string | null { export function getAccessToken(): string | null {
return accessToken return accessToken;
} }
export function setOnUnauthorized(cb: () => void) { export function setOnUnauthorized(cb: () => void) {
onUnauthorized = cb onUnauthorized = cb;
} }
async function refreshTokens(): Promise<boolean> { async function refreshTokens(): Promise<boolean> {
@ -23,31 +23,31 @@ async function refreshTokens(): Promise<boolean> {
const res = await fetch('/api/v1/auth/refresh', { const res = await fetch('/api/v1/auth/refresh', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}) });
if (!res.ok) return false if (!res.ok) return false;
const json = await res.json() const json = await res.json();
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
return true return true;
} catch { } catch {
return false return false;
} }
} }
export async function handleUnauthorized(): Promise<boolean> { export async function handleUnauthorized(): Promise<boolean> {
if (isRefreshing && refreshPromise) { if (isRefreshing && refreshPromise) {
return refreshPromise return refreshPromise;
} }
isRefreshing = true isRefreshing = true;
refreshPromise = refreshTokens().then((success) => { refreshPromise = refreshTokens().then((success) => {
isRefreshing = false isRefreshing = false;
refreshPromise = null refreshPromise = null;
if (!success) { if (!success) {
accessToken = null accessToken = null;
onUnauthorized?.() onUnauthorized?.();
} }
return success return success;
}) });
return refreshPromise return refreshPromise;
} }

View File

@ -1,4 +1,3 @@
export { getMe, login, logout, refresh, register, updateProfile } from './api/sessionApi' export { login, register, refresh, logout, getMe, updateProfile } from './api/sessionApi';
export { SessionContext, type SessionContextValue } from './model/sessionContext' export { SessionContext, type SessionContextValue } from './model/sessionContext';
export { useSession } from './model/useSession' export { useSession } from './model/useSession';
export { useSessionStore } from './model/useSessionStore'

View File

@ -1 +1 @@
export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context' export { SessionContext, type SessionContextValue } from '@/shared/lib/session-context';

View File

@ -1,20 +1,20 @@
import { renderHook } from '@testing-library/react' import { describe, it, expect } from 'vitest';
import type { ReactNode } from 'react' import { renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest' import { SessionContext } from './sessionContext';
import { SessionContext } from './sessionContext' import { useSession } from './useSession';
import { useSession } from './useSession' import type { ReactNode } from 'react';
type SessionState = { type SessionState = {
user: { id: number; email: string; name: string; role: string } | null user: { id: number; email: string; name: string; role: string } | null;
accessToken: string | null accessToken: string | null;
isAuthenticated: boolean isAuthenticated: boolean;
isLoading: boolean isLoading: boolean;
login: () => Promise<void> login: () => Promise<void>;
logout: () => Promise<void> logout: () => Promise<void>;
register: () => Promise<void> register: () => Promise<void>;
updateProfile: () => Promise<void> updateProfile: () => Promise<void>;
refreshSession: () => Promise<void> refreshSession: () => Promise<void>;
} };
const mockSession: SessionState = { const mockSession: SessionState = {
user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' }, user: { id: 1, email: 'user@test.com', name: 'Test User', role: 'user' },
@ -26,34 +26,34 @@ const mockSession: SessionState = {
register: vi.fn().mockResolvedValue(undefined), register: vi.fn().mockResolvedValue(undefined),
updateProfile: vi.fn().mockResolvedValue(undefined), updateProfile: vi.fn().mockResolvedValue(undefined),
refreshSession: vi.fn().mockResolvedValue(undefined), refreshSession: vi.fn().mockResolvedValue(undefined),
} };
function createWrapper(session: SessionState = mockSession) { function createWrapper(session: SessionState = mockSession) {
return function Wrapper({ children }: { children: ReactNode }) { return function Wrapper({ children }: { children: ReactNode }) {
return <SessionContext.Provider value={session}>{children}</SessionContext.Provider> return <SessionContext.Provider value={session}>{children}</SessionContext.Provider>;
} };
} }
describe('useSession', () => { describe('useSession', () => {
it('returns session context with user', () => { it('returns session context with user', () => {
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }) const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
expect(result.current.isAuthenticated).toBe(true) expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user?.email).toBe('user@test.com') expect(result.current.user?.email).toBe('user@test.com');
expect(result.current.accessToken).toBe('mock-access-token') expect(result.current.accessToken).toBe('mock-access-token');
}) });
it('provides login function', () => { it('provides login function', () => {
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }) const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
expect(typeof result.current.login).toBe('function') expect(typeof result.current.login).toBe('function');
}) });
it('provides logout function', () => { it('provides logout function', () => {
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }) const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
expect(typeof result.current.logout).toBe('function') expect(typeof result.current.logout).toBe('function');
}) });
it('provides register function', () => { it('provides register function', () => {
const { result } = renderHook(() => useSession(), { wrapper: createWrapper() }) const { result } = renderHook(() => useSession(), { wrapper: createWrapper() });
expect(typeof result.current.register).toBe('function') expect(typeof result.current.register).toBe('function');
}) });
}) });

View File

@ -1,10 +1,10 @@
import { useContext } from 'react' import { useContext } from 'react';
import { SessionContext, type SessionContextValue } from './sessionContext' import { SessionContext, type SessionContextValue } from './sessionContext';
export function useSession(): SessionContextValue { export function useSession(): SessionContextValue {
const ctx = useContext(SessionContext) const ctx = useContext(SessionContext);
if (!ctx) { if (!ctx) {
throw new Error('useSession must be used within a SessionProvider') throw new Error('useSession must be used within a SessionProvider');
} }
return ctx return ctx;
} }

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