import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; import type { ConfigService } from '@nestjs/config'; import axios from 'axios'; import type { AuthService } from '../../auth/services/AuthService'; import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority'; import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus'; import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient'; import { MoneroWalletAdminService } from './MoneroWalletAdminService'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; describe('MoneroWalletAdminService', () => { let service: MoneroWalletAdminService; let walletRpcClient: { tryRefresh: jest.Mock; getVersion: jest.Mock; getHeight: jest.Mock; getBalance: jest.Mock; sweepAll: jest.Mock; queryMnemonic: jest.Mock; }; let authService: { verifyPassword: jest.Mock; }; let configService: { get: jest.Mock; }; beforeEach(() => { walletRpcClient = { tryRefresh: jest.fn().mockResolvedValue(undefined), getVersion: jest.fn().mockResolvedValue('0.18.3.1'), getHeight: jest.fn().mockResolvedValue(3_000_000), getBalance: jest.fn().mockResolvedValue({ balanceAtomic: '2000000000000', unlockedBalanceAtomic: '1000000000000' }), sweepAll: jest.fn().mockResolvedValue({ txHashes: ['tx-hash-1'], amountAtomic: '1000000000000' }), queryMnemonic: jest.fn().mockResolvedValue('seed words') }; authService = { verifyPassword: jest.fn() }; configService = { get: jest.fn().mockReturnValue({ network: 'mainnet', daemonRpcUrl: 'http://daemon.test/json_rpc', rpcTimeoutMs: 5000 }) }; mockedAxios.post.mockResolvedValue({ data: { result: { height: 3_000_000 } } }); service = new MoneroWalletAdminService( walletRpcClient as unknown as MoneroWalletRpcClient, authService as unknown as AuthService, configService as unknown as ConfigService ); }); it('returns wallet status when RPC and daemon calls succeed', async () => { const status = await service.getStatus(); expect(walletRpcClient.tryRefresh).toHaveBeenCalled(); expect(status).toEqual( expect.objectContaining({ network: 'mainnet', rpcVersion: '0.18.3.1', walletHeight: 3_000_000, daemonHeight: 3_000_000, syncStatus: WalletSyncStatus.Synced, balanceXmr: '2.00000000', unlockedBalanceXmr: '1.00000000' }) ); }); it('throws when wallet status cannot be loaded', async () => { walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down')); await expect(service.getStatus()).rejects.toThrow( new ServiceUnavailableException( 'Could not load wallet status. The Monero wallet may be busy or unavailable.' ) ); }); it('rejects withdrawals when there is no unlocked balance', async () => { walletRpcClient.getBalance.mockResolvedValue({ balanceAtomic: '0', unlockedBalanceAtomic: '0' }); await expect( service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password') ).rejects.toThrow(new BadRequestException('No unlocked balance to withdraw.')); expect(authService.verifyPassword).toHaveBeenCalledWith('password'); expect(walletRpcClient.sweepAll).not.toHaveBeenCalled(); }); it('rejects withdrawals while the wallet is still syncing', async () => { walletRpcClient.getHeight.mockResolvedValue(2_999_000); await expect( service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password') ).rejects.toThrow(new BadRequestException('Wallet is still syncing. Try again after sync completes.')); }); it('sweeps unlocked funds when the wallet is synced', async () => { const result = await service.withdrawAll( '4DestinationAddressExample', MoneroWithdrawPriority.Fast, 'password' ); expect(walletRpcClient.sweepAll).toHaveBeenCalledWith( '4DestinationAddressExample', MoneroWithdrawPriority.Fast ); expect(result).toEqual({ txHashes: ['tx-hash-1'], amountXmr: '1.00000000' }); }); 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.queryMnemonic).toHaveBeenCalled(); }); it('reports unknown sync status when the daemon height cannot be fetched', async () => { mockedAxios.post.mockRejectedValue(new Error('daemon down')); const status = await service.getStatus(); expect(status.syncStatus).toBe(WalletSyncStatus.Unknown); expect(status.daemonHeight).toBeNull(); }); it('treats the wallet as synced when it is one block behind the daemon', async () => { walletRpcClient.getHeight.mockResolvedValue(2_999_999); mockedAxios.post.mockResolvedValue({ data: { result: { height: 3_000_000 } } }); const status = await service.getStatus(); expect(status.syncStatus).toBe(WalletSyncStatus.Synced); }); it('throws when reveal seed RPC fails', async () => { walletRpcClient.queryMnemonic.mockRejectedValue(new Error('rpc down')); await expect(service.revealSeed('password')).rejects.toThrow( new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.') ); }); it('throws when sweep all fails after prechecks pass', async () => { walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed')); await expect( service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password') ).rejects.toThrow( new ServiceUnavailableException( 'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.' ) ); }); });