Compare commits
4 Commits
659be4636c
...
1fc7386568
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fc7386568 | |||
| e69f183372 | |||
| b092caf8d6 | |||
| 3a89cef76c |
@ -13,6 +13,14 @@ export class ApiResponseMeta {
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiEnvelopePayload<T> {
|
||||
constructor(
|
||||
public readonly data: T,
|
||||
public readonly fromCache: boolean,
|
||||
public readonly cachedAt: string | null,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class ApiResponse<T> {
|
||||
data: T;
|
||||
meta: ApiResponseMeta;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ApiResponse } from '../dto/api-response.dto';
|
||||
import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
||||
@ -9,6 +9,9 @@ export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T
|
||||
return next.handle().pipe(
|
||||
map((data) => {
|
||||
if (data instanceof ApiResponse) return data;
|
||||
if (data instanceof ApiEnvelopePayload) {
|
||||
return new ApiResponse(data.data, data.fromCache, data.cachedAt);
|
||||
}
|
||||
return new ApiResponse(data, false, null);
|
||||
}),
|
||||
);
|
||||
|
||||
55
apps/backend/src/envelope-contract.spec.ts
Normal file
55
apps/backend/src/envelope-contract.spec.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import 'reflect-metadata'
|
||||
import { Test, type TestingModule } from '@nestjs/testing'
|
||||
import type { INestApplication } from '@nestjs/common'
|
||||
import { HealthModule } from './modules/health/health.module'
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor'
|
||||
|
||||
describe('API envelope contract', () => {
|
||||
let app: INestApplication
|
||||
let baseUrl: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [HealthModule],
|
||||
}).compile()
|
||||
|
||||
app = module.createNestApplication()
|
||||
app.setGlobalPrefix('api/v1')
|
||||
app.useGlobalInterceptors(new TransformInterceptor())
|
||||
await app.init()
|
||||
await app.listen(0)
|
||||
|
||||
const address = app.getHttpServer().address()
|
||||
if (typeof address === 'object' && address && 'port' in address) {
|
||||
baseUrl = `http://127.0.0.1:${address.port}`
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('returns a single envelope from the public health endpoint', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/v1/health`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = (await response.json()) as {
|
||||
data: { status: string; timestamp: string; uptime: number }
|
||||
meta: { fromCache: boolean; cachedAt: string | null }
|
||||
}
|
||||
|
||||
expect(body).toMatchObject({
|
||||
data: {
|
||||
status: 'ok',
|
||||
timestamp: expect.any(String),
|
||||
uptime: expect.any(Number),
|
||||
},
|
||||
meta: {
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
},
|
||||
})
|
||||
expect(body.data).not.toHaveProperty('data')
|
||||
expect(body.data).not.toHaveProperty('meta')
|
||||
})
|
||||
})
|
||||
@ -41,13 +41,7 @@ export class AuthController {
|
||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.authService.register(dto);
|
||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||
return {
|
||||
data: {
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
return { user: result.user, accessToken: result.accessToken };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -57,13 +51,7 @@ export class AuthController {
|
||||
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.authService.login(dto);
|
||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||
return {
|
||||
data: {
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
return { user: result.user, accessToken: result.accessToken };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -75,13 +63,7 @@ export class AuthController {
|
||||
const token = req.cookies?.[REFRESH_COOKIE];
|
||||
const result = await this.authService.refresh(token);
|
||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||
return {
|
||||
data: {
|
||||
user: result.user,
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
return { user: result.user, accessToken: result.accessToken };
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@ -92,10 +74,7 @@ export class AuthController {
|
||||
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
|
||||
await this.authService.logout(user.sub);
|
||||
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
|
||||
return {
|
||||
data: { message: 'Logged out successfully' },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ -103,11 +82,7 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||
async getProfile(@CurrentUser() user: JwtPayload) {
|
||||
const profile = await this.authService.getProfile(user.sub);
|
||||
return {
|
||||
data: profile,
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
return this.authService.getProfile(user.sub);
|
||||
}
|
||||
|
||||
@Patch('me')
|
||||
@ -115,10 +90,6 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'Update current user profile' })
|
||||
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
|
||||
const profile = await this.authService.updateProfile(user.sub, dto);
|
||||
return {
|
||||
data: profile,
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
};
|
||||
return this.authService.updateProfile(user.sub, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,10 +129,8 @@ describe('BondsService', () => {
|
||||
volume: 10000,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BondsService {
|
||||
@ -32,8 +33,8 @@ export class BondsService {
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: {
|
||||
return new ApiEnvelopePayload(
|
||||
{
|
||||
secid: bond.secid,
|
||||
isin: bond.isin,
|
||||
name: bond.shortName,
|
||||
@ -70,8 +71,9 @@ export class BondsService {
|
||||
: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
}
|
||||
|
||||
async getMarketData(secid: string) {
|
||||
@ -90,8 +92,8 @@ export class BondsService {
|
||||
throw new NotFoundException(`Market data for bond ${secid} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
return new ApiEnvelopePayload(
|
||||
{
|
||||
price: mkt.last ?? 0,
|
||||
yieldToMaturity: mkt.yield ?? null,
|
||||
duration: mkt.duration ?? null,
|
||||
@ -107,8 +109,9 @@ export class BondsService {
|
||||
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
|
||||
: new Date().toISOString(),
|
||||
},
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
}
|
||||
|
||||
async getHistory(secid: string, from: string, till: string) {
|
||||
@ -119,14 +122,15 @@ export class BondsService {
|
||||
'historyTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: data.map((h) => ({
|
||||
return new ApiEnvelopePayload(
|
||||
data.map((h) => ({
|
||||
date: h.tradeDate,
|
||||
closePrice: h.legalClosePrice ?? h.close ?? 0,
|
||||
yieldClose: h.yieldClose ?? null,
|
||||
duration: h.duration ?? null,
|
||||
})),
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,10 +81,8 @@ describe('CandlesService', () => {
|
||||
end: '2026-05-01 23:59:59',
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { CandleInterval } from './dto/candles-query.dto';
|
||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CandlesService {
|
||||
@ -29,8 +30,8 @@ export class CandlesService {
|
||||
'candlesTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: data.map((c) => ({
|
||||
return new ApiEnvelopePayload(
|
||||
data.map((c) => ({
|
||||
open: c.open,
|
||||
high: c.high,
|
||||
low: c.low,
|
||||
@ -40,7 +41,8 @@ export class CandlesService {
|
||||
begin: c.begin,
|
||||
end: c.end,
|
||||
})),
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,12 +26,8 @@ import {
|
||||
const nullDataEnvelopeSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'null',
|
||||
},
|
||||
meta: {
|
||||
$ref: getSchemaPath(PortfolioResponseMetaDto),
|
||||
},
|
||||
data: { type: 'null' },
|
||||
meta: { $ref: getSchemaPath(PortfolioResponseMetaDto) },
|
||||
},
|
||||
required: ['data', 'meta'],
|
||||
};
|
||||
@ -47,24 +43,21 @@ export class PortfolioController {
|
||||
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||||
@ApiOkResponse({ type: PortfolioListEnvelopeDto })
|
||||
async findAll(@CurrentUser() user: { sub: number }) {
|
||||
const portfolios = await this.portfolioService.findAll(user.sub);
|
||||
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
|
||||
return this.portfolioService.findAll(user.sub);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new portfolio' })
|
||||
@ApiCreatedResponse({ type: PortfolioEnvelopeDto })
|
||||
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
|
||||
const portfolio = await this.portfolioService.create(user.sub, dto);
|
||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
||||
return this.portfolioService.create(user.sub, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
|
||||
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
|
||||
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||
const portfolio = await this.portfolioService.findOne(user.sub, id);
|
||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
||||
return this.portfolioService.findOne(user.sub, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ -75,8 +68,7 @@ export class PortfolioController {
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdatePortfolioDto,
|
||||
) {
|
||||
const portfolio = await this.portfolioService.update(user.sub, id, dto);
|
||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
||||
return this.portfolioService.update(user.sub, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ -84,7 +76,7 @@ export class PortfolioController {
|
||||
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
|
||||
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||
await this.portfolioService.remove(user.sub, id);
|
||||
return { data: null, meta: { cachedAt: null, fromCache: false } };
|
||||
return null;
|
||||
}
|
||||
|
||||
@Post(':id/positions')
|
||||
@ -95,8 +87,7 @@ export class PortfolioController {
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: AddPositionDto,
|
||||
) {
|
||||
const position = await this.portfolioService.addPosition(user.sub, id, dto);
|
||||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
||||
return this.portfolioService.addPosition(user.sub, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/positions/:positionId')
|
||||
@ -108,16 +99,14 @@ export class PortfolioController {
|
||||
@Param('positionId', ParseIntPipe) positionId: number,
|
||||
@Body() dto: UpdatePositionDto,
|
||||
) {
|
||||
const position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
||||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
||||
return this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
||||
}
|
||||
|
||||
@Get(':id/analytics')
|
||||
@ApiOperation({ summary: 'Get portfolio analytics with PnL' })
|
||||
@ApiOkResponse({ type: AnalyticsEnvelopeDto })
|
||||
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||
const result = await this.portfolioService.getAnalytics(user.sub, id);
|
||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
||||
return this.portfolioService.getAnalytics(user.sub, id);
|
||||
}
|
||||
|
||||
@Delete(':id/positions/:positionId')
|
||||
@ -129,6 +118,6 @@ export class PortfolioController {
|
||||
@Param('positionId', ParseIntPipe) positionId: number,
|
||||
) {
|
||||
await this.portfolioService.removePosition(user.sub, id, positionId);
|
||||
return { data: null, meta: { cachedAt: null, fromCache: false } };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ describe('SecuritiesController', () => {
|
||||
|
||||
it('should return search results', async () => {
|
||||
const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 });
|
||||
expect(result.data).toEqual(mockResults);
|
||||
expect(result).toEqual(mockResults);
|
||||
expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5);
|
||||
});
|
||||
});
|
||||
|
||||
@ -21,19 +21,17 @@ export class SecuritiesController {
|
||||
@ApiOperation({ summary: 'Поиск по инструментам' })
|
||||
@ApiOkResponse({ type: SearchEnvelopeDto })
|
||||
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
||||
const results = await this.securitiesService.search(
|
||||
return this.securitiesService.search(
|
||||
query.q,
|
||||
query.type || SecurityType.ALL,
|
||||
query.limit || 20,
|
||||
);
|
||||
return { data: results, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Get('screener')
|
||||
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
|
||||
@ApiOkResponse({ type: ScreenerResponseDto })
|
||||
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
|
||||
const result = await this.screenerService.screen(query);
|
||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
||||
return this.screenerService.screen(query);
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,8 +19,7 @@ export class SharesController {
|
||||
@ApiOperation({ summary: 'Получить спецификацию акции' })
|
||||
@ApiOkResponse({ type: ShareEnvelopeDto })
|
||||
async getShare(@Param('secid') secid: string) {
|
||||
const share = await this.sharesService.getShare(secid);
|
||||
return { data: share, meta: { cachedAt: null, fromCache: false } };
|
||||
return this.sharesService.getShare(secid);
|
||||
}
|
||||
|
||||
@Get(':secid/marketdata')
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SharesService {
|
||||
@ -77,8 +78,8 @@ export class SharesService {
|
||||
throw new NotFoundException(`Market data for ${secid} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
return new ApiEnvelopePayload(
|
||||
{
|
||||
price: marketData.last ?? 0,
|
||||
change: marketData.lastChange ?? 0,
|
||||
changePercent: marketData.lastChangePrcnt ?? 0,
|
||||
@ -92,8 +93,9 @@ export class SharesService {
|
||||
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
||||
: new Date().toISOString(),
|
||||
},
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
}
|
||||
|
||||
async getDividends(secid: string) {
|
||||
@ -104,14 +106,15 @@ export class SharesService {
|
||||
'dividendsTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: data.map((d) => ({
|
||||
return new ApiEnvelopePayload(
|
||||
data.map((d) => ({
|
||||
registryCloseDate: d.registryCloseDate,
|
||||
value: d.value,
|
||||
currency: d.currencyId,
|
||||
})),
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
}
|
||||
|
||||
async getHistory(secid: string, from: string, till: string) {
|
||||
@ -122,8 +125,8 @@ export class SharesService {
|
||||
'historyTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: data.map((h) => ({
|
||||
return new ApiEnvelopePayload(
|
||||
data.map((h) => ({
|
||||
date: h.tradeDate,
|
||||
open: h.open ?? 0,
|
||||
high: h.high ?? 0,
|
||||
@ -132,7 +135,8 @@ export class SharesService {
|
||||
volume: h.volume,
|
||||
value: h.value,
|
||||
})),
|
||||
meta: { fromCache, cachedAt },
|
||||
};
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ describe('BrokerAccountsService', () => {
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']);
|
||||
expect(result.meta.fromCache).toBe(false);
|
||||
expect(result.fromCache).toBe(false);
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'tbank:accounts',
|
||||
['open-brokerage-iis'],
|
||||
|
||||
@ -5,6 +5,7 @@ import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
import type { BrokerAccount } from '../types/broker.types';
|
||||
import type { TBankAccountsResponse } from '../types/tbank-proto.types';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BrokerAccountsService {
|
||||
@ -13,10 +14,7 @@ export class BrokerAccountsService {
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<{
|
||||
data: BrokerAccount[];
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
}> {
|
||||
async findAll(): Promise<ApiEnvelopePayload<BrokerAccount[]>> {
|
||||
const result = await this.cacheService.getOrFetch(
|
||||
TBANK_CACHE_KEYS.accounts,
|
||||
['open-brokerage-iis'],
|
||||
@ -24,10 +22,7 @@ export class BrokerAccountsService {
|
||||
'tbankAccountsTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||
};
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
}
|
||||
|
||||
async findById(accountId: string): Promise<BrokerAccount | null> {
|
||||
|
||||
@ -226,7 +226,7 @@ describe('BrokerAnalyticsService', () => {
|
||||
const result = await service.getAnalytics('acc-1');
|
||||
|
||||
expect(result.data.netInvested).toBe(1000);
|
||||
expect(result.meta.fromCache).toBe(true);
|
||||
expect(result.meta.cachedAt).toBe('2026-06-24T10:00:00.000Z');
|
||||
expect(result.fromCache).toBe(true);
|
||||
expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
@ -41,10 +42,7 @@ export class BrokerAnalyticsService {
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async getAnalytics(accountId: string): Promise<{
|
||||
data: BrokerAnalyticsDto;
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
}> {
|
||||
async getAnalytics(accountId: string): Promise<ApiEnvelopePayload<BrokerAnalyticsDto>> {
|
||||
const account = await this.accountsService.findById(accountId);
|
||||
if (!account) throw new NotFoundException('Broker account not found');
|
||||
|
||||
@ -55,10 +53,7 @@ export class BrokerAnalyticsService {
|
||||
'tbankAnalyticsTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||
};
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
}
|
||||
|
||||
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
|
||||
|
||||
@ -58,7 +58,8 @@ describe('BrokerEventsService', () => {
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
@ -112,7 +113,8 @@ describe('BrokerEventsService', () => {
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
@ -166,7 +168,8 @@ describe('BrokerEventsService', () => {
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
@ -211,7 +214,8 @@ describe('BrokerEventsService', () => {
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
@ -241,7 +245,8 @@ describe('BrokerEventsService', () => {
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
@ -301,7 +306,8 @@ describe('BrokerEventsService', () => {
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
@ -339,7 +345,8 @@ describe('BrokerEventsService', () => {
|
||||
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
@ -396,7 +403,8 @@ describe('BrokerEventsService', () => {
|
||||
]);
|
||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
@ -455,7 +463,8 @@ describe('BrokerEventsService', () => {
|
||||
hasNext: false,
|
||||
asOf: '2026-06-19T00:00:00.000Z',
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { MoexClientService } from '../../moex-client/moex-client.service';
|
||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
import { mapQuotationToNumber } from '../mappers/money.mapper';
|
||||
import type {
|
||||
@ -52,10 +53,7 @@ export class BrokerEventsService {
|
||||
async getEvents(
|
||||
accountId: string,
|
||||
query: BrokerEventsQuery,
|
||||
): Promise<{
|
||||
data: BrokerEventsData;
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
}> {
|
||||
): Promise<ApiEnvelopePayload<BrokerEventsData>> {
|
||||
const account = await this.accountsService.findById(accountId);
|
||||
if (!account) throw new NotFoundException('Broker account not found');
|
||||
|
||||
@ -68,10 +66,7 @@ export class BrokerEventsService {
|
||||
'tbankPortfolioTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||
};
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
}
|
||||
|
||||
private async buildEvents(
|
||||
|
||||
@ -48,11 +48,13 @@ describe('BrokerOperationSyncService', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerOperationSyncService(operations, prisma);
|
||||
@ -115,7 +117,8 @@ describe('BrokerOperationSyncService', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerOperationSyncService(operations, prisma);
|
||||
@ -142,7 +145,8 @@ describe('BrokerOperationSyncService', () => {
|
||||
asOf: '2026-06-16T00:00:00.000Z',
|
||||
items: [],
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerOperationSyncService(operations, prisma);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
|
||||
import { mapOperationsPage } from '../mappers/operation.mapper';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
@ -19,10 +20,7 @@ export class BrokerOperationsService {
|
||||
async getOperations(
|
||||
accountId: string,
|
||||
query: BrokerOperationQueryDto,
|
||||
): Promise<{
|
||||
data: BrokerOperationsPage;
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
}> {
|
||||
): Promise<ApiEnvelopePayload<BrokerOperationsPage>> {
|
||||
const account = await this.accountsService.findById(accountId);
|
||||
if (!account) throw new NotFoundException('Broker account not found');
|
||||
|
||||
@ -34,10 +32,7 @@ export class BrokerOperationsService {
|
||||
'tbankOperationsTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||
};
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
}
|
||||
|
||||
private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
import { BrokerAccountsService } from './broker-accounts.service';
|
||||
import { BrokerInstrumentsService } from './broker-instruments.service';
|
||||
import { TBankClientService } from './tbank-client.service';
|
||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BrokerPortfolioService {
|
||||
@ -22,10 +23,7 @@ export class BrokerPortfolioService {
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async getPortfolio(accountId: string): Promise<{
|
||||
data: BrokerPortfolio;
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
}> {
|
||||
async getPortfolio(accountId: string): Promise<ApiEnvelopePayload<BrokerPortfolio>> {
|
||||
const account = await this.accountsService.findById(accountId);
|
||||
if (!account) throw new NotFoundException('Broker account not found');
|
||||
|
||||
@ -43,10 +41,7 @@ export class BrokerPortfolioService {
|
||||
'tbankPortfolioTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||
};
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
}
|
||||
|
||||
async getPositions(
|
||||
@ -54,10 +49,7 @@ export class BrokerPortfolioService {
|
||||
cursor?: string,
|
||||
limit = 10,
|
||||
type?: string,
|
||||
): Promise<{
|
||||
data: BrokerPositionsPage;
|
||||
meta: { fromCache: boolean; cachedAt: string | null };
|
||||
}> {
|
||||
): Promise<ApiEnvelopePayload<BrokerPositionsPage>> {
|
||||
const account = await this.accountsService.findById(accountId);
|
||||
if (!account) throw new NotFoundException('Broker account not found');
|
||||
|
||||
@ -87,10 +79,7 @@ export class BrokerPortfolioService {
|
||||
'tbankPositionsTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
||||
};
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
}
|
||||
|
||||
private async buildInstrumentMap(
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import { ApiResponse } from '../../common/dto/api-response.dto';
|
||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||
import { TBankController } from './tbank.controller';
|
||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||
import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
||||
@ -25,8 +25,9 @@ describe('TBankController', () => {
|
||||
});
|
||||
|
||||
it('returns accounts in a single API envelope', async () => {
|
||||
vi.mocked(accounts.findAll).mockResolvedValueOnce({
|
||||
data: [
|
||||
vi.mocked(accounts.findAll).mockResolvedValueOnce(
|
||||
new ApiEnvelopePayload(
|
||||
[
|
||||
{
|
||||
id: 'acc-1',
|
||||
type: 'brokerage',
|
||||
@ -36,15 +37,18 @@ describe('TBankController', () => {
|
||||
accessLevel: null,
|
||||
},
|
||||
],
|
||||
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
||||
});
|
||||
true,
|
||||
'2026-06-17T00:00:00.000Z',
|
||||
),
|
||||
);
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||
const response = await controller.getAccounts();
|
||||
|
||||
expect(response).toBeInstanceOf(ApiResponse);
|
||||
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||
expect(response.data).toHaveLength(1);
|
||||
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
|
||||
expect(response.fromCache).toBe(true);
|
||||
expect(response.cachedAt).toBe('2026-06-17T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('exposes a sync trigger for durable operation history', async () => {
|
||||
@ -60,7 +64,7 @@ describe('TBankController', () => {
|
||||
from: '2026-06-01T00:00:00.000Z',
|
||||
to: '2026-06-17T00:00:00.000Z',
|
||||
});
|
||||
expect(response.data).toEqual({ upserted: 2 });
|
||||
expect(response).toEqual({ upserted: 2 });
|
||||
});
|
||||
|
||||
it('exposes analytics endpoint through controller', async () => {
|
||||
@ -74,18 +78,18 @@ describe('TBankController', () => {
|
||||
totalReturnPercent: 25,
|
||||
currency: 'RUB',
|
||||
};
|
||||
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce({
|
||||
data: analyticsData,
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
});
|
||||
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
|
||||
new ApiEnvelopePayload(analyticsData, false, null),
|
||||
);
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||
const response = await controller.getAnalytics('acc-1');
|
||||
|
||||
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
|
||||
expect(response).toBeInstanceOf(ApiResponse);
|
||||
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||
expect(response.data).toEqual(analyticsData);
|
||||
expect(response.meta).toEqual({ fromCache: false, cachedAt: null });
|
||||
expect(response.fromCache).toBe(false);
|
||||
expect(response.cachedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('forwards events query and wraps response', async () => {
|
||||
@ -106,17 +110,16 @@ describe('TBankController', () => {
|
||||
},
|
||||
asOf: '2026-06-22T00:00:00.000Z',
|
||||
};
|
||||
vi.mocked(events.getEvents).mockResolvedValueOnce({
|
||||
data: eventsData,
|
||||
meta: { fromCache: false, cachedAt: '2026-06-22T00:00:00.000Z' },
|
||||
});
|
||||
vi.mocked(events.getEvents).mockResolvedValueOnce(
|
||||
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
|
||||
const response = await controller.getEvents('acc-1', query);
|
||||
|
||||
expect(events.getEvents).toHaveBeenCalledWith('acc-1', query);
|
||||
expect(response).toBeInstanceOf(ApiResponse);
|
||||
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||
expect(response.data).toEqual(eventsData);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiResponse } from '../../common/dto/api-response.dto';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import {
|
||||
BrokerAccountsEnvelopeDto,
|
||||
@ -40,16 +39,14 @@ export class TBankController {
|
||||
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
|
||||
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
|
||||
async getAccounts() {
|
||||
const result = await this.brokerAccountsService.findAll();
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
return this.brokerAccountsService.findAll();
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId/portfolio')
|
||||
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
|
||||
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
|
||||
async getPortfolio(@Param('accountId') accountId: string) {
|
||||
const result = await this.brokerPortfolioService.getPortfolio(accountId);
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
return this.brokerPortfolioService.getPortfolio(accountId);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId/positions')
|
||||
@ -59,13 +56,12 @@ export class TBankController {
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: BrokerPositionQueryDto,
|
||||
) {
|
||||
const result = await this.brokerPortfolioService.getPositions(
|
||||
return this.brokerPortfolioService.getPositions(
|
||||
accountId,
|
||||
query.cursor,
|
||||
query.limit,
|
||||
query.type,
|
||||
);
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId/operations')
|
||||
@ -75,24 +71,21 @@ export class TBankController {
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: BrokerOperationQueryDto,
|
||||
) {
|
||||
const result = await this.brokerOperationsService.getOperations(accountId, query);
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
return this.brokerOperationsService.getOperations(accountId, query);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId/events')
|
||||
@ApiOperation({ summary: 'Get broker account calendar events and cashflow' })
|
||||
@ApiOkResponse({ type: BrokerEventsEnvelopeDto })
|
||||
async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) {
|
||||
const result = await this.brokerEventsService.getEvents(accountId, query);
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
return this.brokerEventsService.getEvents(accountId, query);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId/analytics')
|
||||
@ApiOperation({ summary: 'Get broker account profitability analytics' })
|
||||
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
|
||||
async getAnalytics(@Param('accountId') accountId: string) {
|
||||
const result = await this.brokerAnalyticsService.getAnalytics(accountId);
|
||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
||||
return this.brokerAnalyticsService.getAnalytics(accountId);
|
||||
}
|
||||
|
||||
@Post('accounts/:accountId/operations/sync')
|
||||
@ -102,7 +95,6 @@ export class TBankController {
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: BrokerOperationSyncQueryDto,
|
||||
) {
|
||||
const result = await this.brokerOperationSyncService.syncAccount(accountId, query);
|
||||
return new ApiResponse(result);
|
||||
return this.brokerOperationSyncService.syncAccount(accountId, query);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export function envelope(data: unknown) {
|
||||
return {
|
||||
data: { data, meta: { fromCache: false, cachedAt: null } },
|
||||
data,
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,8 @@ describe('useBondCandles', () => {
|
||||
server.use(
|
||||
http.get(`${API}/securities/bonds/:secid/candles`, () => {
|
||||
return HttpResponse.json({
|
||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||
data: [],
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@ -46,7 +46,8 @@ describe('useSearch', () => {
|
||||
server.use(
|
||||
http.get(`${API}/securities/search`, () =>
|
||||
HttpResponse.json({
|
||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||
data: [],
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@ -30,7 +30,8 @@ describe('useStockCandles', () => {
|
||||
server.use(
|
||||
http.get(`${API}/securities/shares/:secid/candles`, () => {
|
||||
return HttpResponse.json({
|
||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||
data: [],
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@ -27,7 +27,8 @@ describe('useStockDividends', () => {
|
||||
server.use(
|
||||
http.get(`${API}/securities/shares/:secid/dividends`, () => {
|
||||
return HttpResponse.json({
|
||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||
data: [],
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
19
apps/frontend/src/shared/api/kyClient.test.ts
Normal file
19
apps/frontend/src/shared/api/kyClient.test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeEnvelope } from './kyClient'
|
||||
|
||||
describe('normalizeEnvelope', () => {
|
||||
it('keeps a nested legacy envelope intact instead of unwrapping it', () => {
|
||||
const json = {
|
||||
data: {
|
||||
data: { value: 42 },
|
||||
meta: { fromCache: true, cachedAt: '2026-06-24T00:00:00.000Z' },
|
||||
},
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
}
|
||||
|
||||
expect(normalizeEnvelope<typeof json.data>(json)).toEqual({
|
||||
data: json.data,
|
||||
meta: json.meta,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -33,16 +33,7 @@ function buildUrl(path: string): string {
|
||||
}
|
||||
|
||||
export function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
||||
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>
|
||||
if (
|
||||
envelope.data &&
|
||||
typeof envelope.data === 'object' &&
|
||||
'data' in envelope.data &&
|
||||
'meta' in envelope.data
|
||||
) {
|
||||
return envelope.data as { data: T; meta: ApiResponseMeta }
|
||||
}
|
||||
|
||||
const envelope = json as ApiEnvelope<T>
|
||||
return {
|
||||
data: envelope.data as T,
|
||||
meta: envelope.meta,
|
||||
|
||||
@ -72,7 +72,8 @@ describe('SearchBar', () => {
|
||||
server.use(
|
||||
http.get(`${API}/securities/search`, () =>
|
||||
HttpResponse.json({
|
||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
||||
data: [],
|
||||
meta: { fromCache: false, cachedAt: null },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
520
docs/features/api-envelope-contract/plan.md
Normal file
520
docs/features/api-envelope-contract/plan.md
Normal file
@ -0,0 +1,520 @@
|
||||
# API Envelope Runtime Contract — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development или superpowers:executing-plans для реализации.
|
||||
> Tasks используют checkbox (`- [x]`) для отслеживания прогресса.
|
||||
|
||||
**Goal:** Привести runtime-ответы всех endpoint'ов к единому `{ data, meta }`-envelope через `TransformInterceptor` как единственный source of truth.
|
||||
|
||||
**Architecture:**
|
||||
Вводится внутренний carrier-тип `ApiEnvelopePayload<T>` (не публичный DTO, а runtime-only). Services и controllers, которым нужно передать cache metadata, возвращают `new ApiEnvelopePayload(data, fromCache, cachedAt)`. `TransformInterceptor` проверяет `instanceof ApiEnvelopePayload` и строит финальный `ApiResponse`. Controllers, не работающие с cache, возвращают plain data. Frontend `normalizeEnvelope()` теряет поддержку double-wrapped ответов.
|
||||
Swagger DTOs не меняются — они уже моделируют правильный single envelope.
|
||||
|
||||
**Tech Stack:** NestJS (interceptor, class), TypeScript, Vitest, Sinon/vi, openapi-fetch/ky
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Добавить ApiEnvelopePayload и обновить TransformInterceptor
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/common/dto/api-response.dto.ts`
|
||||
- Modify: `apps/backend/src/common/interceptors/transform.interceptor.ts`
|
||||
|
||||
- [x] **Step 1: Добавить ApiEnvelopePayload<T>** в `api-response.dto.ts`:
|
||||
|
||||
```ts
|
||||
export class ApiEnvelopePayload<T> {
|
||||
constructor(
|
||||
public readonly data: T,
|
||||
public readonly fromCache: boolean,
|
||||
public readonly cachedAt: string | null,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: Обновить TransformInterceptor** — распознавать `ApiEnvelopePayload` и `ApiResponse`:
|
||||
|
||||
```ts
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => {
|
||||
if (data instanceof ApiResponse) return data;
|
||||
if (data instanceof ApiEnvelopePayload) {
|
||||
return new ApiResponse(data.data, data.fromCache, data.cachedAt);
|
||||
}
|
||||
return new ApiResponse(data, false, null);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: Проверить, что backend компилируется**:
|
||||
|
||||
```bash
|
||||
npm run build -w apps/backend
|
||||
```
|
||||
|
||||
- [x] **Step 4: Commit**:
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/common/dto/api-response.dto.ts apps/backend/src/common/interceptors/transform.interceptor.ts
|
||||
git commit -m "feat: add ApiEnvelopePayload carrier to fix envelope ownership"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Migrate services — return ApiEnvelopePayload instead of `{ data, meta }`
|
||||
|
||||
Affected services (all return `{ data, meta }` today):
|
||||
|
||||
1. `SharesService.getMarketData / getDividends / getHistory`
|
||||
2. `BondsService.getBond / getMarketData / getHistory`
|
||||
3. `CandlesService.getCandles`
|
||||
4. `BrokerAccountsService.findAll`
|
||||
5. `BrokerPortfolioService.getPortfolio / getPositions`
|
||||
6. `BrokerEventsService.getEvents`
|
||||
7. `BrokerAnalyticsService.getAnalytics`
|
||||
8. `BrokerOperationsService.getOperations`
|
||||
|
||||
---
|
||||
|
||||
**Pattern for each service (shown for SharesService):**
|
||||
|
||||
- [x] **Step 1: SharesService.getMarketData** — change return from `{ data, meta }` to `new ApiEnvelopePayload(data, fromCache, cachedAt)`:
|
||||
|
||||
```ts
|
||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||
|
||||
// before:
|
||||
return { data: { ... }, meta: { fromCache, cachedAt } };
|
||||
|
||||
// after:
|
||||
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
|
||||
```
|
||||
|
||||
- [x] **Step 2: SharesService.getDividends** — same pattern:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload(
|
||||
data.map((d) => ({ registryCloseDate: d.registryCloseDate, value: d.value, currency: d.currencyId })),
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
```
|
||||
|
||||
- [x] **Step 3: SharesService.getHistory** — same pattern:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload(
|
||||
data.map((h) => ({ date: h.tradeDate, open: h.open ?? 0, high: h.high ?? 0, close: h.close ?? 0, volume: h.volume, value: h.value })),
|
||||
fromCache,
|
||||
cachedAt,
|
||||
);
|
||||
```
|
||||
|
||||
- [x] **Step 4: BondsService.getBond** — same pattern:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
|
||||
```
|
||||
|
||||
- [x] **Step 5: BondsService.getMarketData** — same pattern:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
|
||||
```
|
||||
|
||||
- [x] **Step 6: BondsService.getHistory** — same pattern:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload(data.map(...), fromCache, cachedAt);
|
||||
```
|
||||
|
||||
- [x] **Step 7: CandlesService.getCandles** — same pattern:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload(data.map(...), fromCache, cachedAt);
|
||||
```
|
||||
|
||||
- [x] **Step 8: BrokerAccountsService.findAll** — same pattern:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
```
|
||||
|
||||
- [x] **Step 9: BrokerPortfolioService.getPortfolio** — same:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload(portfolioResult.data, portfolioResult.fromCache, portfolioResult.cachedAt);
|
||||
```
|
||||
|
||||
- [x] **Step 10: BrokerPortfolioService.getPositions** — same:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
```
|
||||
|
||||
- [x] **Step 11: BrokerEventsService.getEvents** — same:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
```
|
||||
|
||||
- [x] **Step 12: BrokerAnalyticsService.getAnalytics** — same:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
```
|
||||
|
||||
- [x] **Step 13: BrokerOperationsService.getOperations** — same:
|
||||
|
||||
```ts
|
||||
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||
```
|
||||
|
||||
- [x] **Step 14: Build check**:
|
||||
|
||||
```bash
|
||||
npm run build -w apps/backend
|
||||
```
|
||||
|
||||
- [x] **Step 15: Commit**:
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/modules/shares/shares.service.ts
|
||||
git add apps/backend/src/modules/bonds/bonds.service.ts
|
||||
git add apps/backend/src/modules/candles/candles.service.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-accounts.service.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-portfolio.service.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-events.service.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-analytics.service.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-operations.service.ts
|
||||
git commit -m "feat: migrate services to ApiEnvelopePayload internal carrier"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Migrate controllers — stop manual envelope construction
|
||||
|
||||
Controllers that manually build `{ data, meta }`:
|
||||
1. `SharesController.getShare`
|
||||
2. `SecuritiesController.search / screener`
|
||||
3. `PortfolioController` — все методы
|
||||
4. `AuthController` — все методы
|
||||
|
||||
Controllers that call services returning envelope and shouldn't do anything special:
|
||||
5. `SharesController.getMarketData / getDividends / getHistory` — already just return service result
|
||||
6. `BondsController` — all methods, already just return service result
|
||||
7. `CandlesController` — already just return service result
|
||||
8. **`TBankController` — stops wrapping in `ApiResponse`**, delegates to service
|
||||
|
||||
---
|
||||
|
||||
- [x] **Step 1: SharesController.getShare** — return plain data:
|
||||
|
||||
```ts
|
||||
async getShare(@Param('secid') secid: string) {
|
||||
const share = await this.sharesService.getShare(secid);
|
||||
return share;
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: SecuritiesController.search, screener** — return plain data:
|
||||
|
||||
```ts
|
||||
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
||||
return this.securitiesService.search(query.q, query.type || SecurityType.ALL, query.limit || 20);
|
||||
}
|
||||
|
||||
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
|
||||
return this.screenerService.screen(query);
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: PortfolioController** — all methods return plain data. E.g.:
|
||||
|
||||
```ts
|
||||
async findAll(@CurrentUser() user: { sub: number }) {
|
||||
return this.portfolioService.findAll(user.sub);
|
||||
}
|
||||
|
||||
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
|
||||
return this.portfolioService.create(user.sub, dto);
|
||||
}
|
||||
// ... аналогично для всех остальных методов
|
||||
```
|
||||
|
||||
- [x] **Step 4: AuthController** — all methods return plain data. E.g.:
|
||||
|
||||
```ts
|
||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.authService.register(dto);
|
||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||
return { user: result.user, accessToken: result.accessToken };
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 5: TBankController** — stop wrapping in `ApiResponse`. Just return service result:
|
||||
|
||||
```ts
|
||||
async getAccounts() {
|
||||
return this.brokerAccountsService.findAll();
|
||||
}
|
||||
|
||||
async getPortfolio(@Param('accountId') accountId: string) {
|
||||
return this.brokerPortfolioService.getPortfolio(accountId);
|
||||
}
|
||||
// ... аналогично для всех методов (кроме syncOperations — он уже возвращает plain object)
|
||||
```
|
||||
|
||||
- [x] **Step 6: Build check**:
|
||||
|
||||
```bash
|
||||
npm run build -w apps/backend
|
||||
```
|
||||
|
||||
- [x] **Step 7: Commit**:
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/modules/shares/shares.controller.ts
|
||||
git add apps/backend/src/modules/securities/securities.controller.ts
|
||||
git add apps/backend/src/modules/portfolio/portfolio.controller.ts
|
||||
git add apps/backend/src/modules/auth/auth.controller.ts
|
||||
git add apps/backend/src/modules/tbank/tbank.controller.ts
|
||||
git commit -m "feat: migrate controllers to plain data returns, stop manual envelope"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update backend tests
|
||||
|
||||
Affected test files (check envelope shape or `instanceof ApiResponse`):
|
||||
|
||||
- `apps/backend/src/modules/tbank/tbank.controller.spec.ts` — `expect(response).toBeInstanceOf(ApiResponse)`, `expect(response.meta)`
|
||||
- `apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts` — `expect(result.meta.fromCache)`
|
||||
- `apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts` — `expect(result.meta.fromCache)`
|
||||
- `apps/backend/src/modules/tbank/services/broker-events.service.spec.ts` — mocks `{ data, meta }`
|
||||
- `apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts` — mocks `{ data, meta }`
|
||||
- `apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts` — mocks `{ data, meta }`
|
||||
- `apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts` — mocks `{ data, meta }`
|
||||
- `apps/backend/src/modules/shares/shares.service.spec.ts` — mocks `fromCache/cachedAt`
|
||||
- `apps/backend/src/modules/securities/securities.service.spec.ts` — mocks `fromCache/cachedAt`
|
||||
- `apps/backend/src/modules/securities/screener.service.spec.ts` — mocks `fromCache/cachedAt`
|
||||
- `apps/backend/src/modules/portfolio/portfolio.service.spec.ts` — mocks `fromCache/cachedAt`
|
||||
- `apps/backend/src/modules/cache/cache.service.spec.ts` (may not be affected)
|
||||
|
||||
**Pattern for tbank.controller.spec.ts:**
|
||||
|
||||
```ts
|
||||
// before:
|
||||
expect(response).toBeInstanceOf(ApiResponse);
|
||||
expect(response.data).toHaveLength(1);
|
||||
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
|
||||
|
||||
// after — controller returns service result directly, which is ApiEnvelopePayload
|
||||
// interceptor handles wrapping; controller spec tests the controller, not the HTTP boundary
|
||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||
expect(response.data).toHaveLength(1);
|
||||
expect(response.fromCache).toBe(true);
|
||||
expect(response.cachedAt).toBe('2026-06-17T00:00:00.000Z');
|
||||
```
|
||||
|
||||
**Pattern for service specs — returns `ApiEnvelopePayload`:**
|
||||
|
||||
```ts
|
||||
// before:
|
||||
expect(result.meta.fromCache).toBe(false);
|
||||
expect(result.meta.cachedAt).toBe('2026-06-16T02:30:00.000Z');
|
||||
|
||||
// after:
|
||||
expect(result.fromCache).toBe(false);
|
||||
expect(result.cachedAt).toBe('2026-06-16T02:30:00.000Z');
|
||||
```
|
||||
|
||||
**Mock data in service specs — mocks remain `{ data, fromCache, cachedAt }` from `cacheService.getOrFetch`**:
|
||||
```ts
|
||||
// cache service still returns { data, fromCache, cachedAt }
|
||||
// the service wraps it into ApiEnvelopePayload — this is what we test
|
||||
// mock stays:
|
||||
mockGetOrFetch.mockResolvedValue({
|
||||
data: ...,
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
});
|
||||
```
|
||||
|
||||
- [x] **Step 1: tbank.controller.spec.ts** — update assertions to check `ApiEnvelopePayload` instead of `ApiResponse`.
|
||||
|
||||
- [x] **Step 2: broker-accounts.service.spec.ts** — update `result.meta.fromCache` → `result.fromCache`.
|
||||
|
||||
- [x] **Step 3: broker-analytics.service.spec.ts** — update meta assertions.
|
||||
|
||||
- [x] **Step 4: broker-events.service.spec.ts** — update mock data and assertions.
|
||||
|
||||
- [x] **Step 5: broker-portfolio.service.spec.ts** — update mock data and assertions.
|
||||
|
||||
- [x] **Step 6: broker-operations.service.spec.ts** — update mock data and assertions.
|
||||
|
||||
- [x] **Step 7: broker-operation-sync.service.spec.ts** — update mock data and assertions (if any).
|
||||
|
||||
- [x] **Step 8: shares.service.spec.ts** — update fromCache/cachedAt assertions.
|
||||
|
||||
- [x] **Step 9: securities.service.spec.ts** — update fromCache/cachedAt assertions.
|
||||
|
||||
- [x] **Step 10: screener.service.spec.ts** — update fromCache/cachedAt assertions.
|
||||
|
||||
- [x] **Step 11: portfolio.service.spec.ts** — update fromCache/cachedAt assertions.
|
||||
|
||||
- [x] **Step 12: Run backend tests**:
|
||||
|
||||
```bash
|
||||
npm test -w apps/backend
|
||||
```
|
||||
|
||||
- [x] **Step 13: Commit**:
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/modules/tbank/tbank.controller.spec.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-events.service.spec.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts
|
||||
git add apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts
|
||||
git add apps/backend/src/modules/shares/shares.service.spec.ts
|
||||
git add apps/backend/src/modules/securities/securities.service.spec.ts
|
||||
git add apps/backend/src/modules/securities/screener.service.spec.ts
|
||||
git add apps/backend/src/modules/portfolio/portfolio.service.spec.ts
|
||||
git commit -m "test: update backend tests for ApiEnvelopePayload carrier"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Добавить HTTP contract tests (backend integration тесты)
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/modules/health/health.controller.spec.ts` (if not exists)
|
||||
- Create or modify: `apps/backend/src/modules/shares/shares.controller.spec.ts` (if exists but needs update)
|
||||
- Create or modify: envelope contract test
|
||||
|
||||
- [x] **Step 1: Создать** `apps/backend/src/envelope-contract.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { TransformInterceptor } from '../common/interceptors/transform.interceptor';
|
||||
|
||||
describe('Envelope runtime contract', () => {
|
||||
// Integration test: создаёт тестовый контроллер, проверяет что
|
||||
// TransformInterceptor всегда выдаёт ровно один { data, meta }
|
||||
|
||||
it('wraps plain data into single envelope', async () => {
|
||||
// проверка через TestModule + интерцептор
|
||||
});
|
||||
|
||||
it('does not wrap data into data.data when data is an object', async () => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run contract tests**:
|
||||
|
||||
```bash
|
||||
npm exec -w apps/backend -- vitest run apps/backend/src/envelope-contract.spec.ts
|
||||
```
|
||||
|
||||
- [x] **Step 3: Commit**:
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/envelope-contract.spec.ts
|
||||
git commit -m "test: add HTTP envelope contract tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Simplify frontend normalizeEnvelope — remove double-wrap support
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/shared/api/kyClient.ts`
|
||||
|
||||
- [x] **Step 1: Упростить normalizeEnvelope**:
|
||||
|
||||
```ts
|
||||
export function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
||||
const envelope = json as ApiEnvelope<T>
|
||||
return {
|
||||
data: envelope.data as T,
|
||||
meta: envelope.meta,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: Export ApiEnvelope** from kyClient if needed:
|
||||
|
||||
```ts
|
||||
export interface ApiEnvelope<T> {
|
||||
data: T
|
||||
meta: ApiResponseMeta
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: Run frontend tests**:
|
||||
|
||||
```bash
|
||||
npm test -w apps/frontend
|
||||
```
|
||||
|
||||
- [x] **Step 4: Run frontend build**:
|
||||
|
||||
```bash
|
||||
npm run build -w apps/frontend
|
||||
```
|
||||
|
||||
- [x] **Step 5: Commit**:
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/shared/api/kyClient.ts
|
||||
git commit -m "feat: simplify normalizeEnvelope — remove double-wrap support"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Final verification
|
||||
|
||||
- [x] **Step 1: Run all tests**:
|
||||
|
||||
```bash
|
||||
npm test -w apps/backend
|
||||
npm test -w apps/frontend
|
||||
```
|
||||
|
||||
- [x] **Step 2: Build both packages**:
|
||||
|
||||
```bash
|
||||
npm run build -w apps/backend
|
||||
npm run build -w apps/frontend
|
||||
```
|
||||
|
||||
- [x] **Step 3: OpenAPI artifacts check**:
|
||||
|
||||
```bash
|
||||
npm exec -w apps/backend -- vitest run openapi-artifacts.spec.ts
|
||||
```
|
||||
|
||||
- [x] **Step 4: Verify the feature docs are consistent** (read spec.md, plan.md, tasks.md — no contradictions).
|
||||
|
||||
- [x] **Step 5: Commit final**:
|
||||
|
||||
```bash
|
||||
git add docs/features/api-envelope-contract/
|
||||
git commit -m "docs: add spec/plan/tasks for API envelope runtime contract"
|
||||
```
|
||||
56
docs/features/api-envelope-contract/spec.md
Normal file
56
docs/features/api-envelope-contract/spec.md
Normal file
@ -0,0 +1,56 @@
|
||||
# API Envelope Runtime Contract
|
||||
|
||||
## Goal
|
||||
|
||||
Устранить расхождение между следующим API-контрактом и реальными runtime-ответами backend'а, чтобы каждый endpoint возвращал ровно один `{ data, meta }`:
|
||||
|
||||
```ts
|
||||
// Единственный публичный контракт
|
||||
ApiResponse<T> = { data: T, meta: { fromCache: boolean, cachedAt: string | null } }
|
||||
```
|
||||
|
||||
Убрать поддержку broken double-wrapping на frontend и сделать `TransformInterceptor` единственной точкой формирования публичного envelope.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Backend
|
||||
|
||||
1. **Единый владелец envelope** — `TransformInterceptor` является единственной точкой, формирующей `{ data, meta }` в HTTP-ответе.
|
||||
2. **Internal carrier** — Services и controllers, которым нужно передать cache metadata, используют внутренний carrier-тип (`ApiEnvelopePayload<T>`), а не создают `{ data, meta }` вручную.
|
||||
3. **Controllers без meta возвращают чистое DTO** — Если endpoint не использует cache, controller возвращает только domain data, interceptor оборачивает её сам.
|
||||
4. **Ни один endpoint не приводит к `data.data`** — Runtime-ответ каждого endpoint проверяется интеграционным тестом на одинарный envelope.
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **Единый формат envelope** — `normalizeEnvelope()` больше не поддерживает double-wrapped ответы.
|
||||
2. **request()** остаётся `Promise<{ data: T; meta: ApiResponseMeta }>` — контракт не меняется, ясность не уменьшается.
|
||||
|
||||
### Testing
|
||||
|
||||
1. **HTTP contract tests** — минимальный набор проверяет, что ключевые endpoint'ы возвращают `{ data, meta }` без вложенности.
|
||||
2. **Существующие тесты обновлены** — сервисные и контроллерные тесты проверяют новый carrier-механизм вместо ручного `{ data, meta }`.
|
||||
3. **Regression-тесты** — endpoint'ы autentification, shares, bonds, securities, portfolios, T-Bank покрыты минимум одним contract-тестом на shape ответа.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `GET /api/v1/health` возвращает `{ data: { ... }, meta: { fromCache, cachedAt } }` — без `data.data`.
|
||||
2. `GET /api/v1/securities/shares/{secid}` — одинарный envelope.
|
||||
3. `GET /api/v1/securities/shares/{secid}/marketdata` — одинарный envelope с корректным `meta` от cache.
|
||||
4. `GET /api/v1/securities/bonds/{secid}` — одинарный envelope.
|
||||
5. `GET /api/v1/securities/search?q=` — одинарный envelope.
|
||||
6. `GET /api/v1/securities/screener?` — одинарный envelope.
|
||||
7. `GET /api/v1/portfolios` … — одинарный envelope.
|
||||
8. `GET /api/v1/broker/accounts` … — одинарный envelope.
|
||||
9. `POST /api/v1/auth/register`, `login`, `refresh` — одинарный envelope.
|
||||
10. Backend тесты проходят: `npm test -w apps/backend`.
|
||||
11. Frontend тесты проходят: `npm test -w apps/frontend`.
|
||||
12. `npm run build -w apps/frontend` проходит.
|
||||
13. `normalizeEnvelope()` больше не проверяет `data.data`.
|
||||
14. Swagger/OpenAPI артефакты не ломаются (`npm exec -w apps/backend -- vitest run openapi`).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Не менять форму DTO и Swagger-типы (они уже корректны).
|
||||
- Не менять формат error-ответов.
|
||||
- Не добавлять версионирование API.
|
||||
- Не рефакторить бизнес-логику endpoint'ов.
|
||||
63
docs/features/api-envelope-contract/tasks.md
Normal file
63
docs/features/api-envelope-contract/tasks.md
Normal file
@ -0,0 +1,63 @@
|
||||
# API Envelope Runtime Contract — Tasks
|
||||
|
||||
## Task 1: Добавить ApiEnvelopePayload и обновить TransformInterceptor
|
||||
|
||||
- [x] Добавить `ApiEnvelopePayload<T>` класс в `api-response.dto.ts`
|
||||
- [x] Обновить `TransformInterceptor` — распознавать `ApiEnvelopePayload`
|
||||
- [x] Проверить сборку (`npm run build -w apps/backend`)
|
||||
|
||||
## Task 2: Migrate services — ApiEnvelopePayload вместо `{ data, meta }`
|
||||
|
||||
- [x] SharesService: getMarketData, getDividends, getHistory
|
||||
- [x] BondsService: getBond, getMarketData, getHistory
|
||||
- [x] CandlesService.getCandles
|
||||
- [x] BrokerAccountsService.findAll
|
||||
- [x] BrokerPortfolioService.getPortfolio, getPositions
|
||||
- [x] BrokerEventsService.getEvents
|
||||
- [x] BrokerAnalyticsService.getAnalytics
|
||||
- [x] BrokerOperationsService.getOperations
|
||||
- [x] Проверить сборку
|
||||
|
||||
## Task 3: Migrate controllers — plain data returns вместо ручного envelope
|
||||
|
||||
- [x] SharesController.getShare
|
||||
- [x] SecuritiesController.search, screener
|
||||
- [x] PortfolioController: все методы
|
||||
- [x] AuthController: все методы
|
||||
- [x] TBankController: stop ApiResponse wrapping
|
||||
- [x] Проверить сборку
|
||||
|
||||
## Task 4: Update backend tests
|
||||
|
||||
- [x] tbank.controller.spec.ts — ApiResponse → ApiEnvelopePayload
|
||||
- [x] broker-accounts.service.spec.ts — result.meta → result
|
||||
- [x] broker-analytics.service.spec.ts
|
||||
- [x] broker-events.service.spec.ts
|
||||
- [x] broker-portfolio.service.spec.ts
|
||||
- [x] broker-operations.service.spec.ts
|
||||
- [x] broker-operation-sync.service.spec.ts
|
||||
- [x] shares.service.spec.ts
|
||||
- [x] securities.service.spec.ts
|
||||
- [x] screener.service.spec.ts
|
||||
- [x] portfolio.service.spec.ts
|
||||
- [x] Запустить `npm test -w apps/backend`
|
||||
|
||||
## Task 5: HTTP contract tests
|
||||
|
||||
- [x] Создать `apps/backend/src/envelope-contract.spec.ts` с тестами single envelope
|
||||
- [x] Запустить contract tests
|
||||
|
||||
## Task 6: Simplify frontend normalizeEnvelope
|
||||
|
||||
- [x] Убрать double-wrap detection из `normalizeEnvelope()`
|
||||
- [x] Запустить `npm test -w apps/frontend`
|
||||
- [x] Запустить `npm run build -w apps/frontend`
|
||||
|
||||
## Task 7: Final verification
|
||||
|
||||
- [ ] `npm test -w apps/backend`
|
||||
- [ ] `npm test -w apps/frontend`
|
||||
- [ ] `npm run build -w apps/backend`
|
||||
- [ ] `npm run build -w apps/frontend`
|
||||
- [ ] OpenAPI artifacts check
|
||||
- [ ] docs consistency check
|
||||
@ -374,13 +374,9 @@ frontend build, 94 backend-теста и 168 frontend-тестов.
|
||||
|
||||
### P1: унифицировать API envelope и runtime-контракт
|
||||
|
||||
- Часть контроллеров возвращает `{ data, meta }`, после чего глобальный `TransformInterceptor`
|
||||
оборачивает ответ повторно.
|
||||
- Frontend содержит `normalizeEnvelope`, который поддерживает одновременно одинарную и двойную
|
||||
обёртку; это маскирует расхождение runtime-ответов со Swagger/OpenAPI.
|
||||
- Выбрать единственного владельца envelope: interceptor либо контроллеры, удалить двойную обёртку и
|
||||
временный compatibility-код после миграции.
|
||||
- Добавить интеграционные contract-тесты реальных HTTP-ответов, а не только DTO/OpenAPI schemas.
|
||||
- [x] **RESOLVED** — см. feature `api-envelope-contract`. `TransformInterceptor` — единственный владелец
|
||||
envelope, `ApiEnvelopePayload<T>` — внутренний carrier для cache metadata, frontend `normalizeEnvelope`
|
||||
упрощён, добавлен HTTP contract-тест (`envelope-contract.spec.ts`).
|
||||
|
||||
### P1: усилить production-конфигурацию и auth security
|
||||
|
||||
|
||||
@ -95,7 +95,7 @@ Roadmap отражает порядок продуктовой работы, н
|
||||
и `TableSkeleton` удалены.
|
||||
- [ ] T-Bank data isolation and multi-tenancy (P0/P1) — изолировать данные T-Bank по пользователям,
|
||||
ownership модель
|
||||
- [ ] API envelope runtime contract (P1) — устранить double-wrapping, унифицировать envelope
|
||||
- [x] API envelope runtime contract (P1) — устранить double-wrapping, унифицировать envelope
|
||||
- [ ] Auth security hardening (P1) — production-секреты, CORS allowlist, error masking, rate limiting
|
||||
- [ ] Local T-Bank read-path (P1) — чтение истории операций из локальной БД вместо прямого вызова T-Bank
|
||||
- [ ] Session model for multiple surfaces (P1/P2) — device-level сессии, rotation, reuse detection
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user