refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user