import { BadRequestException, ConflictException, ServiceUnavailableException, } from '@nestjs/common'; jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../../../integrations/tbank/tbank-token.service'; import { BrokerageAccountsService } from './brokerage-accounts.service'; describe('BrokerageAccountsService', () => { const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; const prisma = { tbankConnection: { findUnique: jest.fn() } }; const config = { getOrThrow: jest.fn(() => encryptionKey) }; const tbank = { getOpenAccounts: jest.fn(), getPortfolio: jest.fn(), getPositions: jest.fn(), }; const tokens = new TbankTokenService(config as never); const access = new TbankAccessService( prisma as never, tokens, tbank as never, ); const service = new BrokerageAccountsService(access, tbank as never); beforeEach(() => { jest.clearAllMocks(); tbank.getPortfolio.mockResolvedValue({ positions: [] }); tbank.getPositions.mockResolvedValue({ money: [] }); }); it('does not call T-Bank without a connection', async () => { prisma.tbankConnection.findUnique.mockResolvedValue(null); await expect(service.list('user-1')).rejects.toEqual( expect.any(ConflictException), ); expect(tbank.getOpenAccounts).not.toHaveBeenCalled(); }); it('normalizes supported accounts and ignores unsupported ones', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: tokens.encrypt('token'), }); tbank.getOpenAccounts.mockResolvedValue([ { id: 'brokerage', type: 'ACCOUNT_TYPE_TINKOFF', name: ' Основной ', openedDate: { seconds: '1723507200' }, }, { id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Дебетовый', }, { id: 'iis', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: ' ', }, ]); await expect(service.list('user-1')).resolves.toEqual([ { id: 'brokerage', type: 'BROKERAGE', name: 'Основной', openedOn: '2024-08-13', portfolioSummary: { totalsByCurrency: [], stockPositionCount: 0, bondPositionCount: 0, }, }, { id: 'iis', type: 'INDIVIDUAL_INVESTMENT', name: 'ИИС', openedOn: null, portfolioSummary: { totalsByCurrency: [], stockPositionCount: 0, bondPositionCount: 0, }, }, ]); }); it('keeps upstream order and adds exact summaries without loading metadata', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: tokens.encrypt('token'), }); tbank.getOpenAccounts.mockResolvedValue([ { id: 'first', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' }, { id: 'second', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'Второй' }, ]); tbank.getPortfolio .mockResolvedValueOnce({ positions: [ { quantity: { units: 2 }, currentPrice: { currency: 'rub', units: 3 }, }, ], }) .mockResolvedValueOnce({ positions: [] }); tbank.getPositions .mockResolvedValueOnce({ money: [{ currency: 'RUB', nano: 500000000 }] }) .mockResolvedValueOnce({ money: [{ currency: 'USD', units: 4 }] }); await expect(service.list('user-1')).resolves.toEqual([ { id: 'first', type: 'BROKERAGE', name: 'Первый', openedOn: null, portfolioSummary: { totalsByCurrency: [{ currency: 'RUB', value: '6.5' }], stockPositionCount: 0, bondPositionCount: 0, }, }, { id: 'second', type: 'INDIVIDUAL_INVESTMENT', name: 'Второй', openedOn: null, portfolioSummary: { totalsByCurrency: [{ currency: 'USD', value: '4' }], stockPositionCount: 0, bondPositionCount: 0, }, }, ]); expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'first'); expect(tbank.getPositions).toHaveBeenCalledWith('token', 'second'); }); it('counts non-zero stock and bond positions even without a valuation', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: tokens.encrypt('token'), }); tbank.getOpenAccounts.mockResolvedValue([ { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' }, ]); tbank.getPortfolio.mockResolvedValue({ positions: [ { instrumentType: 'share', quantity: { units: 200 } }, { instrumentType: 'SHARE', quantity: { units: -5 } }, { instrumentType: 'share', quantity: { units: 0, nano: 1 } }, { instrumentType: 'bond', quantity: { units: 80 } }, { instrumentType: 'bond', quantity: { units: 0 } }, { instrumentType: 'share' }, { instrumentType: 'etf', quantity: { units: 10 } }, { instrumentType: 'currency', quantity: { units: 100 } }, { quantity: { units: 1 } }, ], virtualPositions: [{ instrumentType: 'bond', quantity: { units: 1 } }], }); await expect(service.list('user-1')).resolves.toMatchObject([ { portfolioSummary: { totalsByCurrency: [], stockPositionCount: 3, bondPositionCount: 1, }, }, ]); }); it('localizes an unavailable summary without suppressing another account', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: tokens.encrypt('token'), }); tbank.getOpenAccounts.mockResolvedValue([ { id: 'unavailable', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' }, { id: 'available', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Второй' }, ]); tbank.getPortfolio .mockRejectedValueOnce( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ) .mockResolvedValueOnce({ positions: [] }); tbank.getPositions.mockResolvedValue({ money: [] }); await expect(service.list('user-1')).resolves.toMatchObject([ { id: 'unavailable', portfolioSummary: null }, { id: 'available', portfolioSummary: { totalsByCurrency: [] } }, ]); }); it('does not mask an invalid token when the other summary input is unavailable', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: tokens.encrypt('token'), }); tbank.getOpenAccounts.mockResolvedValue([ { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' }, ]); tbank.getPortfolio.mockRejectedValue( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ); tbank.getPositions.mockRejectedValue(new Error('INVALID_TBANK_TOKEN')); await expect(service.list('user-1')).rejects.toEqual( expect.any(BadRequestException), ); }); it('retains the connection when T-Bank rejects its token', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: tokens.encrypt('token'), }); tbank.getOpenAccounts.mockRejectedValue(new Error('INVALID_TBANK_TOKEN')); await expect(service.list('user-1')).rejects.toEqual( expect.any(BadRequestException), ); }); it('returns an empty list from T-Bank unchanged', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: tokens.encrypt('token'), }); tbank.getOpenAccounts.mockResolvedValue([]); await expect(service.list('user-1')).resolves.toEqual([]); }); it('propagates an unavailable T-Bank response', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: tokens.encrypt('token'), }); tbank.getOpenAccounts.mockRejectedValue( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ); await expect(service.list('user-1')).rejects.toEqual( expect.any(ServiceUnavailableException), ); }); });