moex-vibe/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts
Sergey Krylov a8715258ca
All checks were successful
CI / lint (pull_request) Successful in 2m15s
CI / test (pull_request) Successful in 2m2s
CI / build (pull_request) Successful in 2m7s
CI / lint (push) Successful in 2m2s
CI / test (push) Successful in 2m10s
CI / build (push) Successful in 2m7s
fix: avoid stale tbank grpc deadlines
2026-06-17 12:08:48 +03:00

159 lines
5.3 KiB
TypeScript

import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { TBankClientService } from './tbank-client.service';
describe('TBankClientService', () => {
const unaryCall = {
cancel: vi.fn(),
getPeer: vi.fn(),
getAuthContext: vi.fn(),
} as unknown as ClientUnaryCall;
const config = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.tbank.token': 'token-1',
'app.tbank.appName': 'ksv741.moex-vibe',
'app.tbank.rateLimitPerSecond': 5,
'app.tbank.requestTimeoutMs': 10000,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
it('builds redacted authorization metadata', () => {
const service = new TBankClientService(config);
const metadata = service.createMetadata();
expect(metadata.get('Authorization')).toEqual(['Bearer token-1']);
expect(metadata.get('x-app-name')).toEqual(['ksv741.moex-vibe']);
expect(service.redactMetadata(metadata)).toEqual({
Authorization: '<redacted>',
'x-app-name': 'ksv741.moex-vibe',
});
});
it('creates service clients from vendored proto contracts', () => {
const service = new TBankClientService(config);
expect(() => service.getServiceClient('UsersService')).not.toThrow();
});
it('creates grpc SSL credentials with configured custom CA certificate', () => {
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');
const createSslSpy = vi.spyOn(ChannelCredentials, 'createSsl');
const caConfig = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.tbank.token': 'token-1',
'app.tbank.caCertPath': caPath,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
const service = new TBankClientService(caConfig);
service.getServiceClient('UsersService');
const [rootCerts] = createSslSpy.mock.calls.at(-1) ?? [];
expect(Buffer.isBuffer(rootCerts)).toBe(true);
expect((rootCerts as Buffer).toString('utf8')).toContain('test-ca');
createSslSpy.mockRestore();
});
it('throws integration unavailable when token is missing', async () => {
const missingConfig = {
get: vi.fn((key: string, fallback?: unknown) =>
key === 'app.tbank.token' ? '' : (fallback as unknown),
),
} as unknown as ConfigService;
const service = new TBankClientService(missingConfig);
await expect(
service.callUnary(
'UsersService/GetAccounts',
(_request, _metadata, _options, callback) => {
callback(null, {});
return unaryCall;
},
{},
),
).rejects.toThrow(ServiceUnavailableException);
});
it('wraps grpc errors with status code and tracking id', async () => {
const service = new TBankClientService(config);
const error = Object.assign(new Error('Too many requests'), {
code: status.RESOURCE_EXHAUSTED,
details: 'Too many requests',
metadata: new Metadata(),
}) as ServiceError;
error.metadata.set('x-tracking-id', 'tracking-1');
await expect(
service.callUnary(
'OperationsService/GetPortfolio',
(_request, _metadata, _options, callback) => {
callback(error, null);
return unaryCall;
},
{},
),
).rejects.toMatchObject({
response: expect.objectContaining({
message: expect.stringContaining('T-Bank upstream error'),
}),
});
});
it('starts grpc deadline when queued call actually executes', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-17T07:00:00.000Z'));
try {
const queuedConfig = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.tbank.token': 'token-1',
'app.tbank.rateLimitPerSecond': 1,
'app.tbank.requestTimeoutMs': 1000,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
const service = new TBankClientService(queuedConfig);
const deadlines: number[] = [];
const startedAt: number[] = [];
const method = vi.fn((_request, _metadata, options, callback) => {
deadlines.push((options.deadline as Date).getTime());
startedAt.push(Date.now());
callback(null, {});
return unaryCall;
});
const first = service.callUnary('UsersService/GetAccounts', method, {});
const second = service.callUnary('UsersService/GetAccounts', method, {});
await first;
expect(startedAt).toEqual([Date.parse('2026-06-17T07:00:00.000Z')]);
vi.setSystemTime(new Date('2026-06-17T07:00:01.000Z'));
await vi.advanceTimersByTimeAsync(1000);
await second;
expect(startedAt[1]).toBeGreaterThan(startedAt[0]);
expect(deadlines).toEqual(startedAt.map((started) => started + 1000));
} finally {
vi.useRealTimers();
}
});
});