488 lines
17 KiB
TypeScript
488 lines
17 KiB
TypeScript
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<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();
|
|
});
|
|
|
|
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<unknown>) => 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<unknown> }).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,
|
|
}));
|
|
const [, , fields] = logger.event.mock.calls.at(-1) as [string, string, { networkMs: unknown }];
|
|
expect(typeof fields.networkMs).toBe('number');
|
|
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<unknown>) => 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<unknown> }).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<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 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<void, [string, string, Record<string, number | string>]>;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
call = jest.fn();
|
|
logger = {
|
|
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
|
|
};
|
|
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',
|
|
);
|
|
});
|
|
});
|