import { CacheService } from '../../cache/cache.service'; import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerInstrumentsService } from './broker-instruments.service'; import { BrokerPortfolioService } from './broker-portfolio.service'; import { TBankClientService } from './tbank-client.service'; describe('BrokerPortfolioService', () => { const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService; const client = { getOperationsClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService; beforeEach(() => { vi.clearAllMocks(); }); it('throws 404 for excluded or missing account', async () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerPortfolioService(accounts, instruments, client, cache); await expect(service.getPortfolio('missing')).rejects.toThrow(EntityNotFoundException); }); it('fetches portfolio through cache without positions', async () => { vi.mocked(accounts.findById).mockResolvedValue({ id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null, }); vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(client.getOperationsClient).mockReturnValue({ getPortfolio: vi.fn(), getPositions: vi.fn(), } as any); vi.mocked(client.callUnary) .mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, positions: [{ instrumentType: 'share' }, { instrumentType: 'bond' }], }) .mockResolvedValueOnce({ accountId: 'acc-1', money: [{ currency: 'rub', units: '1000', nano: 0 }], blocked: [], securities: [], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); const result = await service.getPortfolio('acc-1'); expect(result.data.account.id).toBe('acc-1'); expect(result.data.cash[0].value).toBe(1000); expect(result.data.positionCounts).toEqual({ shares: 1, bonds: 1, etf: 0, other: 0 }); expect('positions' in result.data).toBe(false); expect(client.callUnary).toHaveBeenCalledTimes(2); expect(cache.getOrFetch).toHaveBeenCalledWith( 'tbank:portfolio', ['acc-1'], expect.any(Function), 'tbankPortfolioTtl', ); }); describe('getPositions', () => { function mockAccount() { vi.mocked(accounts.findById).mockResolvedValue({ id: 'acc-1', type: 'brokerage', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN', openedAt: null, accessLevel: null, }); } function mockCache() { vi.mocked(cache.getOrFetch).mockImplementation( async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ data: await fetchFn(), fromCache: false, cachedAt: null, }), ); vi.mocked(instruments.findByInstrumentUid).mockResolvedValue(null); } it('throws 404 for missing account', async () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerPortfolioService(accounts, instruments, client, cache); await expect(service.getPositions('missing')).rejects.toThrow(EntityNotFoundException); }); it('returns first page of positions', async () => { mockAccount(); mockCache(); vi.mocked(client.getOperationsClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); vi.mocked(client.callUnary).mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, positions: [ { figi: 'figi-1', instrumentUid: 'uid-1', positionUid: 'pos-1', quantity: { units: '10', nano: 0 }, }, { figi: 'figi-2', instrumentUid: 'uid-2', positionUid: 'pos-2', quantity: { units: '20', nano: 0 }, }, ], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); const result = await service.getPositions('acc-1', undefined, 1); expect(result.data.accountId).toBe('acc-1'); expect(result.data.items).toHaveLength(1); expect(result.data.items[0].positionUid).toBe('pos-1'); expect(result.data.hasNext).toBe(true); expect(result.data.nextCursor).toBe('pos-1'); }); it('paginates using cursor', async () => { mockAccount(); mockCache(); vi.mocked(client.getOperationsClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); vi.mocked(client.callUnary).mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, positions: [ { figi: 'f1', instrumentUid: 'u1', positionUid: 'p1', quantity: { units: '10', nano: 0 }, }, { figi: 'f2', instrumentUid: 'u2', positionUid: 'p2', quantity: { units: '20', nano: 0 }, }, { figi: 'f3', instrumentUid: 'u3', positionUid: 'p3', quantity: { units: '30', nano: 0 }, }, ], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); const result = await service.getPositions('acc-1', 'p1', 1); expect(result.data.items).toHaveLength(1); expect(result.data.items[0].positionUid).toBe('p2'); expect(result.data.nextCursor).toBe('p2'); expect(result.data.hasNext).toBe(true); }); it('returns last page with hasNext=false', async () => { mockAccount(); mockCache(); vi.mocked(client.getOperationsClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); vi.mocked(client.callUnary).mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, positions: [ { figi: 'f1', instrumentUid: 'u1', positionUid: 'p1', quantity: { units: '10', nano: 0 }, }, ], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); const result = await service.getPositions('acc-1', undefined, 10); expect(result.data.items).toHaveLength(1); expect(result.data.hasNext).toBe(false); expect(result.data.nextCursor).toBeNull(); }); it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => { mockAccount(); mockCache(); vi.mocked(client.getOperationsClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); vi.mocked(client.callUnary).mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 }, positions: [], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); await service.getPositions('acc-1', 'some-cursor', 5); expect(cache.getOrFetch).toHaveBeenCalledWith( 'tbank:positions', ['acc-1', 'some-cursor', '5', ''], expect.any(Function), 'tbankPositionsTtl', ); }); it('filters by instrument type and caches with type in key', async () => { mockAccount(); mockCache(); vi.mocked(client.getOperationsClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); vi.mocked(client.callUnary).mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '5000', nano: 0 }, positions: [ { figi: 'f1', instrumentUid: 'u1', positionUid: 'p1', instrumentType: 'share', ticker: 'SBER', quantity: { units: '10', nano: 0 }, }, { figi: 'f2', instrumentUid: 'u2', positionUid: 'p2', instrumentType: 'bond', ticker: 'SU26238RMFS5', quantity: { units: '5', nano: 0 }, }, { figi: 'f3', instrumentUid: 'u3', positionUid: 'p3', instrumentType: 'share', ticker: 'GAZP', quantity: { units: '3', nano: 0 }, }, ], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); const result = await service.getPositions('acc-1', undefined, 10, 'share'); expect(result.data.items).toHaveLength(2); expect(result.data.items.map((i) => i.ticker)).toEqual(['SBER', 'GAZP']); expect(cache.getOrFetch).toHaveBeenCalledWith( 'tbank:positions', ['acc-1', '', '10', 'share'], expect.any(Function), 'tbankPositionsTtl', ); }); it('returns empty items when type filter matches nothing', async () => { mockAccount(); mockCache(); vi.mocked(client.getOperationsClient).mockReturnValue({ getPortfolio: vi.fn(), } as any); vi.mocked(client.callUnary).mockResolvedValueOnce({ accountId: 'acc-1', totalAmountPortfolio: { currency: 'rub', units: '5000', nano: 0 }, positions: [ { figi: 'f1', instrumentUid: 'u1', positionUid: 'p1', instrumentType: 'share', ticker: 'SBER', quantity: { units: '10', nano: 0 }, }, ], }); const service = new BrokerPortfolioService(accounts, instruments, client, cache); const result = await service.getPositions('acc-1', undefined, 10, 'etf'); expect(result.data.items).toHaveLength(0); expect(result.data.hasNext).toBe(false); expect(result.data.nextCursor).toBeNull(); }); }); });