Compare commits

...

13 Commits

Author SHA1 Message Date
a13b5145f7 docs(frontend): align tooling spec with implementation
Some checks failed
CI / ci (pull_request) Failing after 3m32s
CI / ci (push) Failing after 2m50s
2026-06-23 21:30:11 +03:00
b26021016c fix(frontend): clean up mock setup and route params 2026-06-23 21:22:37 +03:00
76cffc061d fix: provide portfolio via BrokerAccountContext, fix Обзор link adding trailing dot 2026-06-23 20:41:23 +03:00
aea74bda3b fix: sync SessionProvider auth state to Zustand store for router guard
requireAuth() in routeTree.tsx reads isAuthenticated from useSessionStore
(Zustand), but SessionProvider only managed state via React context.
After login, the Zustand store stayed false, causing protected route guards
to redirect to /login even when authenticated.
2026-06-23 20:27:50 +03:00
9932278b64 feat: complete Phase 3 API type unification, delete stale test file
- Delete shared/api/responses.ts, move type re-exports to index.ts
- Update all 55+ imports from shared/api/responses to shared/api
- Delete stale client.test.ts (tested deleted client.ts)
- Run biome checks and fix import ordering
- Update tasks.md and plan.md to reflect actual approach
2026-06-23 20:24:16 +03:00
063e80c375 feat(openapi): unify frontend types with codegen, fix backend nullable DTOs
- Fix ApiResponseMeta nullable property (add type: String) to prevent Record<string, never> in codegen
- Fix broker events DTO nullable fields with proper type annotations
- Regenerate frontend types.ts from updated Swagger schema
- Replace all hand-written types in responses.ts with codegen aliases
- Remove stale BrokerPortfolioEvent/BrokerEventsSummary/BrokerEventsData interfaces
- Fix codegen output path in frontend package.json
2026-06-23 19:58:23 +03:00
5b794c0419 feat: add Swagger response DTOs for all missing endpoints
- Shares: ShareEnvelopeDto, ShareMarketDataEnvelopeDto, DividendsEnvelopeDto,
  ShareHistoryEnvelopeDto, DividendItemDto, HistoryItemDto
- Bonds: BondEnvelopeDto, BondMarketDataEnvelopeDto, BondHistoryEnvelopeDto,
  BondHistoryItemDto
- Candles: CandleItemDto, CandleEnvelopeDto
- Securities: SearchResultItemDto, SearchEnvelopeDto
- Health: HealthResponseDto, HealthEnvelopeDto
- Add @ApiOkResponse decorators to all previously undocumented endpoints
- Reuse ApiResponseMeta from common for all envelope DTOs
2026-06-23 07:17:30 +03:00
cbb5d09bc3 docs: update tasks.md — mark Phase 2 prettier removal as complete 2026-06-23 06:58:55 +03:00
c7a8993b4a chore: remove prettier, consolidate formatting under biome
- Remove prettier dependency and config files (.prettierrc, .prettierignore)
- Update root format/format:check scripts to use biome only (via frontend)
- Update lint-staged: remove prettier --check, keep biome + eslint
- Update CI: remove redundant format:check step
- Update ADR-018 with code-first TanStack Router approach note
2026-06-23 06:58:35 +03:00
7e10b4b8aa docs: update tasks and plan to reflect actual implementation progress
Mark completed tasks across all 6 phases, document code-first
router approach deviation, update Phase 6 plan with actual steps
2026-06-23 06:54:29 +03:00
cfadb2adbe feat: migrate from react-router-dom to @tanstack/react-router (code-first)
- Create code-first route tree in src/app/routing/routeTree.tsx
- Replace ProtectedRoute with beforeLoad auth guards
- Add useSearchParamsCompat for URLSearchParams access
- Update App.tsx, layouts, and all page/widget imports
- Add frontend tooling: biome, prettier, env config
- Update all tests for TanStack Router compatibility
- Remove react-router-dom dependency, @tanstack/router-plugin
- Consolidate biome config at root level
2026-06-23 06:51:48 +03:00
6d3601a8f4 docs: add plan, tasks, research, and ADRs for frontend infrastructure tooling 2026-06-23 06:07:23 +03:00
203b7cbf20 docs: add spec for frontend infrastructure tooling 2026-06-23 06:04:25 +03:00
242 changed files with 6990 additions and 4835 deletions

View File

@ -26,9 +26,6 @@ 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

View File

@ -1,6 +0,0 @@
{
"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({ nullable: true }) @ApiProperty({ type: String, nullable: true })
cachedAt: string | null; cachedAt: string | null;
@ApiProperty() @ApiProperty()

View File

@ -1,26 +1,32 @@
import { Controller, Get, Param, Query } from '@nestjs/common'; import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } 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

