feat: add GPU-specific fields to marketplace offers and create dedicated GPU marketplace router

- Add GPU fields (model, memory, count, CUDA version, price, region) to MarketplaceOffer model
- Create new marketplace_gpu router for GPU-specific operations
- Update offer sync to populate GPU fields from miner capabilities
- Move GPU attributes from generic attributes dict to dedicated fields
- Update MarketplaceOfferView schema with GPU fields
- Expand CLI README with comprehensive documentation and
This commit is contained in:
oib
2026-02-12 19:08:17 +01:00
parent 76a2fc9b6d
commit 5120861e17
57 changed files with 11720 additions and 131 deletions

392
tests/cli/test_admin.py Normal file
View File

@@ -0,0 +1,392 @@
"""Tests for admin CLI commands"""
import pytest
import json
from click.testing import CliRunner
from unittest.mock import Mock, patch
from aitbc_cli.commands.admin import admin
@pytest.fixture
def runner():
"""Create CLI runner"""
return CliRunner()
@pytest.fixture
def mock_config():
"""Mock configuration"""
config = Mock()
config.coordinator_url = "http://test:8000"
config.api_key = "test_admin_key"
return config
class TestAdminCommands:
"""Test admin command group"""
@patch('aitbc_cli.commands.admin.httpx.Client')
def test_status_success(self, mock_client_class, runner, mock_config):
"""Test successful system status check"""
# 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": "healthy",
"version": "1.0.0",
"uptime": 3600
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(admin, [
'status'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['status'] == 'healthy'
assert data['version'] == '1.0.0'
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/admin/status',
headers={"X-Api-Key": "test_admin_key"}
)
@patch('aitbc_cli.commands.admin.httpx.Client')
def test_jobs_with_filter(self, mock_client_class, runner, mock_config):
"""Test jobs listing 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 = {
"jobs": [
{"id": "job1", "status": "completed"},
{"id": "job2", "status": "running"}
]
}
mock_client.get.return_value = mock_response
# Run command with filters
result = runner.invoke(admin, [
'jobs',
'--status', 'running',
'--limit', '50'
], 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 '/v1/admin/jobs' in call_args[0][0]
assert call_args[1]['params']['status'] == 'running'
assert call_args[1]['params']['limit'] == 50
@patch('aitbc_cli.commands.admin.httpx.Client')
def test_job_details_success(self, mock_client_class, runner, mock_config):
"""Test successful job details retrieval"""
# 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": "job123",
"status": "completed",
"result": "Test result",
"created_at": "2024-01-01T00:00:00"
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(admin, [
'job-details',
'job123'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['id'] == 'job123'
assert data['status'] == 'completed'
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/admin/jobs/job123',
headers={"X-Api-Key": "test_admin_key"}
)
@patch('aitbc_cli.commands.admin.httpx.Client')
def test_delete_job_confirmed(self, mock_client_class, runner, mock_config):
"""Test successful job deletion with confirmation"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_client.delete.return_value = mock_response
# Run command with confirmation
result = runner.invoke(admin, [
'delete-job',
'job123'
], obj={'config': mock_config, 'output_format': 'json'}, input='y\n')
# Assertions
assert result.exit_code == 0
assert 'deleted' in result.output
# Verify API call
mock_client.delete.assert_called_once_with(
'http://test:8000/v1/admin/jobs/job123',
headers={"X-Api-Key": "test_admin_key"}
)
def test_delete_job_cancelled(self, runner, mock_config):
"""Test job deletion cancelled by user"""
# Run command with cancellation
result = runner.invoke(admin, [
'delete-job',
'job123'
], obj={'config': mock_config, 'output_format': 'json'}, input='n\n')
# Assertions
assert result.exit_code == 0
# No API calls should be made
@patch('aitbc_cli.commands.admin.httpx.Client')
def test_miners_list(self, mock_client_class, runner, mock_config):
"""Test miners listing"""
# 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 = {
"miners": [
{"id": "miner1", "status": "active", "gpu": "RTX4090"},
{"id": "miner2", "status": "inactive", "gpu": "RTX3080"}
]
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(admin, [
'miners'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data['miners']) == 2
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/admin/miners',
params={"limit": 50},
headers={"X-Api-Key": "test_admin_key"}
)
@patch('aitbc_cli.commands.admin.httpx.Client')
def test_deactivate_miner(self, mock_client_class, runner, mock_config):
"""Test miner deactivation"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_client.post.return_value = mock_response
# Run command with confirmation
result = runner.invoke(admin, [
'deactivate-miner',
'miner123'
], obj={'config': mock_config, 'output_format': 'json'}, input='y\n')
# Assertions
assert result.exit_code == 0
assert 'deactivated' in result.output
# Verify API call
mock_client.post.assert_called_once_with(
'http://test:8000/v1/admin/miners/miner123/deactivate',
headers={"X-Api-Key": "test_admin_key"}
)
@patch('aitbc_cli.commands.admin.httpx.Client')
def test_analytics(self, mock_client_class, runner, mock_config):
"""Test system analytics"""
# 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 = {
"total_jobs": 1000,
"completed_jobs": 950,
"active_miners": 50,
"average_processing_time": 120
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(admin, [
'analytics',
'--days', '7'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['total_jobs'] == 1000
assert data['active_miners'] == 50
# Verify API call
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
assert '/v1/admin/analytics' in call_args[0][0]
assert call_args[1]['params']['days'] == 7
@patch('aitbc_cli.commands.admin.httpx.Client')
def test_logs_with_level(self, mock_client_class, runner, mock_config):
"""Test system logs with level filter"""
# 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 = {
"logs": [
{"level": "ERROR", "message": "Test error", "timestamp": "2024-01-01T00:00:00"}
]
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(admin, [
'logs',
'--level', 'ERROR',
'--limit', '50'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
# Verify API call
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
assert '/v1/admin/logs' in call_args[0][0]
assert call_args[1]['params']['level'] == 'ERROR'
assert call_args[1]['params']['limit'] == 50
@patch('aitbc_cli.commands.admin.httpx.Client')
def test_prioritize_job(self, mock_client_class, runner, mock_config):
"""Test job prioritization"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_client.post.return_value = mock_response
# Run command
result = runner.invoke(admin, [
'prioritize-job',
'job123',
'--reason', 'Urgent request'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'prioritized' in result.output
# Verify API call
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
assert '/v1/admin/jobs/job123/prioritize' in call_args[0][0]
assert call_args[1]['json']['reason'] == 'Urgent request'
@patch('aitbc_cli.commands.admin.httpx.Client')
def test_execute_custom_action(self, mock_client_class, runner, mock_config):
"""Test custom action execution"""
# 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": "success", "result": "Action completed"}
mock_client.post.return_value = mock_response
# Run command
result = runner.invoke(admin, [
'execute',
'--action', 'custom_command',
'--target', 'miner123',
'--data', '{"param": "value"}'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['status'] == 'success'
# Verify API call
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
assert '/v1/admin/execute/custom_command' in call_args[0][0]
assert call_args[1]['json']['target'] == 'miner123'
assert call_args[1]['json']['param'] == 'value'
@patch('aitbc_cli.commands.admin.httpx.Client')
def test_maintenance_cleanup(self, mock_client_class, runner, mock_config):
"""Test maintenance cleanup"""
# 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 = {"cleaned_items": 100}
mock_client.post.return_value = mock_response
# Run command with confirmation
result = runner.invoke(admin, [
'maintenance',
'cleanup'
], obj={'config': mock_config, 'output_format': 'json'}, input='y\n')
# Assertions
assert result.exit_code == 0
assert 'Cleanup completed' in result.output
# Verify API call
mock_client.post.assert_called_once_with(
'http://test:8000/v1/admin/maintenance/cleanup',
headers={"X-Api-Key": "test_admin_key"}
)
@patch('aitbc_cli.commands.admin.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 = 403
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(admin, [
'status'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code != 0
assert 'Error' in result.output

361
tests/cli/test_auth.py Normal file
View File

@@ -0,0 +1,361 @@
"""Tests for auth CLI commands"""
import pytest
import json
import os
from click.testing import CliRunner
from unittest.mock import Mock, patch
from aitbc_cli.commands.auth import auth
@pytest.fixture
def runner():
"""Create CLI runner"""
return CliRunner()
@pytest.fixture
def mock_config():
"""Mock configuration"""
return {}
class TestAuthCommands:
"""Test auth command group"""
@patch('aitbc_cli.commands.auth.AuthManager')
def test_login_success(self, mock_auth_manager_class, runner, mock_config):
"""Test successful login"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'login',
'test_api_key_12345',
'--environment', 'dev'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['status'] == 'logged_in'
assert data['environment'] == 'dev'
# Verify credential stored
mock_auth_manager.store_credential.assert_called_once_with(
'client', 'test_api_key_12345', 'dev'
)
@patch('aitbc_cli.commands.auth.AuthManager')
def test_login_invalid_key(self, mock_auth_manager_class, runner, mock_config):
"""Test login with invalid API key"""
# Run command with short key
result = runner.invoke(auth, [
'login',
'short',
'--environment', 'dev'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code != 0
assert 'Invalid API key' in result.output
# Verify credential not stored
mock_auth_manager_class.return_value.store_credential.assert_not_called()
@patch('aitbc_cli.commands.auth.AuthManager')
def test_logout_success(self, mock_auth_manager_class, runner, mock_config):
"""Test successful logout"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'logout',
'--environment', 'prod'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['status'] == 'logged_out'
assert data['environment'] == 'prod'
# Verify credential deleted
mock_auth_manager.delete_credential.assert_called_once_with(
'client', 'prod'
)
@patch('aitbc_cli.commands.auth.AuthManager')
def test_token_show(self, mock_auth_manager_class, runner, mock_config):
"""Test token command with show flag"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager.get_credential.return_value = 'secret_key_123'
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'token',
'--show',
'--environment', 'staging'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['api_key'] == 'secret_key_123'
assert data['environment'] == 'staging'
# Verify credential retrieved
mock_auth_manager.get_credential.assert_called_once_with(
'client', 'staging'
)
@patch('aitbc_cli.commands.auth.AuthManager')
def test_token_hidden(self, mock_auth_manager_class, runner, mock_config):
"""Test token command without show flag"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager.get_credential.return_value = 'secret_key_123'
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'token',
'--environment', 'staging'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['api_key'] == '***REDACTED***'
assert data['length'] == len('secret_key_123')
@patch('aitbc_cli.commands.auth.AuthManager')
def test_token_not_found(self, mock_auth_manager_class, runner, mock_config):
"""Test token command when no credential stored"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager.get_credential.return_value = None
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'token',
'--environment', 'nonexistent'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['message'] == 'No API key stored'
assert data['environment'] == 'nonexistent'
@patch('aitbc_cli.commands.auth.AuthManager')
def test_status_authenticated(self, mock_auth_manager_class, runner, mock_config):
"""Test status when authenticated"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager.list_credentials.return_value = ['client@dev', 'miner@prod']
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'status'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['status'] == 'authenticated'
assert len(data['stored_credentials']) == 2
assert 'client@dev' in data['stored_credentials']
assert 'miner@prod' in data['stored_credentials']
@patch('aitbc_cli.commands.auth.AuthManager')
def test_status_not_authenticated(self, mock_auth_manager_class, runner, mock_config):
"""Test status when not authenticated"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager.list_credentials.return_value = []
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'status'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['status'] == 'not_authenticated'
assert data['message'] == 'No stored credentials found'
@patch('aitbc_cli.commands.auth.AuthManager')
def test_refresh_success(self, mock_auth_manager_class, runner, mock_config):
"""Test refresh command"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager.get_credential.return_value = 'valid_key'
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'refresh',
'--environment', 'dev'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['status'] == 'refreshed'
assert data['environment'] == 'dev'
assert 'placeholder' in data['message']
@patch('aitbc_cli.commands.auth.AuthManager')
def test_refresh_no_key(self, mock_auth_manager_class, runner, mock_config):
"""Test refresh with no stored key"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager.get_credential.return_value = None
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'refresh',
'--environment', 'nonexistent'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code != 0
assert 'No API key found' in result.output
@patch('aitbc_cli.commands.auth.AuthManager')
def test_keys_list(self, mock_auth_manager_class, runner, mock_config):
"""Test keys list command"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager.list_credentials.return_value = [
'client@dev', 'miner@dev', 'admin@prod'
]
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'keys',
'list'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data['credentials']) == 3
@patch('aitbc_cli.commands.auth.AuthManager')
def test_keys_create(self, mock_auth_manager_class, runner, mock_config):
"""Test keys create command"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'keys',
'create',
'miner',
'miner_key_abcdef',
'--permissions', 'mine,poll',
'--environment', 'prod'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['status'] == 'created'
assert data['name'] == 'miner'
assert data['environment'] == 'prod'
assert data['permissions'] == 'mine,poll'
# Verify credential stored
mock_auth_manager.store_credential.assert_called_once_with(
'miner', 'miner_key_abcdef', 'prod'
)
@patch('aitbc_cli.commands.auth.AuthManager')
def test_keys_revoke(self, mock_auth_manager_class, runner, mock_config):
"""Test keys revoke command"""
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'keys',
'revoke',
'old_miner',
'--environment', 'dev'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['status'] == 'revoked'
assert data['name'] == 'old_miner'
assert data['environment'] == 'dev'
# Verify credential deleted
mock_auth_manager.delete_credential.assert_called_once_with(
'old_miner', 'dev'
)
@patch.dict(os.environ, {'CLIENT_API_KEY': 'env_test_key'})
@patch('aitbc_cli.commands.auth.AuthManager')
def test_import_env_success(self, mock_auth_manager_class, runner, mock_config):
"""Test successful import from environment"""
import os
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'import-env',
'client'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['status'] == 'imported'
assert data['name'] == 'client'
assert data['source'] == 'CLIENT_API_KEY'
# Verify credential stored
mock_auth_manager.store_credential.assert_called_once_with(
'client', 'env_test_key'
)
@patch.dict(os.environ, {})
@patch('aitbc_cli.commands.auth.AuthManager')
def test_import_env_not_set(self, mock_auth_manager_class, runner, mock_config):
"""Test import when environment variable not set"""
import os
# Setup mock
mock_auth_manager = Mock()
mock_auth_manager_class.return_value = mock_auth_manager
# Run command
result = runner.invoke(auth, [
'import-env',
'client'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code != 0
assert 'CLIENT_API_KEY not set' in result.output

View File

@@ -0,0 +1,357 @@
"""Tests for blockchain CLI commands"""
import pytest
import json
from click.testing import CliRunner
from unittest.mock import Mock, patch
from aitbc_cli.commands.blockchain import blockchain
@pytest.fixture
def runner():
"""Create CLI runner"""
return CliRunner()
@pytest.fixture
def mock_config():
"""Mock configuration"""
config = Mock()
config.coordinator_url = "http://test:8000"
config.api_key = "test_api_key"
return config
class TestBlockchainCommands:
"""Test blockchain command group"""
@patch('aitbc_cli.commands.blockchain.httpx.Client')
def test_blocks_success(self, mock_client_class, runner, mock_config):
"""Test successful block listing"""
# 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 = {
"blocks": [
{"height": 100, "hash": "0xabc123", "timestamp": "2024-01-01T00:00:00"},
{"height": 99, "hash": "0xdef456", "timestamp": "2024-01-01T00:01:00"}
]
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(blockchain, [
'blocks',
'--limit', '2'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data['blocks']) == 2
assert data['blocks'][0]['height'] == 100
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/explorer/blocks',
params={"limit": 2},
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.blockchain.httpx.Client')
def test_block_details(self, mock_client_class, runner, mock_config):
"""Test getting block 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 = {
"height": 100,
"hash": "0xabc123",
"transactions": ["0xtx1", "0xtx2"],
"timestamp": "2024-01-01T00:00:00",
"validator": "validator1"
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(blockchain, [
'block',
'100'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['height'] == 100
assert data['hash'] == '0xabc123'
assert len(data['transactions']) == 2
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/explorer/blocks/100',
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.blockchain.httpx.Client')
def test_transaction(self, mock_client_class, runner, mock_config):
"""Test getting transaction 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 = {
"hash": "0xtx123",
"block": 100,
"from": "0xabc",
"to": "0xdef",
"amount": "1000",
"fee": "10",
"status": "confirmed"
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(blockchain, [
'transaction',
'0xtx123'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['hash'] == '0xtx123'
assert data['block'] == 100
assert data['status'] == 'confirmed'
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/explorer/transactions/0xtx123',
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.blockchain.httpx.Client')
def test_node_status(self, mock_client_class, runner, mock_config):
"""Test getting node status"""
# 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": "running",
"version": "1.0.0",
"height": 1000,
"peers": 5,
"synced": True
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(blockchain, [
'status',
'--node', '1'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['node'] == 1
assert data['rpc_url'] == 'http://localhost:8082'
assert data['status']['status'] == 'running'
# Verify API call
mock_client.get.assert_called_once_with(
'http://localhost:8082/status',
timeout=5
)
@patch('aitbc_cli.commands.blockchain.httpx.Client')
def test_sync_status(self, mock_client_class, runner, mock_config):
"""Test getting sync status"""
# 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 = {
"synced": True,
"current_height": 1000,
"target_height": 1000,
"sync_percentage": 100.0
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(blockchain, [
'sync-status'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['synced'] == True
assert data['sync_percentage'] == 100.0
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/blockchain/sync',
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.blockchain.httpx.Client')
def test_peers(self, mock_client_class, runner, mock_config):
"""Test listing peers"""
# 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 = {
"peers": [
{"id": "peer1", "address": "1.2.3.4:8080", "connected": True},
{"id": "peer2", "address": "5.6.7.8:8080", "connected": False}
]
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(blockchain, [
'peers'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data['peers']) == 2
assert data['peers'][0]['connected'] == True
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/blockchain/peers',
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.blockchain.httpx.Client')
def test_info(self, mock_client_class, runner, mock_config):
"""Test getting blockchain info"""
# 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 = {
"network": "aitbc-mainnet",
"chain_id": "aitbc-1",
"block_time": 5,
"min_stake": 1000,
"total_supply": "1000000000"
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(blockchain, [
'info'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['network'] == 'aitbc-mainnet'
assert data['block_time'] == 5
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/blockchain/info',
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.blockchain.httpx.Client')
def test_supply(self, mock_client_class, runner, mock_config):
"""Test getting token supply"""
# 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 = {
"total_supply": "1000000000",
"circulating_supply": "500000000",
"staked": "300000000",
"burned": "200000000"
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(blockchain, [
'supply'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['total_supply'] == '1000000000'
assert data['circulating_supply'] == '500000000'
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/blockchain/supply',
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.blockchain.httpx.Client')
def test_validators(self, mock_client_class, runner, mock_config):
"""Test listing validators"""
# 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 = {
"validators": [
{"address": "0xval1", "stake": "100000", "status": "active"},
{"address": "0xval2", "stake": "50000", "status": "active"}
]
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(blockchain, [
'validators'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data['validators']) == 2
assert data['validators'][0]['stake'] == '100000'
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/blockchain/validators',
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.blockchain.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(blockchain, [
'block',
'999999'
], 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

244
tests/cli/test_client.py Normal file
View File

@@ -0,0 +1,244 @@
"""Tests for client CLI commands"""
import pytest
import json
from click.testing import CliRunner
from unittest.mock import Mock, patch
from aitbc_cli.commands.client import client
@pytest.fixture
def runner():
"""Create CLI runner"""
return CliRunner()
@pytest.fixture
def mock_config():
"""Mock configuration"""
config = Mock()
config.coordinator_url = "http://test:8000"
config.api_key = "test_key"
return config
class TestClientCommands:
"""Test client command group"""
@patch('aitbc_cli.commands.client.httpx.Client')
def test_submit_job_success(self, mock_client_class, runner, mock_config):
"""Test successful job submission"""
# 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 = {"job_id": "test_job_123"}
mock_client.post.return_value = mock_response
# Run command
result = runner.invoke(client, [
'submit',
'--type', 'inference',
'--prompt', 'Test prompt',
'--model', 'test_model'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'job_id' in result.output
# Verify API call
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
assert '/v1/jobs' in call_args[0][0]
assert call_args[1]['json']['payload']['type'] == 'inference'
assert call_args[1]['json']['payload']['prompt'] == 'Test prompt'
@patch('aitbc_cli.commands.client.httpx.Client')
def test_submit_job_from_file(self, mock_client_class, runner, mock_config, tmp_path):
"""Test job submission from file"""
# Create test job file
job_file = tmp_path / "test_job.json"
job_data = {
"type": "training",
"model": "gpt-3",
"dataset": "test_data"
}
job_file.write_text(json.dumps(job_data))
# 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 = {"job_id": "test_job_456"}
mock_client.post.return_value = mock_response
# Run command
result = runner.invoke(client, [
'submit',
'--file', str(job_file)
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'job_id' in result.output
# Verify API call used file data
call_args = mock_client.post.call_args
assert call_args[1]['json']['payload']['type'] == 'training'
assert call_args[1]['json']['payload']['model'] == 'gpt-3'
@patch('aitbc_cli.commands.client.httpx.Client')
def test_status_success(self, mock_client_class, runner, mock_config):
"""Test successful job status check"""
# 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 = {
"job_id": "test_job_123",
"state": "completed",
"result": "Test result"
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(client, [
'status',
'test_job_123'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'completed' in result.output
assert 'test_job_123' in result.output
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/jobs/test_job_123',
headers={"X-Api-Key": "test_key"}
)
@patch('aitbc_cli.commands.client.httpx.Client')
def test_cancel_job_success(self, mock_client_class, runner, mock_config):
"""Test successful job cancellation"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 200
mock_client.post.return_value = mock_response
# Run command
result = runner.invoke(client, [
'cancel',
'test_job_123'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
# Verify API call
mock_client.post.assert_called_once_with(
'http://test:8000/v1/jobs/test_job_123/cancel',
headers={"X-Api-Key": "test_key"}
)
@patch('aitbc_cli.commands.client.httpx.Client')
def test_blocks_success(self, mock_client_class, runner, mock_config):
"""Test successful blocks listing"""
# 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 = {
"items": [
{"height": 100, "hash": "0x123"},
{"height": 101, "hash": "0x456"}
]
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(client, [
'blocks',
'--limit', '2'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'items' in result.output
# Verify API call
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
assert '/v1/explorer/blocks' in call_args[0][0]
assert call_args[1]['params']['limit'] == 2
@patch('aitbc_cli.commands.client.httpx.Client')
def test_history_with_filters(self, mock_client_class, runner, mock_config):
"""Test job history 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 = {
"jobs": [
{"id": "job1", "status": "completed"},
{"id": "job2", "status": "failed"}
]
}
mock_client.get.return_value = mock_response
# Run command with filters
result = runner.invoke(client, [
'history',
'--status', 'completed',
'--type', 'inference',
'--limit', '10'
], 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']['status'] == 'completed'
assert call_args[1]['params']['type'] == 'inference'
assert call_args[1]['params']['limit'] == 10
@patch('aitbc_cli.commands.client.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 = 500
mock_response.text = "Internal Server Error"
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(client, [
'status',
'test_job_123'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code != 0
assert 'Error' in result.output
def test_submit_missing_required_args(self, runner, mock_config):
"""Test submit command with missing required arguments"""
result = runner.invoke(client, [
'submit'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'Error' in result.output

654
tests/cli/test_config.py Normal file
View File

@@ -0,0 +1,654 @@
"""Tests for config CLI commands"""
import pytest
import json
import yaml
import os
import tempfile
from pathlib import Path
from click.testing import CliRunner
from unittest.mock import Mock, patch
from aitbc_cli.commands.config import config
@pytest.fixture
def runner():
"""Create CLI runner"""
return CliRunner()
@pytest.fixture
def mock_config():
"""Mock configuration"""
config = Mock()
config.coordinator_url = "http://127.0.0.1:18000"
config.api_key = None
config.timeout = 30
config.config_file = "/home/oib/.aitbc/config.yaml"
return config
@pytest.fixture
def temp_config_file():
"""Create temporary config file"""
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
config_data = {
"coordinator_url": "http://test:8000",
"api_key": "test_key",
"timeout": 60
}
yaml.dump(config_data, f)
temp_path = f.name
yield temp_path
# Cleanup
os.unlink(temp_path)
class TestConfigCommands:
"""Test config command group"""
def test_show_config(self, runner, mock_config):
"""Test showing current configuration"""
result = runner.invoke(config, [
'show'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
data = json.loads(result.output)
assert data['coordinator_url'] == 'http://127.0.0.1:18000'
assert data['api_key'] is None # mock_config has api_key=None
assert data['timeout'] == 30
def test_set_coordinator_url(self, runner, mock_config, tmp_path):
"""Test setting coordinator URL"""
with runner.isolated_filesystem():
result = runner.invoke(config, [
'set',
'coordinator_url',
'http://new:8000'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'Coordinator URL set to: http://new:8000' in result.output
# Verify file was created in current directory
config_file = Path.cwd() / ".aitbc.yaml"
assert config_file.exists()
with open(config_file) as f:
saved_config = yaml.safe_load(f)
assert saved_config['coordinator_url'] == 'http://new:8000'
def test_set_api_key(self, runner, mock_config):
"""Test setting API key"""
result = runner.invoke(config, [
'set',
'api_key',
'new_test_key_12345'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'API key set (use --global to set permanently)' in result.output
def test_set_timeout(self, runner, mock_config):
"""Test setting timeout"""
with runner.isolated_filesystem():
result = runner.invoke(config, [
'set',
'timeout',
'45'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'Timeout set to: 45s' in result.output
def test_set_invalid_timeout(self, runner, mock_config):
"""Test setting invalid timeout"""
result = runner.invoke(config, [
'set',
'timeout',
'invalid'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'Timeout must be an integer' in result.output
def test_set_invalid_key(self, runner, mock_config):
"""Test setting invalid configuration key"""
result = runner.invoke(config, [
'set',
'invalid_key',
'value'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'Unknown configuration key' in result.output
def test_path_command(self, runner, mock_config, tmp_path):
"""Test showing configuration file path"""
with runner.isolated_filesystem():
result = runner.invoke(config, [
'path'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert '.aitbc.yaml' in result.output
def test_path_global(self, runner, mock_config):
"""Test showing global config path"""
result = runner.invoke(config, [
'path',
'--global'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert '.config/aitbc/config.yaml' in result.output
@patch('os.system')
def test_edit_command(self, mock_system, runner, mock_config, tmp_path):
"""Test editing configuration file"""
# Change to the tmp_path directory
with runner.isolated_filesystem(temp_dir=tmp_path):
# The actual config file will be in the current working directory
actual_config_file = Path.cwd() / ".aitbc.yaml"
result = runner.invoke(config, [
'edit'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
# Verify editor was called
mock_system.assert_called_once()
assert 'nano' in mock_system.call_args[0][0]
assert str(actual_config_file) in mock_system.call_args[0][0]
def test_reset_config_cancelled(self, runner, mock_config, temp_config_file):
"""Test config reset cancelled by user"""
# Change to the directory containing the config file
config_dir = Path(temp_config_file).parent
with runner.isolated_filesystem(temp_dir=config_dir):
# Copy the config file to the current directory
import shutil
local_config = Path.cwd() / ".aitbc.yaml"
shutil.copy2(temp_config_file, local_config)
result = runner.invoke(config, [
'reset'
], obj={'config': mock_config, 'output_format': 'json'}, input='n\n')
assert result.exit_code == 0
# File should still exist
assert local_config.exists()
def test_reset_config_confirmed(self, runner, mock_config, temp_config_file):
"""Test config reset confirmed"""
# Change to the directory containing the config file
config_dir = Path(temp_config_file).parent
with runner.isolated_filesystem(temp_dir=config_dir):
# Copy the config file to the current directory
import shutil
local_config = Path.cwd() / ".aitbc.yaml"
shutil.copy2(temp_config_file, local_config)
result = runner.invoke(config, [
'reset'
], obj={'config': mock_config, 'output_format': 'table'}, input='y\n')
assert result.exit_code == 0
assert 'Configuration reset' in result.output
# File should be deleted
assert not local_config.exists()
def test_reset_no_config(self, runner, mock_config):
"""Test reset when no config file exists"""
with runner.isolated_filesystem():
result = runner.invoke(config, [
'reset'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
assert 'No configuration file found' in result.output
def test_export_yaml(self, runner, mock_config, temp_config_file):
"""Test exporting configuration as YAML"""
# Change to the directory containing the config file
config_dir = Path(temp_config_file).parent
with runner.isolated_filesystem(temp_dir=config_dir):
# Copy the config file to the current directory
import shutil
local_config = Path.cwd() / ".aitbc.yaml"
shutil.copy2(temp_config_file, local_config)
result = runner.invoke(config, [
'export',
'--format', 'yaml'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
output_data = yaml.safe_load(result.output)
assert output_data['coordinator_url'] == 'http://test:8000'
assert output_data['api_key'] == '***REDACTED***'
def test_export_json(self, runner, mock_config, temp_config_file):
"""Test exporting configuration as JSON"""
# Change to the directory containing the config file
config_dir = Path(temp_config_file).parent
with runner.isolated_filesystem(temp_dir=config_dir):
# Copy the config file to the current directory
import shutil
local_config = Path.cwd() / ".aitbc.yaml"
shutil.copy2(temp_config_file, local_config)
result = runner.invoke(config, [
'export',
'--format', 'json'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
data = json.loads(result.output)
assert data['coordinator_url'] == 'http://test:8000'
assert data['api_key'] == '***REDACTED***'
def test_export_no_config(self, runner, mock_config):
"""Test export when no config file exists"""
with runner.isolated_filesystem():
result = runner.invoke(config, [
'export'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'No configuration file found' in result.output
def test_import_config_yaml(self, runner, mock_config, tmp_path):
"""Test importing YAML configuration"""
# Create import file
import_file = tmp_path / "import.yaml"
import_data = {
"coordinator_url": "http://imported:8000",
"timeout": 90
}
import_file.write_text(yaml.dump(import_data))
with runner.isolated_filesystem(temp_dir=tmp_path):
# The config file will be created in the current directory
actual_config_file = Path.cwd() / ".aitbc.yaml"
result = runner.invoke(config, [
'import-config',
str(import_file)
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'Configuration imported' in result.output
# Verify import
with open(actual_config_file) as f:
saved_config = yaml.safe_load(f)
assert saved_config['coordinator_url'] == 'http://imported:8000'
assert saved_config['timeout'] == 90
def test_import_config_json(self, runner, mock_config, tmp_path):
"""Test importing JSON configuration"""
# Create import file
import_file = tmp_path / "import.json"
import_data = {
"coordinator_url": "http://json:8000",
"timeout": 60
}
import_file.write_text(json.dumps(import_data))
config_file = tmp_path / ".aitbc.yaml"
with runner.isolated_filesystem(temp_dir=tmp_path):
# The config file will be created in the current directory
actual_config_file = Path.cwd() / ".aitbc.yaml"
result = runner.invoke(config, [
'import-config',
str(import_file)
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
# Verify import
with open(actual_config_file) as f:
saved_config = yaml.safe_load(f)
assert saved_config['coordinator_url'] == 'http://json:8000'
assert saved_config['timeout'] == 60
def test_import_merge(self, runner, mock_config, temp_config_file, tmp_path):
"""Test importing with merge option"""
# Create import file
import_file = tmp_path / "import.yaml"
import_data = {
"timeout": 45
}
import_file.write_text(yaml.dump(import_data))
# Change to the directory containing the config file
config_dir = Path(temp_config_file).parent
with runner.isolated_filesystem(temp_dir=config_dir):
# Copy the config file to the current directory
import shutil
local_config = Path.cwd() / ".aitbc.yaml"
shutil.copy2(temp_config_file, local_config)
result = runner.invoke(config, [
'import-config',
str(import_file),
'--merge'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
# Verify merge - original values should remain
with open(local_config) as f:
saved_config = yaml.safe_load(f)
assert saved_config['coordinator_url'] == 'http://test:8000' # Original
assert saved_config['timeout'] == 45 # Updated
def test_import_nonexistent_file(self, runner, mock_config):
"""Test importing non-existent file"""
result = runner.invoke(config, [
'import-config',
'/nonexistent/file.yaml'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code != 0
assert 'File not found' in result.output
def test_validate_valid_config(self, runner, mock_config):
"""Test validating valid configuration"""
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'Configuration valid' in result.output
def test_validate_missing_url(self, runner, mock_config):
"""Test validating config with missing URL"""
mock_config.coordinator_url = None
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code != 0
assert 'validation failed' in result.output
def test_validate_invalid_url(self, runner, mock_config):
"""Test validating config with invalid URL"""
mock_config.coordinator_url = "invalid-url"
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code != 0
assert 'validation failed' in result.output
def test_validate_short_api_key(self, runner, mock_config):
"""Test validating config with short API key"""
mock_config.api_key = "short"
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code != 0
assert 'validation failed' in result.output
def test_validate_no_api_key(self, runner, mock_config):
"""Test validating config without API key (warning)"""
mock_config.api_key = None
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'valid with warnings' in result.output
@patch.dict(os.environ, {'CLIENT_API_KEY': 'env_key_123'})
def test_environments(self, runner, mock_config):
"""Test listing environment variables"""
result = runner.invoke(config, [
'environments'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'CLIENT_API_KEY' in result.output
def test_profiles_save(self, runner, mock_config, tmp_path):
"""Test saving a configuration profile"""
profiles_dir = tmp_path / ".config" / "aitbc" / "profiles"
result = runner.invoke(config, [
'profiles',
'save',
'test_profile'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'Profile test_profile saved' in result.output
# Verify profile was created
profile_file = profiles_dir / "test_profile.yaml"
assert profile_file.exists()
with open(profile_file) as f:
profile_data = yaml.safe_load(f)
assert profile_data['coordinator_url'] == 'http://127.0.0.1:18000'
def test_profiles_list(self, runner, mock_config, tmp_path):
"""Test listing configuration profiles"""
# Create test profiles
profiles_dir = tmp_path / ".config" / "aitbc" / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
profile1 = profiles_dir / "profile1.yaml"
profile1.write_text(yaml.dump({"coordinator_url": "http://test1:8000"}))
profile2 = profiles_dir / "profile2.yaml"
profile2.write_text(yaml.dump({"coordinator_url": "http://test2:8000"}))
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'list'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'profile1' in result.output
assert 'profile2' in result.output
def test_profiles_load(self, runner, mock_config, tmp_path):
"""Test loading a configuration profile"""
# Create test profile
profiles_dir = tmp_path / ".config" / "aitbc" / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
profile_file = profiles_dir / "test.yaml"
profile_data = {
"coordinator_url": "http://loaded:8000",
"timeout": 75
}
profile_file.write_text(yaml.dump(profile_data))
config_file = tmp_path / ".aitbc.yaml"
with runner.isolated_filesystem(temp_dir=tmp_path):
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'load',
'test'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'Profile test loaded' in result.output
def test_validate_invalid_url(self, runner, mock_config):
"""Test validating config with invalid URL"""
mock_config.coordinator_url = "invalid-url"
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code != 0
assert 'validation failed' in result.output
def test_validate_short_api_key(self, runner, mock_config):
"""Test validating config with short API key"""
mock_config.api_key = "short"
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code != 0
assert 'validation failed' in result.output
def test_validate_no_api_key(self, runner, mock_config):
"""Test validating config without API key (warning)"""
mock_config.api_key = None
result = runner.invoke(config, [
'validate'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'valid with warnings' in result.output
@patch.dict(os.environ, {'CLIENT_API_KEY': 'env_key_123'})
def test_environments(self, runner, mock_config):
"""Test listing environment variables"""
result = runner.invoke(config, [
'environments'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'CLIENT_API_KEY' in result.output
def test_profiles_save(self, runner, mock_config, tmp_path):
"""Test saving a configuration profile"""
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'save',
'test_profile'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert "Profile 'test_profile' saved" in result.output
# Verify profile was created
profile_file = tmp_path / ".config" / "aitbc" / "profiles" / "test_profile.yaml"
assert profile_file.exists()
with open(profile_file) as f:
profile_data = yaml.safe_load(f)
assert profile_data['coordinator_url'] == 'http://127.0.0.1:18000'
def test_profiles_list(self, runner, mock_config, tmp_path):
"""Test listing configuration profiles"""
# Create test profiles
profiles_dir = tmp_path / ".config" / "aitbc" / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
profile1 = profiles_dir / "profile1.yaml"
profile1.write_text(yaml.dump({"coordinator_url": "http://test1:8000"}))
profile2 = profiles_dir / "profile2.yaml"
profile2.write_text(yaml.dump({"coordinator_url": "http://test2:8000"}))
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'list'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert 'profile1' in result.output
assert 'profile2' in result.output
def test_profiles_load(self, runner, mock_config, tmp_path):
"""Test loading a configuration profile"""
# Create test profile
profiles_dir = tmp_path / ".config" / "aitbc" / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
profile_file = profiles_dir / "load_me.yaml"
profile_file.write_text(yaml.dump({"coordinator_url": "http://127.0.0.1:18000"}))
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'load',
'load_me'
], obj={'config': mock_config, 'output_format': 'table'})
assert result.exit_code == 0
assert "Profile 'load_me' loaded" in result.output
def test_profiles_delete(self, runner, mock_config, tmp_path):
"""Test deleting a configuration profile"""
# Create test profile
profiles_dir = tmp_path / ".config" / "aitbc" / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
profile_file = profiles_dir / "delete_me.yaml"
profile_file.write_text(yaml.dump({"coordinator_url": "http://test:8000"}))
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'delete',
'delete_me'
], obj={'config': mock_config, 'output_format': 'table'}, input='y\n')
assert result.exit_code == 0
assert "Profile 'delete_me' deleted" in result.output
assert not profile_file.exists()
def test_profiles_delete_cancelled(self, runner, mock_config, tmp_path):
"""Test profile deletion cancelled by user"""
# Create test profile
profiles_dir = tmp_path / ".config" / "aitbc" / "profiles"
profiles_dir.mkdir(parents=True, exist_ok=True)
profile_file = profiles_dir / "keep_me.yaml"
profile_file.write_text(yaml.dump({"coordinator_url": "http://test:8000"}))
# Patch Path.home to return tmp_path
with patch('pathlib.Path.home') as mock_home:
mock_home.return_value = tmp_path
result = runner.invoke(config, [
'profiles',
'delete',
'keep_me'
], obj={'config': mock_config, 'output_format': 'json'}, input='n\n')
assert result.exit_code == 0
assert profile_file.exists() # Should still exist

View File

@@ -0,0 +1,553 @@
"""Tests for marketplace CLI commands"""
import pytest
import json
from click.testing import CliRunner
from unittest.mock import Mock, patch
from aitbc_cli.commands.marketplace import marketplace
@pytest.fixture
def runner():
"""Create CLI runner"""
return CliRunner()
@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

371
tests/cli/test_simulate.py Normal file
View File

@@ -0,0 +1,371 @@
"""Tests for simulate CLI commands"""
import pytest
import json
import time
from pathlib import Path
from unittest.mock import patch, MagicMock, Mock
from click.testing import CliRunner
from aitbc_cli.commands.simulate import simulate
def extract_json_from_output(output):
"""Extract first JSON object from CLI output that may contain ANSI escape codes and success messages"""
import re
clean_output = re.sub(r'\x1b\[[0-9;]*m', '', 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
assert json_lines, "No JSON found in output"
json_str = '\n'.join(json_lines)
return json.loads(json_str)
def extract_last_json_from_output(output):
"""Extract the last JSON object from CLI output (for commands that emit multiple JSON objects)"""
import re
clean_output = re.sub(r'\x1b\[[0-9;]*m', '', output)
lines = clean_output.strip().split('\n')
all_objects = []
json_lines = []
in_json = False
brace_depth = 0
for line in lines:
stripped = line.strip()
if stripped.startswith('{') and not in_json:
in_json = True
brace_depth = stripped.count('{') - stripped.count('}')
json_lines = [stripped]
if brace_depth == 0:
try:
all_objects.append(json.loads('\n'.join(json_lines)))
except json.JSONDecodeError:
pass
json_lines = []
in_json = False
elif in_json:
json_lines.append(stripped)
brace_depth += stripped.count('{') - stripped.count('}')
if brace_depth <= 0:
try:
all_objects.append(json.loads('\n'.join(json_lines)))
except json.JSONDecodeError:
pass
json_lines = []
in_json = False
assert all_objects, "No JSON found in output"
return all_objects[-1]
@pytest.fixture
def runner():
"""Create CLI runner"""
return CliRunner()
@pytest.fixture
def mock_config():
"""Mock configuration"""
config = Mock()
config.coordinator_url = "http://test:8000"
config.api_key = "test_api_key"
return config
class TestSimulateCommands:
"""Test simulate command group"""
def test_init_economy(self, runner, mock_config):
"""Test initializing test economy"""
with runner.isolated_filesystem():
# Create a temporary home directory
home_dir = Path("temp_home")
home_dir.mkdir()
# Patch the hardcoded path
with patch('aitbc_cli.commands.simulate.Path') as mock_path_class:
# Make Path return our temp directory
mock_path_class.return_value = home_dir
mock_path_class.side_effect = lambda x: home_dir if x == "/home/oib/windsurf/aitbc/home" else Path(x)
# Run command
result = runner.invoke(simulate, [
'init',
'--distribute', '5000,2000'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = extract_json_from_output(result.output)
assert data['status'] == 'initialized'
assert data['distribution']['client'] == 5000.0
assert data['distribution']['miner'] == 2000.0
def test_init_with_reset(self, runner, mock_config):
"""Test initializing with reset flag"""
with runner.isolated_filesystem():
# Create a temporary home directory with existing files
home_dir = Path("temp_home")
home_dir.mkdir()
# Create existing wallet files
(home_dir / "client_wallet.json").write_text("{}")
(home_dir / "miner_wallet.json").write_text("{}")
# Patch the hardcoded path
with patch('aitbc_cli.commands.simulate.Path') as mock_path_class:
mock_path_class.return_value = home_dir
mock_path_class.side_effect = lambda x: home_dir if x == "/home/oib/windsurf/aitbc/home" else Path(x)
# Run command
result = runner.invoke(simulate, [
'init',
'--reset'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'resetting' in result.output.lower()
def test_create_user(self, runner, mock_config):
"""Test creating a test user"""
with runner.isolated_filesystem():
# Create a temporary home directory
home_dir = Path("temp_home")
home_dir.mkdir()
# Patch the hardcoded path
with patch('aitbc_cli.commands.simulate.Path') as mock_path_class:
mock_path_class.return_value = home_dir
mock_path_class.side_effect = lambda x: home_dir if x == "/home/oib/windsurf/aitbc/home" else Path(x)
# Run command
result = runner.invoke(simulate, [
'user',
'create',
'--type', 'client',
'--name', 'testuser',
'--balance', '1000'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = extract_json_from_output(result.output)
assert data['user_id'] == 'client_testuser'
assert data['balance'] == 1000
def test_list_users(self, runner, mock_config):
"""Test listing test users"""
with runner.isolated_filesystem():
# Create a temporary home directory
home_dir = Path("temp_home")
home_dir.mkdir()
# Create some test wallet files
(home_dir / "client_user1_wallet.json").write_text('{"address": "aitbc1test", "balance": 1000}')
(home_dir / "miner_user2_wallet.json").write_text('{"address": "aitbc1test2", "balance": 2000}')
# Patch the hardcoded path
with patch('aitbc_cli.commands.simulate.Path') as mock_path_class:
mock_path_class.return_value = home_dir
mock_path_class.side_effect = lambda x: home_dir if x == "/home/oib/windsurf/aitbc/home" else Path(x)
# Run command
result = runner.invoke(simulate, [
'user',
'list'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert 'users' in data
assert isinstance(data['users'], list)
assert len(data['users']) == 2
def test_user_balance(self, runner, mock_config):
"""Test checking user balance"""
with runner.isolated_filesystem():
# Create a temporary home directory
home_dir = Path("temp_home")
home_dir.mkdir()
# Create a test wallet file
(home_dir / "testuser_wallet.json").write_text('{"address": "aitbc1testuser", "balance": 1500}')
# Patch the hardcoded path
with patch('aitbc_cli.commands.simulate.Path') as mock_path_class:
mock_path_class.return_value = home_dir
mock_path_class.side_effect = lambda x: home_dir if x == "/home/oib/windsurf/aitbc/home" else Path(x)
# Run command
result = runner.invoke(simulate, [
'user',
'balance',
'testuser'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['balance'] == 1500
def test_fund_user(self, runner, mock_config):
"""Test funding a test user"""
with runner.isolated_filesystem():
# Create a temporary home directory
home_dir = Path("temp_home")
home_dir.mkdir()
# Create genesis and user wallet files
(home_dir / "genesis_wallet.json").write_text('{"address": "aitbc1genesis", "balance": 1000000, "transactions": []}')
(home_dir / "testuser_wallet.json").write_text('{"address": "aitbc1testuser", "balance": 1000, "transactions": []}')
# Patch the hardcoded path
with patch('aitbc_cli.commands.simulate.Path') as mock_path_class:
mock_path_class.return_value = home_dir
mock_path_class.side_effect = lambda x: home_dir if x == "/home/oib/windsurf/aitbc/home" else Path(x)
# Run command
result = runner.invoke(simulate, [
'user',
'fund',
'testuser',
'500'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
# Extract JSON from output
data = extract_json_from_output(result.output)
assert data['amount'] == 500
assert data['new_balance'] == 1500
def test_workflow_command(self, runner, mock_config):
"""Test workflow simulation command"""
result = runner.invoke(simulate, [
'workflow',
'--jobs', '5',
'--rounds', '2'
], obj={'config': mock_config, 'output_format': 'json'})
# The command should exist
assert result.exit_code == 0
# Extract last JSON from output (workflow emits multiple JSON objects)
data = extract_last_json_from_output(result.output)
assert data['status'] == 'completed'
assert data['total_jobs'] == 10
def test_load_test_command(self, runner, mock_config):
"""Test load test command"""
result = runner.invoke(simulate, [
'load-test',
'--clients', '2',
'--miners', '1',
'--duration', '5',
'--job-rate', '2'
], obj={'config': mock_config, 'output_format': 'json'})
# The command should exist
assert result.exit_code == 0
# Extract last JSON from output (load_test emits multiple JSON objects)
data = extract_last_json_from_output(result.output)
assert data['status'] == 'completed'
assert 'duration' in data
assert 'jobs_submitted' in data
def test_scenario_commands(self, runner, mock_config):
"""Test scenario commands"""
with runner.isolated_filesystem():
# Create a test scenario file
scenario_file = Path("test_scenario.json")
scenario_data = {
"name": "Test Scenario",
"description": "A test scenario",
"steps": [
{
"type": "submit_jobs",
"name": "Initial jobs",
"count": 2,
"prompt": "Test job"
},
{
"type": "wait",
"name": "Wait step",
"duration": 1
}
]
}
scenario_file.write_text(json.dumps(scenario_data))
# Run scenario
result = runner.invoke(simulate, [
'scenario',
'--file', str(scenario_file)
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
assert "Running scenario: Test Scenario" in result.output
def test_results_command(self, runner, mock_config):
"""Test results command"""
result = runner.invoke(simulate, [
'results',
'sim_123'
], obj={'config': mock_config, 'output_format': 'json'})
assert result.exit_code == 0
# Extract JSON from output
data = extract_json_from_output(result.output)
assert data['simulation_id'] == 'sim_123'
def test_reset_command(self, runner, mock_config):
"""Test reset command"""
with runner.isolated_filesystem():
# Create a temporary home directory
home_dir = Path("temp_home")
home_dir.mkdir()
# Create existing wallet files
(home_dir / "client_wallet.json").write_text("{}")
(home_dir / "miner_wallet.json").write_text("{}")
# Patch the hardcoded path
with patch('aitbc_cli.commands.simulate.Path') as mock_path_class:
mock_path_class.return_value = home_dir
mock_path_class.side_effect = lambda x: home_dir if x == "/home/oib/windsurf/aitbc/home" else Path(x)
# Run command with reset flag
result = runner.invoke(simulate, [
'init',
'--reset'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'resetting' in result.output.lower()
def test_invalid_distribution_format(self, runner, mock_config):
"""Test invalid distribution format"""
result = runner.invoke(simulate, [
'init',
'--distribute', 'invalid'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'invalid distribution' in result.output.lower()

358
tests/cli/test_wallet.py Normal file
View File

@@ -0,0 +1,358 @@
"""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