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, ) => 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 } ).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 } ).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), ); }); });