Compare commits

..

10 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
124 changed files with 1644 additions and 1089 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 +0,0 @@
apps/frontend/

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

@ -38,7 +38,7 @@
## Решение ## Решение
Мигрировать на **TanStack Router**. Мигрировать на **TanStack Router** (code-first approach).
Причины: Причины:
1. **Типобезопасность** — RouteTree generation исключает опечатки в путях и невалидные search params 1. **Типобезопасность** — RouteTree generation исключает опечатки в путях и невалидные search params
@ -48,6 +48,8 @@
5. **Search params** — типизированная валидация вместо строковых `useSearchParams` 5. **Search params** — типизированная валидация вместо строковых `useSearchParams`
6. **Меньший размер** — 3-4KB vs 8KB react-router-dom 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-плагина.
## Последствия ## Последствия
### Положительные ### Положительные
@ -58,14 +60,17 @@
### Риски ### Риски
- Переписывание всех роутов, компонентов навигации (`Link`, `useNavigate`) и тестов - Переписывание всех роутов, компонентов навигации (`Link`, `useNavigate`) и тестов
- `MemoryRouter` в тестах заменяется на `createMemoryRouter` из TanStack Router - `MemoryRouter` в тестах заменяется на `createMemoryHistory` + `RouterProvider` из TanStack Router
- Файловая структура роутов меняется — `src/app/routes/` с Route Tree generation - Файловая структура роутов меняется — `src/app/routing/` с code-first определением в `routeTree.tsx`
- TanStack Router не экспортирует `useSearchParams` — потребовалась обёртка `useSearchParamsCompat`
- `useNavigate` использует объектный синтаксис: `navigate({ to: '...' })` вместо строкового `navigate('...')`
- Learning curve для команды - Learning curve для команды
### Миграция ### Миграция
- Каждый роут переносится по одному - Все маршруты определены в одном `routeTree.tsx` (code-first)
- Старый `AppRoutes.tsx` сохраняется до полного прохождения тестов - Старый `AppRoutes.tsx` удалён после прохождения тестов
- `react-router-dom` удаляется только после верификации - `react-router-dom` удалён из зависимостей после верификации
- Все 125 тестов проходят, сборка зелёная
## Связанные документы ## Связанные документы
- `docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md` - `docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md`

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,5 @@
export function envelope(data: unknown) {
return {
data: { data, meta: { fromCache: false, cachedAt: null } },
}
}

View File

@ -8,7 +8,7 @@
"build": "vite build", "build": "vite build",
"typecheck": "tsc -b", "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": "biome check src/", "lint": "biome check src/",
"lint:fix": "biome check --write src/", "lint:fix": "biome check --write src/",
"format": "biome format --write src/", "format": "biome format --write src/",

View File

@ -1,3 +1,4 @@
import { server } from '@mocks/server'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 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'
@ -5,7 +6,6 @@ import { HttpResponse, http } from 'msw'
import { useContext } from 'react' import { useContext } from 'react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { SessionContext } from '@/entities/session' import { SessionContext } from '@/entities/session'
import { server } from '@/shared/lib/test/server'
import { SessionProvider } from './SessionProvider' import { SessionProvider } from './SessionProvider'
const API = '/api/v1' const API = '/api/v1'

View File

