Files
aitbc/tests/cli/test_wallet.py
oib 65b63de56f docs: update README with comprehensive test results, CLI documentation, and enhanced feature descriptions
- Update key capabilities to include GPU marketplace, payments, billing, and governance
- Expand CLI section from basic examples to 12 command groups with 90+ subcommands
- Add detailed test results table showing 208 passing tests across 6 test suites
- Update documentation links to reference new CLI reference and coordinator API docs
- Revise test commands to reflect actual test structure (
2026-02-12 20:58:21 +01:00

461 lines
17 KiB
Python

"""Tests for wallet CLI commands"""
import pytest
import json
import re
import tempfile
import os
from pathlib import Path
from click.testing import CliRunner
from unittest.mock import Mock, patch
from aitbc_cli.commands.wallet import wallet
def extract_json_from_output(output):
"""Extract JSON from CLI output that may contain Rich panel markup"""
clean = re.sub(r'\x1b\[[0-9;]*m', '', output)
lines = clean.strip().split('\n')
json_lines = []
in_json = False
for line in lines:
stripped = line.strip()
if stripped.startswith('{'):
in_json = True
json_lines.append(stripped)
elif in_json:
json_lines.append(stripped)
if stripped.startswith('}'):
break
return json.loads('\n'.join(json_lines))
@pytest.fixture
def runner():
"""Create CLI runner"""
return CliRunner()
@pytest.fixture
def temp_wallet():
"""Create temporary wallet file"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
wallet_data = {
"address": "aitbc1test",
"balance": 100.0,
"transactions": [
{
"type": "earn",
"amount": 50.0,
"description": "Test job",
"timestamp": "2024-01-01T00:00:00"
}
],
"created_at": "2024-01-01T00:00:00"
}
json.dump(wallet_data, f)
temp_path = f.name
yield temp_path
# Cleanup
os.unlink(temp_path)
@pytest.fixture
def mock_config():
"""Mock configuration"""
config = Mock()
config.coordinator_url = "http://test:8000"
config.api_key = "test_key"
return config
class TestWalletCommands:
"""Test wallet command group"""
def test_balance_command(self, runner, temp_wallet, mock_config):
"""Test wallet balance command"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'balance'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = json.loads(result.output)
assert data['balance'] == 100.0
assert data['address'] == 'aitbc1test'
def test_balance_new_wallet(self, runner, mock_config, tmp_path):
"""Test balance with new wallet (auto-creation)"""
wallet_path = tmp_path / "new_wallet.json"
result = runner.invoke(wallet, [
'--wallet-path', str(wallet_path),
'balance'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
assert wallet_path.exists()
data = json.loads(result.output)
assert data['balance'] == 0.0
assert 'address' in data
def test_earn_command(self, runner, temp_wallet, mock_config):
"""Test earning command"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'earn',
'25.5',
'job_456',
'--desc', 'Another test job'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = extract_json_from_output(result.output)
assert data['new_balance'] == 125.5 # 100 + 25.5
assert data['job_id'] == 'job_456'
# Verify wallet file updated
with open(temp_wallet) as f:
wallet_data = json.load(f)
assert wallet_data['balance'] == 125.5
assert len(wallet_data['transactions']) == 2
def test_spend_command_success(self, runner, temp_wallet, mock_config):
"""Test successful spend command"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'spend',
'30.0',
'GPU rental'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = extract_json_from_output(result.output)
assert data['new_balance'] == 70.0 # 100 - 30
assert data['description'] == 'GPU rental'
def test_spend_insufficient_balance(self, runner, temp_wallet, mock_config):
"""Test spend with insufficient balance"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'spend',
'200.0',
'Too much'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'Insufficient balance' in result.output
def test_history_command(self, runner, temp_wallet, mock_config):
"""Test transaction history"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'history',
'--limit', '5'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = json.loads(result.output)
assert 'transactions' in data
assert len(data['transactions']) == 1
assert data['transactions'][0]['amount'] == 50.0
def test_address_command(self, runner, temp_wallet, mock_config):
"""Test address command"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'address'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = json.loads(result.output)
assert data['address'] == 'aitbc1test'
def test_stats_command(self, runner, temp_wallet, mock_config):
"""Test wallet statistics"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'stats'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = json.loads(result.output)
assert data['current_balance'] == 100.0
assert data['total_earned'] == 50.0
assert data['total_spent'] == 0.0
assert data['jobs_completed'] == 1
assert data['transaction_count'] == 1
@patch('aitbc_cli.commands.wallet.httpx.Client')
def test_send_command_success(self, mock_client_class, runner, temp_wallet, mock_config):
"""Test successful send command"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {"hash": "0xabc123"}
mock_client.post.return_value = mock_response
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'send',
'aitbc1recipient',
'25.0',
'--description', 'Payment'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = extract_json_from_output(result.output)
assert data['new_balance'] == 75.0 # 100 - 25
assert data['tx_hash'] == '0xabc123'
# Verify API call
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
assert '/transactions' in call_args[0][0]
assert call_args[1]['json']['amount'] == 25.0
assert call_args[1]['json']['to'] == 'aitbc1recipient'
def test_request_payment_command(self, runner, temp_wallet, mock_config):
"""Test payment request command"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'request-payment',
'aitbc1payer',
'50.0',
'--description', 'Service payment'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = json.loads(result.output)
assert 'payment_request' in data
assert data['payment_request']['from_address'] == 'aitbc1payer'
assert data['payment_request']['to_address'] == 'aitbc1test'
assert data['payment_request']['amount'] == 50.0
@patch('aitbc_cli.commands.wallet.httpx.Client')
def test_send_insufficient_balance(self, mock_client_class, runner, temp_wallet, mock_config):
"""Test send with insufficient balance"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'send',
'aitbc1recipient',
'200.0'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'Insufficient balance' in result.output
def test_wallet_file_creation(self, runner, mock_config, tmp_path):
"""Test wallet file is created in correct directory"""
wallet_dir = tmp_path / "wallets"
wallet_path = wallet_dir / "test_wallet.json"
result = runner.invoke(wallet, [
'--wallet-path', str(wallet_path),
'balance'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
assert wallet_path.exists()
assert wallet_path.parent.exists()
def test_stake_command(self, runner, temp_wallet, mock_config):
"""Test staking tokens"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'stake',
'50.0',
'--duration', '30'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = extract_json_from_output(result.output)
assert data['amount'] == 50.0
assert data['duration_days'] == 30
assert data['new_balance'] == 50.0 # 100 - 50
assert 'stake_id' in data
assert 'apy' in data
# Verify wallet file updated
with open(temp_wallet) as f:
wallet_data = json.load(f)
assert wallet_data['balance'] == 50.0
assert len(wallet_data['staking']) == 1
assert wallet_data['staking'][0]['status'] == 'active'
def test_stake_insufficient_balance(self, runner, temp_wallet, mock_config):
"""Test staking with insufficient balance"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'stake',
'200.0'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'Insufficient balance' in result.output
def test_unstake_command(self, runner, temp_wallet, mock_config):
"""Test unstaking tokens"""
# First stake
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'stake',
'50.0',
'--duration', '30'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
stake_data = extract_json_from_output(result.output)
stake_id = stake_data['stake_id']
# Then unstake
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'unstake',
stake_id
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = extract_json_from_output(result.output)
assert data['stake_id'] == stake_id
assert data['principal'] == 50.0
assert 'rewards' in data
assert data['total_returned'] >= 50.0
assert data['new_balance'] >= 100.0 # Got back principal + rewards
def test_unstake_invalid_id(self, runner, temp_wallet, mock_config):
"""Test unstaking with invalid stake ID"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'unstake',
'nonexistent_stake'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'not found' in result.output
def test_staking_info_command(self, runner, temp_wallet, mock_config):
"""Test staking info command"""
# Stake first
runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'stake', '30.0', '--duration', '60'
], obj={'config': mock_config, 'output_format': 'json'})
# Check staking info
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'staking-info'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = json.loads(result.output)
assert data['total_staked'] == 30.0
assert data['active_stakes'] == 1
assert len(data['stakes']) == 1
def test_liquidity_stake_command(self, runner, temp_wallet, mock_config):
"""Test liquidity pool staking"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'liquidity-stake', '40.0',
'--pool', 'main',
'--lock-days', '0'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = extract_json_from_output(result.output)
assert data['amount'] == 40.0
assert data['pool'] == 'main'
assert data['tier'] == 'bronze'
assert data['apy'] == 3.0
assert data['new_balance'] == 60.0
assert 'stake_id' in data
def test_liquidity_stake_gold_tier(self, runner, temp_wallet, mock_config):
"""Test liquidity staking with gold tier (30+ day lock)"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'liquidity-stake', '30.0',
'--lock-days', '30'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = extract_json_from_output(result.output)
assert data['tier'] == 'gold'
assert data['apy'] == 8.0
def test_liquidity_stake_insufficient_balance(self, runner, temp_wallet, mock_config):
"""Test liquidity staking with insufficient balance"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'liquidity-stake', '500.0'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'Insufficient balance' in result.output
def test_liquidity_unstake_command(self, runner, temp_wallet, mock_config):
"""Test liquidity pool unstaking with rewards"""
# Stake first (no lock)
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'liquidity-stake', '50.0',
'--pool', 'main',
'--lock-days', '0'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
stake_id = extract_json_from_output(result.output)['stake_id']
# Unstake
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'liquidity-unstake', stake_id
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = extract_json_from_output(result.output)
assert data['stake_id'] == stake_id
assert data['principal'] == 50.0
assert 'rewards' in data
assert data['total_returned'] >= 50.0
def test_liquidity_unstake_invalid_id(self, runner, temp_wallet, mock_config):
"""Test liquidity unstaking with invalid ID"""
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'liquidity-unstake', 'nonexistent'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'not found' in result.output
def test_rewards_command(self, runner, temp_wallet, mock_config):
"""Test rewards summary command"""
# Stake some tokens first
runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'stake', '20.0', '--duration', '30'
], obj={'config': mock_config, 'output_format': 'json'})
runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'liquidity-stake', '20.0', '--pool', 'main'
], obj={'config': mock_config, 'output_format': 'json'})
result = runner.invoke(wallet, [
'--wallet-path', temp_wallet,
'rewards'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = extract_json_from_output(result.output)
assert 'staking_active_amount' in data
assert 'liquidity_active_amount' in data
assert data['staking_active_amount'] == 20.0
assert data['liquidity_active_amount'] == 20.0
assert data['total_staked'] == 40.0