This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
@@ -0,0 +1,172 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import axios from 'axios';
import type { AuthService } from '../../auth/services/AuthService';
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
import { MoneroWalletAdminService } from './MoneroWalletAdminService';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
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: MoneroWalletSyncStatus.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', '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', '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', 'password');
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('4DestinationAddressExample');
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(MoneroWalletSyncStatus.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(MoneroWalletSyncStatus.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', '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.'
)
);
});
});