test(tbank): update portfolio tests, add getPositions tests, fix operation fixture name
This commit is contained in:
parent
8b202ef7d3
commit
4b87eccba4
@ -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 },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,7 +69,15 @@ describe('BrokerPortfolioService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns portfolio when one instrument enrichment request fails', async () => {
|
describe('getPositions', () => {
|
||||||
|
it('throws 404 for missing account', async () => {
|
||||||
|
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||||
|
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
|
||||||
|
|
||||||
|
await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns first page of positions', async () => {
|
||||||
vi.mocked(accounts.findById).mockResolvedValue({
|
vi.mocked(accounts.findById).mockResolvedValue({
|
||||||
id: 'acc-1',
|
id: 'acc-1',
|
||||||
type: 'brokerage',
|
type: 'brokerage',
|
||||||
@ -86,55 +95,163 @@ describe('BrokerPortfolioService', () => {
|
|||||||
);
|
);
|
||||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
vi.mocked(client.getServiceClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
getPositions: vi.fn(),
|
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary)
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
.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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user