import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; import type { ConfigService } from '@nestjs/config'; import type { AuthService } from '../../auth/services/AuthService'; import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus'; import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient'; import { BitcoinWalletAdminService } from './BitcoinWalletAdminService'; describe('BitcoinWalletAdminService', () => { let service: BitcoinWalletAdminService; let walletRpcClient: { getVersion: jest.Mock; isSynchronized: jest.Mock; getInfo: jest.Mock; getBalance: jest.Mock; sweepAll: jest.Mock; getSeed: jest.Mock; }; let authService: { verifyPassword: jest.Mock; }; let configService: { get: jest.Mock; }; beforeEach(() => { walletRpcClient = { getVersion: jest.fn().mockResolvedValue('4.8.1'), isSynchronized: jest.fn().mockResolvedValue(true), getInfo: jest.fn().mockResolvedValue({ blockchain_height: 900_000, server_height: 900_000 }), getBalance: jest.fn().mockResolvedValue({ balanceAtomic: '150000', confirmedBalanceAtomic: '150000' }), sweepAll: jest.fn().mockResolvedValue({ txHash: 'tx-hash-1', amountAtomic: '140000' }), getSeed: jest.fn().mockResolvedValue('seed words') }; authService = { verifyPassword: jest.fn() }; configService = { get: jest.fn().mockReturnValue({ network: 'testnet4' }) }; service = new BitcoinWalletAdminService( walletRpcClient as unknown as ElectrumWalletRpcClient, authService as unknown as AuthService, configService as unknown as ConfigService ); }); it('returns wallet status when RPC calls succeed', async () => { const status = await service.getStatus(); expect(status).toEqual( expect.objectContaining({ network: 'testnet4', rpcVersion: '4.8.1', blockHeight: 900_000, serverHeight: 900_000, syncStatus: WalletSyncStatus.Synced, balanceBtc: '0.00150000', confirmedBalanceBtc: '0.00150000' }) ); }); it('reports syncing when the wallet is not synchronized', async () => { walletRpcClient.isSynchronized.mockResolvedValue(false); const status = await service.getStatus(); expect(status.syncStatus).toBe(WalletSyncStatus.Syncing); }); it('throws when RPC calls fail', async () => { walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down')); await expect(service.getStatus()).rejects.toBeInstanceOf(ServiceUnavailableException); }); it('rejects withdrawals when there is no confirmed balance', async () => { walletRpcClient.getBalance.mockResolvedValue({ balanceAtomic: '0', confirmedBalanceAtomic: '0' }); await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow( new BadRequestException('No confirmed balance to withdraw.') ); expect(authService.verifyPassword).toHaveBeenCalledWith('password'); expect(walletRpcClient.sweepAll).not.toHaveBeenCalled(); }); it('rejects withdrawals while the wallet is still syncing', async () => { walletRpcClient.isSynchronized.mockResolvedValue(false); await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow( new BadRequestException('Wallet is still syncing. Try again after sync completes.') ); }); it('sweeps confirmed funds when the wallet is synced', async () => { const result = await service.withdrawAll('bc1qdestination', 12, 'password'); expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('bc1qdestination', 12); expect(result).toEqual({ txHash: 'tx-hash-1', amountBtc: '0.00140000' }); }); it('reveals the wallet seed after password verification', async () => { await expect(service.revealSeed('password')).resolves.toEqual({ mnemonic: 'seed words' }); expect(authService.verifyPassword).toHaveBeenCalledWith('password'); expect(walletRpcClient.getSeed).toHaveBeenCalled(); }); it('throws when seed reveal fails', async () => { walletRpcClient.getSeed.mockRejectedValue(new Error('rpc down')); await expect(service.revealSeed('password')).rejects.toThrow( new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.') ); }); it('throws when withdrawal fails after prechecks pass', async () => { walletRpcClient.sweepAll.mockRejectedValue(new Error('payto failed')); await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow( new ServiceUnavailableException( 'Withdrawal failed. Funds may be unspendable dust, still unconfirmed, or the wallet may be out of sync. Refresh status and try again.' ) ); }); });