@ -1,13 +1,13 @@
import { type ReactNode, useCallback, useEffect, useState } 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 { import {
getAccessToken, getAccessToken,
handleUnauthorized, handleUnauthorized,
setOnUnauthorized, setOnUnauthorized,
} from '@/entities/session/api/tokenManager' } from '@/entities/session/api/tokenManager'
import type { UserResponse } from '@/shared/api'
import { configureKyAuth } from '@/shared/api/kyClient' import { configureKyAuth } from '@/shared/api/kyClient'
import type { UserResponse } from '@/shared/api/responses'
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)
@ -18,11 +18,13 @@ export function SessionProvider({ children }: { children: ReactNode }) {
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(

View File

@ -1,11 +1,11 @@
import { request } from '@/shared/api/kyClient'
import type { import type {
ApiResponseMeta, ApiResponseMeta,
BondHistoryItem, BondHistoryItem,
BondMarketData, BondMarketData,
BondResponse, 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)}`)

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { BondResponse } from '@/shared/api/responses' import type { BondResponse } from '@/shared/api'
import { getBond } from '../api/bondApi' import { getBond } from '../api/bondApi'
export function useBond(secid: string) { export function useBond(secid: string) {

View File

@ -1,9 +1,9 @@
import { server } from '@mocks/server'
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 { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { useBondCandles } from './useBondCandles' import { useBondCandles } from './useBondCandles'
const API = '/api/v1' const API = '/api/v1'

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { CandleItem } from '@/shared/api/responses' import type { CandleItem } from '@/shared/api'
import { getBondCandles } from '../api/bondApi' 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) {

View File

@ -1,5 +1,5 @@
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'
export type BrokerOperationQuery = { export type BrokerOperationQuery = {
from?: string from?: string

View File

@ -1,5 +1,5 @@
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(

View File

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

View File

@ -1,5 +1,5 @@
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[]) {

View File

@ -1,5 +1,5 @@
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() {

View File

@ -1,5 +1,5 @@
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) {

View File

@ -1,5 +1,5 @@
import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses'
export type BrokerEventsQuery = { export type BrokerEventsQuery = {
from: string from: string

View File

@ -1,5 +1,5 @@
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 { type BrokerEventsQuery, getBrokerEvents } 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) {

View File

@ -1,5 +1,5 @@
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses'
export type BrokerOperationQuery = { export type BrokerOperationQuery = {
from?: string from?: string

View File

@ -1,4 +1,4 @@
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'

View File

@ -1,5 +1,5 @@
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 { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOperationApi' import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOperationApi'
export function useBrokerOperations( export function useBrokerOperations(

View File

@ -1,5 +1,5 @@
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses'
export function getBrokerPositions( export function getBrokerPositions(
accountId: string, accountId: string,

View File

@ -1,5 +1,5 @@
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 {

View File

@ -1,4 +1,4 @@
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'

View File

@ -5,7 +5,7 @@ import {
getBrokerOperationTypeLabel, getBrokerOperationTypeLabel,
isBrokerOperationType, isBrokerOperationType,
} from '@/entities/broker-operation' } from '@/entities/broker-operation'
import type { BrokerOperation, BrokerPosition } from '@/shared/api/responses' import type { BrokerOperation, BrokerPosition } from '@/shared/api'
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay' import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'
function position(input: Partial<BrokerPosition>): BrokerPosition { function position(input: Partial<BrokerPosition>): BrokerPosition {

View File

@ -1,4 +1,4 @@
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'

View File

@ -1,5 +1,5 @@
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(

View File

@ -1,10 +1,5 @@
import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type {
AnalyticsResponse,
Portfolio,
PortfolioDetail,
Position,
} from '@/shared/api/responses'
export function getPortfolios(): Promise<{ export function getPortfolios(): Promise<{
data: Portfolio[] data: Portfolio[]

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { PortfolioDetail } from '@/shared/api/responses' import type { PortfolioDetail } from '@/shared/api'
import { getPortfolio } from '../api/portfolioApi' import { getPortfolio } from '../api/portfolioApi'
export function usePortfolio(id: number) { export function usePortfolio(id: number) {

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { AnalyticsResponse } from '@/shared/api/responses' import type { AnalyticsResponse } from '@/shared/api'
import { getPortfolioAnalytics } from '../api/portfolioApi' import { getPortfolioAnalytics } from '../api/portfolioApi'
export function usePortfolioAnalytics(portfolioId: number) { export function usePortfolioAnalytics(portfolioId: number) {

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { Portfolio } from '@/shared/api/responses' import type { Portfolio } from '@/shared/api'
import { getPortfolios } from '../api/portfolioApi' import { getPortfolios } from '../api/portfolioApi'
export function usePortfolios() { export function usePortfolios() {

View File

@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { PortfolioDetail } from '@/shared/api/responses' import type { PortfolioDetail } from '@/shared/api'
import { addPosition, removePosition, updatePosition } from '../api/portfolioApi' import { addPosition, removePosition, updatePosition } from '../api/portfolioApi'
export function usePositionMutations(portfolioId: number) { export function usePositionMutations(portfolioId: number) {

View File

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

View File

@ -1,10 +1,10 @@
import { server } from '@mocks/server'
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 { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { useSearch } from '@/entities/search' import { useSearch } from '@/entities/search'
import { server } from '@/shared/lib/test/server'
const API = '/api/v1' const API = '/api/v1'

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { SearchResultItem } from '@/shared/api/responses' import type { SearchResultItem } from '@/shared/api'
import { searchSecurities } from '../api/searchApi' import { searchSecurities } from '../api/searchApi'
export function useSearch(query: string) { export function useSearch(query: string) {

View File

@ -1,6 +1,6 @@
import { server } from '@mocks/server'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { getMe, login, logout, refresh, register, updateProfile } from './sessionApi' import { getMe, login, logout, refresh, register, updateProfile } from './sessionApi'
import { getAccessToken, setAccessToken } from './tokenManager' import { getAccessToken, setAccessToken } from './tokenManager'

View File

@ -1,5 +1,5 @@
import type { AuthResponse, UserResponse } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { AuthResponse, UserResponse } from '@/shared/api/responses'
import { setAccessToken } from './tokenManager' import { setAccessToken } from './tokenManager'
export async function login(email: string, password: string) { export async function login(email: string, password: string) {

View File

@ -1,5 +1,5 @@
import type { AuthResponse } from '@/shared/api'
import { normalizeEnvelope } from '@/shared/api/kyClient' import { normalizeEnvelope } from '@/shared/api/kyClient'
import type { AuthResponse } from '@/shared/api/responses'
let accessToken: string | null = null let accessToken: string | null = null
let onUnauthorized: (() => void) | null = null let onUnauthorized: (() => void) | null = null

View File

@ -1,5 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { UserResponse } from '@/shared/api/responses' import type { UserResponse } from '@/shared/api'
interface SessionState { interface SessionState {
user: UserResponse | null user: UserResponse | null

View File

@ -1,4 +1,3 @@
import { request } from '@/shared/api/kyClient'
import type { import type {
ApiResponseMeta, ApiResponseMeta,
CandleItem, CandleItem,
@ -6,7 +5,8 @@ import type {
ShareHistoryItem, ShareHistoryItem,
ShareResponse, ShareResponse,
StockMarketData, StockMarketData,
} from '@/shared/api/responses' } from '@/shared/api'
import { request } from '@/shared/api/kyClient'
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> { export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`) return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`)

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { ShareResponse } from '@/shared/api/responses' import type { ShareResponse } from '@/shared/api'
import { getShare } from '../api/stockApi' import { getShare } from '../api/stockApi'
export function useStock(secid: string) { export function useStock(secid: string) {

View File

@ -1,9 +1,9 @@
import { server } from '@mocks/server'
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 { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { useStockCandles } from './useStockCandles' import { useStockCandles } from './useStockCandles'
const API = '/api/v1' const API = '/api/v1'

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { CandleItem } from '@/shared/api/responses' import type { CandleItem } from '@/shared/api'
import { getShareCandles } from '../api/stockApi' import { getShareCandles } from '../api/stockApi'
export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {

View File

@ -1,9 +1,9 @@
import { server } from '@mocks/server'
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 { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { useStockDividends } from './useStockDividends' import { useStockDividends } from './useStockDividends'
const API = '/api/v1' const API = '/api/v1'

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { DividendItem } from '@/shared/api/responses' import type { DividendItem } from '@/shared/api'
import { getShareDividends } from '../api/stockApi' import { getShareDividends } from '../api/stockApi'
export function useStockDividends(secid: string) { export function useStockDividends(secid: string) {

View File

@ -1,5 +1,5 @@
import type { ScreenerResult } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { ScreenerResult } from '@/shared/api/responses'
export interface ScreenerQuery { export interface ScreenerQuery {
type: 'share' | 'bond' type: 'share' | 'bond'

View File

@ -1,5 +1,5 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { ScreenerResult } from '@/shared/api/responses' import type { ScreenerResult } from '@/shared/api'
interface Props { interface Props {
result: ScreenerResult result: ScreenerResult

View File

@ -7,7 +7,7 @@ import { env } from './shared/config/env'
async function startApp() { async function startApp() {
if (env.VITE_API_MOCK) { if (env.VITE_API_MOCK) {
const { worker } = await import('./shared/lib/test/browser') const { worker } = await import('../mocks/browser')
await worker.start({ onUnhandledRequest: 'bypass' }) await worker.start({ onUnhandledRequest: 'bypass' })
} }

View File

@ -1,3 +1,4 @@
import { server } from '@mocks/server'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { import {
createMemoryHistory, createMemoryHistory,
@ -10,7 +11,6 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider' import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider'
import { LoginPage } from './ui/LoginPage' import { LoginPage } from './ui/LoginPage'

View File

@ -9,7 +9,7 @@ import { PortfolioSummary } from '@/widgets/portfolio-summary'
import { SharePositionTable } from '@/widgets/share-positions-table' import { SharePositionTable } from '@/widgets/share-positions-table'
export function PortfolioDetailPage() { export function PortfolioDetailPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams({ from: '/portfolios/$id' })
const portfolioId = parseInt(id!, 10) const portfolioId = parseInt(id!, 10)
const { data: portfolio, isLoading, error } = usePortfolio(portfolioId) const { data: portfolio, isLoading, error } = usePortfolio(portfolioId)

View File

@ -1,8 +1,8 @@
import { server } from '@mocks/server'
import { screen, waitFor } from '@testing-library/react' import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { renderWithProviders } from '@/shared/lib/test/test-utils' import { renderWithProviders } from '@/shared/lib/test/test-utils'
import { ProfilePage } from './ui/ProfilePage' import { ProfilePage } from './ui/ProfilePage'

View File

@ -1,3 +1,4 @@
import { server } from '@mocks/server'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { import {
createMemoryHistory, createMemoryHistory,
@ -10,7 +11,6 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider' import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider'
import { RegisterPage } from './ui/RegisterPage' import { RegisterPage } from './ui/RegisterPage'

View File

@ -5,7 +5,7 @@ import { PriceChart } from '@/widgets/price-chart'
import { StockDetails } from '@/widgets/stock-details' import { StockDetails } from '@/widgets/stock-details'
export function StockPage() { export function StockPage() {
const { secid } = useParams<{ secid: string }>() const { secid } = useParams({ from: '/stocks/$secid' })
const { data: stock, isLoading, error } = useStock(secid!) const { data: stock, isLoading, error } = useStock(secid!)
const till = new Date().toISOString().split('T')[0] const till = new Date().toISOString().split('T')[0]
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]

View File

@ -1,163 +0,0 @@
import { HttpResponse, http } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest'
import {
getAccessToken,
handleUnauthorized,
setAccessToken,
setOnUnauthorized,
} from '@/entities/session/api/tokenManager'
import { server } from '@/shared/lib/test/server'
import { configureKyAuth, request } from './kyClient'
const API = '/api/v1'
beforeEach(() => {
setAccessToken(null)
configureKyAuth({
getAccessToken,
handleUnauthorized,
})
})
describe('request', () => {
it('makes GET request and returns data', async () => {
const result = await request<{ status: string; timestamp: string; uptime: number }>(
'/api/v1/health',
)
expect(result.data.status).toBe('ok')
})
it('supports the single API envelope shape documented by Swagger', async () => {
server.use(
http.get(`${API}/test-single-envelope`, () =>
HttpResponse.json({
data: { ok: true },
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
}),
),
)
const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope')
expect(result).toEqual({
data: { ok: true },
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
})
})
it('includes Authorization header when token is set', async () => {
setAccessToken('test-token')
let capturedAuth: string | null = null
server.use(
http.get(`${API}/test-auth`, ({ request }) => {
capturedAuth = request.headers.get('Authorization')
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
})
}),
)
await request('/api/v1/test-auth')
expect(capturedAuth).toBe('Bearer test-token')
})
it('retries on 401 and succeeds after refresh', async () => {
setAccessToken('expired-token')
let attempts = 0
server.use(
http.get(`${API}/test-retry`, ({ request }) => {
attempts++
const auth = request.headers.get('Authorization')
if (auth === 'Bearer expired-token') {
return new HttpResponse(null, { status: 401 })
}
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
})
}),
http.post(`${API}/auth/refresh`, () =>
HttpResponse.json({
data: {
data: {
user: { id: 1, email: 'user@test.com', name: null, role: 'user' },
accessToken: 'new-token',
},
meta: { fromCache: false, cachedAt: null },
},
}),
),
)
const result = await request<{ ok: boolean }>('/api/v1/test-retry')
expect(attempts).toBe(2)
expect(result.data).toEqual({ ok: true })
expect(getAccessToken()).toBe('new-token')
})
it('throws on persistent 401 and clears token', async () => {
setAccessToken('expired-token')
let unauthorizedCalled = false
setOnUnauthorized(() => {
unauthorizedCalled = true
})
server.use(
http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })),
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
)
await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла')
expect(getAccessToken()).toBeNull()
expect(unauthorizedCalled).toBe(true)
})
it('throws on non-ok response with status text', async () => {
server.use(
http.get(
`${API}/test-error`,
() => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' }),
),
)
await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404')
})
it('sends JSON body for POST requests', async () => {
let capturedBody: string | null = null
server.use(
http.post(`${API}/test-post`, async ({ request }) => {
capturedBody = await request.text()
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
})
}),
)
await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } })
expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' }))
})
it('does not send auth header when skipAuth is true', async () => {
setAccessToken('test-token')
let capturedAuth: string | null = null
server.use(
http.get(`${API}/test-skip`, ({ request }) => {
capturedAuth = request.headers.get('Authorization')
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
})
}),
)
await request('/api/v1/test-skip', undefined, { skipAuth: true })
expect(capturedAuth).toBeNull()
})
it('sets query params correctly', async () => {
let capturedUrl = ''
server.use(
http.get(`${API}/test-params`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
})
}),
)
await request('/api/v1/test-params', { q: 'sber', type: 'share' })
expect(capturedUrl).toContain('q=sber')
expect(capturedUrl).toContain('type=share')
})
})

View File

@ -1,33 +1,62 @@
import type { components } from './types'
export { configureKyAuth, getHealth, request } from './kyClient' export { configureKyAuth, getHealth, request } from './kyClient'
export type {
AnalyticsResponse, export type ApiResponseMeta = components['schemas']['ApiResponseMeta']
ApiEnvelope,
ApiResponseMeta, export interface ApiEnvelope<T> {
AuthResponse, data: T
BondHistoryItem, meta: ApiResponseMeta
BondMarketData, }
BondResponse,
BrokerAccount, // Auth
BrokerMoney, export type AuthResponse = components['schemas']['AuthTokenDataDto']
BrokerOperation, export type UserResponse = components['schemas']['AuthUserDto']
BrokerOperationCategory,
BrokerOperationsPage, // Shares
BrokerPortfolio, export type ShareResponse = components['schemas']['ShareResponseDto']
BrokerPosition, export type StockMarketData = components['schemas']['StockMarketDataDto']
BrokerPositionsPage, export type DividendItem = components['schemas']['DividendItemDto']
CandleItem, export type ShareHistoryItem = components['schemas']['HistoryItemDto']
DividendItem,
HealthResponse, // Bonds
Portfolio, export type BondResponse = components['schemas']['BondResponseDto']
PortfolioDetail, export type BondMarketData = components['schemas']['BondMarketDataDto']
PortfolioSummary, export type BondHistoryItem = components['schemas']['BondHistoryItemDto']
Position,
PositionWithPrice, // Candles
ScreenerItem, export type CandleItem = components['schemas']['CandleItemDto']
ScreenerResult,
SearchResultItem, // Search
ShareHistoryItem, export type SearchResultItem = components['schemas']['SearchResultItemDto']
ShareResponse,
StockMarketData, // Screener
UserResponse, export type ScreenerResult = components['schemas']['ScreenerResultDto']
} from './responses' export type ScreenerItem = components['schemas']['ScreenerItemDto']
// Portfolio
export type Portfolio = components['schemas']['PortfolioListResponseDto']
export type PortfolioDetail = components['schemas']['PortfolioDetailResponseDto']
export type Position = components['schemas']['PositionResponseDto']
export type PositionWithPrice = components['schemas']['PositionWithPriceDto']
export type PortfolioSummary = components['schemas']['PortfolioSummaryDto']
export type AnalyticsResponse = components['schemas']['AnalyticsResponseDto']
// Health
export type HealthResponse = components['schemas']['HealthResponseDto']
// Broker
export type BrokerAccount = components['schemas']['BrokerAccountResponseDto']
export type BrokerPortfolio = components['schemas']['BrokerPortfolioResponseDto']
export type BrokerMoney = components['schemas']['BrokerMoneyDto']
export type BrokerPosition = components['schemas']['BrokerPositionResponseDto']
export type BrokerOperation = components['schemas']['BrokerOperationResponseDto']
export type BrokerOperationsPage = components['schemas']['BrokerOperationsPageResponseDto']
export type BrokerPositionsPage = components['schemas']['BrokerPositionsPageResponseDto']
export type BrokerOperationCategory =
components['schemas']['BrokerOperationResponseDto']['category']
// Broker events
export type BrokerEventItem = components['schemas']['BrokerEventItemDto']
export type BrokerEventsData = components['schemas']['BrokerEventsDataDto']
export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto']

View File

@ -1,391 +0,0 @@
export interface ApiResponseMeta {
cachedAt: string | null
fromCache: boolean
}
export interface ApiEnvelope<T> {
data: T
meta: ApiResponseMeta
}
export interface StockMarketData {
price: number | null
change: number | null
changePercent: number | null
open: number | null
high: number | null
low: number | null
volume: number
value: number
issueCapitalization: number | null
updatedAt: string
}
export interface ShareResponse {
secid: string
isin: string
name: string
shortName: string
latName: string | null
listLevel: number
issueSize: number
faceValue: number
faceUnit: string
type: string
marketData: StockMarketData
}
export interface DividendItem {
registryCloseDate: string
value: number
currency: string
}
export interface ShareHistoryItem {
date: string
open: number
high: number
low: number
close: number
volume: number
value: number
}
export interface BondMarketData {
price: number | null
yieldToMaturity: number | null
duration: number | null
accruedInt: number | null
couponValue: number | null
couponPercent: number | null
nextCouponDate: string | null
open: number
high: number | null
low: number | null
volume: number
updatedAt: string
}
export interface BondResponse {
secid: string
isin: string
name: string
shortName: string
latName: string | null
listLevel: number
issueSize: number
faceValue: number
faceUnit: string
matDate: string
couponValue: number
couponPercent: number | null
couponPeriod: number
nextCoupon: string | null
accruedInt: number
bondType: string
bondSubType: string
offerDate: string | null
buybackDate: string | null
marketData: BondMarketData
}
export interface BondHistoryItem {
date: string
closePrice: number
yieldClose: number | null
duration: number | null
}
export interface CandleItem {
open: number
high: number
low: number
close: number
volume: number
value: number
begin: string
end: string
}
export interface SearchResultItem {
secid: string
isin: string
shortName: string
type: 'share' | 'bond'
listLevel: number
currency: string | null
price: number | null
}
export interface HealthResponse {
status: string
timestamp: string
uptime: number
}
export interface UserResponse {
id: number
email: string
name: string | null
role: string
}
export interface AuthResponse {
user: UserResponse
accessToken: string
}
export interface Portfolio {
id: number
name: string
description: string | null
currency: string
createdAt: string
updatedAt: string
totalValue: number
positionCount: number
shareCount: number
bondCount: number
}
export interface PositionWithPrice {
id: number
portfolioId: number
secid: string
shortName: string | null
type: 'share' | 'bond'
quantity: number
notes: string | null
tags: string[] | null
currentPrice: number | null
buyPrice: number | null
buyDate: string | null
totalCost: number | null
currentValue: number | null
pnl: number | null
pnlPercent: number | null
dividendIncome: number | null
totalReturn: number | null
totalReturnPercent: number | null
weightPercent: number
change?: number | null
changePercent?: number | null
yieldToMaturity?: number | null
duration?: number | null
couponValue?: number | null
couponPercent?: number | null
nextCouponDate?: string | null
matDate?: string | null
accruedInt?: number | null
bid?: number | null
offer?: number | null
couponPeriod?: number | null
bondType?: string | null
offerDate?: string | null
}
export interface PortfolioDetail extends Portfolio {
positions: PositionWithPrice[]
totalValue: number
analytics: PortfolioSummary
}
export interface Position {
id: number
secid: string
quantity: number
notes: string | null
tags: string[] | null
portfolioId: number
createdAt: string
updatedAt: string
}
export interface PortfolioSummary {
totalInvested: number
totalValue: number
totalPnl: number
totalPnlPercent: number | null
totalDividends: number
totalReturn: number
totalReturnPercent: number | null
positionCount: number
weightedYield: number | null
}
export interface AnalyticsResponse {
positions: PositionWithPrice[]
summary: PortfolioSummary
}
export interface ScreenerItem {
secid: string
shortName: string
isin: string
type: 'share' | 'bond'
price: number | null
change: number | null
changePercent: number | null
volume: number
listLevel: number
capitalization: number | null
yieldToMaturity: number | null
duration: number | null
couponValue: number | null
couponPercent: number | null
accruedInt: number | null
matDate: string | null
bondType: string | null
}
export interface ScreenerResult {
items: ScreenerItem[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface BrokerMoney {
currency: string
units: string
nano: number
value: number
}
export interface BrokerAccount {
id: string
type: 'brokerage' | 'iis'
name: string
status: string
openedAt: string | null
accessLevel: string | null
}
export interface BrokerPosition {
figi: string | null
instrumentUid: string | null
positionUid: string | null
ticker: string | null
classCode: string | null
instrumentType: string | null
name: string | null
quantity: number | null
blockedLots: number | null
currentPrice: BrokerMoney | null
currentValue: BrokerMoney | null
averagePositionPrice: BrokerMoney | null
expectedYieldPercent: number | null
dailyYield: BrokerMoney | null
}
export interface BrokerPortfolio {
account: BrokerAccount
positionCounts: {
shares: number
bonds: number
etf: number
other: number
}
totals: {
shares: BrokerMoney | null
bonds: BrokerMoney | null
etf: BrokerMoney | null
currencies: BrokerMoney | null
futures: BrokerMoney | null
options: BrokerMoney | null
structuredProducts: BrokerMoney | null
dfa: BrokerMoney | null
portfolio: BrokerMoney | null
}
yields: {
expectedPercent: number | null
daily: BrokerMoney | null
dailyPercent: number | null
}
cash: BrokerMoney[]
blockedCash: BrokerMoney[]
asOf: string
}
export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other'
export interface BrokerOperation {
cursor: string | null
accountId: string
id: string | null
parentOperationId: string | null
date: string | null
type: string
category: BrokerOperationCategory
description: string | null
name: string | null
state: string | null
instrumentUid: string | null
figi: string | null
ticker: string | null
classCode: string | null
instrumentType: string | null
payment: BrokerMoney | null
price: BrokerMoney | null
commission: BrokerMoney | null
yield: BrokerMoney | null
accruedInt: BrokerMoney | null
quantity: number | null
quantityDone: number | null
}
export interface BrokerOperationsPage {
accountId: string
items: BrokerOperation[]
nextCursor: string | null
hasNext: boolean
asOf: string
}
export interface BrokerPositionsPage {
accountId: string
items: BrokerPosition[]
nextCursor: string | null
hasNext: boolean
asOf: string
}
export interface BrokerPortfolioEvent {
id: string
type: 'dividend' | 'coupon' | 'maturity' | 'offer'
source: 'forecast' | 'actual'
category: 'cashflow' | 'corporate'
eventDate: string
paymentDate: string | null
ticker: string | null
name: string | null
instrumentUid: string | null
instrumentType: 'share' | 'bond' | 'other'
quantitySnapshot: number | null
payoutPerUnit: number | null
estimatedAmount: number | null
actualAmount: number | null
currency: string | null
estimateMode: 'current_position' | null
}
export interface BrokerEventsSummary {
eventCount: number
nearestEventDate: string | null
totalEstimatedCashflow: number
actualCashflow: number
forecastEstimatedCashflow: number
dividendsTotal: number
couponsTotal: number
principalRepaymentTotal: number
actualDividendsTotal: number
actualCouponsTotal: number
actualPrincipalRepaymentTotal: number
}
export interface BrokerEventsData {
items: BrokerPortfolioEvent[]
summary: BrokerEventsSummary
asOf: string
}

View File

@ -451,6 +451,23 @@ export interface paths {
patch?: never patch?: never
trace?: never trace?: never
} }
'/api/v1/broker/accounts/{accountId}/events': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Get broker account calendar events and cashflow */
get: operations['TBankController_getEvents']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/v1/broker/accounts/{accountId}/operations/sync': { '/api/v1/broker/accounts/{accountId}/operations/sync': {
parameters: { parameters: {
query?: never query?: never
@ -472,6 +489,22 @@ export interface paths {
export type webhooks = Record<string, never> export type webhooks = Record<string, never>
export interface components { export interface components {
schemas: { schemas: {
ApiResponseMeta: {
cachedAt: string | null
fromCache: boolean
}
HealthResponseDto: {
/** @example ok */
status: string
/** @example 2026-06-23T06:00:00.000Z */
timestamp: string
/** @example 12345 */
uptime: number
}
HealthEnvelopeDto: {
data: components['schemas']['HealthResponseDto']
meta: components['schemas']['ApiResponseMeta']
}
RegisterDto: { RegisterDto: {
/** @example user@example.com */ /** @example user@example.com */
email: string email: string
@ -519,6 +552,26 @@ export interface components {
/** @example John Doe */ /** @example John Doe */
name?: string name?: string
} }
SearchResultItemDto: {
/** @example SBER */
secid: string
/** @example RU0009029540 */
isin: string
/** @example Сбербанк */
shortName: string
/** @enum {string} */
type: 'share' | 'bond'
/** @example 1 */
listLevel: number
/** @example RUB */
currency?: string | null
/** @example 322.35 */
price?: number | null
}
SearchEnvelopeDto: {
data: components['schemas']['SearchResultItemDto'][]
meta: components['schemas']['ApiResponseMeta']
}
ScreenerItemDto: { ScreenerItemDto: {
/** @example SBER */ /** @example SBER */
secid: string secid: string
@ -570,6 +623,215 @@ export interface components {
data: components['schemas']['ScreenerResultDto'] data: components['schemas']['ScreenerResultDto']
meta: components['schemas']['ScreenerResponseMetaDto'] meta: components['schemas']['ScreenerResponseMetaDto']
} }
StockMarketDataDto: {
/** @example 322.35 */
price: number
/** @example 1.15 */
change: number
/** @example 0.36 */
changePercent: number
/** @example 321.3 */
open: number
/** @example 322.66 */
high?: Record<string, never>
/** @example 321.2 */
low?: Record<string, never>
/** @example 1925163 */
volume: number
/** @example 620184479 */
value: number
/** @example 6958336818320 */
issueCapitalization?: Record<string, never>
updatedAt: string
}
ShareResponseDto: {
/** @example SBER */
secid: string
/** @example RU0009029540 */
isin: string
/** @example Сбербанк России ПАО ао */
name: string
/** @example Сбербанк */
shortName: string
latName?: Record<string, never>
/** @example 1 */
listLevel: number
/** @example 21586948000 */
issueSize: number
/** @example 3 */
faceValue: number
/** @example RUB */
faceUnit: string
/** @example common_share */
type: string
marketData: components['schemas']['StockMarketDataDto']
}
ShareEnvelopeDto: {
data: components['schemas']['ShareResponseDto']
meta: components['schemas']['ApiResponseMeta']
}
ShareMarketDataResponseDto: {
/** @example 322.35 */
price: number
/** @example 1.15 */
change: number
/** @example 0.36 */
changePercent: number
/** @example 321.3 */
open: number
/** @example 322.66 */
high?: Record<string, never>
/** @example 321.2 */
low?: Record<string, never>
/** @example 1925163 */
volume: number
/** @example 620184479 */
value: number
/** @example 6958336818320 */
issueCapitalization?: Record<string, never>
updatedAt: string
}
ShareMarketDataEnvelopeDto: {
data: components['schemas']['ShareMarketDataResponseDto']
meta: components['schemas']['ApiResponseMeta']
}
DividendItemDto: {
/** @example 2026-05-15 */
registryCloseDate: string
/** @example 33.47 */
value: number
/** @example RUB */
currency: string
}
DividendsEnvelopeDto: {
data: components['schemas']['DividendItemDto'][]
meta: components['schemas']['ApiResponseMeta']
}
HistoryItemDto: {
/** @example 2026-06-01 */
date: string
/** @example 321.3 */
open: number
/** @example 322.66 */
high: number
/** @example 321.2 */
low: number
/** @example 322.35 */
close: number
/** @example 1925163 */
volume: number
/** @example 620184479 */
value: number
}
ShareHistoryEnvelopeDto: {
data: components['schemas']['HistoryItemDto'][]
meta: components['schemas']['ApiResponseMeta']
}
BondMarketDataDto: {
/**
* @description Цена в % от номинала
* @example 100.45
*/
price: number
/** @example 12.71 */
yieldToMaturity?: Record<string, never>
duration?: Record<string, never>
/** @example 29.48 */
accruedInt: number
/** @example 40.64 */
couponValue: number
/** @example 8.15 */
couponPercent?: Record<string, never>
/** @example 2026-08-05 */
nextCouponDate?: Record<string, never>
open: number
high?: Record<string, never>
low?: Record<string, never>
volume: number
updatedAt: string
}
BondResponseDto: {
/** @example SU26207RMFS9 */
secid: string
/** @example RU000A0JS3W6 */
isin: string
/** @example ОФЗ-ПД 26207 03/02/27 */
name: string
/** @example ОФЗ 26207 */
shortName: string
latName?: Record<string, never>
/** @example 1 */
listLevel: number
/** @example 370200604 */
issueSize: number
/** @example 1000 */
faceValue: number
/** @example RUB */
faceUnit: string
/** @example 2027-02-03 */
matDate: string
/** @example 40.64 */
couponValue: number
/** @example 8.15 */
couponPercent?: Record<string, never>
/** @example 182 */
couponPeriod: number
/** @example 2026-08-05 */
nextCoupon: Record<string, never>
/** @example 29.48 */
accruedInt: number
/** @example Фикс с известным купоном */
bondType: string
/** @example До погашения */
bondSubType: string
offerDate?: Record<string, never>
buybackDate?: Record<string, never>
marketData: components['schemas']['BondMarketDataDto']
}
BondEnvelopeDto: {
data: components['schemas']['BondResponseDto']
meta: components['schemas']['ApiResponseMeta']
}
BondMarketDataEnvelopeDto: {
data: components['schemas']['BondMarketDataDto']
meta: components['schemas']['ApiResponseMeta']
}
BondHistoryItemDto: {
/** @example 2026-06-01 */
date: string
/** @example 100.45 */
closePrice: number
/** @example 12.71 */
yieldClose?: number | null
/** @example 4.5 */
duration?: number | null
}
BondHistoryEnvelopeDto: {
data: components['schemas']['BondHistoryItemDto'][]
meta: components['schemas']['ApiResponseMeta']
}
CandleItemDto: {
/** @example 321.3 */
open: number
/** @example 322.66 */
high: number
/** @example 321.2 */
low: number
/** @example 322.35 */
close: number
/** @example 1925163 */
volume: number
/** @example 620184479 */
value: number
/** @example 2026-06-01T10:00:00 */
begin: string
/** @example 2026-06-01T10:59:00 */
end: string
}
CandleEnvelopeDto: {
data: components['schemas']['CandleItemDto'][]
meta: components['schemas']['ApiResponseMeta']
}
PortfolioResponseMetaDto: { PortfolioResponseMetaDto: {
cachedAt: string | null cachedAt: string | null
fromCache: boolean fromCache: boolean
@ -895,6 +1157,51 @@ export interface components {
data: components['schemas']['BrokerOperationsPageResponseDto'] data: components['schemas']['BrokerOperationsPageResponseDto']
meta: components['schemas']['BrokerResponseMetaDto'] meta: components['schemas']['BrokerResponseMetaDto']
} }
BrokerEventItemDto: {
id: string
/** @enum {string} */
type: 'dividend' | 'coupon' | 'maturity' | 'offer'
/** @enum {string} */
source: 'forecast' | 'actual'
/** @enum {string} */
category: 'cashflow' | 'corporate'
eventDate: string
paymentDate: string | null
ticker: string | null
name: string | null
instrumentUid: string | null
/** @enum {string} */
instrumentType: 'share' | 'bond' | 'other'
quantitySnapshot: number | null
payoutPerUnit: number | null
estimatedAmount: number | null
actualAmount: number | null
currency: string | null
/** @enum {string|null} */
estimateMode: 'current_position' | null
}
BrokerEventsSummaryDto: {
eventCount: number
nearestEventDate: string | null
totalEstimatedCashflow: number
actualCashflow: number
forecastEstimatedCashflow: number
dividendsTotal: number
couponsTotal: number
principalRepaymentTotal: number
actualDividendsTotal: number
actualCouponsTotal: number
actualPrincipalRepaymentTotal: number
}
BrokerEventsDataDto: {
items: components['schemas']['BrokerEventItemDto'][]
summary: components['schemas']['BrokerEventsSummaryDto']
asOf: string
}
BrokerEventsEnvelopeDto: {
data: components['schemas']['BrokerEventsDataDto']
meta: components['schemas']['BrokerResponseMetaDto']
}
BrokerOperationSyncResponseDto: { BrokerOperationSyncResponseDto: {
/** @example 42 */ /** @example 42 */
upserted: number upserted: number
@ -925,7 +1232,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content?: never content: {
'application/json': components['schemas']['HealthEnvelopeDto']
}
} }
} }
} }
@ -1073,7 +1382,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content?: never content: {
'application/json': components['schemas']['SearchEnvelopeDto']
}
} }
} }
} }
@ -1135,7 +1446,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content?: never content: {
'application/json': components['schemas']['ShareEnvelopeDto']
}
} }
} }
} }
@ -1154,7 +1467,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content?: never content: {
'application/json': components['schemas']['ShareMarketDataEnvelopeDto']
}
} }
} }
} }
@ -1173,7 +1488,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content?: never content: {
'application/json': components['schemas']['DividendsEnvelopeDto']
}
} }
} }
} }
@ -1195,7 +1512,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content?: never content: {
'application/json': components['schemas']['ShareHistoryEnvelopeDto']
}
} }
} }
} }
@ -1214,7 +1533,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content?: never content: {
'application/json': components['schemas']['BondEnvelopeDto']
}
} }
} }
} }
@ -1233,7 +1554,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content?: never content: {
'application/json': components['schemas']['BondMarketDataEnvelopeDto']
}
} }
} }
} }
@ -1255,7 +1578,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content?: never content: {
'application/json': components['schemas']['BondHistoryEnvelopeDto']
}
} }
} }
} }
@ -1278,7 +1603,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content?: never content: {
'application/json': components['schemas']['CandleEnvelopeDto']
}
} }
} }
} }
@ -1301,7 +1628,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content?: never content: {
'application/json': components['schemas']['CandleEnvelopeDto']
}
} }
} }
} }
@ -1610,6 +1939,34 @@ export interface operations {
} }
} }
} }
TBankController_getEvents: {
parameters: {
query: {
/** @description Start date inclusive (YYYY-MM-DD) */
from: string
/** @description End date inclusive (YYYY-MM-DD) */
to: string
/** @description Comma-separated event types to include */
types?: string
}
header?: never
path: {
accountId: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['BrokerEventsEnvelopeDto']
}
}
}
}
TBankController_syncOperations: { TBankController_syncOperations: {
parameters: { parameters: {
query: { query: {

View File

@ -1,4 +1,4 @@
import type { BrokerMoney } from '@/shared/api/responses' import type { BrokerMoney } from '@/shared/api'
export function formatBrokerCurrencyValue(currency: string, value: number): string { export function formatBrokerCurrencyValue(currency: string, value: number): string {
return new Intl.NumberFormat('ru-RU', { return new Intl.NumberFormat('ru-RU', {

View File

@ -1,5 +1,5 @@
import { createContext } from 'react' import { createContext } from 'react'
import type { UserResponse } from '@/shared/api/responses' import type { UserResponse } from '@/shared/api'
export interface SessionContextValue { export interface SessionContextValue {
user: UserResponse | null user: UserResponse | null

View File

@ -8,7 +8,7 @@ import type {
ShareResponse, ShareResponse,
StockMarketData, StockMarketData,
UserResponse, UserResponse,
} from '@/shared/api/responses' } from '@/shared/api'
export function createMockMarketData(overrides: Partial<StockMarketData> = {}): StockMarketData { export function createMockMarketData(overrides: Partial<StockMarketData> = {}): StockMarketData {
return { return {

View File

@ -1,195 +0,0 @@
import { HttpResponse, http } from 'msw'
import type {
BondResponse,
CandleItem,
SearchResultItem,
ShareResponse,
} from '@/shared/api/responses'
const API = '/api/v1'
const mockShare: ShareResponse = {
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',
},
}
const mockBond: BondResponse = {
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',
},
}
const mockCandles: CandleItem[] = [
{
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',
},
]
const mockDividends = [
{ registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' },
{ registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' },
]
const mockSearchResults: SearchResultItem[] = [
{
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,
},
]
const userResponse = {
id: 1,
email: 'user@test.com',
name: 'Test User',
role: 'user',
}
const authResponse = {
user: userResponse,
accessToken: 'mock-access-token',
}
const envelope = (data: unknown) => ({
data: { data, meta: { fromCache: false, cachedAt: null } },
})
export const handlers = [
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 } as ShareResponse))
}),
http.get(`${API}/securities/shares/:secid/candles`, () =>
HttpResponse.json(envelope(mockCandles)),
),
http.get(`${API}/securities/shares/:secid/dividends`, () =>
HttpResponse.json(envelope(mockDividends)),
),
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 } as BondResponse))
}),
http.get(`${API}/securities/bonds/:secid/candles`, () =>
HttpResponse.json(envelope(mockCandles)),
),
http.get(`${API}/auth/me`, () => HttpResponse.json(envelope(userResponse))),
http.post(`${API}/auth/login`, () => HttpResponse.json(envelope(authResponse))),
http.post(`${API}/auth/register`, () => HttpResponse.json(envelope(authResponse))),
http.post(`${API}/auth/refresh`, () => HttpResponse.json(envelope(authResponse))),
http.post(`${API}/auth/logout`, () => HttpResponse.json(envelope({ message: 'Logged out' }))),
http.patch(`${API}/auth/me`, () =>
HttpResponse.json(envelope({ ...userResponse, name: 'Updated' })),
),
http.get(`${API}/health`, () =>
HttpResponse.json(
envelope({ status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }),
),
),
]

View File

@ -1,5 +1,5 @@
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import { server } from './server' import { server } from '@mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers()) afterEach(() => server.resetHandlers())

View File

@ -1,4 +1,4 @@
import type { BondResponse } from '@/shared/api/responses' import type { BondResponse } from '@/shared/api'
interface BondDetailsProps { interface BondDetailsProps {
bond: BondResponse bond: BondResponse

View File

@ -1,6 +1,6 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import type { PositionWithPrice } from '@/shared/api/responses' import type { PositionWithPrice } from '@/shared/api'
interface Props { interface Props {
position: PositionWithPrice position: PositionWithPrice

View File

@ -1,4 +1,4 @@
import type { PositionWithPrice } from '@/shared/api/responses' import type { PositionWithPrice } from '@/shared/api'
import { BondPositionRow } from './BondPositionRow' import { BondPositionRow } from './BondPositionRow'
interface Props { interface Props {

View File

@ -2,7 +2,7 @@ import { Alert, Button, Heading, Skeleton, Text } from '@moex-vibe/design-system
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { buildBrokerAllocation } from '@/entities/broker-position' import { buildBrokerAllocation } from '@/entities/broker-position'
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses' import type { BrokerAccount, BrokerPortfolio } from '@/shared/api'
import { import {
formatBrokerDate, formatBrokerDate,
formatBrokerMoney, formatBrokerMoney,

View File

@ -1,3 +1,3 @@
export { useBrokerAccountContext } from './lib/useBrokerAccountContext' export { useBrokerAccountContext } from './lib/useBrokerAccountContext'
export type { BrokerAccountContext } from './ui/BrokerAccountLayout' export type { BrokerAccountContextValue } from './ui/BrokerAccountLayout'
export { BrokerAccountLayout } from './ui/BrokerAccountLayout' export { BrokerAccountLayout } from './ui/BrokerAccountLayout'

View File

@ -1,6 +1,10 @@
import { useParams } from '@tanstack/react-router' import { useContext } from 'react'
import { BrokerAccountContext } from '../ui/BrokerAccountLayout'
export function useBrokerAccountContext() { export function useBrokerAccountContext() {
const { accountId = '' } = useParams({ from: '/broker/$accountId' }) const context = useContext(BrokerAccountContext)
return { accountId } if (!context) {
throw new Error('useBrokerAccountContext must be used within BrokerAccountLayout')
}
return context
} }

View File

@ -1,8 +1,10 @@
import { Heading } from '@moex-vibe/design-system' import { Heading } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import type { UseQueryResult } from '@tanstack/react-query'
import { Link, useParams } from '@tanstack/react-router' import { Link, useParams } from '@tanstack/react-router'
import type { ReactNode } from 'react' import { createContext, type ReactNode } from 'react'
import { useBrokerPortfolio } from '@/entities/broker-account' import { useBrokerPortfolio } from '@/entities/broker-account'
import type { BrokerPortfolio } from '@/shared/api'
const baseLinkStyle: React.CSSProperties = { const baseLinkStyle: React.CSSProperties = {
padding: '10px 12px', padding: '10px 12px',
@ -17,69 +19,79 @@ const baseLinkStyle: React.CSSProperties = {
} }
const links = [ const links = [
{ to: '.', label: 'Обзор' }, { to: '', label: 'Обзор' },
{ to: '/shares', label: 'Акции' }, { to: '/shares', label: 'Акции' },
{ to: '/bonds', label: 'Облигации' }, { to: '/bonds', label: 'Облигации' },
{ to: '/operations', label: 'Операции' }, { to: '/operations', label: 'Операции' },
{ to: '/events', label: 'События' }, { to: '/events', label: 'События' },
] ]
export interface BrokerAccountContextValue {
accountId: string
portfolio: UseQueryResult<BrokerPortfolio>
}
export const BrokerAccountContext = createContext<BrokerAccountContextValue | null>(null)
export function BrokerAccountLayout({ children }: { children: ReactNode }) { export function BrokerAccountLayout({ children }: { children: ReactNode }) {
const { accountId = '' } = useParams({ from: '/broker/$accountId' }) const { accountId = '' } = useParams({ from: '/broker/$accountId' })
const portfolio = useBrokerPortfolio(accountId) const portfolio = useBrokerPortfolio(accountId)
const basePath = `/broker/${encodeURIComponent(accountId)}` const basePath = `/broker/${encodeURIComponent(accountId)}`
return ( return (
<Box sx={{ display: 'grid', gap: 3 }}> <BrokerAccountContext.Provider value={{ accountId, portfolio }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}> <Box sx={{ display: 'grid', gap: 3 }}>
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
</Box> <Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: 2,
'@media (min-width: 720px)': {
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
gap: 3,
},
}}
>
<Box <Box
component="nav"
aria-label="Разделы брокерского счёта"
sx={{ sx={{
display: 'flex', display: 'grid',
flexDirection: 'column', gridTemplateColumns: '1fr',
gap: 0.5, gap: 2,
'@media (max-width: 719px)': { '@media (min-width: 720px)': {
flexDirection: 'row', gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
overflowX: 'auto', gap: 3,
scrollbarWidth: 'thin',
}, },
}} }}
> >
{links.map((link) => ( <Box
<Link component="nav"
key={link.to} aria-label="Разделы брокерского счёта"
to={`${basePath}${link.to}`} sx={{
style={baseLinkStyle} display: 'flex',
activeProps={{ flexDirection: 'column',
style: { gap: 0.5,
...baseLinkStyle, '@media (max-width: 719px)': {
color: 'var(--color-primary)', flexDirection: 'row',
fontWeight: 700, overflowX: 'auto',
}, scrollbarWidth: 'thin',
}} },
> }}
{link.label} >
</Link> {links.map((link) => (
))} <Link
</Box> key={link.to}
to={`${basePath}${link.to}`}
style={baseLinkStyle}
activeOptions={{ exact: link.to === '' }}
activeProps={{
style: {
...baseLinkStyle,
color: 'var(--color-primary)',
fontWeight: 700,
},
}}
>
{link.label}
</Link>
))}
</Box>
<Box sx={{ minWidth: 0 }}>{children}</Box> <Box sx={{ minWidth: 0 }}>{children}</Box>
</Box>
</Box> </Box>
</Box> </BrokerAccountContext.Provider>
) )
} }

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