@ -0,0 +1,28 @@
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

@ -0,0 +1,15 @@
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,15 +1,19 @@
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common'; import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } 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,
@ -19,6 +23,7 @@ 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

@ -0,0 +1,27 @@
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

@ -0,0 +1,11 @@
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

@ -0,0 +1,11 @@
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

@ -0,0 +1,12 @@
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,13 +1,17 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } 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

@ -0,0 +1,33 @@
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,12 +1,15 @@
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } 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(
@ -16,6 +19,7 @@ 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

@ -0,0 +1,12 @@
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

@ -0,0 +1,24 @@
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

@ -0,0 +1,38 @@
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,14 +1,23 @@
import { Controller, Get, Param, Query } from '@nestjs/common'; import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } 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 } };
@ -16,18 +25,21 @@ 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({ nullable: true }) @ApiProperty({ type: String, nullable: true })
paymentDate!: string | null; paymentDate!: string | null;
@ApiProperty({ nullable: true }) @ApiProperty({ type: String, nullable: true })
ticker!: string | null; ticker!: string | null;
@ApiProperty({ nullable: true }) @ApiProperty({ type: String, nullable: true })
name!: string | null; name!: string | null;
@ApiProperty({ nullable: true }) @ApiProperty({ type: String, nullable: true })
instrumentUid!: string | null; instrumentUid!: string | null;
@ApiProperty({ enum: instrumentTypes }) @ApiProperty({ enum: instrumentTypes })
instrumentType!: string; instrumentType!: string;
@ApiProperty({ nullable: true }) @ApiProperty({ type: Number, nullable: true })
quantitySnapshot!: number | null; quantitySnapshot!: number | null;
@ApiProperty({ nullable: true }) @ApiProperty({ type: Number, nullable: true })
payoutPerUnit!: number | null; payoutPerUnit!: number | null;
@ApiProperty({ nullable: true }) @ApiProperty({ type: Number, nullable: true })
estimatedAmount!: number | null; estimatedAmount!: number | null;
@ApiProperty({ nullable: true }) @ApiProperty({ type: Number, nullable: true })
actualAmount!: number | null; actualAmount!: number | null;
@ApiProperty({ nullable: true }) @ApiProperty({ type: String, nullable: true })
currency!: string | null; currency!: string | null;
@ApiProperty({ nullable: true }) @ApiProperty({ type: String, nullable: true, enum: ['current_position'] })
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({ nullable: true }) @ApiProperty({ type: String, nullable: true })
nearestEventDate!: string | null; nearestEventDate!: string | null;
@ApiProperty() @ApiProperty()

View File

@ -0,0 +1,67 @@
# 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

@ -0,0 +1,77 @@
# 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

@ -0,0 +1,64 @@
# 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

@ -17,6 +17,9 @@
| [ADR-013](ADR-013-frontend-fsd-broker-pilot) | Accepted | Пилотная FSD-миграция broker-домена | | [ADR-013](ADR-013-frontend-fsd-broker-pilot) | Accepted | Пилотная FSD-миграция broker-домена |
| [ADR-014](ADR-014-frontend-fsd-market-pages) | Accepted | FSD-миграция market pages и market widgets | | [ADR-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,21 +5,14 @@ module.exports = {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
parserOptions: { parserOptions: {
sourceType: 'module', sourceType: 'module',
ecmaFeatures: { jsx: true },
}, },
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import', '@conarti/feature-sliced'], plugins: ['@conarti/feature-sliced', 'import'],
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,
@ -29,63 +22,31 @@ 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

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

View File

@ -0,0 +1,11 @@
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

@ -0,0 +1,40 @@
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

@ -0,0 +1,189 @@
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

@ -0,0 +1,22 @@
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,138 @@
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

@ -0,0 +1,38 @@
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

@ -0,0 +1,20 @@
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

@ -0,0 +1,50 @@
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

@ -0,0 +1,151 @@
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

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

View File

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

View File

@ -5,10 +5,14 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "vite build",
"typecheck": "tsc -b",
"preview": "vite preview", "preview": "vite preview",
"codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts", "codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/shared/api/types.ts",
"lint": "eslint \"src/**/*.{ts,tsx}\"", "lint": "biome check src/",
"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"
}, },
@ -16,11 +20,12 @@
"@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",
"@moex-vibe/design-system": "*",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@moex-vibe/design-system": "*",
"@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",
@ -31,27 +36,25 @@
"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

