Files
backend/src/integrations/tbank/tbank.client.spec.ts
T
ksv741 d698ed9425
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 9m36s
refactor!: simplify runtime coordination #7
Keep financial ownership, atomic publication and refresh CAS.
Failed refresh leaves cookies untouched; logout still clears them.

Remove full-history candle prefetch and special metadata retries.
Use native Pino request context instead of parallel middleware.

BREAKING CHANGE: remove investment stateVersion, queued sync state,
and tbank-sync status progress/resources. Consumers must observe
resources through sync=observe with the updated frontend contract.
2026-10-03 11:38:08 +03:00

285 lines
9.5 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 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);
});
});