Compare commits

...

4 Commits

37 changed files with 889 additions and 232 deletions

View File

@ -13,6 +13,14 @@ export class ApiResponseMeta {
}
}
export class ApiEnvelopePayload<T> {
constructor(
public readonly data: T,
public readonly fromCache: boolean,
public readonly cachedAt: string | null,
) {}
}
export class ApiResponse<T> {
data: T;
meta: ApiResponseMeta;

View File

@ -1,7 +1,7 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ApiResponse } from '../dto/api-response.dto';
import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
@ -9,6 +9,9 @@ export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T
return next.handle().pipe(
map((data) => {
if (data instanceof ApiResponse) return data;
if (data instanceof ApiEnvelopePayload) {
return new ApiResponse(data.data, data.fromCache, data.cachedAt);
}
return new ApiResponse(data, false, null);
}),
);

View 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')
})
})

View File

@ -41,13 +41,7 @@ export class AuthController {
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.register(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
return { user: result.user, accessToken: result.accessToken };
}
@Public()
@ -57,13 +51,7 @@ export class AuthController {
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.login(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
return { user: result.user, accessToken: result.accessToken };
}
@Public()
@ -75,13 +63,7 @@ export class AuthController {
const token = req.cookies?.[REFRESH_COOKIE];
const result = await this.authService.refresh(token);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
return { user: result.user, accessToken: result.accessToken };
}
@Post('logout')
@ -92,10 +74,7 @@ export class AuthController {
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
await this.authService.logout(user.sub);
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
return {
data: { message: 'Logged out successfully' },
meta: { fromCache: false, cachedAt: null },
};
return { message: 'Logged out successfully' };
}
@Get('me')
@ -103,11 +82,7 @@ export class AuthController {
@ApiOperation({ summary: 'Get current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto })
async getProfile(@CurrentUser() user: JwtPayload) {
const profile = await this.authService.getProfile(user.sub);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
return this.authService.getProfile(user.sub);
}
@Patch('me')
@ -115,10 +90,6 @@ export class AuthController {
@ApiOperation({ summary: 'Update current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto })
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
const profile = await this.authService.updateProfile(user.sub, dto);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
return this.authService.updateProfile(user.sub, dto);
}
}

View File

@ -129,10 +129,8 @@ describe('BondsService', () => {
volume: 10000,
},
},
meta: {
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
},
});
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
});

View File

@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
@Injectable()
export class BondsService {
@ -32,8 +33,8 @@ export class BondsService {
'marketDataTtl',
);
return {
data: {
return new ApiEnvelopePayload(
{
secid: bond.secid,
isin: bond.isin,
name: bond.shortName,
@ -70,8 +71,9 @@ export class BondsService {
: new Date().toISOString(),
},
},
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getMarketData(secid: string) {
@ -90,8 +92,8 @@ export class BondsService {
throw new NotFoundException(`Market data for bond ${secid} not found`);
}
return {
data: {
return new ApiEnvelopePayload(
{
price: mkt.last ?? 0,
yieldToMaturity: mkt.yield ?? null,
duration: mkt.duration ?? null,
@ -107,8 +109,9 @@ export class BondsService {
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
: new Date().toISOString(),
},
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getHistory(secid: string, from: string, till: string) {
@ -119,14 +122,15 @@ export class BondsService {
'historyTtl',
);
return {
data: data.map((h) => ({
return new ApiEnvelopePayload(
data.map((h) => ({
date: h.tradeDate,
closePrice: h.legalClosePrice ?? h.close ?? 0,
yieldClose: h.yieldClose ?? null,
duration: h.duration ?? null,
})),
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
}

View File

@ -81,10 +81,8 @@ describe('CandlesService', () => {
end: '2026-05-01 23:59:59',
},
],
meta: {
fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
},
});
});

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import { CandleInterval } from './dto/candles-query.dto';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
@Injectable()
export class CandlesService {
@ -29,8 +30,8 @@ export class CandlesService {
'candlesTtl',
);
return {
data: data.map((c) => ({
return new ApiEnvelopePayload(
data.map((c) => ({
open: c.open,
high: c.high,
low: c.low,
@ -40,7 +41,8 @@ export class CandlesService {
begin: c.begin,
end: c.end,
})),
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
}

View File

@ -26,12 +26,8 @@ import {
const nullDataEnvelopeSchema = {
type: 'object',
properties: {
data: {
type: 'null',
},
meta: {
$ref: getSchemaPath(PortfolioResponseMetaDto),
},
data: { type: 'null' },
meta: { $ref: getSchemaPath(PortfolioResponseMetaDto) },
},
required: ['data', 'meta'],
};
@ -47,24 +43,21 @@ export class PortfolioController {
@ApiOperation({ summary: 'Get all portfolios for current user' })
@ApiOkResponse({ type: PortfolioListEnvelopeDto })
async findAll(@CurrentUser() user: { sub: number }) {
const portfolios = await this.portfolioService.findAll(user.sub);
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.findAll(user.sub);
}
@Post()
@ApiOperation({ summary: 'Create a new portfolio' })
@ApiCreatedResponse({ type: PortfolioEnvelopeDto })
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
const portfolio = await this.portfolioService.create(user.sub, dto);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.create(user.sub, dto);
}
@Get(':id')
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
const portfolio = await this.portfolioService.findOne(user.sub, id);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.findOne(user.sub, id);
}
@Patch(':id')
@ -75,8 +68,7 @@ export class PortfolioController {
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdatePortfolioDto,
) {
const portfolio = await this.portfolioService.update(user.sub, id, dto);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.update(user.sub, id, dto);
}
@Delete(':id')
@ -84,7 +76,7 @@ export class PortfolioController {
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
await this.portfolioService.remove(user.sub, id);
return { data: null, meta: { cachedAt: null, fromCache: false } };
return null;
}
@Post(':id/positions')
@ -95,8 +87,7 @@ export class PortfolioController {
@Param('id', ParseIntPipe) id: number,
@Body() dto: AddPositionDto,
) {
const position = await this.portfolioService.addPosition(user.sub, id, dto);
return { data: position, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.addPosition(user.sub, id, dto);
}
@Patch(':id/positions/:positionId')
@ -108,16 +99,14 @@ export class PortfolioController {
@Param('positionId', ParseIntPipe) positionId: number,
@Body() dto: UpdatePositionDto,
) {
const position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto);
return { data: position, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.updatePosition(user.sub, id, positionId, dto);
}
@Get(':id/analytics')
@ApiOperation({ summary: 'Get portfolio analytics with PnL' })
@ApiOkResponse({ type: AnalyticsEnvelopeDto })
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
const result = await this.portfolioService.getAnalytics(user.sub, id);
return { data: result, meta: { cachedAt: null, fromCache: false } };
return this.portfolioService.getAnalytics(user.sub, id);
}
@Delete(':id/positions/:positionId')
@ -129,6 +118,6 @@ export class PortfolioController {
@Param('positionId', ParseIntPipe) positionId: number,
) {
await this.portfolioService.removePosition(user.sub, id, positionId);
return { data: null, meta: { cachedAt: null, fromCache: false } };
return null;
}
}

View File

@ -47,7 +47,7 @@ describe('SecuritiesController', () => {
it('should return search results', async () => {
const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 });
expect(result.data).toEqual(mockResults);
expect(result).toEqual(mockResults);
expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5);
});
});

View File

@ -21,19 +21,17 @@ export class SecuritiesController {
@ApiOperation({ summary: 'Поиск по инструментам' })
@ApiOkResponse({ type: SearchEnvelopeDto })
async search(@Query(ValidationPipe) query: SearchQueryDto) {
const results = await this.securitiesService.search(
return this.securitiesService.search(
query.q,
query.type || SecurityType.ALL,
query.limit || 20,
);
return { data: results, meta: { cachedAt: null, fromCache: false } };
}
@Get('screener')
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
@ApiOkResponse({ type: ScreenerResponseDto })
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
const result = await this.screenerService.screen(query);
return { data: result, meta: { cachedAt: null, fromCache: false } };
return this.screenerService.screen(query);
}
}

View File

@ -19,8 +19,7 @@ export class SharesController {
@ApiOperation({ summary: 'Получить спецификацию акции' })
@ApiOkResponse({ type: ShareEnvelopeDto })
async getShare(@Param('secid') secid: string) {
const share = await this.sharesService.getShare(secid);
return { data: share, meta: { cachedAt: null, fromCache: false } };
return this.sharesService.getShare(secid);
}
@Get(':secid/marketdata')

View File

@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
@Injectable()
export class SharesService {
@ -77,8 +78,8 @@ export class SharesService {
throw new NotFoundException(`Market data for ${secid} not found`);
}
return {
data: {
return new ApiEnvelopePayload(
{
price: marketData.last ?? 0,
change: marketData.lastChange ?? 0,
changePercent: marketData.lastChangePrcnt ?? 0,
@ -92,8 +93,9 @@ export class SharesService {
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(),
},
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getDividends(secid: string) {
@ -104,14 +106,15 @@ export class SharesService {
'dividendsTtl',
);
return {
data: data.map((d) => ({
return new ApiEnvelopePayload(
data.map((d) => ({
registryCloseDate: d.registryCloseDate,
value: d.value,
currency: d.currencyId,
})),
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
async getHistory(secid: string, from: string, till: string) {
@ -122,8 +125,8 @@ export class SharesService {
'historyTtl',
);
return {
data: data.map((h) => ({
return new ApiEnvelopePayload(
data.map((h) => ({
date: h.tradeDate,
open: h.open ?? 0,
high: h.high ?? 0,
@ -132,7 +135,8 @@ export class SharesService {
volume: h.volume,
value: h.value,
})),
meta: { fromCache, cachedAt },
};
fromCache,
cachedAt,
);
}
}

View File

@ -38,7 +38,7 @@ describe('BrokerAccountsService', () => {
expect(result.data).toHaveLength(2);
expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']);
expect(result.meta.fromCache).toBe(false);
expect(result.fromCache).toBe(false);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:accounts',
['open-brokerage-iis'],

View File

@ -5,6 +5,7 @@ import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccountsResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
@Injectable()
export class BrokerAccountsService {
@ -13,10 +14,7 @@ export class BrokerAccountsService {
private readonly cacheService: CacheService,
) {}
async findAll(): Promise<{
data: BrokerAccount[];
meta: { fromCache: boolean; cachedAt: string | null };
}> {
async findAll(): Promise<ApiEnvelopePayload<BrokerAccount[]>> {
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.accounts,
['open-brokerage-iis'],
@ -24,10 +22,7 @@ export class BrokerAccountsService {
'tbankAccountsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
async findById(accountId: string): Promise<BrokerAccount | null> {

View File

@ -226,7 +226,7 @@ describe('BrokerAnalyticsService', () => {
const result = await service.getAnalytics('acc-1');
expect(result.data.netInvested).toBe(1000);
expect(result.meta.fromCache).toBe(true);
expect(result.meta.cachedAt).toBe('2026-06-24T10:00:00.000Z');
expect(result.fromCache).toBe(true);
expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z');
});
});

View File

@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CacheService } from '../../cache/cache.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
import { BrokerAccountsService } from './broker-accounts.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
@ -41,10 +42,7 @@ export class BrokerAnalyticsService {
private readonly cacheService: CacheService,
) {}
async getAnalytics(accountId: string): Promise<{
data: BrokerAnalyticsDto;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
async getAnalytics(accountId: string): Promise<ApiEnvelopePayload<BrokerAnalyticsDto>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
@ -55,10 +53,7 @@ export class BrokerAnalyticsService {
'tbankAnalyticsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {

View File

@ -58,7 +58,8 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' },
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
@ -112,7 +113,8 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
@ -166,7 +168,8 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
@ -211,7 +214,8 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
@ -241,7 +245,8 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
@ -301,7 +306,8 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
@ -339,7 +345,8 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
@ -396,7 +403,8 @@ describe('BrokerEventsService', () => {
]);
vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
@ -455,7 +463,8 @@ describe('BrokerEventsService', () => {
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);

View File

@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { MoexClientService } from '../../moex-client/moex-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapQuotationToNumber } from '../mappers/money.mapper';
import type {
@ -52,10 +53,7 @@ export class BrokerEventsService {
async getEvents(
accountId: string,
query: BrokerEventsQuery,
): Promise<{
data: BrokerEventsData;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
): Promise<ApiEnvelopePayload<BrokerEventsData>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
@ -68,10 +66,7 @@ export class BrokerEventsService {
'tbankPortfolioTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async buildEvents(

View File

@ -48,11 +48,13 @@ describe('BrokerOperationSyncService', () => {
},
],
},
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
})
.mockResolvedValueOnce({
data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerOperationSyncService(operations, prisma);
@ -115,7 +117,8 @@ describe('BrokerOperationSyncService', () => {
},
],
},
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerOperationSyncService(operations, prisma);
@ -142,7 +145,8 @@ describe('BrokerOperationSyncService', () => {
asOf: '2026-06-16T00:00:00.000Z',
items: [],
},
meta: { fromCache: false, cachedAt: null },
fromCache: false,
cachedAt: null,
});
const service = new BrokerOperationSyncService(operations, prisma);

View File

@ -1,5 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
import { mapOperationsPage } from '../mappers/operation.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
@ -19,10 +20,7 @@ export class BrokerOperationsService {
async getOperations(
accountId: string,
query: BrokerOperationQueryDto,
): Promise<{
data: BrokerOperationsPage;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
): Promise<ApiEnvelopePayload<BrokerOperationsPage>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
@ -34,10 +32,7 @@ export class BrokerOperationsService {
'tbankOperationsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {

View File

@ -12,6 +12,7 @@ import type {
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { TBankClientService } from './tbank-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
@Injectable()
export class BrokerPortfolioService {
@ -22,10 +23,7 @@ export class BrokerPortfolioService {
private readonly cacheService: CacheService,
) {}
async getPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
async getPortfolio(accountId: string): Promise<ApiEnvelopePayload<BrokerPortfolio>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
@ -43,10 +41,7 @@ export class BrokerPortfolioService {
'tbankPortfolioTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
async getPositions(
@ -54,10 +49,7 @@ export class BrokerPortfolioService {
cursor?: string,
limit = 10,
type?: string,
): Promise<{
data: BrokerPositionsPage;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
): Promise<ApiEnvelopePayload<BrokerPositionsPage>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new NotFoundException('Broker account not found');
@ -87,10 +79,7 @@ export class BrokerPortfolioService {
'tbankPositionsTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async buildInstrumentMap(

View File

@ -1,5 +1,5 @@
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { ApiResponse } from '../../common/dto/api-response.dto';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerAnalyticsService } from './services/broker-analytics.service';
@ -25,8 +25,9 @@ describe('TBankController', () => {
});
it('returns accounts in a single API envelope', async () => {
vi.mocked(accounts.findAll).mockResolvedValueOnce({
data: [
vi.mocked(accounts.findAll).mockResolvedValueOnce(
new ApiEnvelopePayload(
[
{
id: 'acc-1',
type: 'brokerage',
@ -36,15 +37,18 @@ describe('TBankController', () => {
accessLevel: null,
},
],
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
});
true,
'2026-06-17T00:00:00.000Z',
),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const response = await controller.getAccounts();
expect(response).toBeInstanceOf(ApiResponse);
expect(response).toBeInstanceOf(ApiEnvelopePayload);
expect(response.data).toHaveLength(1);
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
expect(response.fromCache).toBe(true);
expect(response.cachedAt).toBe('2026-06-17T00:00:00.000Z');
});
it('exposes a sync trigger for durable operation history', async () => {
@ -60,7 +64,7 @@ describe('TBankController', () => {
from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z',
});
expect(response.data).toEqual({ upserted: 2 });
expect(response).toEqual({ upserted: 2 });
});
it('exposes analytics endpoint through controller', async () => {
@ -74,18 +78,18 @@ describe('TBankController', () => {
totalReturnPercent: 25,
currency: 'RUB',
};
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce({
data: analyticsData,
meta: { fromCache: false, cachedAt: null },
});
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
new ApiEnvelopePayload(analyticsData, false, null),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const response = await controller.getAnalytics('acc-1');
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
expect(response).toBeInstanceOf(ApiResponse);
expect(response).toBeInstanceOf(ApiEnvelopePayload);
expect(response.data).toEqual(analyticsData);
expect(response.meta).toEqual({ fromCache: false, cachedAt: null });
expect(response.fromCache).toBe(false);
expect(response.cachedAt).toBeNull();
});
it('forwards events query and wraps response', async () => {
@ -106,17 +110,16 @@ describe('TBankController', () => {
},
asOf: '2026-06-22T00:00:00.000Z',
};
vi.mocked(events.getEvents).mockResolvedValueOnce({
data: eventsData,
meta: { fromCache: false, cachedAt: '2026-06-22T00:00:00.000Z' },
});
vi.mocked(events.getEvents).mockResolvedValueOnce(
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
const response = await controller.getEvents('acc-1', query);
expect(events.getEvents).toHaveBeenCalledWith('acc-1', query);
expect(response).toBeInstanceOf(ApiResponse);
expect(response).toBeInstanceOf(ApiEnvelopePayload);
expect(response.data).toEqual(eventsData);
});
});

View File

@ -1,6 +1,5 @@
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiResponse } from '../../common/dto/api-response.dto';
import { Roles } from '../auth/decorators/roles.decorator';
import {
BrokerAccountsEnvelopeDto,
@ -40,16 +39,14 @@ export class TBankController {
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
async getAccounts() {
const result = await this.brokerAccountsService.findAll();
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
return this.brokerAccountsService.findAll();
}
@Get('accounts/:accountId/portfolio')
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
async getPortfolio(@Param('accountId') accountId: string) {
const result = await this.brokerPortfolioService.getPortfolio(accountId);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
return this.brokerPortfolioService.getPortfolio(accountId);
}
@Get('accounts/:accountId/positions')
@ -59,13 +56,12 @@ export class TBankController {
@Param('accountId') accountId: string,
@Query() query: BrokerPositionQueryDto,
) {
const result = await this.brokerPortfolioService.getPositions(
return this.brokerPortfolioService.getPositions(
accountId,
query.cursor,
query.limit,
query.type,
);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
}
@Get('accounts/:accountId/operations')
@ -75,24 +71,21 @@ export class TBankController {
@Param('accountId') accountId: string,
@Query() query: BrokerOperationQueryDto,
) {
const result = await this.brokerOperationsService.getOperations(accountId, query);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
return this.brokerOperationsService.getOperations(accountId, query);
}
@Get('accounts/:accountId/events')
@ApiOperation({ summary: 'Get broker account calendar events and cashflow' })
@ApiOkResponse({ type: BrokerEventsEnvelopeDto })
async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) {
const result = await this.brokerEventsService.getEvents(accountId, query);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
return this.brokerEventsService.getEvents(accountId, query);
}
@Get('accounts/:accountId/analytics')
@ApiOperation({ summary: 'Get broker account profitability analytics' })
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
async getAnalytics(@Param('accountId') accountId: string) {
const result = await this.brokerAnalyticsService.getAnalytics(accountId);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
return this.brokerAnalyticsService.getAnalytics(accountId);
}
@Post('accounts/:accountId/operations/sync')
@ -102,7 +95,6 @@ export class TBankController {
@Param('accountId') accountId: string,
@Query() query: BrokerOperationSyncQueryDto,
) {
const result = await this.brokerOperationSyncService.syncAccount(accountId, query);
return new ApiResponse(result);
return this.brokerOperationSyncService.syncAccount(accountId, query);
}
}

View File

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

View File

@ -29,7 +29,8 @@ describe('useBondCandles', () => {
server.use(
http.get(`${API}/securities/bonds/:secid/candles`, () => {
return HttpResponse.json({
data: { data: [], meta: { fromCache: false, cachedAt: null } },
data: [],
meta: { fromCache: false, cachedAt: null },
})
}),
)

View File

@ -46,7 +46,8 @@ describe('useSearch', () => {
server.use(
http.get(`${API}/securities/search`, () =>
HttpResponse.json({
data: { data: [], meta: { fromCache: false, cachedAt: null } },
data: [],
meta: { fromCache: false, cachedAt: null },
}),
),
)

View File

@ -30,7 +30,8 @@ describe('useStockCandles', () => {
server.use(
http.get(`${API}/securities/shares/:secid/candles`, () => {
return HttpResponse.json({
data: { data: [], meta: { fromCache: false, cachedAt: null } },
data: [],
meta: { fromCache: false, cachedAt: null },
})
}),
)

View File

@ -27,7 +27,8 @@ describe('useStockDividends', () => {
server.use(
http.get(`${API}/securities/shares/:secid/dividends`, () => {
return HttpResponse.json({
data: { data: [], meta: { fromCache: false, cachedAt: null } },
data: [],
meta: { fromCache: false, cachedAt: null },
})
}),
)

View 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,
})
})
})

View File

@ -33,16 +33,7 @@ function buildUrl(path: string): string {
}
export function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>
if (
envelope.data &&
typeof envelope.data === 'object' &&
'data' in envelope.data &&
'meta' in envelope.data
) {
return envelope.data as { data: T; meta: ApiResponseMeta }
}
const envelope = json as ApiEnvelope<T>
return {
data: envelope.data as T,
meta: envelope.meta,

View File

@ -72,7 +72,8 @@ describe('SearchBar', () => {
server.use(
http.get(`${API}/securities/search`, () =>
HttpResponse.json({
data: { data: [], meta: { fromCache: false, cachedAt: null } },
data: [],
meta: { fromCache: false, cachedAt: null },
}),
),
)

View 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"
```

View 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'ов.

View 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

View File

@ -374,13 +374,9 @@ frontend build, 94 backend-теста и 168 frontend-тестов.
### P1: унифицировать API envelope и runtime-контракт
- Часть контроллеров возвращает `{ data, meta }`, после чего глобальный `TransformInterceptor`
оборачивает ответ повторно.
- Frontend содержит `normalizeEnvelope`, который поддерживает одновременно одинарную и двойную
обёртку; это маскирует расхождение runtime-ответов со Swagger/OpenAPI.
- Выбрать единственного владельца envelope: interceptor либо контроллеры, удалить двойную обёртку и
временный compatibility-код после миграции.
- Добавить интеграционные contract-тесты реальных HTTP-ответов, а не только DTO/OpenAPI schemas.
- [x] **RESOLVED** — см. feature `api-envelope-contract`. `TransformInterceptor` — единственный владелец
envelope, `ApiEnvelopePayload<T>` — внутренний carrier для cache metadata, frontend `normalizeEnvelope`
упрощён, добавлен HTTP contract-тест (`envelope-contract.spec.ts`).
### P1: усилить production-конфигурацию и auth security

View File

@ -95,7 +95,7 @@ Roadmap отражает порядок продуктовой работы, н
и `TableSkeleton` удалены.
- [ ] T-Bank data isolation and multi-tenancy (P0/P1) — изолировать данные T-Bank по пользователям,
ownership модель
- [ ] API envelope runtime contract (P1) — устранить double-wrapping, унифицировать envelope
- [x] API envelope runtime contract (P1) — устранить double-wrapping, унифицировать envelope
- [ ] Auth security hardening (P1) — production-секреты, CORS allowlist, error masking, rate limiting
- [ ] Local T-Bank read-path (P1) — чтение истории операций из локальной БД вместо прямого вызова T-Bank
- [ ] Session model for multiple surfaces (P1/P2) — device-level сессии, rotation, reuse detection