import { Metadata, status } from '@grpc/grpc-js'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { EventEmitter } from 'node:events'; import { TbankOperationState } from './tbank-protocol'; import { TbankClient } from './tbank.client'; describe('TbankClient operations', () => { const request = { accountId: 'account', from: { seconds: '1' }, to: { seconds: '2' }, cursor: 'cursor', limit: 10, state: TbankOperationState.Executed, }; 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-19T00:00:00Z') / 1000)), }, }); }); it.each(['getDividends', 'getBondCoupons'] as const)( '%s converts a leap-day calendar window to a full half-open UTC day', async (method) => { const client = Object.create(TbankClient.prototype) as TbankClient; const call = jest.fn().mockResolvedValue({}); (client as unknown as { call: jest.Mock }).call = call; // eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table. await client[method]('token', 'uid', '2024-02-29', '2024-02-29'); expect(call).toHaveBeenCalledWith('token', undefined, method, { instrumentId: 'uid', from: { seconds: String(Date.parse('2024-02-29T00:00:00Z') / 1000) }, to: { seconds: String(Date.parse('2024-03-01T00: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', {}); await Promise.all([ expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)), jest.advanceTimersByTimeAsync(1000), ]); 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(); }); it('distinguishes a gRPC deadline from queue delay without logging upstream secrets', async () => { const logger = { event: jest.fn() }; const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, { logger, queue: { size: 0, run: (operation: () => Promise) => operation() }, }); const channel = { getConnectivityState: () => 1 }; const service = { getChannel: () => channel, getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: unknown) => void) => { const call = new EventEmitter(); void Promise.resolve().then(() => callback({ code: status.DEADLINE_EXCEEDED, details: 'Bearer secret' })); return call; }, }; const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise }).call.bind(client); jest.useFakeTimers(); try { const failed = invoke('secret', service, 'getAccounts', {}); await Promise.all([ expect(failed).rejects.toBeInstanceOf(ServiceUnavailableException), jest.advanceTimersByTimeAsync(1000), ]); expect(logger.event).toHaveBeenLastCalledWith('error', 'tbank.rpc.failed', expect.objectContaining({ operation: 'getAccounts', grpcCode: status.DEADLINE_EXCEEDED, queueWaitMs: 0, channelState: 'CONNECTING', headersReceived: false, })); expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret'); } finally { jest.useRealTimers(); } }); it('records response headers and a validated upstream tracking ID', async () => { const logger = { event: jest.fn() }; const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, { logger, queue: { size: 0, run: (operation: () => Promise) => operation() }, }); const headers = new Metadata(); headers.set('x-tracking-id', '123e4567-e89b-12d3-a456-426614174000'); headers.set('authorization', 'Bearer secret'); const service = { getChannel: () => ({ getConnectivityState: () => 2 }), getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: null, response: object) => void) => { const call = new EventEmitter(); void Promise.resolve().then(() => { call.emit('metadata', headers); callback(null, { accounts: [] }); }); return call; }, }; const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise }).call.bind(client); await expect(invoke('secret', service, 'getAccounts', {})).resolves.toEqual({ accounts: [] }); expect(logger.event).toHaveBeenLastCalledWith('debug', 'tbank.rpc.completed', expect.objectContaining({ channelState: 'READY', headersReceived: true, trackingId: '123e4567-e89b-12d3-a456-426614174000', })); expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret'); }); }); describe('TbankClient metadata retries', () => { const instrument = { name: 'Облигация', ticker: 'BOND' }; let client: TbankClient; let requestRpc: jest.Mock; let queue: { run: jest.Mock; size: number }; 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 one retry for transient gRPC status %s', async (code) => { requestRpc .mockRejectedValueOnce({ code }) .mockResolvedValueOnce({ instrument }); const result = client.getInstrumentByUid('token', 'uid'); await jest.advanceTimersByTimeAsync(999); expect(requestRpc).toHaveBeenCalledTimes(1); await jest.advanceTimersByTimeAsync(1); expect(requestRpc).toHaveBeenCalledTimes(2); await expect(result).resolves.toEqual(instrument); expect(queue.run).toHaveBeenCalledTimes(2); expect(jest.getTimerCount()).toBe(0); }, ); it('rejects metadata immediately when the queue is full', async () => { queue.size = 50; requestRpc.mockResolvedValue({ instrument }); await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException); expect(queue.run).not.toHaveBeenCalled(); expect(jest.getTimerCount()).toBe(0); }); it('does not bypass queue admission after an upstream failure', async () => { requestRpc .mockRejectedValueOnce({ code: status.UNAVAILABLE }) .mockResolvedValueOnce({ instrument }); const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error); queue.size = 50; await jest.advanceTimersByTimeAsync(1000); expect(queue.run).toHaveBeenCalledTimes(1); expect(await result).toBeInstanceOf(ServiceUnavailableException); expect(jest.getTimerCount()).toBe(0); }); it('stops retrying an unavailable upstream after two RPCs', async () => { requestRpc.mockRejectedValue({ code: status.UNAVAILABLE }); await Promise.all([ expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException), jest.advanceTimersByTimeAsync(1000), ]); expect(queue.run).toHaveBeenCalledTimes(2); 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 }); // eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table. 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); }); }); describe('TbankClient share catalog', () => { const token = 'token'; const catalogSize = 40; let client: TbankClient; let call: jest.Mock; let logger: { event: jest.Mock]>; }; beforeEach(() => { call = jest.fn(); logger = { event: jest.fn]>(), }; client = Object.assign( Object.create(TbankClient.prototype) as TbankClient, { call, logger }, ); }); it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => { const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }]; call.mockResolvedValue({ instruments }); await expect(client.getAllShares(token)).resolves.toEqual(instruments); expect(call).toHaveBeenCalledWith('token', undefined, 'shares', { instrumentStatus: 'INSTRUMENT_STATUS_ALL', }); }); it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => { const catalog = Array.from({ length: catalogSize }, (_, index) => { const fitting = index === catalogSize - 1; return { apiTradeAvailableFlag: fitting, buyAvailableFlag: fitting, ticker: fitting ? 'FIT' : `T${index}`, uid: fitting ? 'uid-fit' : `uid-${index}`, }; }); call.mockResolvedValue({ instruments: catalog }); const shares = await client.getAllShares(token); expect(shares).toEqual(catalog); expect(shares).toHaveLength(catalogSize); expect(shares).toContainEqual({ apiTradeAvailableFlag: true, buyAvailableFlag: true, ticker: 'FIT', uid: 'uid-fit', }); }); it('rejects when the source refuses instead of returning an empty success', async () => { const error = new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE', }); call.mockRejectedValue(error); await expect(client.getAllShares(token)).rejects.toBe(error); }); it('rejects when the response carries no instrument list', async () => { call.mockResolvedValue({}); await expect(client.getAllShares(token)).rejects.toBeInstanceOf( ServiceUnavailableException, ); }); it('rejects an empty instrument list with the public unavailable code', async () => { call.mockResolvedValue({ instruments: [] }); const failure = await client .getAllShares(token) .catch((error: unknown) => error); expect(failure).toBeInstanceOf(ServiceUnavailableException); expect((failure as ServiceUnavailableException).getResponse()).toEqual({ code: 'TBANK_UNAVAILABLE', }); }); it('rejects a non-list instrument payload as uninterpretable', async () => { call.mockResolvedValue({ instruments: 'corrupted' }); await expect(client.getAllShares(token)).rejects.toBeInstanceOf( ServiceUnavailableException, ); }); it('rejects an uninterpretable entry without instrument identity', async () => { call.mockResolvedValue({ instruments: [ { ticker: 'FIT', uid: 'uid-fit' }, { ticker: 'BROKEN' }, ], }); await expect(client.getAllShares(token)).rejects.toBeInstanceOf( ServiceUnavailableException, ); }); it('registers asOf and source instrument count on a successful catalog fetch', async () => { call.mockResolvedValue({ instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }], }); await client.getAllShares(token); expect(logger.event).toHaveBeenCalledWith( 'info', 'tbank.shares.catalog.fetched', expect.objectContaining({ count: 2, operation: 'shares' }), ); expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String)); }); it('logs a distinguishable failure without secrets when the catalog is unusable', async () => { call.mockResolvedValue({ instruments: [] }); await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf( ServiceUnavailableException, ); expect(logger.event).toHaveBeenCalledWith( 'error', 'tbank.shares.catalog.unusable', expect.objectContaining({ operation: 'shares' }), ); expect(JSON.stringify(logger.event.mock.calls)).not.toContain( 'secret-token', ); }); });