feat: finalize api-envelope-contract — tests, frontend simplify, contract tests, docs

This commit is contained in:
Sergey Krylov 2026-06-25 06:15:26 +03:00
parent b092caf8d6
commit e69f183372
22 changed files with 795 additions and 77 deletions

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

@ -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$/);
}); });

View File

@ -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',
},
}); });
}); });

View File

@ -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);
}); });
}); });

View File

@ -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'],

View File

@ -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');
}); });
}); });

View File

@ -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);

View File

@ -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);

View File

@ -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);
}); });
}); });

View File

@ -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 },
} }
} }

View File

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

View File

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

View File

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

View File

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

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 } { 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,

View File

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

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-контракт ### 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

View File

@ -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