Files
backend/src/tbank/portfolio.service.spec.ts
T

182 lines
5.6 KiB
TypeScript

import {
BadRequestException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('./tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
}));
import { PortfolioService } from './portfolio.service';
import { InstrumentMetadataService } from './instrument-metadata.service';
import type { TbankAccountContext } from './tbank-access.service';
describe('PortfolioService', () => {
const access = { withAccount: jest.fn() };
const tbank = {
getPortfolio: jest.fn(),
getPositions: jest.fn(),
getInstrumentByFigi: jest.fn(),
getInstrumentByUid: jest.fn(),
};
const requestContext = { set: jest.fn() };
const logger = { event: jest.fn() };
const metadata = new InstrumentMetadataService(
tbank as never,
logger as never,
);
const service = new PortfolioService(
access as never,
tbank as never,
requestContext as never,
metadata,
);
beforeEach(() => {
jest.clearAllMocks();
service.invalidate('user');
access.withAccount.mockImplementation(
(
_userId: string,
_accountId: string,
handler: (context: TbankAccountContext) => Promise<unknown>,
) =>
handler({
token: 'token',
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getPortfolio.mockResolvedValue({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
});
it('resolves account-scoped access before loading upstream portfolio', async () => {
await service.get('user', 'account');
expect(access.withAccount).toHaveBeenCalledWith(
'user',
'account',
expect.any(Function),
);
expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'account');
expect(tbank.getPositions).toHaveBeenCalledWith('token', 'account');
});
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'ONE',
instrumentType: 'share',
quantity: { units: 2 },
currentPrice: { currency: 'rub', units: 3 },
averagePositionPrice: { currency: 'RUB', units: 2 },
},
{
figi: 'TWO',
instrumentType: 'new-kind',
quantity: { nano: -500000000 },
currentPrice: { currency: 'USD', units: 4 },
},
{ figi: 'ZERO', quantity: {} },
],
});
tbank.getPositions.mockResolvedValue({
money: [
{ currency: 'RUB', units: 1 },
{ currency: 'rub', nano: 500000000 },
{ currency: 'USD' },
],
});
tbank.getInstrumentByFigi
.mockResolvedValueOnce({
ticker: 'ONE',
name: 'Первая позиция',
isin: 'RU000A0JX0J2',
brand: { logoName: 'RU000A0JX0J2x160.png' },
})
.mockRejectedValueOnce(new Error('unavailable'));
await expect(service.get('user', 'account')).resolves.toMatchObject({
positions: [
{
figi: 'ONE',
displayName: 'Первая позиция',
type: 'STOCK',
quantity: '2',
currentValue: { currency: 'RUB', value: '6' },
absoluteReturn: { currency: 'RUB', value: '2' },
isin: 'RU000A0JX0J2',
logoName: 'RU000A0JX0J2x160.png',
},
{
figi: 'TWO',
displayName: 'TWO',
type: 'OTHER',
quantity: '-0.5',
currentValue: { currency: 'USD', value: '-2' },
isin: null,
logoName: null,
},
],
cashBalances: [{ currency: 'RUB', value: '1.5' }],
totalsByCurrency: [
{ currency: 'RUB', value: '7.5' },
{ currency: 'USD', value: '-2' },
],
});
});
it('uses instrument UID before FIGI when resolving a name', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'FIGI',
instrumentUid: 'UID',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' });
await expect(service.get('user', 'account')).resolves.toMatchObject({
positions: [{ displayName: 'Имя по UID', logoName: null }],
});
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
});
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
await service.get('user', 'account');
await service.get('user', 'account');
expect(access.withAccount).toHaveBeenCalledTimes(1);
service.invalidate('user');
await service.get('user', 'account');
expect(access.withAccount).toHaveBeenCalledTimes(2);
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
access.withAccount.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).resolves.toMatchObject({
isStale: true,
});
});
it('does not mask an invalid token with a snapshot', async () => {
await service.get('user', 'account');
const snapshots = (
service as never as { snapshots: Map<string, { storedAt: number }> }
).snapshots;
snapshots.get('user:account')!.storedAt = Date.now() - 30_001;
access.withAccount.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(BadRequestException),
);
});
});