182 lines
5.6 KiB
TypeScript
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),
|
|
);
|
|
});
|
|
});
|