Files
backend/src/integrations/tbank/tbank.client.spec.ts
T

307 lines
9.8 KiB
TypeScript

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<unknown>;
}
).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<void, [string, string, Record<string, unknown>]>;
};
beforeEach(() => {
jest.useFakeTimers();
requestRpc = jest.fn();
queue = {
size: 0,
run: jest.fn((operation: () => Promise<unknown>) => operation()),
};
logger = {
event: jest.fn<void, [string, string, Record<string, unknown>]>(),
};
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);
});
});