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 = { '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: '', '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 = { '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 = { '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(); } }); });