import { BadRequestException, 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; beforeEach(() => { jest.clearAllMocks(); access.withAccount.mockReset(); tbank.getOperationsByCursor.mockReset(); metadata.resolve.mockReset(); access.withAccount.mockImplementation( ( _userId: string, _accountId: string, handler: (context: TbankAccountContext) => Promise, ) => handler({ token: 'token', 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('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); }); });