import { BadRequestException, ConflictException, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; jest.mock('../../prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); import type { TbankAccountContext } from './tbank-access.service'; import { TbankAccessService } from './tbank-access.service'; import { TbankTokenService } from './tbank-token.service'; describe('TbankAccessService', () => { const prisma = { tbankConnection: { findUnique: jest.fn() } }; const token = { decrypt: jest.fn(), translateInvalidToken: jest.fn((operation: () => Promise) => new TbankTokenService({ getOrThrow: () => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=', } as never).translateInvalidToken(operation), ), }; const tbank = { getOpenAccounts: jest.fn() }; const service = new TbankAccessService( prisma as never, token as never, tbank as never, ); beforeEach(() => { jest.clearAllMocks(); prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: 'encrypted', }); token.decrypt.mockReturnValue('plain-token'); tbank.getOpenAccounts.mockResolvedValue([ { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: ' Основной ', openedDate: { seconds: '1723507200' }, }, { id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' }, ]); }); it('lists supported accounts through the saved decrypted token', async () => { await expect(service.listSupportedAccounts('user')).resolves.toEqual([ { id: 'account', type: 'BROKERAGE', name: 'Основной', openedOn: '2024-08-13', }, ]); expect(prisma.tbankConnection.findUnique).toHaveBeenCalledWith({ where: { userId: 'user' }, }); expect(token.decrypt).toHaveBeenCalledWith('encrypted'); expect(tbank.getOpenAccounts).toHaveBeenCalledWith('plain-token'); }); it('rejects missing connections before calling T-Bank', async () => { prisma.tbankConnection.findUnique.mockResolvedValue(null); await expect(service.listSupportedAccounts('user')).rejects.toEqual( expect.any(ConflictException), ); expect(tbank.getOpenAccounts).not.toHaveBeenCalled(); }); it('resolves the persisted connection generation without an upstream account call', async () => { const context = await service.withConnection('user', (value) => value); expect(context).toEqual({ token: 'plain-token', generation: expect.any(String) as string, }); expect(tbank.getOpenAccounts).not.toHaveBeenCalled(); }); it('translates invalid tokens and preserves unavailable upstream errors', async () => { tbank.getOpenAccounts.mockRejectedValueOnce( new Error('INVALID_TBANK_TOKEN'), ); await expect(service.listSupportedAccounts('user')).rejects.toEqual( expect.any(BadRequestException), ); const unavailable = new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE', }); tbank.getOpenAccounts.mockRejectedValueOnce(unavailable); await expect(service.listSupportedAccounts('user')).rejects.toBe( unavailable, ); }); it('wraps account-scoped handlers with resolved account access', async () => { const handler = jest.fn((context: TbankAccountContext) => Promise.resolve({ resolvedToken: context.token, generation: context.generation, accountId: context.account.id, }), ); await expect( service.withAccount('user', 'account', handler), ).resolves.toEqual({ resolvedToken: 'plain-token', generation: expect.any(String) as string, accountId: 'account', }); }); it('derives a stable opaque generation from each persisted encrypted payload', async () => { const generation = () => service.withAccount('user', 'account', ({ generation: value }) => Promise.resolve(value), ); prisma.tbankConnection.findUnique .mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' }) .mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' }) .mockResolvedValueOnce({ encryptedToken: 'encrypted:second-nonce' }); const first = await generation(); const sameRecord = await generation(); const replaced = await generation(); expect(sameRecord).toBe(first); expect(replaced).not.toBe(first); expect(first).not.toContain('plain-token'); expect(first).not.toContain('encrypted:first-nonce'); }); it('rejects inaccessible accounts before running the handler', async () => { const handler = jest.fn(); await expect( service.withAccount('user', 'missing', handler), ).rejects.toEqual(expect.any(NotFoundException)); expect(handler).not.toHaveBeenCalled(); }); it('translates invalid-token handler failures and preserves unrelated handler failures', async () => { await expect( service.withAccount('user', 'account', () => Promise.reject(new Error('INVALID_TBANK_TOKEN')), ), ).rejects.toEqual(expect.any(BadRequestException)); const failure = new Error('source failed'); await expect( service.withAccount('user', 'account', () => Promise.reject(failure)), ).rejects.toBe(failure); }); });