test(tbank): update portfolio tests, add getPositions tests, fix operation fixture name

This commit is contained in:
Sergey Krylov 2026-06-17 14:41:32 +03:00
parent 8b202ef7d3
commit 4b87eccba4
4 changed files with 174 additions and 58 deletions

View File

@ -11,7 +11,7 @@ describe('portfolio.mapper', () => {
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS', accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
}; };
it('combines portfolio totals, cash, and enriched positions', () => { it('combines portfolio totals and cash (positions removed)', () => {
const result = mapBrokerPortfolio({ const result = mapBrokerPortfolio({
account, account,
portfolio: { portfolio: {
@ -44,11 +44,5 @@ describe('portfolio.mapper', () => {
expect(result.totals.shares?.value).toBe(1000); expect(result.totals.shares?.value).toBe(1000);
expect(result.cash[0].value).toBe(500); expect(result.cash[0].value).toBe(500);
expect(result.blockedCash[0].value).toBe(10); expect(result.blockedCash[0].value).toBe(10);
expect(result.positions[0]).toMatchObject({
ticker: 'SBER',
name: 'Sberbank',
quantity: 10,
currentValue: { value: 2500 },
});
}); });
}); });

View File

@ -32,6 +32,7 @@ describe('BrokerOperationSyncService', () => {
category: 'trade', category: 'trade',
description: null, description: null,
state: 'OPERATION_STATE_EXECUTED', state: 'OPERATION_STATE_EXECUTED',
name: null,
instrumentUid: 'uid-1', instrumentUid: 'uid-1',
figi: null, figi: null,
ticker: 'SBER', ticker: 'SBER',
@ -98,6 +99,7 @@ describe('BrokerOperationSyncService', () => {
category: 'trade', category: 'trade',
description: null, description: null,
state: null, state: null,
name: null,
instrumentUid: null, instrumentUid: null,
figi: null, figi: null,
ticker: null, ticker: null,

View File

@ -22,7 +22,7 @@ describe('BrokerPortfolioService', () => {
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException); await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
}); });
it('fetches portfolio and positions through cache', async () => { it('fetches portfolio through cache without positions', async () => {
vi.mocked(accounts.findById).mockResolvedValue({ vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1', id: 'acc-1',
type: 'brokerage', type: 'brokerage',
@ -60,6 +60,7 @@ describe('BrokerPortfolioService', () => {
expect(result.data.account.id).toBe('acc-1'); expect(result.data.account.id).toBe('acc-1');
expect(result.data.cash[0].value).toBe(1000); expect(result.data.cash[0].value).toBe(1000);
expect('positions' in result.data).toBe(false);
expect(cache.getOrFetch).toHaveBeenCalledWith( expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:portfolio', 'tbank:portfolio',
['acc-1'], ['acc-1'],
@ -68,73 +69,189 @@ describe('BrokerPortfolioService', () => {
); );
}); });
it('returns portfolio when one instrument enrichment request fails', async () => { describe('getPositions', () => {
vi.mocked(accounts.findById).mockResolvedValue({ it('throws 404 for missing account', async () => {
id: 'acc-1', vi.mocked(accounts.findById).mockResolvedValue(null);
type: 'brokerage', const service = new BrokerPortfolioService(accounts, instruments, client, cache);
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN', await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException);
openedAt: null,
accessLevel: null,
}); });
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({ it('returns first page of positions', async () => {
data: await fetchFn(), vi.mocked(accounts.findById).mockResolvedValue({
fromCache: false, id: 'acc-1',
cachedAt: null, type: 'brokerage',
}), name: 'Broker',
); status: 'ACCOUNT_STATUS_OPEN',
vi.mocked(client.getServiceClient).mockReturnValue({ openedAt: null,
getPortfolio: vi.fn(), accessLevel: null,
getPositions: vi.fn(), });
} as any); vi.mocked(cache.getOrFetch).mockImplementation(
vi.mocked(client.callUnary) async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
.mockResolvedValueOnce({ data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1', accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [ positions: [
{ {
figi: 'figi-1', figi: 'figi-1',
instrumentUid: 'uid-1', instrumentUid: 'uid-1',
quantity: { units: '1', nano: 0 }, positionUid: 'pos-1',
quantity: { units: '10', nano: 0 },
}, },
{ {
figi: 'figi-2', figi: 'figi-2',
instrumentUid: 'uid-2', instrumentUid: 'uid-2',
quantity: { units: '2', nano: 0 }, positionUid: 'pos-2',
quantity: { units: '20', nano: 0 },
}, },
], ],
})
.mockResolvedValueOnce({
accountId: 'acc-1',
money: [],
blocked: [],
securities: [],
}); });
vi.mocked(instruments.findByInstrumentUid)
.mockResolvedValueOnce({
uid: 'uid-1',
figi: 'figi-1',
ticker: 'AAA',
classCode: 'TQBR',
name: 'First share',
instrumentType: 'share',
})
.mockRejectedValueOnce(new Error('instrument lookup failed'));
const service = new BrokerPortfolioService(accounts, instruments, client, cache); const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPortfolio('acc-1'); const result = await service.getPositions('acc-1', undefined, 1);
expect(result.data.positions).toHaveLength(2); expect(result.data.accountId).toBe('acc-1');
expect(result.data.positions[0]).toMatchObject({ expect(result.data.items).toHaveLength(1);
instrumentUid: 'uid-1', expect(result.data.items[0].positionUid).toBe('pos-1');
ticker: 'AAA', expect(result.data.hasNext).toBe(true);
name: 'First share', expect(result.data.nextCursor).toBe('pos-1');
}); });
expect(result.data.positions[1]).toMatchObject({
instrumentUid: 'uid-2', it('paginates using cursor', async () => {
ticker: null, vi.mocked(accounts.findById).mockResolvedValue({
name: null, id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [
{
figi: 'f1',
instrumentUid: 'u1',
positionUid: 'p1',
quantity: { units: '10', nano: 0 },
},
{
figi: 'f2',
instrumentUid: 'u2',
positionUid: 'p2',
quantity: { units: '20', nano: 0 },
},
{
figi: 'f3',
instrumentUid: 'u3',
positionUid: 'p3',
quantity: { units: '30', nano: 0 },
},
],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPositions('acc-1', 'p1', 1);
expect(result.data.items).toHaveLength(1);
expect(result.data.items[0].positionUid).toBe('p2');
expect(result.data.nextCursor).toBe('p2');
expect(result.data.hasNext).toBe(true);
});
it('returns last page with hasNext=false', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [
{
figi: 'f1',
instrumentUid: 'u1',
positionUid: 'p1',
quantity: { units: '10', nano: 0 },
},
],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPositions('acc-1', undefined, 10);
expect(result.data.items).toHaveLength(1);
expect(result.data.hasNext).toBe(false);
expect(result.data.nextCursor).toBeNull();
});
it('caches positions with cursor/limit in key and tbankPositionsTtl', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await service.getPositions('acc-1', 'some-cursor', 5);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:positions',
['acc-1', 'some-cursor', '5'],
expect.any(Function),
'tbankPositionsTtl',
);
}); });
}); });
}); });

View File

@ -21,6 +21,7 @@ describe('T-Bank configuration', () => {
expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443'); expect(config.tbank.baseUrl).toBe('invest-public-api.tbank.ru:443');
expect(config.tbank.rateLimitPerSecond).toBe(5); expect(config.tbank.rateLimitPerSecond).toBe(5);
expect(config.cache.tbankPortfolioTtl).toBe(60); expect(config.cache.tbankPortfolioTtl).toBe(60);
expect(config.cache.tbankPositionsTtl).toBe(60);
}); });
it('reads T-Bank token and TTL overrides from environment', () => { it('reads T-Bank token and TTL overrides from environment', () => {
@ -29,6 +30,7 @@ describe('T-Bank configuration', () => {
process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem'; process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem';
process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2'; process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2';
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120'; process.env.CACHE_TBANK_ACCOUNTS_TTL = '120';
process.env.CACHE_TBANK_POSITIONS_TTL = '45';
const config = configuration(); const config = configuration();
@ -37,5 +39,6 @@ describe('T-Bank configuration', () => {
expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem'); expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem');
expect(config.tbank.rateLimitPerSecond).toBe(2); expect(config.tbank.rateLimitPerSecond).toBe(2);
expect(config.cache.tbankAccountsTtl).toBe(120); expect(config.cache.tbankAccountsTtl).toBe(120);
expect(config.cache.tbankPositionsTtl).toBe(45);
}); });
}); });