import { BadRequestException, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; jest.mock('../prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); import { BrokerageOperationsService } from './brokerage-operations.service'; import { TbankCrypto } from './tbank.crypto'; describe('BrokerageOperationsService', () => { const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; const prisma = { tbankConnection: { findUnique: jest.fn() } }; const config = { getOrThrow: jest.fn(() => key) }; const tbank = { getOpenAccounts: jest.fn(), getOperationsByCursor: jest.fn(), }; const requestContext = { set: jest.fn() }; const service = new BrokerageOperationsService( prisma as never, config as never, tbank as never, requestContext as never, ); const crypto = TbankCrypto.fromBase64(key); let operationsRequest: unknown; beforeEach(() => { jest.clearAllMocks(); prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: crypto.encrypt('token'), }); tbank.getOpenAccounts.mockResolvedValue([ { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, ]); tbank.getOperationsByCursor.mockImplementation( (_token: string, request: unknown) => { operationsRequest = request; return Promise.resolve({ items: [] }); }, ); (service as unknown as { pages: Map }).pages.clear(); }); 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('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 () => { tbank.getOpenAccounts.mockResolvedValue([]); await expect(service.get('user', 'account', {})).rejects.toEqual( expect.any(NotFoundException), ); expect(tbank.getOperationsByCursor).not.toHaveBeenCalled(); }); it('retains existing T-Bank error semantics', async () => { tbank.getOpenAccounts.mockRejectedValueOnce( new Error('INVALID_TBANK_TOKEN'), ); await expect(service.get('user', 'account', {})).rejects.toEqual( expect.any(BadRequestException), ); tbank.getOpenAccounts.mockResolvedValueOnce([ { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, ]); tbank.getOperationsByCursor.mockRejectedValueOnce( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ); await expect(service.get('user', 'account', {})).rejects.toEqual( expect.any(ServiceUnavailableException), ); }); });