From e69f183372be8654d6eb781f7fdaf8887ed749fb Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 06:15:26 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20finalize=20api-envelope-contract=20?= =?UTF-8?q?=E2=80=94=20tests,=20frontend=20simplify,=20contract=20tests,?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/envelope-contract.spec.ts | 55 ++ .../src/modules/bonds/bonds.service.spec.ts | 6 +- .../modules/candles/candles.service.spec.ts | 6 +- .../securities/securities.controller.spec.ts | 2 +- .../services/broker-accounts.service.spec.ts | 2 +- .../services/broker-analytics.service.spec.ts | 4 +- .../services/broker-events.service.spec.ts | 27 +- .../broker-operation-sync.service.spec.ts | 12 +- .../modules/tbank/tbank.controller.spec.ts | 59 +- apps/frontend/mocks/utils.ts | 3 +- .../bond/model/useBondCandles.test.tsx | 3 +- .../entities/search/model/useSearch.test.tsx | 3 +- .../stock/model/useStockCandles.test.tsx | 3 +- .../stock/model/useStockDividends.test.tsx | 3 +- apps/frontend/src/shared/api/kyClient.test.ts | 19 + apps/frontend/src/shared/api/kyClient.ts | 11 +- .../widgets/search-bar/ui/SearchBar.test.tsx | 3 +- docs/features/api-envelope-contract/plan.md | 520 ++++++++++++++++++ docs/features/api-envelope-contract/spec.md | 56 ++ docs/features/api-envelope-contract/tasks.md | 63 +++ docs/inbox.md | 10 +- docs/roadmap.md | 2 +- 22 files changed, 795 insertions(+), 77 deletions(-) create mode 100644 apps/backend/src/envelope-contract.spec.ts create mode 100644 apps/frontend/src/shared/api/kyClient.test.ts create mode 100644 docs/features/api-envelope-contract/plan.md create mode 100644 docs/features/api-envelope-contract/spec.md create mode 100644 docs/features/api-envelope-contract/tasks.md diff --git a/apps/backend/src/envelope-contract.spec.ts b/apps/backend/src/envelope-contract.spec.ts new file mode 100644 index 0000000..abc9bcf --- /dev/null +++ b/apps/backend/src/envelope-contract.spec.ts @@ -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') + }) +}) diff --git a/apps/backend/src/modules/bonds/bonds.service.spec.ts b/apps/backend/src/modules/bonds/bonds.service.spec.ts index 532214e..6cd3f62 100644 --- a/apps/backend/src/modules/bonds/bonds.service.spec.ts +++ b/apps/backend/src/modules/bonds/bonds.service.spec.ts @@ -129,10 +129,8 @@ describe('BondsService', () => { volume: 10000, }, }, - meta: { - fromCache: false, - cachedAt: '2026-06-15T00:00:00.000Z', - }, + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', }); expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); }); diff --git a/apps/backend/src/modules/candles/candles.service.spec.ts b/apps/backend/src/modules/candles/candles.service.spec.ts index d97ce7e..f340d09 100644 --- a/apps/backend/src/modules/candles/candles.service.spec.ts +++ b/apps/backend/src/modules/candles/candles.service.spec.ts @@ -81,10 +81,8 @@ describe('CandlesService', () => { end: '2026-05-01 23:59:59', }, ], - meta: { - fromCache: false, - cachedAt: '2026-06-15T00:00:00.000Z', - }, + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', }); }); diff --git a/apps/backend/src/modules/securities/securities.controller.spec.ts b/apps/backend/src/modules/securities/securities.controller.spec.ts index 6f30508..eb6b182 100644 --- a/apps/backend/src/modules/securities/securities.controller.spec.ts +++ b/apps/backend/src/modules/securities/securities.controller.spec.ts @@ -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); }); }); diff --git a/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts index e37584e..dc63349 100644 --- a/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts @@ -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'], diff --git a/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts index 4e3b9d7..3997603 100644 --- a/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts @@ -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'); }); }); diff --git a/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts index b32d9a0..d5edd12 100644 --- a/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts @@ -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); diff --git a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts index 70b5892..af4d38b 100644 --- a/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts @@ -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); diff --git a/apps/backend/src/modules/tbank/tbank.controller.spec.ts b/apps/backend/src/modules/tbank/tbank.controller.spec.ts index 8bebdc9..e658d45 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.spec.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.spec.ts @@ -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,26 +25,30 @@ describe('TBankController', () => { }); it('returns accounts in a single API envelope', async () => { - vi.mocked(accounts.findAll).mockResolvedValueOnce({ - data: [ - { - id: 'acc-1', - type: 'brokerage', - name: 'Broker', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }, - ], - meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' }, - }); + vi.mocked(accounts.findAll).mockResolvedValueOnce( + new ApiEnvelopePayload( + [ + { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + ], + 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); }); }); diff --git a/apps/frontend/mocks/utils.ts b/apps/frontend/mocks/utils.ts index f9dff52..d555cf9 100644 --- a/apps/frontend/mocks/utils.ts +++ b/apps/frontend/mocks/utils.ts @@ -1,5 +1,6 @@ export function envelope(data: unknown) { return { - data: { data, meta: { fromCache: false, cachedAt: null } }, + data, + meta: { fromCache: false, cachedAt: null }, } } diff --git a/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx b/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx index b0aa23f..f672b26 100644 --- a/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx +++ b/apps/frontend/src/entities/bond/model/useBondCandles.test.tsx @@ -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 }, }) }), ) diff --git a/apps/frontend/src/entities/search/model/useSearch.test.tsx b/apps/frontend/src/entities/search/model/useSearch.test.tsx index 371f89d..aafa3a3 100644 --- a/apps/frontend/src/entities/search/model/useSearch.test.tsx +++ b/apps/frontend/src/entities/search/model/useSearch.test.tsx @@ -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 }, }), ), ) diff --git a/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx b/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx index 9bb2cd3..4f6ad92 100644 --- a/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx +++ b/apps/frontend/src/entities/stock/model/useStockCandles.test.tsx @@ -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 }, }) }), ) diff --git a/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx b/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx index 7d8415c..9aebac7 100644 --- a/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx +++ b/apps/frontend/src/entities/stock/model/useStockDividends.test.tsx @@ -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 }, }) }), ) diff --git a/apps/frontend/src/shared/api/kyClient.test.ts b/apps/frontend/src/shared/api/kyClient.test.ts new file mode 100644 index 0000000..e95961b --- /dev/null +++ b/apps/frontend/src/shared/api/kyClient.test.ts @@ -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(json)).toEqual({ + data: json.data, + meta: json.meta, + }) + }) +}) diff --git a/apps/frontend/src/shared/api/kyClient.ts b/apps/frontend/src/shared/api/kyClient.ts index 0bcc757..ea530c5 100644 --- a/apps/frontend/src/shared/api/kyClient.ts +++ b/apps/frontend/src/shared/api/kyClient.ts @@ -33,16 +33,7 @@ function buildUrl(path: string): string { } export function normalizeEnvelope(json: unknown): { data: T; meta: ApiResponseMeta } { - const envelope = json as ApiEnvelope - 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 return { data: envelope.data as T, meta: envelope.meta, diff --git a/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx b/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx index 3d921c2..bc5ab5b 100644 --- a/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx +++ b/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx @@ -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 }, }), ), ) diff --git a/docs/features/api-envelope-contract/plan.md b/docs/features/api-envelope-contract/plan.md new file mode 100644 index 0000000..f0add6b --- /dev/null +++ b/docs/features/api-envelope-contract/plan.md @@ -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` (не публичный 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** в `api-response.dto.ts`: + +```ts +export class ApiEnvelopePayload { + 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 implements NestInterceptor> { + intercept(context: ExecutionContext, next: CallHandler): Observable> { + 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(json: unknown): { data: T; meta: ApiResponseMeta } { + const envelope = json as ApiEnvelope + return { + data: envelope.data as T, + meta: envelope.meta, + } +} +``` + +- [x] **Step 2: Export ApiEnvelope** from kyClient if needed: + +```ts +export interface ApiEnvelope { + 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" +``` diff --git a/docs/features/api-envelope-contract/spec.md b/docs/features/api-envelope-contract/spec.md new file mode 100644 index 0000000..3a8c9a6 --- /dev/null +++ b/docs/features/api-envelope-contract/spec.md @@ -0,0 +1,56 @@ +# API Envelope Runtime Contract + +## Goal + +Устранить расхождение между следующим API-контрактом и реальными runtime-ответами backend'а, чтобы каждый endpoint возвращал ровно один `{ data, meta }`: + +```ts +// Единственный публичный контракт +ApiResponse = { 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`), а не создают `{ 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'ов. diff --git a/docs/features/api-envelope-contract/tasks.md b/docs/features/api-envelope-contract/tasks.md new file mode 100644 index 0000000..0c6cb9c --- /dev/null +++ b/docs/features/api-envelope-contract/tasks.md @@ -0,0 +1,63 @@ +# API Envelope Runtime Contract — Tasks + +## Task 1: Добавить ApiEnvelopePayload и обновить TransformInterceptor + +- [x] Добавить `ApiEnvelopePayload` класс в `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 diff --git a/docs/inbox.md b/docs/inbox.md index 86ea752..97380bf 100644 --- a/docs/inbox.md +++ b/docs/inbox.md @@ -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` — внутренний carrier для cache metadata, frontend `normalizeEnvelope` + упрощён, добавлен HTTP contract-тест (`envelope-contract.spec.ts`). ### P1: усилить production-конфигурацию и auth security diff --git a/docs/roadmap.md b/docs/roadmap.md index c04a7a7..ccaa7cd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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