import { status } from '@grpc/grpc-js'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { TbankClient } from './tbank.client'; describe('TbankClient operations', () => { const request = { accountId: 'account', from: { seconds: '1' }, to: { seconds: '2' }, cursor: 'cursor', limit: 10, state: 'OPERATION_STATE_EXECUTED' as const, }; it('passes a cursor operations request to OperationsService', async () => { const client = Object.create(TbankClient.prototype) as TbankClient; const call = jest.fn().mockResolvedValue({ items: [] }); (client as unknown as { call: jest.Mock }).call = call; await expect( client.getOperationsByCursor('token', request), ).resolves.toEqual({ items: [], }); expect(call).toHaveBeenCalledWith( 'token', undefined, 'getOperationsByCursor', request, ); }); it('passes a GetDividends request to InstrumentsService', async () => { const client = Object.create(TbankClient.prototype) as TbankClient; const call = jest.fn().mockResolvedValue({ dividends: [{ dividendType: 'Regular Cash' }], }); (client as unknown as { call: jest.Mock }).call = call; await expect( client.getDividends('token', 'uid', '2026-08-19', '2026-09-18'), ).resolves.toEqual([{ dividendType: 'Regular Cash' }]); expect(call).toHaveBeenCalledWith('token', undefined, 'getDividends', { instrumentId: 'uid', from: { seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)), }, to: { seconds: String(Math.floor(Date.parse('2026-09-18T00:00:00Z') / 1000)), }, }); }); it('propagates the normalized upstream error', async () => { const client = Object.create(TbankClient.prototype) as TbankClient; const error = new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE', }); (client as unknown as { call: jest.Mock }).call = jest .fn() .mockRejectedValue(error); await expect(client.getOperationsByCursor('token', request)).rejects.toBe( error, ); }); it('logs success, retry, and final failure with the RPC context', async () => { jest.useFakeTimers(); const logger = { event: jest.fn(), info: jest.fn() }; const client = Object.create(TbankClient.prototype) as TbankClient; (client as unknown as { logger: unknown }).logger = logger; (client as unknown as { queue: unknown }).queue = { size: 0, run: (operation: () => unknown) => operation(), }; const invoke = (...args: [string, unknown, string, object]) => ( client as unknown as { call: ( token: string, service: unknown, method: string, input: object, ) => Promise; } ).call(...args); const requestRpc = jest .fn() .mockResolvedValueOnce({ ok: true }) .mockRejectedValueOnce({ code: status.UNAVAILABLE }) .mockRejectedValueOnce({ code: status.UNAVAILABLE }); (client as unknown as { request: jest.Mock }).request = requestRpc; await expect(invoke('token', {}, 'getPortfolio', {})).resolves.toEqual({ ok: true, }); const failed = invoke('token', {}, 'getPortfolio', {}); const rejected = expect(failed).rejects.toEqual( expect.any(ServiceUnavailableException), ); await jest.advanceTimersByTimeAsync(1000); await rejected; expect(logger.event).toHaveBeenCalledWith( 'debug', 'tbank.rpc.completed', expect.objectContaining({ operation: 'getPortfolio', attempts: 1 }), ); expect(logger.event).toHaveBeenCalledWith( 'warn', 'tbank.rpc.retry', expect.objectContaining({ grpcCode: status.UNAVAILABLE, attempts: 1 }), ); expect(logger.event).toHaveBeenCalledWith( 'error', 'tbank.rpc.failed', expect.objectContaining({ grpcCode: status.UNAVAILABLE, attempts: 2 }), ); jest.useRealTimers(); }); }); describe('TbankClient metadata retries', () => { const instrument = { name: 'Облигация', ticker: 'BOND' }; let client: TbankClient; let requestRpc: jest.Mock; let queue: { size: number; run: jest.Mock }; let logger: { event: jest.Mock]>; }; beforeEach(() => { jest.useFakeTimers(); requestRpc = jest.fn(); queue = { size: 0, run: jest.fn((operation: () => Promise) => operation()), }; logger = { event: jest.fn]>(), }; client = Object.assign( Object.create(TbankClient.prototype) as TbankClient, { request: requestRpc, queue, logger, }, ); }); afterEach(() => { jest.useRealTimers(); }); it.each([ status.UNAVAILABLE, status.DEADLINE_EXCEEDED, status.RESOURCE_EXHAUSTED, ])( 'recovers metadata after transient gRPC status %s with 2s/4s pauses', async (code) => { requestRpc .mockRejectedValueOnce({ code }) .mockRejectedValueOnce({ code }) .mockResolvedValueOnce({ instrument }); const result = client.getInstrumentByUid('token', 'uid'); await jest.advanceTimersByTimeAsync(1999); expect(requestRpc).toHaveBeenCalledTimes(1); await jest.advanceTimersByTimeAsync(1); expect(requestRpc).toHaveBeenCalledTimes(2); await jest.advanceTimersByTimeAsync(3999); expect(requestRpc).toHaveBeenCalledTimes(2); await jest.advanceTimersByTimeAsync(1); await expect(result).resolves.toEqual(instrument); expect(queue.run).toHaveBeenCalledTimes(3); expect(jest.getTimerCount()).toBe(0); }, ); it('rechecks a full queue before admitting a metadata retry', async () => { queue.size = 50; requestRpc.mockResolvedValue({ instrument }); const result = client.getInstrumentByFigi('token', 'figi'); await jest.advanceTimersByTimeAsync(1999); expect(queue.run).not.toHaveBeenCalled(); queue.size = 49; await jest.advanceTimersByTimeAsync(1); await expect(result).resolves.toEqual(instrument); expect(queue.run).toHaveBeenCalledTimes(1); expect(logger.event).toHaveBeenCalledWith( 'warn', 'tbank.rpc.retry', expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }), ); expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode'); }); it('does not bypass queue admission after an upstream failure', async () => { requestRpc .mockRejectedValueOnce({ code: status.UNAVAILABLE }) .mockResolvedValueOnce({ instrument }); const result = client.getInstrumentByUid('token', 'uid'); queue.size = 50; await jest.advanceTimersByTimeAsync(2000); expect(queue.run).toHaveBeenCalledTimes(1); queue.size = 0; await jest.advanceTimersByTimeAsync(4000); await expect(result).resolves.toEqual(instrument); expect(queue.run).toHaveBeenCalledTimes(2); }); it('stops retrying a saturated queue after three admission attempts', async () => { queue.size = 50; const result = expect( client.getInstrumentByUid('token', 'uid'), ).rejects.toBeInstanceOf(ServiceUnavailableException); await jest.advanceTimersByTimeAsync(6000); await result; expect(queue.run).not.toHaveBeenCalled(); expect(logger.event).toHaveBeenCalledTimes(3); expect(logger.event).toHaveBeenLastCalledWith( 'error', 'tbank.rpc.failed', expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }), ); expect(jest.getTimerCount()).toBe(0); }); it('stops retrying an unavailable upstream after three RPCs', async () => { requestRpc.mockRejectedValue({ code: status.UNAVAILABLE }); const result = expect( client.getInstrumentByUid('token', 'uid'), ).rejects.toBeInstanceOf(ServiceUnavailableException); await jest.advanceTimersByTimeAsync(6000); await result; expect(queue.run).toHaveBeenCalledTimes(3); expect(jest.getTimerCount()).toBe(0); }); it.each([status.UNAUTHENTICATED, status.PERMISSION_DENIED])( 'does not retry or mask metadata authentication status %s', async (code) => { requestRpc.mockRejectedValue({ code }); await expect(client.getInstrumentByUid('token', 'uid')).rejects.toThrow( 'INVALID_TBANK_TOKEN', ); expect(requestRpc).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); }, ); it.each(['getInstrumentByUid', 'getInstrumentByFigi'] as const)( 'returns confirmed absence from %s without retrying', async (method) => { requestRpc.mockRejectedValue({ code: status.NOT_FOUND }); await expect(client[method]('token', 'instrument')).resolves.toBeNull(); expect(requestRpc).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); }, ); it('keeps confirmed absence from ShareBy as a not-found error', async () => { requestRpc.mockRejectedValue({ code: status.NOT_FOUND }); await expect(client.getShare('token', 'MISSING')).rejects.toBeInstanceOf( NotFoundException, ); expect(requestRpc).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); }); it('does not retry invalid metadata arguments', async () => { requestRpc.mockRejectedValue({ code: status.INVALID_ARGUMENT }); await expect( client.getInstrumentByUid('token', 'uid'), ).rejects.toBeInstanceOf(ServiceUnavailableException); expect(requestRpc).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); }); it('keeps other RPCs failing immediately when the queue is full', async () => { queue.size = 50; await expect( client.getPortfolio('token', 'account'), ).rejects.toBeInstanceOf(ServiceUnavailableException); expect(queue.run).not.toHaveBeenCalled(); expect(jest.getTimerCount()).toBe(0); }); });