Use owner enums for error and T-Bank protocol values with ESLint regression checks. Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma. Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { status } from '@grpc/grpc-js';
|
|
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
|
|
|
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<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', {});
|
|
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();
|
|
});
|
|
});
|
|
|
|
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<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;
|
|
await Promise.all([
|
|
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
|
jest.advanceTimersByTimeAsync(6000),
|
|
]);
|
|
|
|
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 });
|
|
await Promise.all([
|
|
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
|
jest.advanceTimersByTimeAsync(6000),
|
|
]);
|
|
|
|
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 });
|
|
|
|
// 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);
|
|
});
|
|
});
|