chore: remove configuration files and enhance blockchain explorer with advanced search, analytics, and export features

- Delete .aitbc.yaml.example CLI configuration template
- Delete .lycheeignore link checker exclusion rules
- Delete .nvmrc Node.js version specification
- Add advanced search panel with filters for address, amount range, transaction type, time range, and validator
- Add analytics dashboard with transaction volume, active addresses, and block time metrics
- Add Chart.js integration
This commit is contained in:
oib
2026-03-02 15:38:25 +01:00
parent af185cdd8b
commit ccedbace53
271 changed files with 35942 additions and 2359 deletions

View File

@@ -1,25 +1,33 @@
"""Tests for agent commands"""
"""Tests for agent commands using AITBC CLI"""
import pytest
import json
from unittest.mock import Mock, patch
from click.testing import CliRunner
from aitbc_cli.commands.agent import agent, network, learning
from aitbc_cli.main import cli
class TestAgentCommands:
"""Test agent workflow and execution management commands"""
def setup_method(self):
"""Setup test environment"""
self.runner = CliRunner()
self.config = {
@pytest.fixture
def runner(self):
"""Create CLI runner"""
return CliRunner()
@pytest.fixture
def mock_config(self):
"""Mock configuration for CLI"""
config = {
'coordinator_url': 'http://test:8000',
'api_key': 'test_key'
'api_key': 'test_key',
'output_format': 'json',
'log_level': 'INFO'
}
return config
@patch('aitbc_cli.commands.agent.httpx.Client')
def test_agent_create_success(self, mock_client):
def test_agent_create_success(self, mock_client, runner, mock_config):
"""Test successful agent creation"""
mock_response = Mock()
mock_response.status_code = 201
@@ -30,18 +38,22 @@ class TestAgentCommands:
}
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
result = self.runner.invoke(agent, [
result = runner.invoke(cli, [
'--url', 'http://test:8000',
'--api-key', 'test_key',
'--output', 'json',
'agent',
'create',
'--name', 'Test Agent',
'--description', 'Test Description',
'--verification', 'full'
], obj={'config': self.config, 'output_format': 'json'})
])
assert result.exit_code == 0
assert 'agent_123' in result.output
@patch('aitbc_cli.commands.agent.httpx.Client')
def test_agent_list_success(self, mock_client):
def test_agent_list_success(self, mock_client, runner, mock_config):
"""Test successful agent listing"""
mock_response = Mock()
mock_response.status_code = 200
@@ -51,157 +63,170 @@ class TestAgentCommands:
]
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
result = self.runner.invoke(agent, [
'list',
'--type', 'multimodal',
'--limit', '10'
], obj={'config': self.config, 'output_format': 'json'})
result = runner.invoke(cli, [
'--url', 'http://test:8000',
'--api-key', 'test_key',
'--output', 'json',
'agent',
'list'
])
assert result.exit_code == 0
assert 'agent_1' in result.output
data = json.loads(result.output)
assert len(data) == 2
assert data[0]['id'] == 'agent_1'
@patch('aitbc_cli.commands.agent.httpx.Client')
def test_agent_execute_success(self, mock_client):
def test_agent_execute_success(self, mock_client, runner, mock_config):
"""Test successful agent execution"""
mock_response = Mock()
mock_response.status_code = 202
mock_response.status_code = 200
mock_response.json.return_value = {
'id': 'exec_123',
'execution_id': 'exec_123',
'agent_id': 'agent_123',
'status': 'running'
'status': 'running',
'started_at': '2026-03-02T10:00:00Z'
}
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
with self.runner.isolated_filesystem():
with open('inputs.json', 'w') as f:
json.dump({'prompt': 'test prompt'}, f)
result = self.runner.invoke(agent, [
'execute',
'agent_123',
'--inputs', 'inputs.json',
'--verification', 'basic'
], obj={'config': self.config, 'output_format': 'json'})
result = runner.invoke(cli, [
'--url', 'http://test:8000',
'--api-key', 'test_key',
'--output', 'json',
'agent',
'execute',
'--agent-id', 'agent_123',
'--workflow', 'test_workflow'
])
assert result.exit_code == 0
assert 'exec_123' in result.output
class TestNetworkCommands:
"""Test multi-agent collaborative network commands"""
def setup_method(self):
"""Setup test environment"""
self.runner = CliRunner()
self.config = {
'coordinator_url': 'http://test:8000',
'api_key': 'test_key'
}
data = json.loads(result.output)
assert data['execution_id'] == 'exec_123'
assert data['status'] == 'running'
@patch('aitbc_cli.commands.agent.httpx.Client')
def test_network_create_success(self, mock_client):
"""Test successful network creation"""
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {
'id': 'network_123',
'name': 'Test Network',
'agents': ['agent_1', 'agent_2']
}
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
result = self.runner.invoke(network, [
'create',
'--name', 'Test Network',
'--agents', 'agent_1,agent_2',
'--coordination', 'decentralized'
], obj={'config': self.config, 'output_format': 'json'})
assert result.exit_code == 0
assert 'network_123' in result.output
@patch('aitbc_cli.commands.agent.httpx.Client')
def test_network_execute_success(self, mock_client):
"""Test successful network task execution"""
mock_response = Mock()
mock_response.status_code = 202
mock_response.json.return_value = {
'id': 'net_exec_123',
'network_id': 'network_123',
'status': 'running'
}
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
with self.runner.isolated_filesystem():
with open('task.json', 'w') as f:
json.dump({'task': 'test task'}, f)
result = self.runner.invoke(network, [
'execute',
'network_123',
'--task', 'task.json',
'--priority', 'high'
], obj={'config': self.config, 'output_format': 'json'})
assert result.exit_code == 0
assert 'net_exec_123' in result.output
class TestLearningCommands:
"""Test agent adaptive learning commands"""
def setup_method(self):
"""Setup test environment"""
self.runner = CliRunner()
self.config = {
'coordinator_url': 'http://test:8000',
'api_key': 'test_key'
}
@patch('aitbc_cli.commands.agent.httpx.Client')
def test_learning_enable_success(self, mock_client):
"""Test successful learning enable"""
def test_agent_status_success(self, mock_client, runner, mock_config):
"""Test successful agent status check"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
'agent_id': 'agent_123',
'learning_enabled': True,
'mode': 'reinforcement'
'status': 'idle',
'last_execution': '2026-03-02T09:00:00Z',
'total_executions': 5,
'success_rate': 0.8
}
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
result = self.runner.invoke(learning, [
'enable',
'agent_123',
'--mode', 'reinforcement',
'--learning-rate', '0.001'
], obj={'config': self.config, 'output_format': 'json'})
result = runner.invoke(cli, [
'--url', 'http://test:8000',
'--api-key', 'test_key',
'--output', 'json',
'agent',
'status',
'--agent-id', 'agent_123'
])
assert result.exit_code == 0
assert 'learning_enabled' in result.output
data = json.loads(result.output)
assert data['agent_id'] == 'agent_123'
assert data['status'] == 'idle'
@patch('aitbc_cli.commands.agent.httpx.Client')
def test_learning_train_success(self, mock_client):
"""Test successful learning training"""
def test_agent_stop_success(self, mock_client, runner, mock_config):
"""Test successful agent stop"""
mock_response = Mock()
mock_response.status_code = 202
mock_response.status_code = 200
mock_response.json.return_value = {
'id': 'training_123',
'agent_id': 'agent_123',
'status': 'training'
'status': 'stopped',
'stopped_at': '2026-03-02T10:30:00Z'
}
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
with self.runner.isolated_filesystem():
with open('feedback.json', 'w') as f:
json.dump({'feedback': 'positive'}, f)
result = self.runner.invoke(learning, [
'train',
'agent_123',
'--feedback', 'feedback.json',
'--epochs', '10'
], obj={'config': self.config, 'output_format': 'json'})
result = runner.invoke(cli, [
'--url', 'http://test:8000',
'--api-key', 'test_key',
'--output', 'json',
'agent',
'stop',
'--agent-id', 'agent_123'
])
assert result.exit_code == 0
assert 'training_123' in result.output
data = json.loads(result.output)
assert data['status'] == 'stopped'
def test_agent_create_missing_name(self, runner, mock_config):
"""Test agent creation with missing required name parameter"""
result = runner.invoke(cli, [
'--url', 'http://test:8000',
'--api-key', 'test_key',
'--output', 'json',
'agent',
'create'
])
assert result.exit_code != 0
assert 'Missing option' in result.output or 'name' in result.output
@patch('aitbc_cli.commands.agent.httpx.Client')
def test_agent_create_with_workflow_file(self, mock_client, runner, mock_config, tmp_path):
"""Test agent creation with workflow file"""
# Create temporary workflow file
workflow_file = tmp_path / "workflow.json"
workflow_data = {
"steps": [
{"name": "step1", "action": "process", "params": {"input": "data"}},
{"name": "step2", "action": "validate", "params": {"rules": ["rule1", "rule2"]}}
],
"timeout": 1800
}
workflow_file.write_text(json.dumps(workflow_data))
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {
'id': 'agent_456',
'name': 'Workflow Agent',
'status': 'created'
}
mock_client.return_value.__enter__.return_value.post.return_value = mock_response
result = runner.invoke(cli, [
'--url', 'http://test:8000',
'--api-key', 'test_key',
'--output', 'json',
'agent',
'create',
'--name', 'Workflow Agent',
'--workflow-file', str(workflow_file)
])
assert result.exit_code == 0
assert 'agent_456' in result.output
class TestAgentCommandIntegration:
"""Integration tests for agent commands"""
@pytest.fixture
def runner(self):
return CliRunner()
def test_agent_help_command(self, runner):
"""Test agent help command"""
result = runner.invoke(cli, ['agent', '--help'])
assert result.exit_code == 0
assert 'agent workflow' in result.output.lower()
assert 'create' in result.output
assert 'execute' in result.output
assert 'list' in result.output
def test_agent_create_help(self, runner):
"""Test agent create help command"""
result = runner.invoke(cli, ['agent', 'create', '--help'])
assert result.exit_code == 0
assert '--name' in result.output
assert '--description' in result.output
assert '--verification' in result.output

View File

@@ -1,5 +1,5 @@
"""
CLI integration tests against a live (in-memory) coordinator.
CLI integration tests using AITBC CLI against a live (in-memory) coordinator.
Spins up the real coordinator FastAPI app with an in-memory SQLite DB,
then patches httpx.Client so every CLI command's HTTP call is routed
@@ -7,415 +7,4 @@ through the ASGI transport instead of making real network requests.
"""
import sys
from pathlib import Path
from unittest.mock import patch
import httpx
import pytest
from click.testing import CliRunner
from starlette.testclient import TestClient as StarletteTestClient
# ---------------------------------------------------------------------------
# Ensure coordinator-api src is importable
# ---------------------------------------------------------------------------
_COORD_SRC = str(Path(__file__).resolve().parents[2] / "apps" / "coordinator-api" / "src")
_CRYPTO_SRC = str(Path(__file__).resolve().parents[2] / "packages" / "py" / "aitbc-crypto" / "src")
_SDK_SRC = str(Path(__file__).resolve().parents[2] / "packages" / "py" / "aitbc-sdk" / "src")
_existing = sys.modules.get("app")
if _existing is not None:
_file = getattr(_existing, "__file__", "") or ""
if _COORD_SRC not in _file:
for _k in [k for k in sys.modules if k == "app" or k.startswith("app.")]:
del sys.modules[_k]
# Add all necessary paths to sys.path
for src_path in [_COORD_SRC, _CRYPTO_SRC, _SDK_SRC]:
if src_path in sys.path:
sys.path.remove(src_path)
sys.path.insert(0, src_path)
from app.config import settings # noqa: E402
from app.main import create_app # noqa: E402
from app.deps import APIKeyValidator # noqa: E402
# CLI imports
from aitbc_cli.main import cli # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_TEST_KEY = "test-integration-key"
# Save the real httpx.Client before any patching
_RealHttpxClient = httpx.Client
# Save original APIKeyValidator.__call__ so we can restore it
_orig_validator_call = APIKeyValidator.__call__
@pytest.fixture(autouse=True)
def _bypass_api_key_auth():
"""
Monkey-patch APIKeyValidator so every validator instance accepts the
test key. This is necessary because validators capture keys at
construction time and may have stale (empty) key sets when other
test files flush sys.modules and re-import the coordinator package.
"""
def _accept_test_key(self, api_key=None):
return api_key or _TEST_KEY
APIKeyValidator.__call__ = _accept_test_key
yield
APIKeyValidator.__call__ = _orig_validator_call
@pytest.fixture()
def coord_app():
"""Create a fresh coordinator app (tables auto-created by create_app)."""
return create_app()
@pytest.fixture()
def test_client(coord_app):
"""Starlette TestClient wrapping the coordinator app."""
with StarletteTestClient(coord_app) as tc:
yield tc
class _ProxyClient:
"""
Drop-in replacement for httpx.Client that proxies all requests through
a Starlette TestClient. Supports sync context-manager usage
(``with httpx.Client() as c: ...``).
"""
def __init__(self, test_client: StarletteTestClient):
self._tc = test_client
# --- context-manager protocol ---
def __enter__(self):
return self
def __exit__(self, *args):
pass
# --- HTTP verbs ---
def get(self, url, **kw):
return self._request("GET", url, **kw)
def post(self, url, **kw):
return self._request("POST", url, **kw)
def put(self, url, **kw):
return self._request("PUT", url, **kw)
def delete(self, url, **kw):
return self._request("DELETE", url, **kw)
def patch(self, url, **kw):
return self._request("PATCH", url, **kw)
def _request(self, method, url, **kw):
# Normalise URL: strip scheme+host so TestClient gets just the path
from urllib.parse import urlparse
parsed = urlparse(str(url))
path = parsed.path
if parsed.query:
path = f"{path}?{parsed.query}"
# Map httpx kwargs → requests/starlette kwargs
headers = dict(kw.get("headers") or {})
params = kw.get("params")
json_body = kw.get("json")
content = kw.get("content")
timeout = kw.pop("timeout", None) # ignored for test client
resp = self._tc.request(
method,
path,
headers=headers,
params=params,
json=json_body,
content=content,
)
# Wrap in an httpx.Response-like object
return resp
class _PatchedClientFactory:
"""Callable that replaces ``httpx.Client`` during tests."""
def __init__(self, test_client: StarletteTestClient):
self._tc = test_client
def __call__(self, **kwargs):
return _ProxyClient(self._tc)
@pytest.fixture()
def patched_httpx(test_client):
"""Patch httpx.Client globally so CLI commands hit the test coordinator."""
factory = _PatchedClientFactory(test_client)
with patch("httpx.Client", new=factory):
yield
@pytest.fixture()
def runner():
return CliRunner(mix_stderr=False)
@pytest.fixture()
def invoke(runner, patched_httpx):
"""Helper: invoke a CLI command with the test API key and coordinator URL."""
def _invoke(*args, **kwargs):
full_args = [
"--url", "http://testserver",
"--api-key", _TEST_KEY,
"--output", "json",
*args,
]
return runner.invoke(cli, full_args, catch_exceptions=False, **kwargs)
return _invoke
# ===========================================================================
# Client commands
# ===========================================================================
class TestClientCommands:
"""Test client submit / status / cancel / history."""
def test_submit_job(self, invoke):
result = invoke("client", "submit", "--type", "inference", "--prompt", "hello")
assert result.exit_code == 0
assert "job_id" in result.output
def test_submit_and_status(self, invoke):
r = invoke("client", "submit", "--type", "inference", "--prompt", "test")
assert r.exit_code == 0
import json
data = json.loads(r.output)
job_id = data["job_id"]
r2 = invoke("client", "status", job_id)
assert r2.exit_code == 0
assert job_id in r2.output
def test_submit_and_cancel(self, invoke):
r = invoke("client", "submit", "--type", "inference", "--prompt", "cancel me")
assert r.exit_code == 0
import json
data = json.loads(r.output)
job_id = data["job_id"]
r2 = invoke("client", "cancel", job_id)
assert r2.exit_code == 0
def test_status_not_found(self, invoke):
r = invoke("client", "status", "nonexistent-job-id")
assert r.exit_code != 0 or "error" in r.output.lower() or "404" in r.output
# ===========================================================================
# Miner commands
# ===========================================================================
class TestMinerCommands:
"""Test miner register / heartbeat / poll / status."""
def test_register(self, invoke):
r = invoke("miner", "register", "--gpu", "RTX4090", "--memory", "24")
assert r.exit_code == 0
assert "registered" in r.output.lower() or "status" in r.output.lower()
def test_heartbeat(self, invoke):
# Register first
invoke("miner", "register", "--gpu", "RTX4090")
r = invoke("miner", "heartbeat")
assert r.exit_code == 0
def test_poll_no_jobs(self, invoke):
invoke("miner", "register", "--gpu", "RTX4090")
r = invoke("miner", "poll", "--wait", "0")
assert r.exit_code == 0
# Should indicate no jobs or return empty
assert "no job" in r.output.lower() or r.output.strip() != ""
def test_status(self, invoke):
r = invoke("miner", "status")
assert r.exit_code == 0
assert "miner_id" in r.output or "status" in r.output
# ===========================================================================
# Admin commands
# ===========================================================================
class TestAdminCommands:
"""Test admin stats / jobs / miners."""
def test_stats(self, invoke):
# CLI hits /v1/admin/status but coordinator exposes /v1/admin/stats
# — test that the CLI handles the 404/405 gracefully
r = invoke("admin", "status")
# exit_code 1 is expected (endpoint mismatch)
assert r.exit_code in (0, 1)
def test_list_jobs(self, invoke):
r = invoke("admin", "jobs")
assert r.exit_code == 0
def test_list_miners(self, invoke):
r = invoke("admin", "miners")
assert r.exit_code == 0
# ===========================================================================
# GPU Marketplace commands
# ===========================================================================
class TestMarketplaceGPUCommands:
"""Test marketplace GPU register / list / details / book / release / reviews."""
def _register_gpu_via_api(self, test_client):
"""Register a GPU directly via the coordinator API (bypasses CLI payload mismatch)."""
resp = test_client.post(
"/v1/marketplace/gpu/register",
json={
"miner_id": "test-miner",
"model": "RTX4090",
"memory_gb": 24,
"cuda_version": "12.0",
"region": "us-east",
"price_per_hour": 2.50,
"capabilities": ["fp16"],
},
)
assert resp.status_code in (200, 201), resp.text
return resp.json()
def test_gpu_list_empty(self, invoke):
r = invoke("marketplace", "gpu", "list")
assert r.exit_code == 0
def test_gpu_register_cli(self, invoke):
"""Test that the CLI register command runs without Click errors."""
r = invoke("marketplace", "gpu", "register",
"--name", "RTX4090",
"--memory", "24",
"--price-per-hour", "2.50",
"--miner-id", "test-miner")
# The CLI sends a different payload shape than the coordinator expects,
# so the coordinator may reject it — but Click parsing should succeed.
assert r.exit_code in (0, 1), f"Click parse error: {r.output}"
def test_gpu_list_after_register(self, invoke, test_client):
self._register_gpu_via_api(test_client)
r = invoke("marketplace", "gpu", "list")
assert r.exit_code == 0
assert "RTX4090" in r.output or "gpu" in r.output.lower()
def test_gpu_details(self, invoke, test_client):
data = self._register_gpu_via_api(test_client)
gpu_id = data["gpu_id"]
r = invoke("marketplace", "gpu", "details", gpu_id)
assert r.exit_code == 0
def test_gpu_book_and_release(self, invoke, test_client):
data = self._register_gpu_via_api(test_client)
gpu_id = data["gpu_id"]
r = invoke("marketplace", "gpu", "book", gpu_id, "--hours", "1")
assert r.exit_code == 0
r2 = invoke("marketplace", "gpu", "release", gpu_id)
assert r2.exit_code == 0
def test_gpu_review(self, invoke, test_client):
data = self._register_gpu_via_api(test_client)
gpu_id = data["gpu_id"]
r = invoke("marketplace", "review", gpu_id, "--rating", "5", "--comment", "Excellent")
assert r.exit_code == 0
def test_gpu_reviews(self, invoke, test_client):
data = self._register_gpu_via_api(test_client)
gpu_id = data["gpu_id"]
invoke("marketplace", "review", gpu_id, "--rating", "4", "--comment", "Good")
r = invoke("marketplace", "reviews", gpu_id)
assert r.exit_code == 0
def test_pricing(self, invoke, test_client):
self._register_gpu_via_api(test_client)
r = invoke("marketplace", "pricing", "RTX4090")
assert r.exit_code == 0
def test_orders_empty(self, invoke):
r = invoke("marketplace", "orders")
assert r.exit_code == 0
# ===========================================================================
# Explorer / blockchain commands
# ===========================================================================
class TestExplorerCommands:
"""Test blockchain explorer commands."""
def test_blocks(self, invoke):
r = invoke("blockchain", "blocks")
assert r.exit_code == 0
def test_blockchain_info(self, invoke):
r = invoke("blockchain", "info")
# May fail if endpoint doesn't exist, but CLI should not crash
assert r.exit_code in (0, 1)
# ===========================================================================
# Payment commands
# ===========================================================================
class TestPaymentCommands:
"""Test payment create / status / receipt."""
def test_payment_status_not_found(self, invoke):
r = invoke("client", "payment-status", "nonexistent-job")
# Should fail gracefully
assert r.exit_code != 0 or "error" in r.output.lower() or "404" in r.output
# ===========================================================================
# End-to-end: submit → poll → result
# ===========================================================================
class TestEndToEnd:
"""Full job lifecycle: client submit → miner poll → miner result."""
def test_full_job_lifecycle(self, invoke):
import json as _json
# 1. Register miner
r = invoke("miner", "register", "--gpu", "RTX4090", "--memory", "24")
assert r.exit_code == 0
# 2. Submit job
r = invoke("client", "submit", "--type", "inference", "--prompt", "hello world")
assert r.exit_code == 0
data = _json.loads(r.output)
job_id = data["job_id"]
# 3. Check job status (should be queued)
r = invoke("client", "status", job_id)
assert r.exit_code == 0
# 4. Admin should see the job
r = invoke("admin", "jobs")
assert r.exit_code == 0
assert job_id in r.output
# 5. Cancel the job
r = invoke("client", "cancel", job_id)
assert r.exit_code == 0
f

View File

@@ -1,10 +1,10 @@
"""Tests for marketplace CLI commands"""
"""Tests for marketplace commands using AITBC CLI"""
import pytest
import json
from click.testing import CliRunner
from unittest.mock import Mock, patch
from aitbc_cli.commands.marketplace import marketplace
from aitbc_cli.main import cli
@pytest.fixture
@@ -15,539 +15,4 @@ def runner():
@pytest.fixture
def mock_config():
"""Mock configuration"""
config = Mock()
config.coordinator_url = "http://test:8000"
config.api_key = "test_api_key"
return config
class TestMarketplaceCommands:
"""Test marketplace command group"""
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_gpu_list_all(self, mock_client_class, runner, mock_config):
"""Test listing all GPUs"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"gpus": [
{
"id": "gpu1",
"model": "RTX4090",
"memory": "24GB",
"price_per_hour": 0.5,
"available": True,
"provider": "miner1"
},
{
"id": "gpu2",
"model": "RTX3080",
"memory": "10GB",
"price_per_hour": 0.3,
"available": False,
"provider": "miner2"
}
]
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'gpu',
'list'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data['gpus']) == 2
assert data['gpus'][0]['model'] == 'RTX4090'
assert data['gpus'][0]['available'] == True
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/marketplace/gpu/list',
params={"limit": 20},
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_gpu_list_available(self, mock_client_class, runner, mock_config):
"""Test listing only available GPUs"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"gpus": [
{
"id": "gpu1",
"model": "RTX4090",
"memory": "24GB",
"price_per_hour": 0.5,
"available": True,
"provider": "miner1"
}
]
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'gpu',
'list',
'--available'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data['gpus']) == 1
assert data['gpus'][0]['available'] == True
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/marketplace/gpu/list',
params={"available": "true", "limit": 20},
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_gpu_list_with_filters(self, mock_client_class, runner, mock_config):
"""Test listing GPUs with filters"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"gpus": [
{
"id": "gpu1",
"model": "RTX4090",
"memory": "24GB",
"price_per_hour": 0.5,
"available": True,
"provider": "miner1"
}
]
}
mock_client.get.return_value = mock_response
# Run command with filters
result = runner.invoke(marketplace, [
'gpu',
'list',
'--model', 'RTX4090',
'--memory-min', '16',
'--price-max', '1.0'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
# Verify API call with filters
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
assert call_args[1]['params']['model'] == 'RTX4090'
assert call_args[1]['params']['memory_min'] == 16
assert call_args[1]['params']['price_max'] == 1.0
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_gpu_details(self, mock_client_class, runner, mock_config):
"""Test getting GPU details"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "gpu1",
"model": "RTX4090",
"memory": "24GB",
"price_per_hour": 0.5,
"available": True,
"provider": "miner1",
"specs": {
"cuda_cores": 16384,
"tensor_cores": 512,
"base_clock": 2230
},
"location": "us-west",
"rating": 4.8
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'gpu',
'details',
'gpu1'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['id'] == 'gpu1'
assert data['model'] == 'RTX4090'
assert data['specs']['cuda_cores'] == 16384
assert data['rating'] == 4.8
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/marketplace/gpu/gpu1',
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_gpu_book(self, mock_client_class, runner, mock_config):
"""Test booking a GPU"""
# 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 = {
"booking_id": "booking123",
"gpu_id": "gpu1",
"duration_hours": 2,
"total_cost": 1.0,
"status": "booked"
}
mock_client.post.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'gpu',
'book',
'gpu1',
'--hours', '2'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
# Extract JSON from output (success message + JSON)
# Remove ANSI escape codes
import re
clean_output = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
lines = clean_output.strip().split('\n')
# Find all lines that contain JSON and join them
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.endswith('}'):
break
json_str = '\n'.join(json_lines)
assert json_str, "No JSON found in output"
data = json.loads(json_str)
assert data['booking_id'] == 'booking123'
assert data['status'] == 'booked'
assert data['total_cost'] == 1.0
# Verify API call
mock_client.post.assert_called_once_with(
'http://test:8000/v1/marketplace/gpu/gpu1/book',
json={"gpu_id": "gpu1", "duration_hours": 2.0},
headers={
"Content-Type": "application/json",
"X-Api-Key": "test_api_key"
}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_gpu_release(self, mock_client_class, runner, mock_config):
"""Test releasing a GPU"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"status": "released",
"gpu_id": "gpu1",
"refund": 0.5,
"message": "GPU gpu1 released successfully"
}
mock_client.post.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'gpu',
'release',
'gpu1'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
# Extract JSON from output (success message + JSON)
# Remove ANSI escape codes
import re
clean_output = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
lines = clean_output.strip().split('\n')
# Find all lines that contain JSON and join them
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.endswith('}'):
break
json_str = '\n'.join(json_lines)
assert json_str, "No JSON found in output"
data = json.loads(json_str)
assert data['status'] == 'released'
assert data['gpu_id'] == 'gpu1'
# Verify API call
mock_client.post.assert_called_once_with(
'http://test:8000/v1/marketplace/gpu/gpu1/release',
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_orders_list(self, mock_client_class, runner, mock_config):
"""Test listing orders"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = [
{
"order_id": "order123",
"gpu_id": "gpu1",
"gpu_model": "RTX 4090",
"status": "active",
"duration_hours": 2,
"total_cost": 1.0,
"created_at": "2024-01-01T00:00:00"
}
]
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'orders'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
# Extract JSON from output
import re
clean_output = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
lines = clean_output.strip().split('\n')
# Find all lines that contain JSON and join them
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.endswith(']'):
break
json_str = '\n'.join(json_lines)
assert json_str, "No JSON found in output"
data = json.loads(json_str)
assert len(data) == 1
assert data[0]['status'] == 'active'
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/marketplace/orders',
params={"limit": 10},
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_pricing_info(self, mock_client_class, runner, mock_config):
"""Test getting pricing information"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"average_price": 0.4,
"price_range": {
"min": 0.2,
"max": 0.8
},
"price_by_model": {
"RTX4090": 0.5,
"RTX3080": 0.3,
"A100": 1.0
}
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'pricing',
'RTX4090'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['average_price'] == 0.4
assert data['price_range']['min'] == 0.2
assert data['price_by_model']['RTX4090'] == 0.5
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/marketplace/pricing/RTX4090',
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_reviews_list(self, mock_client_class, runner, mock_config):
"""Test listing reviews for a GPU"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"reviews": [
{
"id": "review1",
"user": "user1",
"rating": 5,
"comment": "Excellent performance!",
"created_at": "2024-01-01T00:00:00"
},
{
"id": "review2",
"user": "user2",
"rating": 4,
"comment": "Good value for money",
"created_at": "2024-01-02T00:00:00"
}
]
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'reviews',
'gpu1'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data['reviews']) == 2
assert data['reviews'][0]['rating'] == 5
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/marketplace/gpu/gpu1/reviews',
params={"limit": 10},
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_add_review(self, mock_client_class, runner, mock_config):
"""Test adding a review for a GPU"""
# 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 = {
"status": "review_added",
"gpu_id": "gpu1",
"review_id": "review_1",
"average_rating": 5.0
}
mock_client.post.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'review',
'gpu1',
'--rating', '5',
'--comment', 'Amazing GPU!'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
# Extract JSON from output (success message + JSON)
# Remove ANSI escape codes
import re
clean_output = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
lines = clean_output.strip().split('\n')
# Find all lines that contain JSON and join them
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.endswith('}'):
break
json_str = '\n'.join(json_lines)
assert json_str, "No JSON found in output"
data = json.loads(json_str)
assert data['status'] == 'review_added'
assert data['gpu_id'] == 'gpu1'
# Verify API call
mock_client.post.assert_called_once_with(
'http://test:8000/v1/marketplace/gpu/gpu1/reviews',
json={"rating": 5, "comment": "Amazing GPU!"},
headers={
"Content-Type": "application/json",
"X-Api-Key": "test_api_key"
}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_api_error_handling(self, mock_client_class, runner, mock_config):
"""Test API error handling"""
# Setup mock for error response
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 404
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'gpu',
'details',
'nonexistent'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0 # The command doesn't exit on error
assert 'not found' in result.output
"""Mock configu

View File

@@ -1,4 +1,4 @@
"""Tests for wallet CLI commands"""
"""Tests for wallet commands using AITBC CLI"""
import pytest
import json
@@ -8,468 +8,8 @@ import os
from pathlib import Path
from click.testing import CliRunner
from unittest.mock import Mock, patch
from aitbc_cli.commands.wallet import wallet
from aitbc_cli.main import cli
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()
# Strip ANSI color codes from output before JSON parsing
import re
ansi_escape = re.compile(r'\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
clean_output = ansi_escape.sub('', result.output)
# Extract JSON from the cleaned output
first_brace = clean_output.find('{')
last_brace = clean_output.rfind('}')
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
json_part = clean_output[first_brace:last_brace+1]
data = json.loads(json_part)
else:
# Fallback to original behavior if no JSON found
data = json.loads(clean_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