@ -0,0 +1,349 @@
/* 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,10 +1,6 @@
import { BrowserRouter } from 'react-router-dom'; import { RouterProvider } from '@tanstack/react-router'
import { AppRoutes } from './routing/AppRoutes'; import { router } from './routing/router'
export default function App() { export default function App() {
return ( return <RouterProvider router={router} />
<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 { Outlet, Link, useNavigate } from 'react-router-dom'; import { Link, Outlet, useNavigate } from '@tanstack/react-router'
import { SearchBar } from '@/widgets/search-bar'; import { useSession } from '@/entities/session'
import { useSession } from '@/entities/session'; import { SearchBar } from '@/widgets/search-bar'
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 { type ReactNode } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ReactNode } from 'react'
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 { describe, it, expect } from 'vitest'; import { server } from '@mocks/server'
import { useContext } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
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 { http, HttpResponse } from 'msw'; import { HttpResponse, http } from 'msw'
import { server } from '@/shared/lib/test/server'; import { useContext } from 'react'
import { SessionContext } from '@/entities/session'; import { describe, expect, it } from 'vitest'
import { SessionProvider } from './SessionProvider'; import { SessionContext } from '@/entities/session'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SessionProvider } from './SessionProvider'
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,97 +1,99 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react'; import { type ReactNode, useCallback, useEffect, useState } from 'react'
import * as sessionApi from '@/entities/session'; import * as sessionApi from '@/entities/session'
import { SessionContext, type SessionContextValue } from '@/entities/session'; import { SessionContext, type SessionContextValue, useSessionStore } from '@/entities/session'
import { configureAuth } from '@/shared/api/client';
import { import {
setOnUnauthorized,
getAccessToken, getAccessToken,
handleUnauthorized, handleUnauthorized,
} from '@/entities/session/api/tokenManager'; setOnUnauthorized,
import type { UserResponse } from '@/shared/api/responses'; } from '@/entities/session/api/tokenManager'
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(() => {
configureAuth({ configureKyAuth({
getAccessToken, getAccessToken,
handleUnauthorized, handleUnauthorized,
}); })
setOnUnauthorized(() => { setOnUnauthorized(() => {
clearSession(); clearSession()
}); })
}, [clearSession]); }, [clearSession])
if (!initialized && isLoading) { if (!initialized && isLoading) {
return ( return (
@ -106,7 +108,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
> >
Загрузка... Загрузка...
</div> </div>
); )
} }
const value: SessionContextValue = { const value: SessionContextValue = {
@ -118,7 +120,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 { SessionProvider } from './SessionProvider'; export { AppProviders } from './AppProviders'
export { AppProviders } from './AppProviders'; export { SessionProvider } from './SessionProvider'

View File

@ -1,78 +0,0 @@
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

@ -1,29 +0,0 @@
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,2 +1 @@
export { AppRoutes } from './AppRoutes'; export { router } from './routeTree'
export { ProtectedRoute } from './ProtectedRoute';

View File

@ -0,0 +1,169 @@
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

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

View File

@ -1,22 +1,20 @@
import { request } from '@/shared/api/client';
import type { import type {
ApiResponseMeta, ApiResponseMeta,
BondResponse,
BondMarketData,
BondHistoryItem, BondHistoryItem,
BondMarketData,
BondResponse,
CandleItem, CandleItem,
} from '@/shared/api/responses'; } from '@/shared/api'
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>( return request<BondMarketData>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`)
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`,
);
} }
export function getBondHistory( export function getBondHistory(
@ -27,7 +25,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(
@ -40,5 +38,5 @@ export function getBondCandles(
interval, interval,
from, from,
till, till,
}); })
} }

View File

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

View File

@ -1,26 +1,26 @@
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 type { ReactNode } from 'react'
import { useBond } from './useBond'; import { describe, expect, it } from 'vitest'
import { type ReactNode } from 'react'; import { useBond } from './useBond'
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 { getBond } from '../api/bondApi'; import type { BondResponse } from '@/shared/api'
import type { BondResponse } from '@/shared/api/responses'; import { getBond } from '../api/bondApi'
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 { describe, it, expect } from 'vitest'; import { server } from '@mocks/server'
import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'; import { HttpResponse, http } from 'msw'
import { server } from '@/shared/lib/test/server'; import type { ReactNode } from 'react'
import { useBondCandles } from './useBondCandles'; import { describe, expect, it } from 'vitest'
import { type ReactNode } from 'react'; import { useBondCandles } from './useBondCandles'
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 { getBondCandles } from '../api/bondApi'; import type { CandleItem } from '@/shared/api'
import type { CandleItem } from '@/shared/api/responses'; import { getBondCandles } from '../api/bondApi'
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 { request } from '@/shared/api/client'; import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api'
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'; import { request } from '@/shared/api/kyClient'
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 { useBrokerAccounts } from './model/useBrokerAccounts'; export {
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'; type BrokerOperationQuery,
export { useBrokerPortfolio } from './model/useBrokerPortfolio'; getBrokerAccounts,
getBrokerPortfolio,
} from './api/brokerAccountApi'
export { export {
aggregateBrokerAccounts, aggregateBrokerAccounts,
type BrokerAccountsAggregate, type BrokerAccountsAggregate,
} from './model/brokerAccountsOverview'; } from './model/brokerAccountsOverview'
export { export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'
getBrokerAccounts, export { useBrokerAccounts } from './model/useBrokerAccounts'
getBrokerPortfolio, export { useBrokerPortfolio } from './model/useBrokerPortfolio'
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/responses'; import type { BrokerPortfolio } from '@/shared/api'
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/responses'; import type { BrokerMoney, BrokerPortfolio } from '@/shared/api'
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,45 +77,43 @@ 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 daily === null || startOfDay === null || startOfDay <= 0 ? null : (daily / startOfDay) * 100
? null
: (daily / startOfDay) * 100;
return { return {
currency: summary.currency, currency: summary.currency,
@ -123,8 +121,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/responses'; import type { BrokerAccount, BrokerPortfolio } from '@/shared/api'
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/responses'; import type { BrokerAccount } from '@/shared/api'
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/responses'; import type { BrokerPortfolio } from '@/shared/api'
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 { request } from '@/shared/api/client'; import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api'
import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses'; import { request } from '@/shared/api/kyClient'
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 { getBrokerEvents, type BrokerEventsQuery } from './api/brokerEventApi'; export { type BrokerEventsQuery, getBrokerEvents } 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/responses'; import type { BrokerEventsData } from '@/shared/api'
import { getBrokerEvents, type BrokerEventsQuery } from '../api/brokerEventApi'; import { type BrokerEventsQuery, getBrokerEvents } 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 { request } from '@/shared/api/client'; import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api'
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses'; import { request } from '@/shared/api/kyClient'
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 { getBrokerOperations, type BrokerOperationQuery } from './api/brokerOperationApi'; export { type BrokerOperationQuery, getBrokerOperations } from './api/brokerOperationApi'
export { export {
BROKER_OPERATION_TYPE_OPTIONS, BROKER_OPERATION_TYPE_OPTIONS,
type BrokerOperationImpact,
getBrokerOperationImpact, getBrokerOperationImpact,
getBrokerOperationTypeLabel, getBrokerOperationTypeLabel,
isBrokerOperationType, isBrokerOperationType,
type BrokerOperationImpact, } from './model/operationFilters'
} from './model/operationFilters'; export { useBrokerOperations } from './model/useBrokerOperations'
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/responses'; import type { BrokerOperation } from '@/shared/api'
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/responses'; import type { BrokerOperationsPage } from '@/shared/api'
import { getBrokerOperations, type BrokerOperationQuery } from '../api/brokerOperationApi'; import { type BrokerOperationQuery, getBrokerOperations } 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 { request } from '@/shared/api/client'; import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api'
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses'; import { request } from '@/shared/api/kyClient'
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,
} from './model/brokerAllocation'; buildBrokerAllocation,
} from './model/brokerAllocation'
export { export {
type BrokerPositionGroup,
getBrokerInstrumentPath, getBrokerInstrumentPath,
getBrokerPositionGroup, getBrokerPositionGroup,
type BrokerPositionGroup, } from './model/brokerDisplay'
} from './model/brokerDisplay'; export { useBrokerPositions } from './model/useBrokerPositions'
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/responses'; import type { BrokerMoney, BrokerPortfolio } from '@/shared/api'
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,73 +72,71 @@ 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( const result = buildBrokerAllocation(portfolio({ shares: 0.3, portfolio: 0.30000000000000004 }))
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/responses'; import type { BrokerPortfolio } from '@/shared/api'
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/responses'; import type { BrokerPosition } from '@/shared/api'
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/responses'; import type { BrokerPositionsPage } from '@/shared/api'
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,33 +1,28 @@
import { request } from '@/shared/api/client'; import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from '@/shared/api'
import type { import { request } from '@/shared/api/kyClient'
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(
@ -37,7 +32,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(
@ -45,41 +40,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(
@ -88,11 +83,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 {
getPortfolios,
getPortfolio,
createPortfolio,
updatePortfolio,
deletePortfolio,
addPosition, addPosition,
updatePosition, createPortfolio,
removePosition, deletePortfolio,
getPortfolio,
getPortfolioAnalytics, getPortfolioAnalytics,
} from './api/portfolioApi'; getPortfolios,
removePosition,
updatePortfolio,
updatePosition,
} from './api/portfolioApi'
export { usePortfolio } from './model/usePortfolio'
export { usePortfolioAnalytics } from './model/usePortfolioAnalytics'
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 { getPortfolio } from '../api/portfolioApi'; import type { PortfolioDetail } from '@/shared/api'
import type { PortfolioDetail } from '@/shared/api/responses'; import { getPortfolio } from '../api/portfolioApi'
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 { getPortfolioAnalytics } from '../api/portfolioApi'; import type { AnalyticsResponse } from '@/shared/api'
import type { AnalyticsResponse } from '@/shared/api/responses'; import { getPortfolioAnalytics } from '../api/portfolioApi'
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 { createPortfolio, updatePortfolio, deletePortfolio } from '../api/portfolioApi'; import { useNavigate } from '@tanstack/react-router'
import { useNavigate } from 'react-router-dom'; import { createPortfolio, deletePortfolio, updatePortfolio } from '../api/portfolioApi'
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(`/portfolios/${res.data.id}`); navigate({ to: `/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('/portfolios'); navigate({ to: '/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 { getPortfolios } from '../api/portfolioApi'; import type { Portfolio } from '@/shared/api'
import type { Portfolio } from '@/shared/api/responses'; import { getPortfolios } from '../api/portfolioApi'
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 { addPosition, updatePosition, removePosition } from '../api/portfolioApi'; import type { PortfolioDetail } from '@/shared/api'
import type { PortfolioDetail } from '@/shared/api/responses'; import { addPosition, removePosition, updatePosition } from '../api/portfolioApi'
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 { request } from '@/shared/api/client'; import type { SearchResultItem } from '@/shared/api'
import type { SearchResultItem } from '@/shared/api/responses'; import { request } from '@/shared/api/kyClient'
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 { useSearch } from './model/useSearch'; export { searchSecurities } from './api/searchApi'
export { searchSecurities } from './api/searchApi'; export { useSearch } from './model/useSearch'

View File

@ -1,46 +1,46 @@
import { describe, it, expect } from 'vitest'; import { server } from '@mocks/server'
import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'; import { HttpResponse, http } from 'msw'
import { type ReactNode } from 'react'; import type { ReactNode } from 'react'
import { server } from '@/shared/lib/test/server'; import { describe, expect, it } from 'vitest'
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 { searchSecurities } from '../api/searchApi'; import type { SearchResultItem } from '@/shared/api'
import type { SearchResultItem } from '@/shared/api/responses'; import { searchSecurities } from '../api/searchApi'
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 { describe, it, expect, beforeEach } from 'vitest'; import { server } from '@mocks/server'
import { http, HttpResponse } from 'msw'; import { HttpResponse, http } from 'msw'
import { server } from '@/shared/lib/test/server'; import { beforeEach, describe, expect, it } from 'vitest'
import { setAccessToken, getAccessToken } from './tokenManager'; import { getMe, login, logout, refresh, register, updateProfile } from './sessionApi'
import { login, register, refresh, logout, getMe, updateProfile } from './sessionApi'; import { getAccessToken, setAccessToken } from './tokenManager'
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 { request } from '@/shared/api/client'; import type { AuthResponse, UserResponse } from '@/shared/api'
import { setAccessToken } from './tokenManager'; import { request } from '@/shared/api/kyClient'
import type { AuthResponse, UserResponse } from '@/shared/api/responses'; import { setAccessToken } from './tokenManager'
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/responses'; import type { AuthResponse } from '@/shared/api'
import { normalizeEnvelope } from '@/shared/api/client'; import { normalizeEnvelope } from '@/shared/api/kyClient'
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,3 +1,4 @@
export { login, register, refresh, logout, getMe, updateProfile } from './api/sessionApi'; export { getMe, login, logout, refresh, register, 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 { describe, it, expect } from 'vitest'; import { renderHook } from '@testing-library/react'
import { renderHook } from '@testing-library/react'; import type { ReactNode } from 'react'
import { SessionContext } from './sessionContext'; import { describe, expect, it } from 'vitest'
import { useSession } from './useSession'; import { SessionContext } from './sessionContext'
import type { ReactNode } from 'react'; import { useSession } from './useSession'
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