307 lines
9.8 KiB
TypeScript
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);
|
|
});
|
|
});
|