import { BadRequestException, ConflictException, ServiceUnavailableException, } from '@nestjs/common'; import assert from 'node:assert/strict'; jest.mock('../../integrations/tbank/tbank-access.service', () => ({ TbankAccessService: class {}, })); import type { TbankConnectionContext } from '../../integrations/tbank/tbank-access.service'; import { IncomeAnalyticsService } from './income/income-analytics.service'; import { BrokerageOperationsService } from './operations/application/brokerage-operations.service'; import { PortfolioService } from './portfolio/application/portfolio.service'; import { UpcomingEventsService } from './upcoming-events/upcoming-events.service'; function setup() { const context = { token: 'token', generation: 'first', accounts: [ { id: 'a', name: 'Основной', openedOn: '2020-01-01' }, { id: 'b', name: 'ИИС', openedOn: '2026-01-01' }, ], }; const access = { withSupportedAccounts: jest.fn( (_user: string, handler: (value: typeof context) => unknown) => handler(context), ), withAccountForConnection: jest.fn( ( _connection: TbankConnectionContext, _id: string, handler: () => unknown, ) => handler(), ), }; const tbank = { getOperationsByCursor: jest.fn(), getPortfolio: jest.fn().mockResolvedValue({ positions: [] }), getPositions: jest.fn().mockResolvedValue({ money: [] }), getDividends: jest.fn().mockResolvedValue([]), getInstrumentByFigi: jest.fn().mockResolvedValue({ name: 'Облигация' }), }; const metadata = { resolve: jest.fn().mockResolvedValue(null) }; const requestContext = { set: jest.fn() }; const operations = new BrokerageOperationsService( access as never, tbank as never, requestContext as never, metadata as never, ); const portfolio = new PortfolioService( access as never, tbank as never, requestContext as never, metadata as never, ); const moex = { bondEvents: jest.fn().mockResolvedValue([]) }; const events = new UpcomingEventsService( access as never, tbank as never, moex as never, requestContext as never, { info: jest.fn(), event: jest.fn() } as never, ); return { context, access, tbank, operations, portfolio, events, moex, income: new IncomeAnalyticsService(operations), }; } const operation = ( id: string, second: number, type = 'OPERATION_TYPE_COUPON', value = 1, ) => ({ id, date: { seconds: String(second) }, type, payment: { currency: 'RUB', units: value }, }); const query = { from: '2025-01-01', to: '2026-12-31', limit: 2 }; describe('All accounts', () => { it('does not truncate instrument history when an account opening date is missing', async () => { const { context, operations, tbank } = setup(); Object.assign(context.accounts[0], { openedOn: null }); tbank.getOperationsByCursor.mockResolvedValue({ items: [] }); await operations.getAll('user', { instrumentUid: 'common' }); expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( 'token', expect.objectContaining({ from: { seconds: '0' }, instrumentId: 'common', }), ); }); it('filters upstream by UID, keeps sold-position history and binds continuations to the instrument', async () => { const { operations, tbank } = setup(); tbank.getOperationsByCursor.mockImplementation( ( _token: string, request: { instrumentId: string; accountId: string }, ) => ({ items: [ { ...operation( request.accountId + '-dividend', 9, 'OPERATION_TYPE_DIVIDEND', ), instrumentUid: request.instrumentId, }, { ...operation('wrong-paper', 8, 'OPERATION_TYPE_DIVIDEND'), instrumentUid: 'preferred', }, ], }), ); const first = await operations.getAll('user', { instrumentUid: 'common', limit: 1, }); assert.ok(first.nextCursor); expect(first.items).toHaveLength(1); expect(first.items[0].instrumentUid).toBe('common'); expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( 'token', expect.objectContaining({ instrumentId: 'common', from: { seconds: '1577836800' }, }), ); await expect( operations.getAll('user', { instrumentUid: 'preferred', limit: 1, cursor: first.nextCursor, }), ).rejects.toBeInstanceOf(BadRequestException); const second = await operations.getAll('user', { instrumentUid: 'common', limit: 1, cursor: first.nextCursor, }); expect(second.items[0].instrumentUid).toBe('common'); expect(second.items[0].id).not.toBe(first.items[0].id); expect(second.hasNext).toBe(false); }); afterEach(() => jest.useRealTimers()); it('merges exact amounts by currency and keeps equal instruments on different accounts', async () => { const { portfolio, tbank } = setup(); tbank.getPortfolio.mockResolvedValue({ positions: [ { figi: 'SAME', instrumentType: 'share', quantity: { units: 1 }, currentPrice: { currency: 'RUB', nano: 100000000 }, }, ], }); tbank.getPositions.mockImplementation((_token: string, id: string) => ({ money: [{ currency: id === 'a' ? 'RUB' : 'USD', nano: 200000000 }], })); const result = await portfolio.getAll('user'); expect(result.structure.currencies).toMatchObject([ { currency: 'RUB', totalValue: '0.4', categories: [ { type: 'STOCK', value: '0.2', sharePercent: '50' }, { type: 'BOND', value: '0' }, { type: 'OTHER', value: '0' }, { type: 'CASH', value: '0.2', sharePercent: '50' }, ], }, { currency: 'USD', totalValue: '0.2' }, ]); expect(result.totalsByCurrency).toEqual([ { currency: 'RUB', value: '0.4' }, { currency: 'USD', value: '0.2' }, ]); expect(result.cashBalances).toEqual([ { currency: 'RUB', value: '0.2' }, { currency: 'USD', value: '0.2' }, ]); expect(result.positions.map((position) => position.account?.id)).toEqual([ 'a', 'b', ]); }); it('rejects missing access and a partial portfolio instead of publishing incomplete totals', async () => { const { portfolio, access, tbank } = setup(); access.withSupportedAccounts.mockImplementationOnce(() => { throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); }); await expect(portfolio.getAll('user')).rejects.toBeInstanceOf( ConflictException, ); expect(tbank.getPortfolio).not.toHaveBeenCalled(); tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException()); await expect(portfolio.getAll('user')).rejects.toBeInstanceOf( ServiceUnavailableException, ); }); it('marks an aggregate stale and uses its oldest snapshot timestamp', async () => { jest.useFakeTimers().setSystemTime(new Date('2026-09-01T10:00:00Z')); const { portfolio, tbank } = setup(); const first = await portfolio.getAll('user'); jest.setSystemTime(new Date('2026-09-01T10:01:00Z')); tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException()); const result = await portfolio.getAll('user'); expect(result.isStale).toBe(true); expect(result.lastSuccessfulAt).toBe(first.lastSuccessfulAt); }); it('paginates interleaved streams without dropping buffered rows, preserving duplicate IDs', async () => { const { operations, tbank } = setup(); tbank.getOperationsByCursor.mockImplementation( ( _token: string, { accountId, cursor }: { accountId: string; cursor?: string }, ) => { if (accountId === 'a') return cursor ? { items: [operation('last', 3)] } : { items: [operation('same', 9), operation('a2', 5)], hasNext: true, nextCursor: 'a-next', }; return { items: [operation('same', 8), operation('b2', 4)] }; }, ); const first = await operations.getAll('user', query); assert.ok(first.nextCursor); const second = await operations.getAll('user', { ...query, cursor: first.nextCursor, }); const repeated = await operations.getAll('user', { ...query, cursor: first.nextCursor, }); assert.ok(second.nextCursor); const third = await operations.getAll('user', { ...query, cursor: second.nextCursor, }); expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([ ['same', 'a'], ['same', 'b'], ]); expect(second.items.map((item) => item.id)).toEqual(['a2', 'b2']); expect(repeated).toEqual(second); expect(third.items.map((item) => item.id)).toEqual(['last']); expect(third.hasNext).toBe(false); }); it('continues through empty filtered pages and breaks ties by account', async () => { const { operations, tbank } = setup(); tbank.getOperationsByCursor.mockImplementation( (_token: string, { cursor }: { cursor?: string }) => cursor ? { items: [operation('income', 5)] } : { items: [operation('fee', 8, 'OPERATION_TYPE_BROKER_FEE')], hasNext: true, nextCursor: 'next', }, ); const page = await operations.getAll('user', { ...query, categories: ['income'], }); expect(page.items.map((item) => item.account?.id)).toEqual(['a', 'b']); expect(page.items.every((item) => item.category === 'income')).toBe(true); }); it('rejects damaged cursor chains and partial streams', async () => { const { operations, tbank } = setup(); tbank.getOperationsByCursor.mockResolvedValue({ items: [], hasNext: true, nextCursor: 'loop', }); await expect(operations.getAll('user', query)).rejects.toBeInstanceOf( ServiceUnavailableException, ); tbank.getOperationsByCursor.mockRejectedValue( new ServiceUnavailableException(), ); await expect( operations.getAll('another-user', query), ).rejects.toBeInstanceOf(ServiceUnavailableException); }); it('binds continuations to user, connection, accounts and filters', async () => { const { operations, context, tbank } = setup(); tbank.getOperationsByCursor.mockResolvedValue({ items: [operation('first', 8), operation('last', 4)], }); const first = await operations.getAll('user', query); assert.ok(first.nextCursor); const next = { ...query, cursor: first.nextCursor }; await expect(operations.getAll('other', next)).rejects.toBeInstanceOf( BadRequestException, ); await expect( operations.getAll('user', { ...next, categories: ['fee'] }), ).rejects.toBeInstanceOf(BadRequestException); context.generation = 'replaced'; await expect(operations.getAll('user', next)).rejects.toBeInstanceOf( BadRequestException, ); context.generation = 'first'; context.accounts.pop(); await expect(operations.getAll('user', next)).rejects.toBeInstanceOf( BadRequestException, ); expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2); }); it('collects income across the same cohort and computes comparisons from all receipts', async () => { const { income, tbank } = setup(); tbank.getOperationsByCursor.mockImplementation( ( _token: string, { accountId, from }: { accountId: string; from: { seconds: string } }, ) => { const year = new Date(Number(from.seconds) * 1000).getUTCFullYear(); const second = Date.parse( year === 2025 ? '2025-08-15T12:00:00Z' : '2026-08-15T12:00:00Z', ) / 1000; return { items: year === 2025 && accountId === 'b' ? [] : [ operation( 'same', second, 'OPERATION_TYPE_COUPON', year === 2025 ? 2 : 3, ), ], }; }, ); const result = await income.analytics( 'user', 'all', '2026-08-01', '2026-08-31', ); expect(result.totalsByCurrency[0].total.value).toBe('6'); expect( result.yearComparison?.totalsByCurrency[0].compared.total.value, ).toBe('2'); expect( result.yearComparison?.totalsByCurrency[0].difference.total.value, ).toBe('4'); expect(result.entries.map((entry) => entry.account?.id)).toEqual([ 'a', 'b', ]); expect( new Set(result.entries.map((entry) => entry.instrumentKey)).size, ).toBe(2); }); it('preserves separate future events and fails the section when one source fails', async () => { const { events, tbank, moex } = setup(); const today = new Date().toISOString().slice(0, 10); tbank.getPortfolio.mockResolvedValue({ positions: [ { figi: 'BOND', ticker: 'BOND', instrumentType: 'bond', quantity: { units: 2 }, }, ], }); moex.bondEvents.mockResolvedValue([ { type: 'coupon', date: today, value: '0.1', currency: 'RUB' }, ]); const result = await events.getAll('user'); expect(result.events.map((event) => event.account?.id)).toEqual(['a', 'b']); expect(result.incomeTotalsByCurrency).toEqual([ { currency: 'RUB', value: '0.4' }, ]); tbank.getPortfolio .mockResolvedValueOnce({ positions: [] }) .mockRejectedValueOnce(new ServiceUnavailableException()); await expect(events.getAll('user')).rejects.toBeInstanceOf( ServiceUnavailableException, ); }); it('returns valid empty results for an empty account set', async () => { const { context, portfolio, operations, income, events, tbank } = setup(); context.accounts = []; expect((await portfolio.getAll('user')).positions).toEqual([]); expect((await operations.getAll('user', query)).hasNext).toBe(false); expect((await income.summary('user', 'all')).totalsByCurrency).toEqual([]); expect((await events.getAll('user')).events).toEqual([]); expect(tbank.getOperationsByCursor).not.toHaveBeenCalled(); expect(tbank.getPortfolio).not.toHaveBeenCalled(); }); });