import { BadRequestException, ConflictException, ServiceUnavailableException, } from '@nestjs/common'; jest.mock('../prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); import { BrokerageAccountsService } from './brokerage-accounts.service'; import { TbankAccessService } from './tbank-access.service'; import { TbankTokenService } from './tbank-token.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: [] }, }, { id: 'iis', type: 'INDIVIDUAL_INVESTMENT', name: 'ИИС', openedOn: null, portfolioSummary: { totalsByCurrency: [] }, }, ]); }); 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' }], }, }, { id: 'second', type: 'INDIVIDUAL_INVESTMENT', name: 'Второй', openedOn: null, portfolioSummary: { totalsByCurrency: [{ currency: 'USD', value: '4' }], }, }, ]); expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'first'); expect(tbank.getPositions).toHaveBeenCalledWith('token', 'second'); }); 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), ); }); });