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> {
|
export class ApiResponse<T> {
|
||||||
data: T;
|
data: T;
|
||||||
meta: ApiResponseMeta;
|
meta: ApiResponseMeta;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { ApiResponse } from '../dto/api-response.dto';
|
import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
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(
|
return next.handle().pipe(
|
||||||
map((data) => {
|
map((data) => {
|
||||||
if (data instanceof ApiResponse) return 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);
|
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) {
|
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||||
const result = await this.authService.register(dto);
|
const result = await this.authService.register(dto);
|
||||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
return {
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
data: {
|
|
||||||
user: result.user,
|
|
||||||
accessToken: result.accessToken,
|
|
||||||
},
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -57,13 +51,7 @@ export class AuthController {
|
|||||||
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||||
const result = await this.authService.login(dto);
|
const result = await this.authService.login(dto);
|
||||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
return {
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
data: {
|
|
||||||
user: result.user,
|
|
||||||
accessToken: result.accessToken,
|
|
||||||
},
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -75,13 +63,7 @@ export class AuthController {
|
|||||||
const token = req.cookies?.[REFRESH_COOKIE];
|
const token = req.cookies?.[REFRESH_COOKIE];
|
||||||
const result = await this.authService.refresh(token);
|
const result = await this.authService.refresh(token);
|
||||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
return {
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
data: {
|
|
||||||
user: result.user,
|
|
||||||
accessToken: result.accessToken,
|
|
||||||
},
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@ -92,10 +74,7 @@ export class AuthController {
|
|||||||
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
|
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
|
||||||
await this.authService.logout(user.sub);
|
await this.authService.logout(user.sub);
|
||||||
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
|
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
|
||||||
return {
|
return { message: 'Logged out successfully' };
|
||||||
data: { message: 'Logged out successfully' },
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@ -103,11 +82,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: 'Get current user profile' })
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
@ApiOkResponse({ type: AuthProfileResponseDto })
|
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||||
async getProfile(@CurrentUser() user: JwtPayload) {
|
async getProfile(@CurrentUser() user: JwtPayload) {
|
||||||
const profile = await this.authService.getProfile(user.sub);
|
return this.authService.getProfile(user.sub);
|
||||||
return {
|
|
||||||
data: profile,
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('me')
|
@Patch('me')
|
||||||
@ -115,10 +90,6 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: 'Update current user profile' })
|
@ApiOperation({ summary: 'Update current user profile' })
|
||||||
@ApiOkResponse({ type: AuthProfileResponseDto })
|
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||||
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
|
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
|
||||||
const profile = await this.authService.updateProfile(user.sub, dto);
|
return this.authService.updateProfile(user.sub, dto);
|
||||||
return {
|
|
||||||
data: profile,
|
|
||||||
meta: { fromCache: false, cachedAt: null },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -129,10 +129,8 @@ describe('BondsService', () => {
|
|||||||
volume: 10000,
|
volume: 10000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meta: {
|
fromCache: false,
|
||||||
fromCache: false,
|
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
|
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BondsService {
|
export class BondsService {
|
||||||
@ -32,8 +33,8 @@ export class BondsService {
|
|||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: {
|
{
|
||||||
secid: bond.secid,
|
secid: bond.secid,
|
||||||
isin: bond.isin,
|
isin: bond.isin,
|
||||||
name: bond.shortName,
|
name: bond.shortName,
|
||||||
@ -70,8 +71,9 @@ export class BondsService {
|
|||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMarketData(secid: string) {
|
async getMarketData(secid: string) {
|
||||||
@ -90,8 +92,8 @@ export class BondsService {
|
|||||||
throw new NotFoundException(`Market data for bond ${secid} not found`);
|
throw new NotFoundException(`Market data for bond ${secid} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: {
|
{
|
||||||
price: mkt.last ?? 0,
|
price: mkt.last ?? 0,
|
||||||
yieldToMaturity: mkt.yield ?? null,
|
yieldToMaturity: mkt.yield ?? null,
|
||||||
duration: mkt.duration ?? null,
|
duration: mkt.duration ?? null,
|
||||||
@ -107,8 +109,9 @@ export class BondsService {
|
|||||||
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
|
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
|
||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(secid: string, from: string, till: string) {
|
async getHistory(secid: string, from: string, till: string) {
|
||||||
@ -119,14 +122,15 @@ export class BondsService {
|
|||||||
'historyTtl',
|
'historyTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: data.map((h) => ({
|
data.map((h) => ({
|
||||||
date: h.tradeDate,
|
date: h.tradeDate,
|
||||||
closePrice: h.legalClosePrice ?? h.close ?? 0,
|
closePrice: h.legalClosePrice ?? h.close ?? 0,
|
||||||
yieldClose: h.yieldClose ?? null,
|
yieldClose: h.yieldClose ?? null,
|
||||||
duration: h.duration ?? null,
|
duration: h.duration ?? null,
|
||||||
})),
|
})),
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,10 +81,8 @@ describe('CandlesService', () => {
|
|||||||
end: '2026-05-01 23:59:59',
|
end: '2026-05-01 23:59:59',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
meta: {
|
fromCache: false,
|
||||||
fromCache: false,
|
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||||
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 { MoexClientService } from '../moex-client/moex-client.service';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import { CandleInterval } from './dto/candles-query.dto';
|
import { CandleInterval } from './dto/candles-query.dto';
|
||||||
|
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CandlesService {
|
export class CandlesService {
|
||||||
@ -29,8 +30,8 @@ export class CandlesService {
|
|||||||
'candlesTtl',
|
'candlesTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: data.map((c) => ({
|
data.map((c) => ({
|
||||||
open: c.open,
|
open: c.open,
|
||||||
high: c.high,
|
high: c.high,
|
||||||
low: c.low,
|
low: c.low,
|
||||||
@ -40,7 +41,8 @@ export class CandlesService {
|
|||||||
begin: c.begin,
|
begin: c.begin,
|
||||||
end: c.end,
|
end: c.end,
|
||||||
})),
|
})),
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,12 +26,8 @@ import {
|
|||||||
const nullDataEnvelopeSchema = {
|
const nullDataEnvelopeSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
data: {
|
data: { type: 'null' },
|
||||||
type: 'null',
|
meta: { $ref: getSchemaPath(PortfolioResponseMetaDto) },
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
$ref: getSchemaPath(PortfolioResponseMetaDto),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
required: ['data', 'meta'],
|
required: ['data', 'meta'],
|
||||||
};
|
};
|
||||||
@ -47,24 +43,21 @@ export class PortfolioController {
|
|||||||
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||||||
@ApiOkResponse({ type: PortfolioListEnvelopeDto })
|
@ApiOkResponse({ type: PortfolioListEnvelopeDto })
|
||||||
async findAll(@CurrentUser() user: { sub: number }) {
|
async findAll(@CurrentUser() user: { sub: number }) {
|
||||||
const portfolios = await this.portfolioService.findAll(user.sub);
|
return this.portfolioService.findAll(user.sub);
|
||||||
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new portfolio' })
|
@ApiOperation({ summary: 'Create a new portfolio' })
|
||||||
@ApiCreatedResponse({ type: PortfolioEnvelopeDto })
|
@ApiCreatedResponse({ type: PortfolioEnvelopeDto })
|
||||||
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
|
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
|
||||||
const portfolio = await this.portfolioService.create(user.sub, dto);
|
return this.portfolioService.create(user.sub, dto);
|
||||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
|
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
|
||||||
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
|
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
|
||||||
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||||
const portfolio = await this.portfolioService.findOne(user.sub, id);
|
return this.portfolioService.findOne(user.sub, id);
|
||||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ -75,8 +68,7 @@ export class PortfolioController {
|
|||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdatePortfolioDto,
|
@Body() dto: UpdatePortfolioDto,
|
||||||
) {
|
) {
|
||||||
const portfolio = await this.portfolioService.update(user.sub, id, dto);
|
return this.portfolioService.update(user.sub, id, dto);
|
||||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ -84,7 +76,7 @@ export class PortfolioController {
|
|||||||
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
|
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
|
||||||
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||||
await this.portfolioService.remove(user.sub, id);
|
await this.portfolioService.remove(user.sub, id);
|
||||||
return { data: null, meta: { cachedAt: null, fromCache: false } };
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/positions')
|
@Post(':id/positions')
|
||||||
@ -95,8 +87,7 @@ export class PortfolioController {
|
|||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: AddPositionDto,
|
@Body() dto: AddPositionDto,
|
||||||
) {
|
) {
|
||||||
const position = await this.portfolioService.addPosition(user.sub, id, dto);
|
return this.portfolioService.addPosition(user.sub, id, dto);
|
||||||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/positions/:positionId')
|
@Patch(':id/positions/:positionId')
|
||||||
@ -108,16 +99,14 @@ export class PortfolioController {
|
|||||||
@Param('positionId', ParseIntPipe) positionId: number,
|
@Param('positionId', ParseIntPipe) positionId: number,
|
||||||
@Body() dto: UpdatePositionDto,
|
@Body() dto: UpdatePositionDto,
|
||||||
) {
|
) {
|
||||||
const position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
return this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
||||||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/analytics')
|
@Get(':id/analytics')
|
||||||
@ApiOperation({ summary: 'Get portfolio analytics with PnL' })
|
@ApiOperation({ summary: 'Get portfolio analytics with PnL' })
|
||||||
@ApiOkResponse({ type: AnalyticsEnvelopeDto })
|
@ApiOkResponse({ type: AnalyticsEnvelopeDto })
|
||||||
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||||
const result = await this.portfolioService.getAnalytics(user.sub, id);
|
return this.portfolioService.getAnalytics(user.sub, id);
|
||||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id/positions/:positionId')
|
@Delete(':id/positions/:positionId')
|
||||||
@ -129,6 +118,6 @@ export class PortfolioController {
|
|||||||
@Param('positionId', ParseIntPipe) positionId: number,
|
@Param('positionId', ParseIntPipe) positionId: number,
|
||||||
) {
|
) {
|
||||||
await this.portfolioService.removePosition(user.sub, id, positionId);
|
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 () => {
|
it('should return search results', async () => {
|
||||||
const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 });
|
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);
|
expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -21,19 +21,17 @@ export class SecuritiesController {
|
|||||||
@ApiOperation({ summary: 'Поиск по инструментам' })
|
@ApiOperation({ summary: 'Поиск по инструментам' })
|
||||||
@ApiOkResponse({ type: SearchEnvelopeDto })
|
@ApiOkResponse({ type: SearchEnvelopeDto })
|
||||||
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
||||||
const results = await this.securitiesService.search(
|
return this.securitiesService.search(
|
||||||
query.q,
|
query.q,
|
||||||
query.type || SecurityType.ALL,
|
query.type || SecurityType.ALL,
|
||||||
query.limit || 20,
|
query.limit || 20,
|
||||||
);
|
);
|
||||||
return { data: results, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('screener')
|
@Get('screener')
|
||||||
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
|
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
|
||||||
@ApiOkResponse({ type: ScreenerResponseDto })
|
@ApiOkResponse({ type: ScreenerResponseDto })
|
||||||
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
|
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
|
||||||
const result = await this.screenerService.screen(query);
|
return this.screenerService.screen(query);
|
||||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,8 +19,7 @@ export class SharesController {
|
|||||||
@ApiOperation({ summary: 'Получить спецификацию акции' })
|
@ApiOperation({ summary: 'Получить спецификацию акции' })
|
||||||
@ApiOkResponse({ type: ShareEnvelopeDto })
|
@ApiOkResponse({ type: ShareEnvelopeDto })
|
||||||
async getShare(@Param('secid') secid: string) {
|
async getShare(@Param('secid') secid: string) {
|
||||||
const share = await this.sharesService.getShare(secid);
|
return this.sharesService.getShare(secid);
|
||||||
return { data: share, meta: { cachedAt: null, fromCache: false } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':secid/marketdata')
|
@Get(':secid/marketdata')
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
|
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SharesService {
|
export class SharesService {
|
||||||
@ -77,8 +78,8 @@ export class SharesService {
|
|||||||
throw new NotFoundException(`Market data for ${secid} not found`);
|
throw new NotFoundException(`Market data for ${secid} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: {
|
{
|
||||||
price: marketData.last ?? 0,
|
price: marketData.last ?? 0,
|
||||||
change: marketData.lastChange ?? 0,
|
change: marketData.lastChange ?? 0,
|
||||||
changePercent: marketData.lastChangePrcnt ?? 0,
|
changePercent: marketData.lastChangePrcnt ?? 0,
|
||||||
@ -92,8 +93,9 @@ export class SharesService {
|
|||||||
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDividends(secid: string) {
|
async getDividends(secid: string) {
|
||||||
@ -104,14 +106,15 @@ export class SharesService {
|
|||||||
'dividendsTtl',
|
'dividendsTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: data.map((d) => ({
|
data.map((d) => ({
|
||||||
registryCloseDate: d.registryCloseDate,
|
registryCloseDate: d.registryCloseDate,
|
||||||
value: d.value,
|
value: d.value,
|
||||||
currency: d.currencyId,
|
currency: d.currencyId,
|
||||||
})),
|
})),
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(secid: string, from: string, till: string) {
|
async getHistory(secid: string, from: string, till: string) {
|
||||||
@ -122,8 +125,8 @@ export class SharesService {
|
|||||||
'historyTtl',
|
'historyTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
data: data.map((h) => ({
|
data.map((h) => ({
|
||||||
date: h.tradeDate,
|
date: h.tradeDate,
|
||||||
open: h.open ?? 0,
|
open: h.open ?? 0,
|
||||||
high: h.high ?? 0,
|
high: h.high ?? 0,
|
||||||
@ -132,7 +135,8 @@ export class SharesService {
|
|||||||
volume: h.volume,
|
volume: h.volume,
|
||||||
value: h.value,
|
value: h.value,
|
||||||
})),
|
})),
|
||||||
meta: { fromCache, cachedAt },
|
fromCache,
|
||||||
};
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,7 @@ describe('BrokerAccountsService', () => {
|
|||||||
|
|
||||||
expect(result.data).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']);
|
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(
|
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||||
'tbank:accounts',
|
'tbank:accounts',
|
||||||
['open-brokerage-iis'],
|
['open-brokerage-iis'],
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { TBANK_CACHE_KEYS } from '../tbank.config';
|
|||||||
import type { BrokerAccount } from '../types/broker.types';
|
import type { BrokerAccount } from '../types/broker.types';
|
||||||
import type { TBankAccountsResponse } from '../types/tbank-proto.types';
|
import type { TBankAccountsResponse } from '../types/tbank-proto.types';
|
||||||
import { TBankClientService } from './tbank-client.service';
|
import { TBankClientService } from './tbank-client.service';
|
||||||
|
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerAccountsService {
|
export class BrokerAccountsService {
|
||||||
@ -13,10 +14,7 @@ export class BrokerAccountsService {
|
|||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<{
|
async findAll(): Promise<ApiEnvelopePayload<BrokerAccount[]>> {
|
||||||
data: BrokerAccount[];
|
|
||||||
meta: { fromCache: boolean; cachedAt: string | null };
|
|
||||||
}> {
|
|
||||||
const result = await this.cacheService.getOrFetch(
|
const result = await this.cacheService.getOrFetch(
|
||||||
TBANK_CACHE_KEYS.accounts,
|
TBANK_CACHE_KEYS.accounts,
|
||||||
['open-brokerage-iis'],
|
['open-brokerage-iis'],
|
||||||
@ -24,10 +22,7 @@ export class BrokerAccountsService {
|
|||||||
'tbankAccountsTtl',
|
'tbankAccountsTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
data: result.data,
|
|
||||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(accountId: string): Promise<BrokerAccount | null> {
|
async findById(accountId: string): Promise<BrokerAccount | null> {
|
||||||
|
|||||||
@ -226,7 +226,7 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
const result = await service.getAnalytics('acc-1');
|
const result = await service.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(result.data.netInvested).toBe(1000);
|
expect(result.data.netInvested).toBe(1000);
|
||||||
expect(result.meta.fromCache).toBe(true);
|
expect(result.fromCache).toBe(true);
|
||||||
expect(result.meta.cachedAt).toBe('2026-06-24T10:00:00.000Z');
|
expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { CacheService } from '../../cache/cache.service';
|
import { CacheService } from '../../cache/cache.service';
|
||||||
|
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||||
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
|
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
|
||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
import { BrokerAccountsService } from './broker-accounts.service';
|
||||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||||
@ -41,10 +42,7 @@ export class BrokerAnalyticsService {
|
|||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAnalytics(accountId: string): Promise<{
|
async getAnalytics(accountId: string): Promise<ApiEnvelopePayload<BrokerAnalyticsDto>> {
|
||||||
data: BrokerAnalyticsDto;
|
|
||||||
meta: { fromCache: boolean; cachedAt: string | null };
|
|
||||||
}> {
|
|
||||||
const account = await this.accountsService.findById(accountId);
|
const account = await this.accountsService.findById(accountId);
|
||||||
if (!account) throw new NotFoundException('Broker account not found');
|
if (!account) throw new NotFoundException('Broker account not found');
|
||||||
|
|
||||||
@ -55,10 +53,7 @@ export class BrokerAnalyticsService {
|
|||||||
'tbankAnalyticsTtl',
|
'tbankAnalyticsTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
data: result.data,
|
|
||||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
|
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
|
||||||
|
|||||||
@ -58,7 +58,8 @@ describe('BrokerEventsService', () => {
|
|||||||
|
|
||||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' },
|
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);
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
@ -112,7 +113,8 @@ describe('BrokerEventsService', () => {
|
|||||||
|
|
||||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
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);
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
@ -166,7 +168,8 @@ describe('BrokerEventsService', () => {
|
|||||||
|
|
||||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
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);
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
@ -211,7 +214,8 @@ describe('BrokerEventsService', () => {
|
|||||||
|
|
||||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
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);
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
@ -241,7 +245,8 @@ describe('BrokerEventsService', () => {
|
|||||||
|
|
||||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
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);
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
@ -301,7 +306,8 @@ describe('BrokerEventsService', () => {
|
|||||||
|
|
||||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
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);
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
@ -339,7 +345,8 @@ describe('BrokerEventsService', () => {
|
|||||||
|
|
||||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
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);
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
@ -396,7 +403,8 @@ describe('BrokerEventsService', () => {
|
|||||||
]);
|
]);
|
||||||
vi.mocked(operations.getOperations).mockResolvedValue({
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
||||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
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);
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
@ -455,7 +463,8 @@ describe('BrokerEventsService', () => {
|
|||||||
hasNext: false,
|
hasNext: false,
|
||||||
asOf: '2026-06-19T00:00:00.000Z',
|
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);
|
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { CacheService } from '../../cache/cache.service';
|
import { CacheService } from '../../cache/cache.service';
|
||||||
import { MoexClientService } from '../../moex-client/moex-client.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 { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||||
import { mapQuotationToNumber } from '../mappers/money.mapper';
|
import { mapQuotationToNumber } from '../mappers/money.mapper';
|
||||||
import type {
|
import type {
|
||||||
@ -52,10 +53,7 @@ export class BrokerEventsService {
|
|||||||
async getEvents(
|
async getEvents(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
query: BrokerEventsQuery,
|
query: BrokerEventsQuery,
|
||||||
): Promise<{
|
): Promise<ApiEnvelopePayload<BrokerEventsData>> {
|
||||||
data: BrokerEventsData;
|
|
||||||
meta: { fromCache: boolean; cachedAt: string | null };
|
|
||||||
}> {
|
|
||||||
const account = await this.accountsService.findById(accountId);
|
const account = await this.accountsService.findById(accountId);
|
||||||
if (!account) throw new NotFoundException('Broker account not found');
|
if (!account) throw new NotFoundException('Broker account not found');
|
||||||
|
|
||||||
@ -68,10 +66,7 @@ export class BrokerEventsService {
|
|||||||
'tbankPortfolioTtl',
|
'tbankPortfolioTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
data: result.data,
|
|
||||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildEvents(
|
private async buildEvents(
|
||||||
|
|||||||
@ -48,11 +48,13 @@ describe('BrokerOperationSyncService', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
meta: { fromCache: false, cachedAt: null },
|
fromCache: false,
|
||||||
|
cachedAt: null,
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
|
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);
|
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);
|
const service = new BrokerOperationSyncService(operations, prisma);
|
||||||
@ -142,7 +145,8 @@ describe('BrokerOperationSyncService', () => {
|
|||||||
asOf: '2026-06-16T00:00:00.000Z',
|
asOf: '2026-06-16T00:00:00.000Z',
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
meta: { fromCache: false, cachedAt: null },
|
fromCache: false,
|
||||||
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerOperationSyncService(operations, prisma);
|
const service = new BrokerOperationSyncService(operations, prisma);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { CacheService } from '../../cache/cache.service';
|
import { CacheService } from '../../cache/cache.service';
|
||||||
|
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||||
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
|
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
|
||||||
import { mapOperationsPage } from '../mappers/operation.mapper';
|
import { mapOperationsPage } from '../mappers/operation.mapper';
|
||||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||||
@ -19,10 +20,7 @@ export class BrokerOperationsService {
|
|||||||
async getOperations(
|
async getOperations(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
query: BrokerOperationQueryDto,
|
query: BrokerOperationQueryDto,
|
||||||
): Promise<{
|
): Promise<ApiEnvelopePayload<BrokerOperationsPage>> {
|
||||||
data: BrokerOperationsPage;
|
|
||||||
meta: { fromCache: boolean; cachedAt: string | null };
|
|
||||||
}> {
|
|
||||||
const account = await this.accountsService.findById(accountId);
|
const account = await this.accountsService.findById(accountId);
|
||||||
if (!account) throw new NotFoundException('Broker account not found');
|
if (!account) throw new NotFoundException('Broker account not found');
|
||||||
|
|
||||||
@ -34,10 +32,7 @@ export class BrokerOperationsService {
|
|||||||
'tbankOperationsTtl',
|
'tbankOperationsTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
data: result.data,
|
|
||||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {
|
private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import type {
|
|||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
import { BrokerAccountsService } from './broker-accounts.service';
|
||||||
import { BrokerInstrumentsService } from './broker-instruments.service';
|
import { BrokerInstrumentsService } from './broker-instruments.service';
|
||||||
import { TBankClientService } from './tbank-client.service';
|
import { TBankClientService } from './tbank-client.service';
|
||||||
|
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerPortfolioService {
|
export class BrokerPortfolioService {
|
||||||
@ -22,10 +23,7 @@ export class BrokerPortfolioService {
|
|||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getPortfolio(accountId: string): Promise<{
|
async getPortfolio(accountId: string): Promise<ApiEnvelopePayload<BrokerPortfolio>> {
|
||||||
data: BrokerPortfolio;
|
|
||||||
meta: { fromCache: boolean; cachedAt: string | null };
|
|
||||||
}> {
|
|
||||||
const account = await this.accountsService.findById(accountId);
|
const account = await this.accountsService.findById(accountId);
|
||||||
if (!account) throw new NotFoundException('Broker account not found');
|
if (!account) throw new NotFoundException('Broker account not found');
|
||||||
|
|
||||||
@ -43,10 +41,7 @@ export class BrokerPortfolioService {
|
|||||||
'tbankPortfolioTtl',
|
'tbankPortfolioTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
data: result.data,
|
|
||||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPositions(
|
async getPositions(
|
||||||
@ -54,10 +49,7 @@ export class BrokerPortfolioService {
|
|||||||
cursor?: string,
|
cursor?: string,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
type?: string,
|
type?: string,
|
||||||
): Promise<{
|
): Promise<ApiEnvelopePayload<BrokerPositionsPage>> {
|
||||||
data: BrokerPositionsPage;
|
|
||||||
meta: { fromCache: boolean; cachedAt: string | null };
|
|
||||||
}> {
|
|
||||||
const account = await this.accountsService.findById(accountId);
|
const account = await this.accountsService.findById(accountId);
|
||||||
if (!account) throw new NotFoundException('Broker account not found');
|
if (!account) throw new NotFoundException('Broker account not found');
|
||||||
|
|
||||||
@ -87,10 +79,7 @@ export class BrokerPortfolioService {
|
|||||||
'tbankPositionsTtl',
|
'tbankPositionsTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
data: result.data,
|
|
||||||
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildInstrumentMap(
|
private async buildInstrumentMap(
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
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 { TBankController } from './tbank.controller';
|
||||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
import { BrokerAccountsService } from './services/broker-accounts.service';
|
||||||
import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
import { BrokerAnalyticsService } from './services/broker-analytics.service';
|
||||||
@ -25,26 +25,30 @@ describe('TBankController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns accounts in a single API envelope', async () => {
|
it('returns accounts in a single API envelope', async () => {
|
||||||
vi.mocked(accounts.findAll).mockResolvedValueOnce({
|
vi.mocked(accounts.findAll).mockResolvedValueOnce(
|
||||||
data: [
|
new ApiEnvelopePayload(
|
||||||
{
|
[
|
||||||
id: 'acc-1',
|
{
|
||||||
type: 'brokerage',
|
id: 'acc-1',
|
||||||
name: 'Broker',
|
type: 'brokerage',
|
||||||
status: 'ACCOUNT_STATUS_OPEN',
|
name: 'Broker',
|
||||||
openedAt: null,
|
status: 'ACCOUNT_STATUS_OPEN',
|
||||||
accessLevel: null,
|
openedAt: null,
|
||||||
},
|
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 controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||||
const response = await controller.getAccounts();
|
const response = await controller.getAccounts();
|
||||||
|
|
||||||
expect(response).toBeInstanceOf(ApiResponse);
|
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||||
expect(response.data).toHaveLength(1);
|
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 () => {
|
it('exposes a sync trigger for durable operation history', async () => {
|
||||||
@ -60,7 +64,7 @@ describe('TBankController', () => {
|
|||||||
from: '2026-06-01T00:00:00.000Z',
|
from: '2026-06-01T00:00:00.000Z',
|
||||||
to: '2026-06-17T00: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 () => {
|
it('exposes analytics endpoint through controller', async () => {
|
||||||
@ -74,18 +78,18 @@ describe('TBankController', () => {
|
|||||||
totalReturnPercent: 25,
|
totalReturnPercent: 25,
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
};
|
};
|
||||||
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce({
|
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
|
||||||
data: analyticsData,
|
new ApiEnvelopePayload(analyticsData, false, null),
|
||||||
meta: { fromCache: false, cachedAt: null },
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||||
const response = await controller.getAnalytics('acc-1');
|
const response = await controller.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
|
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
|
||||||
expect(response).toBeInstanceOf(ApiResponse);
|
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||||
expect(response.data).toEqual(analyticsData);
|
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 () => {
|
it('forwards events query and wraps response', async () => {
|
||||||
@ -106,17 +110,16 @@ describe('TBankController', () => {
|
|||||||
},
|
},
|
||||||
asOf: '2026-06-22T00:00:00.000Z',
|
asOf: '2026-06-22T00:00:00.000Z',
|
||||||
};
|
};
|
||||||
vi.mocked(events.getEvents).mockResolvedValueOnce({
|
vi.mocked(events.getEvents).mockResolvedValueOnce(
|
||||||
data: eventsData,
|
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'),
|
||||||
meta: { fromCache: false, cachedAt: '2026-06-22T00:00:00.000Z' },
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||||
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
|
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
|
||||||
const response = await controller.getEvents('acc-1', query);
|
const response = await controller.getEvents('acc-1', query);
|
||||||
|
|
||||||
expect(events.getEvents).toHaveBeenCalledWith('acc-1', query);
|
expect(events.getEvents).toHaveBeenCalledWith('acc-1', query);
|
||||||
expect(response).toBeInstanceOf(ApiResponse);
|
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||||
expect(response.data).toEqual(eventsData);
|
expect(response.data).toEqual(eventsData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
|
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { ApiResponse } from '../../common/dto/api-response.dto';
|
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import {
|
import {
|
||||||
BrokerAccountsEnvelopeDto,
|
BrokerAccountsEnvelopeDto,
|
||||||
@ -40,16 +39,14 @@ export class TBankController {
|
|||||||
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
|
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
|
||||||
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
|
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
|
||||||
async getAccounts() {
|
async getAccounts() {
|
||||||
const result = await this.brokerAccountsService.findAll();
|
return this.brokerAccountsService.findAll();
|
||||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountId/portfolio')
|
@Get('accounts/:accountId/portfolio')
|
||||||
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
|
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
|
||||||
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
|
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
|
||||||
async getPortfolio(@Param('accountId') accountId: string) {
|
async getPortfolio(@Param('accountId') accountId: string) {
|
||||||
const result = await this.brokerPortfolioService.getPortfolio(accountId);
|
return this.brokerPortfolioService.getPortfolio(accountId);
|
||||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountId/positions')
|
@Get('accounts/:accountId/positions')
|
||||||
@ -59,13 +56,12 @@ export class TBankController {
|
|||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
@Query() query: BrokerPositionQueryDto,
|
@Query() query: BrokerPositionQueryDto,
|
||||||
) {
|
) {
|
||||||
const result = await this.brokerPortfolioService.getPositions(
|
return this.brokerPortfolioService.getPositions(
|
||||||
accountId,
|
accountId,
|
||||||
query.cursor,
|
query.cursor,
|
||||||
query.limit,
|
query.limit,
|
||||||
query.type,
|
query.type,
|
||||||
);
|
);
|
||||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountId/operations')
|
@Get('accounts/:accountId/operations')
|
||||||
@ -75,24 +71,21 @@ export class TBankController {
|
|||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
@Query() query: BrokerOperationQueryDto,
|
@Query() query: BrokerOperationQueryDto,
|
||||||
) {
|
) {
|
||||||
const result = await this.brokerOperationsService.getOperations(accountId, query);
|
return this.brokerOperationsService.getOperations(accountId, query);
|
||||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountId/events')
|
@Get('accounts/:accountId/events')
|
||||||
@ApiOperation({ summary: 'Get broker account calendar events and cashflow' })
|
@ApiOperation({ summary: 'Get broker account calendar events and cashflow' })
|
||||||
@ApiOkResponse({ type: BrokerEventsEnvelopeDto })
|
@ApiOkResponse({ type: BrokerEventsEnvelopeDto })
|
||||||
async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) {
|
async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) {
|
||||||
const result = await this.brokerEventsService.getEvents(accountId, query);
|
return this.brokerEventsService.getEvents(accountId, query);
|
||||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountId/analytics')
|
@Get('accounts/:accountId/analytics')
|
||||||
@ApiOperation({ summary: 'Get broker account profitability analytics' })
|
@ApiOperation({ summary: 'Get broker account profitability analytics' })
|
||||||
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
|
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
|
||||||
async getAnalytics(@Param('accountId') accountId: string) {
|
async getAnalytics(@Param('accountId') accountId: string) {
|
||||||
const result = await this.brokerAnalyticsService.getAnalytics(accountId);
|
return this.brokerAnalyticsService.getAnalytics(accountId);
|
||||||
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('accounts/:accountId/operations/sync')
|
@Post('accounts/:accountId/operations/sync')
|
||||||
@ -102,7 +95,6 @@ export class TBankController {
|
|||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
@Query() query: BrokerOperationSyncQueryDto,
|
@Query() query: BrokerOperationSyncQueryDto,
|
||||||
) {
|
) {
|
||||||
const result = await this.brokerOperationSyncService.syncAccount(accountId, query);
|
return this.brokerOperationSyncService.syncAccount(accountId, query);
|
||||||
return new ApiResponse(result);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export function envelope(data: unknown) {
|
export function envelope(data: unknown) {
|
||||||
return {
|
return {
|
||||||
data: { data, meta: { fromCache: false, cachedAt: null } },
|
data,
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,8 @@ describe('useBondCandles', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/bonds/:secid/candles`, () => {
|
http.get(`${API}/securities/bonds/:secid/candles`, () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: [],
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -46,7 +46,8 @@ describe('useSearch', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/search`, () =>
|
http.get(`${API}/securities/search`, () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: [],
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -30,7 +30,8 @@ describe('useStockCandles', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/shares/:secid/candles`, () => {
|
http.get(`${API}/securities/shares/:secid/candles`, () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: [],
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -27,7 +27,8 @@ describe('useStockDividends', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/shares/:secid/dividends`, () => {
|
http.get(`${API}/securities/shares/:secid/dividends`, () => {
|
||||||
return HttpResponse.json({
|
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 } {
|
export function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
||||||
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>
|
const envelope = json as ApiEnvelope<T>
|
||||||
if (
|
|
||||||
envelope.data &&
|
|
||||||
typeof envelope.data === 'object' &&
|
|
||||||
'data' in envelope.data &&
|
|
||||||
'meta' in envelope.data
|
|
||||||
) {
|
|
||||||
return envelope.data as { data: T; meta: ApiResponseMeta }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: envelope.data as T,
|
data: envelope.data as T,
|
||||||
meta: envelope.meta,
|
meta: envelope.meta,
|
||||||
|
|||||||
@ -72,7 +72,8 @@ describe('SearchBar', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/search`, () =>
|
http.get(`${API}/securities/search`, () =>
|
||||||
HttpResponse.json({
|
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-контракт
|
### P1: унифицировать API envelope и runtime-контракт
|
||||||
|
|
||||||
- Часть контроллеров возвращает `{ data, meta }`, после чего глобальный `TransformInterceptor`
|
- [x] **RESOLVED** — см. feature `api-envelope-contract`. `TransformInterceptor` — единственный владелец
|
||||||
оборачивает ответ повторно.
|
envelope, `ApiEnvelopePayload<T>` — внутренний carrier для cache metadata, frontend `normalizeEnvelope`
|
||||||
- Frontend содержит `normalizeEnvelope`, который поддерживает одновременно одинарную и двойную
|
упрощён, добавлен HTTP contract-тест (`envelope-contract.spec.ts`).
|
||||||
обёртку; это маскирует расхождение runtime-ответов со Swagger/OpenAPI.
|
|
||||||
- Выбрать единственного владельца envelope: interceptor либо контроллеры, удалить двойную обёртку и
|
|
||||||
временный compatibility-код после миграции.
|
|
||||||
- Добавить интеграционные contract-тесты реальных HTTP-ответов, а не только DTO/OpenAPI schemas.
|
|
||||||
|
|
||||||
### P1: усилить production-конфигурацию и auth security
|
### P1: усилить production-конфигурацию и auth security
|
||||||
|
|
||||||
|
|||||||
@ -95,7 +95,7 @@ Roadmap отражает порядок продуктовой работы, н
|
|||||||
и `TableSkeleton` удалены.
|
и `TableSkeleton` удалены.
|
||||||
- [ ] T-Bank data isolation and multi-tenancy (P0/P1) — изолировать данные T-Bank по пользователям,
|
- [ ] T-Bank data isolation and multi-tenancy (P0/P1) — изолировать данные T-Bank по пользователям,
|
||||||
ownership модель
|
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
|
- [ ] Auth security hardening (P1) — production-секреты, CORS allowlist, error masking, rate limiting
|
||||||
- [ ] Local T-Bank read-path (P1) — чтение истории операций из локальной БД вместо прямого вызова T-Bank
|
- [ ] Local T-Bank read-path (P1) — чтение истории операций из локальной БД вместо прямого вызова T-Bank
|
||||||
- [ ] Session model for multiple surfaces (P1/P2) — device-level сессии, rotation, reuse detection
|
- [ ] Session model for multiple surfaces (P1/P2) — device-level сессии, rotation, reuse detection
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user