feat: finalize api-envelope-contract — tests, frontend simplify, contract tests, docs
This commit is contained in:
parent
b092caf8d6
commit
e69f183372
55
apps/backend/src/envelope-contract.spec.ts
Normal file
55
apps/backend/src/envelope-contract.spec.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import 'reflect-metadata'
|
||||||
|
import { Test, type TestingModule } from '@nestjs/testing'
|
||||||
|
import type { INestApplication } from '@nestjs/common'
|
||||||
|
import { HealthModule } from './modules/health/health.module'
|
||||||
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor'
|
||||||
|
|
||||||
|
describe('API envelope contract', () => {
|
||||||
|
let app: INestApplication
|
||||||
|
let baseUrl: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [HealthModule],
|
||||||
|
}).compile()
|
||||||
|
|
||||||
|
app = module.createNestApplication()
|
||||||
|
app.setGlobalPrefix('api/v1')
|
||||||
|
app.useGlobalInterceptors(new TransformInterceptor())
|
||||||
|
await app.init()
|
||||||
|
await app.listen(0)
|
||||||
|
|
||||||
|
const address = app.getHttpServer().address()
|
||||||
|
if (typeof address === 'object' && address && 'port' in address) {
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a single envelope from the public health endpoint', async () => {
|
||||||
|
const response = await fetch(`${baseUrl}/api/v1/health`)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
data: { status: string; timestamp: string; uptime: number }
|
||||||
|
meta: { fromCache: boolean; cachedAt: string | null }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
data: {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: expect.any(String),
|
||||||
|
uptime: expect.any(Number),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fromCache: false,
|
||||||
|
cachedAt: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(body.data).not.toHaveProperty('data')
|
||||||
|
expect(body.data).not.toHaveProperty('meta')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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$/);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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'],
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -48,11 +48,13 @@ describe('BrokerOperationSyncService', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
meta: { fromCache: false, cachedAt: null },
|
fromCache: false,
|
||||||
|
cachedAt: null,
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
|
data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
|
||||||
meta: { fromCache: false, cachedAt: null },
|
fromCache: false,
|
||||||
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerOperationSyncService(operations, prisma);
|
const service = new BrokerOperationSyncService(operations, prisma);
|
||||||
@ -115,7 +117,8 @@ describe('BrokerOperationSyncService', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
meta: { fromCache: false, cachedAt: null },
|
fromCache: false,
|
||||||
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerOperationSyncService(operations, prisma);
|
const service = new BrokerOperationSyncService(operations, prisma);
|
||||||
@ -142,7 +145,8 @@ describe('BrokerOperationSyncService', () => {
|
|||||||
asOf: '2026-06-16T00:00:00.000Z',
|
asOf: '2026-06-16T00:00:00.000Z',
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
meta: { fromCache: false, cachedAt: null },
|
fromCache: false,
|
||||||
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerOperationSyncService(operations, prisma);
|
const service = new BrokerOperationSyncService(operations, prisma);
|
||||||
|
|||||||
@ -1,5 +1,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,8 +25,9 @@ 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',
|
id: 'acc-1',
|
||||||
type: 'brokerage',
|
type: 'brokerage',
|
||||||
@ -36,15 +37,18 @@ describe('TBankController', () => {
|
|||||||
accessLevel: null,
|
accessLevel: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
|
true,
|
||||||
});
|
'2026-06-17T00:00:00.000Z',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||||
const response = await controller.getAccounts();
|
const response = await controller.getAccounts();
|
||||||
|
|
||||||
expect(response).toBeInstanceOf(ApiResponse);
|
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||||
expect(response.data).toHaveLength(1);
|
expect(response.data).toHaveLength(1);
|
||||||
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
|
expect(response.fromCache).toBe(true);
|
||||||
|
expect(response.cachedAt).toBe('2026-06-17T00:00:00.000Z');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes a sync trigger for durable operation history', async () => {
|
it('exposes a sync trigger for durable operation history', async () => {
|
||||||
@ -60,7 +64,7 @@ describe('TBankController', () => {
|
|||||||
from: '2026-06-01T00:00:00.000Z',
|
from: '2026-06-01T00:00:00.000Z',
|
||||||
to: '2026-06-17T00:00:00.000Z',
|
to: '2026-06-17T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
expect(response.data).toEqual({ upserted: 2 });
|
expect(response).toEqual({ upserted: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes analytics endpoint through controller', async () => {
|
it('exposes analytics endpoint through controller', async () => {
|
||||||
@ -74,18 +78,18 @@ describe('TBankController', () => {
|
|||||||
totalReturnPercent: 25,
|
totalReturnPercent: 25,
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
};
|
};
|
||||||
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce({
|
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce(
|
||||||
data: analyticsData,
|
new ApiEnvelopePayload(analyticsData, false, null),
|
||||||
meta: { fromCache: false, cachedAt: null },
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||||
const response = await controller.getAnalytics('acc-1');
|
const response = await controller.getAnalytics('acc-1');
|
||||||
|
|
||||||
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
|
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
|
||||||
expect(response).toBeInstanceOf(ApiResponse);
|
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||||
expect(response.data).toEqual(analyticsData);
|
expect(response.data).toEqual(analyticsData);
|
||||||
expect(response.meta).toEqual({ fromCache: false, cachedAt: null });
|
expect(response.fromCache).toBe(false);
|
||||||
|
expect(response.cachedAt).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forwards events query and wraps response', async () => {
|
it('forwards events query and wraps response', async () => {
|
||||||
@ -106,17 +110,16 @@ describe('TBankController', () => {
|
|||||||
},
|
},
|
||||||
asOf: '2026-06-22T00:00:00.000Z',
|
asOf: '2026-06-22T00:00:00.000Z',
|
||||||
};
|
};
|
||||||
vi.mocked(events.getEvents).mockResolvedValueOnce({
|
vi.mocked(events.getEvents).mockResolvedValueOnce(
|
||||||
data: eventsData,
|
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'),
|
||||||
meta: { fromCache: false, cachedAt: '2026-06-22T00:00:00.000Z' },
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
|
||||||
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
|
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
|
||||||
const response = await controller.getEvents('acc-1', query);
|
const response = await controller.getEvents('acc-1', query);
|
||||||
|
|
||||||
expect(events.getEvents).toHaveBeenCalledWith('acc-1', query);
|
expect(events.getEvents).toHaveBeenCalledWith('acc-1', query);
|
||||||
expect(response).toBeInstanceOf(ApiResponse);
|
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||||
expect(response.data).toEqual(eventsData);
|
expect(response.data).toEqual(eventsData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export function envelope(data: unknown) {
|
export function envelope(data: unknown) {
|
||||||
return {
|
return {
|
||||||
data: { data, meta: { fromCache: false, cachedAt: null } },
|
data,
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,8 @@ describe('useBondCandles', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/bonds/:secid/candles`, () => {
|
http.get(`${API}/securities/bonds/:secid/candles`, () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: [],
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -46,7 +46,8 @@ describe('useSearch', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/search`, () =>
|
http.get(`${API}/securities/search`, () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: [],
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -30,7 +30,8 @@ describe('useStockCandles', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/shares/:secid/candles`, () => {
|
http.get(`${API}/securities/shares/:secid/candles`, () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: [],
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -27,7 +27,8 @@ describe('useStockDividends', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/shares/:secid/dividends`, () => {
|
http.get(`${API}/securities/shares/:secid/dividends`, () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: [],
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
19
apps/frontend/src/shared/api/kyClient.test.ts
Normal file
19
apps/frontend/src/shared/api/kyClient.test.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { normalizeEnvelope } from './kyClient'
|
||||||
|
|
||||||
|
describe('normalizeEnvelope', () => {
|
||||||
|
it('keeps a nested legacy envelope intact instead of unwrapping it', () => {
|
||||||
|
const json = {
|
||||||
|
data: {
|
||||||
|
data: { value: 42 },
|
||||||
|
meta: { fromCache: true, cachedAt: '2026-06-24T00:00:00.000Z' },
|
||||||
|
},
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(normalizeEnvelope<typeof json.data>(json)).toEqual({
|
||||||
|
data: json.data,
|
||||||
|
meta: json.meta,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -33,16 +33,7 @@ function buildUrl(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
export function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
||||||
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>
|
const envelope = json as ApiEnvelope<T>
|
||||||
if (
|
|
||||||
envelope.data &&
|
|
||||||
typeof envelope.data === 'object' &&
|
|
||||||
'data' in envelope.data &&
|
|
||||||
'meta' in envelope.data
|
|
||||||
) {
|
|
||||||
return envelope.data as { data: T; meta: ApiResponseMeta }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: envelope.data as T,
|
data: envelope.data as T,
|
||||||
meta: envelope.meta,
|
meta: envelope.meta,
|
||||||
|
|||||||
@ -72,7 +72,8 @@ describe('SearchBar', () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get(`${API}/securities/search`, () =>
|
http.get(`${API}/securities/search`, () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json({
|
||||||
data: { data: [], meta: { fromCache: false, cachedAt: null } },
|
data: [],
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
520
docs/features/api-envelope-contract/plan.md
Normal file
520
docs/features/api-envelope-contract/plan.md
Normal file
@ -0,0 +1,520 @@
|
|||||||
|
# API Envelope Runtime Contract — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development или superpowers:executing-plans для реализации.
|
||||||
|
> Tasks используют checkbox (`- [x]`) для отслеживания прогресса.
|
||||||
|
|
||||||
|
**Goal:** Привести runtime-ответы всех endpoint'ов к единому `{ data, meta }`-envelope через `TransformInterceptor` как единственный source of truth.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
Вводится внутренний carrier-тип `ApiEnvelopePayload<T>` (не публичный DTO, а runtime-only). Services и controllers, которым нужно передать cache metadata, возвращают `new ApiEnvelopePayload(data, fromCache, cachedAt)`. `TransformInterceptor` проверяет `instanceof ApiEnvelopePayload` и строит финальный `ApiResponse`. Controllers, не работающие с cache, возвращают plain data. Frontend `normalizeEnvelope()` теряет поддержку double-wrapped ответов.
|
||||||
|
Swagger DTOs не меняются — они уже моделируют правильный single envelope.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS (interceptor, class), TypeScript, Vitest, Sinon/vi, openapi-fetch/ky
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Добавить ApiEnvelopePayload и обновить TransformInterceptor
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/backend/src/common/dto/api-response.dto.ts`
|
||||||
|
- Modify: `apps/backend/src/common/interceptors/transform.interceptor.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: Добавить ApiEnvelopePayload<T>** в `api-response.dto.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class ApiEnvelopePayload<T> {
|
||||||
|
constructor(
|
||||||
|
public readonly data: T,
|
||||||
|
public readonly fromCache: boolean,
|
||||||
|
public readonly cachedAt: string | null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Обновить TransformInterceptor** — распознавать `ApiEnvelopePayload` и `ApiResponse`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data) => {
|
||||||
|
if (data instanceof ApiResponse) return data;
|
||||||
|
if (data instanceof ApiEnvelopePayload) {
|
||||||
|
return new ApiResponse(data.data, data.fromCache, data.cachedAt);
|
||||||
|
}
|
||||||
|
return new ApiResponse(data, false, null);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Проверить, что backend компилируется**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build -w apps/backend
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Commit**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/backend/src/common/dto/api-response.dto.ts apps/backend/src/common/interceptors/transform.interceptor.ts
|
||||||
|
git commit -m "feat: add ApiEnvelopePayload carrier to fix envelope ownership"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Migrate services — return ApiEnvelopePayload instead of `{ data, meta }`
|
||||||
|
|
||||||
|
Affected services (all return `{ data, meta }` today):
|
||||||
|
|
||||||
|
1. `SharesService.getMarketData / getDividends / getHistory`
|
||||||
|
2. `BondsService.getBond / getMarketData / getHistory`
|
||||||
|
3. `CandlesService.getCandles`
|
||||||
|
4. `BrokerAccountsService.findAll`
|
||||||
|
5. `BrokerPortfolioService.getPortfolio / getPositions`
|
||||||
|
6. `BrokerEventsService.getEvents`
|
||||||
|
7. `BrokerAnalyticsService.getAnalytics`
|
||||||
|
8. `BrokerOperationsService.getOperations`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Pattern for each service (shown for SharesService):**
|
||||||
|
|
||||||
|
- [x] **Step 1: SharesService.getMarketData** — change return from `{ data, meta }` to `new ApiEnvelopePayload(data, fromCache, cachedAt)`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||||
|
|
||||||
|
// before:
|
||||||
|
return { data: { ... }, meta: { fromCache, cachedAt } };
|
||||||
|
|
||||||
|
// after:
|
||||||
|
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: SharesService.getDividends** — same pattern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload(
|
||||||
|
data.map((d) => ({ registryCloseDate: d.registryCloseDate, value: d.value, currency: d.currencyId })),
|
||||||
|
fromCache,
|
||||||
|
cachedAt,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: SharesService.getHistory** — same pattern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload(
|
||||||
|
data.map((h) => ({ date: h.tradeDate, open: h.open ?? 0, high: h.high ?? 0, close: h.close ?? 0, volume: h.volume, value: h.value })),
|
||||||
|
fromCache,
|
||||||
|
cachedAt,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: BondsService.getBond** — same pattern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: BondsService.getMarketData** — same pattern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload({ ... }, fromCache, cachedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 6: BondsService.getHistory** — same pattern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload(data.map(...), fromCache, cachedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 7: CandlesService.getCandles** — same pattern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload(data.map(...), fromCache, cachedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 8: BrokerAccountsService.findAll** — same pattern:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 9: BrokerPortfolioService.getPortfolio** — same:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload(portfolioResult.data, portfolioResult.fromCache, portfolioResult.cachedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 10: BrokerPortfolioService.getPositions** — same:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 11: BrokerEventsService.getEvents** — same:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 12: BrokerAnalyticsService.getAnalytics** — same:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 13: BrokerOperationsService.getOperations** — same:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 14: Build check**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build -w apps/backend
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 15: Commit**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/backend/src/modules/shares/shares.service.ts
|
||||||
|
git add apps/backend/src/modules/bonds/bonds.service.ts
|
||||||
|
git add apps/backend/src/modules/candles/candles.service.ts
|
||||||
|
git add apps/backend/src/modules/tbank/services/broker-accounts.service.ts
|
||||||
|
git add apps/backend/src/modules/tbank/services/broker-portfolio.service.ts
|
||||||
|
git add apps/backend/src/modules/tbank/services/broker-events.service.ts
|
||||||
|
git add apps/backend/src/modules/tbank/services/broker-analytics.service.ts
|
||||||
|
git add apps/backend/src/modules/tbank/services/broker-operations.service.ts
|
||||||
|
git commit -m "feat: migrate services to ApiEnvelopePayload internal carrier"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Migrate controllers — stop manual envelope construction
|
||||||
|
|
||||||
|
Controllers that manually build `{ data, meta }`:
|
||||||
|
1. `SharesController.getShare`
|
||||||
|
2. `SecuritiesController.search / screener`
|
||||||
|
3. `PortfolioController` — все методы
|
||||||
|
4. `AuthController` — все методы
|
||||||
|
|
||||||
|
Controllers that call services returning envelope and shouldn't do anything special:
|
||||||
|
5. `SharesController.getMarketData / getDividends / getHistory` — already just return service result
|
||||||
|
6. `BondsController` — all methods, already just return service result
|
||||||
|
7. `CandlesController` — already just return service result
|
||||||
|
8. **`TBankController` — stops wrapping in `ApiResponse`**, delegates to service
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [x] **Step 1: SharesController.getShare** — return plain data:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async getShare(@Param('secid') secid: string) {
|
||||||
|
const share = await this.sharesService.getShare(secid);
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: SecuritiesController.search, screener** — return plain data:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
||||||
|
return this.securitiesService.search(query.q, query.type || SecurityType.ALL, query.limit || 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
|
||||||
|
return this.screenerService.screen(query);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: PortfolioController** — all methods return plain data. E.g.:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async findAll(@CurrentUser() user: { sub: number }) {
|
||||||
|
return this.portfolioService.findAll(user.sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
|
||||||
|
return this.portfolioService.create(user.sub, dto);
|
||||||
|
}
|
||||||
|
// ... аналогично для всех остальных методов
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: AuthController** — all methods return plain data. E.g.:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const result = await this.authService.register(dto);
|
||||||
|
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||||
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: TBankController** — stop wrapping in `ApiResponse`. Just return service result:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async getAccounts() {
|
||||||
|
return this.brokerAccountsService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPortfolio(@Param('accountId') accountId: string) {
|
||||||
|
return this.brokerPortfolioService.getPortfolio(accountId);
|
||||||
|
}
|
||||||
|
// ... аналогично для всех методов (кроме syncOperations — он уже возвращает plain object)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 6: Build check**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build -w apps/backend
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/backend/src/modules/shares/shares.controller.ts
|
||||||
|
git add apps/backend/src/modules/securities/securities.controller.ts
|
||||||
|
git add apps/backend/src/modules/portfolio/portfolio.controller.ts
|
||||||
|
git add apps/backend/src/modules/auth/auth.controller.ts
|
||||||
|
git add apps/backend/src/modules/tbank/tbank.controller.ts
|
||||||
|
git commit -m "feat: migrate controllers to plain data returns, stop manual envelope"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update backend tests
|
||||||
|
|
||||||
|
Affected test files (check envelope shape or `instanceof ApiResponse`):
|
||||||
|
|
||||||
|
- `apps/backend/src/modules/tbank/tbank.controller.spec.ts` — `expect(response).toBeInstanceOf(ApiResponse)`, `expect(response.meta)`
|
||||||
|
- `apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts` — `expect(result.meta.fromCache)`
|
||||||
|
- `apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts` — `expect(result.meta.fromCache)`
|
||||||
|
- `apps/backend/src/modules/tbank/services/broker-events.service.spec.ts` — mocks `{ data, meta }`
|
||||||
|
- `apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts` — mocks `{ data, meta }`
|
||||||
|
- `apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts` — mocks `{ data, meta }`
|
||||||
|
- `apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts` — mocks `{ data, meta }`
|
||||||
|
- `apps/backend/src/modules/shares/shares.service.spec.ts` — mocks `fromCache/cachedAt`
|
||||||
|
- `apps/backend/src/modules/securities/securities.service.spec.ts` — mocks `fromCache/cachedAt`
|
||||||
|
- `apps/backend/src/modules/securities/screener.service.spec.ts` — mocks `fromCache/cachedAt`
|
||||||
|
- `apps/backend/src/modules/portfolio/portfolio.service.spec.ts` — mocks `fromCache/cachedAt`
|
||||||
|
- `apps/backend/src/modules/cache/cache.service.spec.ts` (may not be affected)
|
||||||
|
|
||||||
|
**Pattern for tbank.controller.spec.ts:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// before:
|
||||||
|
expect(response).toBeInstanceOf(ApiResponse);
|
||||||
|
expect(response.data).toHaveLength(1);
|
||||||
|
expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
|
||||||
|
|
||||||
|
// after — controller returns service result directly, which is ApiEnvelopePayload
|
||||||
|
// interceptor handles wrapping; controller spec tests the controller, not the HTTP boundary
|
||||||
|
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||||
|
expect(response).toBeInstanceOf(ApiEnvelopePayload);
|
||||||
|
expect(response.data).toHaveLength(1);
|
||||||
|
expect(response.fromCache).toBe(true);
|
||||||
|
expect(response.cachedAt).toBe('2026-06-17T00:00:00.000Z');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern for service specs — returns `ApiEnvelopePayload`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// before:
|
||||||
|
expect(result.meta.fromCache).toBe(false);
|
||||||
|
expect(result.meta.cachedAt).toBe('2026-06-16T02:30:00.000Z');
|
||||||
|
|
||||||
|
// after:
|
||||||
|
expect(result.fromCache).toBe(false);
|
||||||
|
expect(result.cachedAt).toBe('2026-06-16T02:30:00.000Z');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mock data in service specs — mocks remain `{ data, fromCache, cachedAt }` from `cacheService.getOrFetch`**:
|
||||||
|
```ts
|
||||||
|
// cache service still returns { data, fromCache, cachedAt }
|
||||||
|
// the service wraps it into ApiEnvelopePayload — this is what we test
|
||||||
|
// mock stays:
|
||||||
|
mockGetOrFetch.mockResolvedValue({
|
||||||
|
data: ...,
|
||||||
|
fromCache: false,
|
||||||
|
cachedAt: null,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 1: tbank.controller.spec.ts** — update assertions to check `ApiEnvelopePayload` instead of `ApiResponse`.
|
||||||
|
|
||||||
|
- [x] **Step 2: broker-accounts.service.spec.ts** — update `result.meta.fromCache` → `result.fromCache`.
|
||||||
|
|
||||||
|
- [x] **Step 3: broker-analytics.service.spec.ts** — update meta assertions.
|
||||||
|
|
||||||
|
- [x] **Step 4: broker-events.service.spec.ts** — update mock data and assertions.
|
||||||
|
|
||||||
|
- [x] **Step 5: broker-portfolio.service.spec.ts** — update mock data and assertions.
|
||||||
|
|
||||||
|
- [x] **Step 6: broker-operations.service.spec.ts** — update mock data and assertions.
|
||||||
|
|
||||||
|
- [x] **Step 7: broker-operation-sync.service.spec.ts** — update mock data and assertions (if any).
|
||||||
|
|
||||||
|
- [x] **Step 8: shares.service.spec.ts** — update fromCache/cachedAt assertions.
|
||||||
|
|
||||||
|
- [x] **Step 9: securities.service.spec.ts** — update fromCache/cachedAt assertions.
|
||||||
|
|
||||||
|
- [x] **Step 10: screener.service.spec.ts** — update fromCache/cachedAt assertions.
|
||||||
|
|
||||||
|
- [x] **Step 11: portfolio.service.spec.ts** — update fromCache/cachedAt assertions.
|
||||||
|
|
||||||
|
- [x] **Step 12: Run backend tests**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -w apps/backend
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 13: Commit**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/backend/src/modules/tbank/tbank.controller.spec.ts
|
||||||
|
git add apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts
|
||||||
|
git add apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts
|
||||||
|
git add apps/backend/src/modules/tbank/services/broker-events.service.spec.ts
|
||||||
|
git add apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts
|
||||||
|
git add apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts
|
||||||
|
git add apps/backend/src/modules/tbank/services/broker-operation-sync.service.spec.ts
|
||||||
|
git add apps/backend/src/modules/shares/shares.service.spec.ts
|
||||||
|
git add apps/backend/src/modules/securities/securities.service.spec.ts
|
||||||
|
git add apps/backend/src/modules/securities/screener.service.spec.ts
|
||||||
|
git add apps/backend/src/modules/portfolio/portfolio.service.spec.ts
|
||||||
|
git commit -m "test: update backend tests for ApiEnvelopePayload carrier"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Добавить HTTP contract tests (backend integration тесты)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/backend/src/modules/health/health.controller.spec.ts` (if not exists)
|
||||||
|
- Create or modify: `apps/backend/src/modules/shares/shares.controller.spec.ts` (if exists but needs update)
|
||||||
|
- Create or modify: envelope contract test
|
||||||
|
|
||||||
|
- [x] **Step 1: Создать** `apps/backend/src/envelope-contract.spec.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { TransformInterceptor } from '../common/interceptors/transform.interceptor';
|
||||||
|
|
||||||
|
describe('Envelope runtime contract', () => {
|
||||||
|
// Integration test: создаёт тестовый контроллер, проверяет что
|
||||||
|
// TransformInterceptor всегда выдаёт ровно один { data, meta }
|
||||||
|
|
||||||
|
it('wraps plain data into single envelope', async () => {
|
||||||
|
// проверка через TestModule + интерцептор
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not wrap data into data.data when data is an object', async () => {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run contract tests**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm exec -w apps/backend -- vitest run apps/backend/src/envelope-contract.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Commit**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/backend/src/envelope-contract.spec.ts
|
||||||
|
git commit -m "test: add HTTP envelope contract tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Simplify frontend normalizeEnvelope — remove double-wrap support
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/frontend/src/shared/api/kyClient.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: Упростить normalizeEnvelope**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
|
||||||
|
const envelope = json as ApiEnvelope<T>
|
||||||
|
return {
|
||||||
|
data: envelope.data as T,
|
||||||
|
meta: envelope.meta,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Export ApiEnvelope** from kyClient if needed:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ApiEnvelope<T> {
|
||||||
|
data: T
|
||||||
|
meta: ApiResponseMeta
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Run frontend tests**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -w apps/frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Run frontend build**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build -w apps/frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/frontend/src/shared/api/kyClient.ts
|
||||||
|
git commit -m "feat: simplify normalizeEnvelope — remove double-wrap support"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Final verification
|
||||||
|
|
||||||
|
- [x] **Step 1: Run all tests**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -w apps/backend
|
||||||
|
npm test -w apps/frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Build both packages**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build -w apps/backend
|
||||||
|
npm run build -w apps/frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: OpenAPI artifacts check**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm exec -w apps/backend -- vitest run openapi-artifacts.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Verify the feature docs are consistent** (read spec.md, plan.md, tasks.md — no contradictions).
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit final**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/features/api-envelope-contract/
|
||||||
|
git commit -m "docs: add spec/plan/tasks for API envelope runtime contract"
|
||||||
|
```
|
||||||
56
docs/features/api-envelope-contract/spec.md
Normal file
56
docs/features/api-envelope-contract/spec.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# API Envelope Runtime Contract
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Устранить расхождение между следующим API-контрактом и реальными runtime-ответами backend'а, чтобы каждый endpoint возвращал ровно один `{ data, meta }`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Единственный публичный контракт
|
||||||
|
ApiResponse<T> = { data: T, meta: { fromCache: boolean, cachedAt: string | null } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Убрать поддержку broken double-wrapping на frontend и сделать `TransformInterceptor` единственной точкой формирования публичного envelope.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. **Единый владелец envelope** — `TransformInterceptor` является единственной точкой, формирующей `{ data, meta }` в HTTP-ответе.
|
||||||
|
2. **Internal carrier** — Services и controllers, которым нужно передать cache metadata, используют внутренний carrier-тип (`ApiEnvelopePayload<T>`), а не создают `{ data, meta }` вручную.
|
||||||
|
3. **Controllers без meta возвращают чистое DTO** — Если endpoint не использует cache, controller возвращает только domain data, interceptor оборачивает её сам.
|
||||||
|
4. **Ни один endpoint не приводит к `data.data`** — Runtime-ответ каждого endpoint проверяется интеграционным тестом на одинарный envelope.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. **Единый формат envelope** — `normalizeEnvelope()` больше не поддерживает double-wrapped ответы.
|
||||||
|
2. **request()** остаётся `Promise<{ data: T; meta: ApiResponseMeta }>` — контракт не меняется, ясность не уменьшается.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
1. **HTTP contract tests** — минимальный набор проверяет, что ключевые endpoint'ы возвращают `{ data, meta }` без вложенности.
|
||||||
|
2. **Существующие тесты обновлены** — сервисные и контроллерные тесты проверяют новый carrier-механизм вместо ручного `{ data, meta }`.
|
||||||
|
3. **Regression-тесты** — endpoint'ы autentification, shares, bonds, securities, portfolios, T-Bank покрыты минимум одним contract-тестом на shape ответа.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. `GET /api/v1/health` возвращает `{ data: { ... }, meta: { fromCache, cachedAt } }` — без `data.data`.
|
||||||
|
2. `GET /api/v1/securities/shares/{secid}` — одинарный envelope.
|
||||||
|
3. `GET /api/v1/securities/shares/{secid}/marketdata` — одинарный envelope с корректным `meta` от cache.
|
||||||
|
4. `GET /api/v1/securities/bonds/{secid}` — одинарный envelope.
|
||||||
|
5. `GET /api/v1/securities/search?q=` — одинарный envelope.
|
||||||
|
6. `GET /api/v1/securities/screener?` — одинарный envelope.
|
||||||
|
7. `GET /api/v1/portfolios` … — одинарный envelope.
|
||||||
|
8. `GET /api/v1/broker/accounts` … — одинарный envelope.
|
||||||
|
9. `POST /api/v1/auth/register`, `login`, `refresh` — одинарный envelope.
|
||||||
|
10. Backend тесты проходят: `npm test -w apps/backend`.
|
||||||
|
11. Frontend тесты проходят: `npm test -w apps/frontend`.
|
||||||
|
12. `npm run build -w apps/frontend` проходит.
|
||||||
|
13. `normalizeEnvelope()` больше не проверяет `data.data`.
|
||||||
|
14. Swagger/OpenAPI артефакты не ломаются (`npm exec -w apps/backend -- vitest run openapi`).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Не менять форму DTO и Swagger-типы (они уже корректны).
|
||||||
|
- Не менять формат error-ответов.
|
||||||
|
- Не добавлять версионирование API.
|
||||||
|
- Не рефакторить бизнес-логику endpoint'ов.
|
||||||
63
docs/features/api-envelope-contract/tasks.md
Normal file
63
docs/features/api-envelope-contract/tasks.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# API Envelope Runtime Contract — Tasks
|
||||||
|
|
||||||
|
## Task 1: Добавить ApiEnvelopePayload и обновить TransformInterceptor
|
||||||
|
|
||||||
|
- [x] Добавить `ApiEnvelopePayload<T>` класс в `api-response.dto.ts`
|
||||||
|
- [x] Обновить `TransformInterceptor` — распознавать `ApiEnvelopePayload`
|
||||||
|
- [x] Проверить сборку (`npm run build -w apps/backend`)
|
||||||
|
|
||||||
|
## Task 2: Migrate services — ApiEnvelopePayload вместо `{ data, meta }`
|
||||||
|
|
||||||
|
- [x] SharesService: getMarketData, getDividends, getHistory
|
||||||
|
- [x] BondsService: getBond, getMarketData, getHistory
|
||||||
|
- [x] CandlesService.getCandles
|
||||||
|
- [x] BrokerAccountsService.findAll
|
||||||
|
- [x] BrokerPortfolioService.getPortfolio, getPositions
|
||||||
|
- [x] BrokerEventsService.getEvents
|
||||||
|
- [x] BrokerAnalyticsService.getAnalytics
|
||||||
|
- [x] BrokerOperationsService.getOperations
|
||||||
|
- [x] Проверить сборку
|
||||||
|
|
||||||
|
## Task 3: Migrate controllers — plain data returns вместо ручного envelope
|
||||||
|
|
||||||
|
- [x] SharesController.getShare
|
||||||
|
- [x] SecuritiesController.search, screener
|
||||||
|
- [x] PortfolioController: все методы
|
||||||
|
- [x] AuthController: все методы
|
||||||
|
- [x] TBankController: stop ApiResponse wrapping
|
||||||
|
- [x] Проверить сборку
|
||||||
|
|
||||||
|
## Task 4: Update backend tests
|
||||||
|
|
||||||
|
- [x] tbank.controller.spec.ts — ApiResponse → ApiEnvelopePayload
|
||||||
|
- [x] broker-accounts.service.spec.ts — result.meta → result
|
||||||
|
- [x] broker-analytics.service.spec.ts
|
||||||
|
- [x] broker-events.service.spec.ts
|
||||||
|
- [x] broker-portfolio.service.spec.ts
|
||||||
|
- [x] broker-operations.service.spec.ts
|
||||||
|
- [x] broker-operation-sync.service.spec.ts
|
||||||
|
- [x] shares.service.spec.ts
|
||||||
|
- [x] securities.service.spec.ts
|
||||||
|
- [x] screener.service.spec.ts
|
||||||
|
- [x] portfolio.service.spec.ts
|
||||||
|
- [x] Запустить `npm test -w apps/backend`
|
||||||
|
|
||||||
|
## Task 5: HTTP contract tests
|
||||||
|
|
||||||
|
- [x] Создать `apps/backend/src/envelope-contract.spec.ts` с тестами single envelope
|
||||||
|
- [x] Запустить contract tests
|
||||||
|
|
||||||
|
## Task 6: Simplify frontend normalizeEnvelope
|
||||||
|
|
||||||
|
- [x] Убрать double-wrap detection из `normalizeEnvelope()`
|
||||||
|
- [x] Запустить `npm test -w apps/frontend`
|
||||||
|
- [x] Запустить `npm run build -w apps/frontend`
|
||||||
|
|
||||||
|
## Task 7: Final verification
|
||||||
|
|
||||||
|
- [ ] `npm test -w apps/backend`
|
||||||
|
- [ ] `npm test -w apps/frontend`
|
||||||
|
- [ ] `npm run build -w apps/backend`
|
||||||
|
- [ ] `npm run build -w apps/frontend`
|
||||||
|
- [ ] OpenAPI artifacts check
|
||||||
|
- [ ] docs consistency check
|
||||||
@ -374,13 +374,9 @@ frontend build, 94 backend-теста и 168 frontend-тестов.
|
|||||||
|
|
||||||
### P1: унифицировать API envelope и runtime-контракт
|
### P1: унифицировать API envelope и runtime-контракт
|
||||||
|
|
||||||
- Часть контроллеров возвращает `{ data, meta }`, после чего глобальный `TransformInterceptor`
|
- [x] **RESOLVED** — см. feature `api-envelope-contract`. `TransformInterceptor` — единственный владелец
|
||||||
оборачивает ответ повторно.
|
envelope, `ApiEnvelopePayload<T>` — внутренний carrier для cache metadata, frontend `normalizeEnvelope`
|
||||||
- Frontend содержит `normalizeEnvelope`, который поддерживает одновременно одинарную и двойную
|
упрощён, добавлен HTTP contract-тест (`envelope-contract.spec.ts`).
|
||||||
обёртку; это маскирует расхождение runtime-ответов со Swagger/OpenAPI.
|
|
||||||
- Выбрать единственного владельца envelope: interceptor либо контроллеры, удалить двойную обёртку и
|
|
||||||
временный compatibility-код после миграции.
|
|
||||||
- Добавить интеграционные contract-тесты реальных HTTP-ответов, а не только DTO/OpenAPI schemas.
|
|
||||||
|
|
||||||
### P1: усилить production-конфигурацию и auth security
|
### P1: усилить production-конфигурацию и auth security
|
||||||
|
|
||||||
|
|||||||
@ -95,7 +95,7 @@ Roadmap отражает порядок продуктовой работы, н
|
|||||||
и `TableSkeleton` удалены.
|
и `TableSkeleton` удалены.
|
||||||
- [ ] T-Bank data isolation and multi-tenancy (P0/P1) — изолировать данные T-Bank по пользователям,
|
- [ ] T-Bank data isolation and multi-tenancy (P0/P1) — изолировать данные T-Bank по пользователям,
|
||||||
ownership модель
|
ownership модель
|
||||||
- [ ] API envelope runtime contract (P1) — устранить double-wrapping, унифицировать envelope
|
- [x] API envelope runtime contract (P1) — устранить double-wrapping, унифицировать envelope
|
||||||
- [ ] Auth security hardening (P1) — production-секреты, CORS allowlist, error masking, rate limiting
|
- [ ] Auth security hardening (P1) — production-секреты, CORS allowlist, error masking, rate limiting
|
||||||
- [ ] Local T-Bank read-path (P1) — чтение истории операций из локальной БД вместо прямого вызова T-Bank
|
- [ ] Local T-Bank read-path (P1) — чтение истории операций из локальной БД вместо прямого вызова T-Bank
|
||||||
- [ ] Session model for multiple surfaces (P1/P2) — device-level сессии, rotation, reuse detection
|
- [ ] Session model for multiple surfaces (P1/P2) — device-level сессии, rotation, reuse detection
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user