import { BadRequestException, ConflictException, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; jest.mock('./tbank-access.service', () => ({ TbankAccessService: class TbankAccessService {}, })); import { BrokerageOperationsService } from './brokerage-operations.service'; import type { TbankAccountContext } from './tbank-access.service'; describe('BrokerageOperationsService', () => { const access = { withAccount: jest.fn() }; const tbank = { getOperationsByCursor: jest.fn(), }; const requestContext = { set: jest.fn() }; const metadata = { resolve: jest.fn() }; const service = new BrokerageOperationsService( access as never, tbank as never, requestContext as never, metadata as never, ); let operationsRequest: unknown; let generation: string; beforeEach(() => { jest.clearAllMocks(); access.withAccount.mockReset(); tbank.getOperationsByCursor.mockReset(); metadata.resolve.mockReset(); generation = 'generation-1'; access.withAccount.mockImplementation( ( _userId: string, _accountId: string, handler: (context: TbankAccountContext) => Promise, ) => handler({ token: 'token', generation, account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' }, }), ); tbank.getOperationsByCursor.mockImplementation( (_token: string, request: unknown) => { operationsRequest = request; return Promise.resolve({ items: [] }); }, ); (service as unknown as { pages: Map }).pages.clear(); }); afterEach(() => { jest.useRealTimers(); jest.restoreAllMocks(); }); it('uses the current UTC year by default and asks only for executed operations', async () => { await service.get('user', 'account', {}); const request = operationsRequest as { state: string; from: { seconds: string }; }; expect(request.state).toBe('OPERATION_STATE_EXECUTED'); expect(request.from.seconds).toBe( String(Math.floor(Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000)), ); }); it('uses inclusive date boundaries, filters categories, and preserves cursors', async () => { tbank.getOperationsByCursor.mockResolvedValue({ hasNext: true, nextCursor: 'next', items: [ { id: 'income', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON' }, { id: 'fee', date: { seconds: '2' }, type: 'OPERATION_TYPE_BROKER_FEE', }, ], }); await expect( service.get('user', 'account', { from: '2025-01-01', to: '2025-01-31', categories: ['income'], cursor: 'previous', limit: 10, }), ).resolves.toMatchObject({ items: [{ id: 'income' }], nextCursor: 'next', hasNext: true, }); expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( 'token', expect.objectContaining({ from: { seconds: '1735689600' }, to: { seconds: '1738367999', nanos: 999_999_999 }, cursor: 'previous', }), ); }); it('includes resolved instrument logo metadata in a ledger entry', async () => { tbank.getOperationsByCursor.mockResolvedValue({ items: [ { id: 'coupon', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON', instrumentUid: 'instrument-uid', }, ], }); metadata.resolve.mockResolvedValue({ brand: { logoName: 'instrument-logo.png' }, }); await expect(service.get('user', 'account', {})).resolves.toMatchObject({ items: [{ id: 'coupon', logoName: 'instrument-logo.png' }], }); }); it('resolves repeated instrument metadata only once per ledger page', async () => { tbank.getOperationsByCursor.mockResolvedValue({ items: [ { id: 'first', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON', instrumentUid: 'instrument-uid', }, { id: 'second', date: { seconds: '2' }, type: 'OPERATION_TYPE_COUPON', instrumentUid: 'instrument-uid', }, ], }); metadata.resolve.mockResolvedValue({ brand: { logoName: 'instrument-logo.png' }, }); await service.get('user', 'account', {}); expect(metadata.resolve).toHaveBeenCalledTimes(1); }); it('does not let a UID-only lookup suppress a later FIGI fallback on the same page', async () => { tbank.getOperationsByCursor.mockResolvedValue({ items: [ { id: 'first', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON', instrumentUid: 'instrument-uid', }, { id: 'second', date: { seconds: '2' }, type: 'OPERATION_TYPE_COUPON', instrumentUid: 'instrument-uid', figi: 'BBG000000001', }, ], }); metadata.resolve.mockResolvedValueOnce(null).mockResolvedValueOnce({ brand: { logoName: 'fallback-logo.png' }, }); await expect(service.get('user', 'account', {})).resolves.toMatchObject({ items: [ { id: 'first', logoName: null }, { id: 'second', logoName: 'fallback-logo.png' }, ], }); expect(metadata.resolve).toHaveBeenNthCalledWith( 1, 'token', 'instrument-uid', null, ); expect(metadata.resolve).toHaveBeenNthCalledWith( 2, 'token', 'instrument-uid', 'BBG000000001', ); }); it('returns an empty page without calling T-Bank when the period starts in the future', async () => { await expect( service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }), ).resolves.toMatchObject({ accountId: 'account', items: [], nextCursor: null, hasNext: false, }); expect(tbank.getOperationsByCursor).not.toHaveBeenCalled(); }); it('rejects inaccessible accounts before reading operations', async () => { access.withAccount.mockRejectedValueOnce(new NotFoundException()); await expect(service.get('user', 'account', {})).rejects.toEqual( expect.any(NotFoundException), ); expect(tbank.getOperationsByCursor).not.toHaveBeenCalled(); }); it('retains existing T-Bank error semantics', async () => { access.withAccount.mockRejectedValueOnce( new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }), ); await expect(service.get('user', 'account', {})).rejects.toEqual( expect.any(BadRequestException), ); tbank.getOperationsByCursor.mockRejectedValueOnce( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ); await expect(service.get('user', 'account', {})).rejects.toEqual( expect.any(ServiceUnavailableException), ); }); it('reuses one fresh page for equivalent categories within the default end-time bucket', async () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-08-23T12:00:00.100Z')); await service.get('user', 'account', { categories: ['tax', 'income', 'income'], }); jest.advanceTimersByTime(500); await service.get('user', 'account', { categories: ['income', 'tax'] }); expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1); }); it('does not let a cached page bypass account ownership checks', async () => { const query = { from: '2025-01-01', to: '2025-01-31' }; await service.get('user', 'account', query); access.withAccount.mockRejectedValueOnce(new NotFoundException()); await expect(service.get('user', 'account', query)).rejects.toEqual( expect.any(NotFoundException), ); expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1); }); it('does not reuse a cached ledger page across connection generations', async () => { const query = { from: '2025-01-01', to: '2025-01-31' }; await service.get('user', 'account', query); await service.get('user', 'account', query); generation = 'generation-2'; await service.get('user', 'account', query); expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2); }); it('does not reuse a cached ledger page after the connection is deleted', async () => { const query = { from: '2025-01-01', to: '2025-01-31' }; await service.get('user', 'account', query); access.withAccount.mockRejectedValueOnce( new ConflictException({ code: 'TBANK_NOT_CONNECTED' }), ); await expect(service.get('user', 'account', query)).rejects.toEqual( expect.any(ConflictException), ); expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1); }); it('keeps a late previous-generation page unavailable to ledger and income collection', async () => { let finishPrevious: ((value: unknown) => void) | undefined; const previous = new Promise((resolve) => { finishPrevious = resolve; }); tbank.getOperationsByCursor .mockReturnValueOnce(previous) .mockResolvedValueOnce({ items: [operation('current')], }); const range = { from: '2025-01-01', to: '2025-01-31' }; const previousRequest = service.collectRanges('user', 'account', [range]); generation = 'generation-2'; const current = await service.collectRanges('user', 'account', [range]); finishPrevious?.({ items: [operation('previous')] }); await previousRequest; const reused = await service.collectRanges('user', 'account', [range]); expect(current.pages[0][0].items[0].id).toBe('current'); expect(reused.pages[0][0].items[0].id).toBe('current'); expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2); }); it('collects every cursor page and rejects a malformed continuation cursor', async () => { tbank.getOperationsByCursor .mockResolvedValueOnce({ hasNext: true, nextCursor: 'next', items: [] }) .mockResolvedValueOnce({ hasNext: false, items: [] }); await expect( service.collectRanges('user', 'account', [ { from: '2026-01-01', to: '2026-01-31' }, ]), ).resolves.toMatchObject({ pages: [[{ hasNext: true }, { hasNext: false }]], }); tbank.getOperationsByCursor.mockReset(); tbank.getOperationsByCursor.mockResolvedValue({ hasNext: true, nextCursor: 'same', items: [], }); await expect( service.collectRanges('user', 'account', [ { from: '2026-02-01', to: '2026-02-28' }, ]), ).rejects.toMatchObject({ response: { code: 'TBANK_UNAVAILABLE' } }); }); it('collects an in-progress Moscow day only through the current instant', async () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-08-27T21:44:18.510Z')); await service.collectRanges('user', 'account', [ { from: '2026-08-01', to: '2026-08-28' }, ]); expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( 'token', expect.objectContaining({ from: { seconds: '1785531600' }, to: { seconds: '1787867058' }, limit: 1000, }), ); }); it('evicts the least recently used ledger page after one hundred entries', async () => { for (let index = 0; index < 100; index += 1) await service.get('user', 'account', { cursor: String(index) }); await service.get('user', 'account', { cursor: '0' }); await service.get('user', 'account', { cursor: '100' }); await service.get('user', 'account', { cursor: '1' }); const pages = (service as unknown as { pages: { size: number } }).pages; expect(pages.size).toBe(100); expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102); }); }); function operation(id: string) { return { id, date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON', }; }