feat: add blockchain RPC blocks-range endpoint and marketplace bid listing

Blockchain Node:
- Replace /blocks (pagination) with /blocks-range (height range query)
- Add start/end height parameters with 1000-block max range validation
- Return blocks in ascending height order instead of descending
- Update metrics names (rpc_get_blocks_range_*)
- Remove total count from response (return start/end/count instead)

Coordinator API:
- Add effective_url property to DatabaseConfig (SQLite/PostgreSQL defaults
This commit is contained in:
oib
2026-02-16 22:54:08 +01:00
parent fdc3012780
commit 31d3d70836
20 changed files with 3330 additions and 80 deletions

View File

@@ -105,36 +105,35 @@ async def get_block(height: int) -> Dict[str, Any]:
}
@router.get("/blocks", summary="Get latest blocks")
async def get_blocks(limit: int = 10, offset: int = 0) -> Dict[str, Any]:
metrics_registry.increment("rpc_get_blocks_total")
start = time.perf_counter()
@router.get("/blocks-range", summary="Get blocks in height range")
async def get_blocks_range(start: int, end: int) -> Dict[str, Any]:
metrics_registry.increment("rpc_get_blocks_range_total")
start_time = time.perf_counter()
# Validate parameters
if limit < 1 or limit > 100:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="limit must be between 1 and 100")
if offset < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="offset must be non-negative")
if start < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start must be non-negative")
if end < start:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="end must be greater than or equal to start")
if end - start > 1000:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="range cannot exceed 1000 blocks")
with session_scope() as session:
# Get blocks in descending order (newest first)
# Get blocks in the specified height range (ascending order by height)
blocks = session.exec(
select(Block)
.order_by(Block.height.desc())
.offset(offset)
.limit(limit)
.where(Block.height >= start)
.where(Block.height <= end)
.order_by(Block.height.asc())
).all()
# Get total count for pagination info
total_count = len(session.exec(select(Block)).all())
if not blocks:
metrics_registry.increment("rpc_get_blocks_empty_total")
metrics_registry.increment("rpc_get_blocks_range_empty_total")
return {
"blocks": [],
"total": total_count,
"limit": limit,
"offset": offset,
"start": start,
"end": end,
"count": 0,
}
# Serialize blocks
@@ -149,14 +148,14 @@ async def get_blocks(limit: int = 10, offset: int = 0) -> Dict[str, Any]:
"state_root": block.state_root,
})
metrics_registry.increment("rpc_get_blocks_success_total")
metrics_registry.observe("rpc_get_blocks_duration_seconds", time.perf_counter() - start)
metrics_registry.increment("rpc_get_blocks_range_success_total")
metrics_registry.observe("rpc_get_blocks_range_duration_seconds", time.perf_counter() - start_time)
return {
"blocks": block_list,
"total": total_count,
"limit": limit,
"offset": offset,
"start": start,
"end": end,
"count": len(block_list),
}

View File

@@ -18,6 +18,19 @@ class DatabaseConfig(BaseSettings):
max_overflow: int = 20
pool_pre_ping: bool = True
@property
def effective_url(self) -> str:
"""Get the effective database URL."""
if self.url:
return self.url
# Default SQLite path
if self.adapter == "sqlite":
return "sqlite:///./coordinator.db"
# Default PostgreSQL connection string
return f"{self.adapter}://localhost:5432/coordinator"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",

View File

@@ -12,7 +12,15 @@ import logging
logger = logging.getLogger(__name__)
from ..schemas import ExchangePaymentRequest, ExchangePaymentResponse
from ..schemas import (
ExchangePaymentRequest,
ExchangePaymentResponse,
ExchangeRatesResponse,
PaymentStatusResponse,
MarketStatsResponse,
WalletBalanceResponse,
WalletInfoResponse
)
from ..services.bitcoin_wallet import get_wallet_balance, get_wallet_info
router = APIRouter(tags=["exchange"])
@@ -70,7 +78,8 @@ async def create_payment(
return payment
@router.get("/exchange/payment-status/{payment_id}")
@router.get("/exchange/payment-status/{payment_id}", response_model=PaymentStatusResponse)
async def get_payment_status(payment_id: str) -> Dict[str, Any]:
"""Get payment status"""
@@ -85,6 +94,7 @@ async def get_payment_status(payment_id: str) -> Dict[str, Any]:
return payment
@router.post("/exchange/confirm-payment/{payment_id}")
async def confirm_payment(
payment_id: str,
@@ -121,18 +131,20 @@ async def confirm_payment(
'aitbc_amount': payment['aitbc_amount']
}
@router.get("/exchange/rates")
async def get_exchange_rates() -> Dict[str, float]:
@router.get("/exchange/rates", response_model=ExchangeRatesResponse)
async def get_exchange_rates() -> ExchangeRatesResponse:
"""Get current exchange rates"""
return {
'btc_to_aitbc': BITCOIN_CONFIG['exchange_rate'],
'aitbc_to_btc': 1.0 / BITCOIN_CONFIG['exchange_rate'],
'fee_percent': 0.5
}
return ExchangeRatesResponse(
btc_to_aitbc=BITCOIN_CONFIG['exchange_rate'],
aitbc_to_btc=1.0 / BITCOIN_CONFIG['exchange_rate'],
fee_percent=0.5
)
@router.get("/exchange/market-stats")
async def get_market_stats() -> Dict[str, Any]:
@router.get("/exchange/market-stats", response_model=MarketStatsResponse)
async def get_market_stats() -> MarketStatsResponse:
"""Get market statistics"""
# Calculate 24h volume from payments
@@ -148,28 +160,32 @@ async def get_market_stats() -> Dict[str, Any]:
base_price = 1.0 / BITCOIN_CONFIG['exchange_rate']
price_change_percent = 5.2 # Simulated +5.2%
return {
'price': base_price,
'price_change_24h': price_change_percent,
'daily_volume': daily_volume,
'daily_volume_btc': daily_volume / BITCOIN_CONFIG['exchange_rate'],
'total_payments': len([p for p in payments.values() if p['status'] == 'confirmed']),
'pending_payments': len([p for p in payments.values() if p['status'] == 'pending'])
}
return MarketStatsResponse(
price=base_price,
price_change_24h=price_change_percent,
daily_volume=daily_volume,
daily_volume_btc=daily_volume / BITCOIN_CONFIG['exchange_rate'],
total_payments=len([p for p in payments.values() if p['status'] == 'confirmed']),
pending_payments=len([p for p in payments.values() if p['status'] == 'pending'])
)
@router.get("/exchange/wallet/balance")
async def get_wallet_balance_api() -> Dict[str, Any]:
@router.get("/exchange/wallet/balance", response_model=WalletBalanceResponse)
async def get_wallet_balance_api() -> WalletBalanceResponse:
"""Get Bitcoin wallet balance"""
try:
return get_wallet_balance()
balance_data = get_wallet_balance()
return WalletBalanceResponse(**balance_data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/exchange/wallet/info")
async def get_wallet_info_api() -> Dict[str, Any]:
@router.get("/exchange/wallet/info", response_model=WalletInfoResponse)
async def get_wallet_info_api() -> WalletInfoResponse:
"""Get comprehensive wallet information"""
try:
return get_wallet_info()
wallet_data = get_wallet_info()
return WalletInfoResponse(**wallet_data)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import status as http_status
from ..schemas import MarketplaceBidRequest, MarketplaceOfferView, MarketplaceStatsView
from ..schemas import MarketplaceBidRequest, MarketplaceOfferView, MarketplaceStatsView, MarketplaceBidView
from ..services import MarketplaceService
from ..storage import SessionDep
from ..metrics import marketplace_requests_total, marketplace_errors_total
@@ -74,3 +74,51 @@ async def submit_marketplace_bid(
except Exception:
marketplace_errors_total.labels(endpoint="/marketplace/bids", method="POST", error_type="internal").inc()
raise
@router.get(
"/marketplace/bids",
response_model=list[MarketplaceBidView],
summary="List marketplace bids",
)
async def list_marketplace_bids(
*,
session: SessionDep,
status_filter: str | None = Query(default=None, alias="status", description="Filter by bid status"),
provider_filter: str | None = Query(default=None, alias="provider", description="Filter by provider ID"),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> list[MarketplaceBidView]:
marketplace_requests_total.labels(endpoint="/marketplace/bids", method="GET").inc()
service = _get_service(session)
try:
return service.list_bids(status=status_filter, provider=provider_filter, limit=limit, offset=offset)
except ValueError:
marketplace_errors_total.labels(endpoint="/marketplace/bids", method="GET", error_type="invalid_request").inc()
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail="invalid filter") from None
except Exception:
marketplace_errors_total.labels(endpoint="/marketplace/bids", method="GET", error_type="internal").inc()
raise
@router.get(
"/marketplace/bids/{bid_id}",
response_model=MarketplaceBidView,
summary="Get bid details",
)
async def get_marketplace_bid(
bid_id: str,
session: SessionDep,
) -> MarketplaceBidView:
marketplace_requests_total.labels(endpoint="/marketplace/bids/{bid_id}", method="GET").inc()
service = _get_service(session)
try:
bid = service.get_bid(bid_id)
if not bid:
raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail="bid not found")
return bid
except HTTPException:
raise
except Exception:
marketplace_errors_total.labels(endpoint="/marketplace/bids/{bid_id}", method="GET", error_type="internal").inc()
raise

View File

@@ -14,6 +14,8 @@ class WebVitalsEntry(BaseModel):
name: str
startTime: Optional[float] = None
duration: Optional[float] = None
value: Optional[float] = None
hadRecentInput: Optional[bool] = None
class WebVitalsMetric(BaseModel):
name: str
@@ -31,6 +33,20 @@ async def collect_web_vitals(metric: WebVitalsMetric):
This endpoint receives Core Web Vitals (LCP, FID, CLS, TTFB, FCP) for monitoring.
"""
try:
# Filter entries to only include supported fields
filtered_entries = []
for entry in metric.entries:
filtered_entry = {
"name": entry.name,
"startTime": entry.startTime,
"duration": entry.duration,
"value": entry.value,
"hadRecentInput": entry.hadRecentInput
}
# Remove None values
filtered_entry = {k: v for k, v in filtered_entry.items() if v is not None}
filtered_entries.append(filtered_entry)
# Log the metric for monitoring/analysis
logging.info(f"Web Vitals - {metric.name}: {metric.value}ms (ID: {metric.id}) from {metric.url or 'unknown'}")

View File

@@ -109,6 +109,7 @@ class TransactionHistory(BaseModel):
user_id: str
transactions: List[Transaction]
total: int
class ExchangePaymentRequest(BaseModel):
user_id: str
aitbc_amount: float
@@ -124,6 +125,48 @@ class ExchangePaymentResponse(BaseModel):
created_at: int
expires_at: int
class ExchangeRatesResponse(BaseModel):
btc_to_aitbc: float
aitbc_to_btc: float
fee_percent: float
class PaymentStatusResponse(BaseModel):
payment_id: str
user_id: str
aitbc_amount: float
btc_amount: float
payment_address: str
status: str
created_at: int
expires_at: int
confirmations: int = 0
tx_hash: Optional[str] = None
confirmed_at: Optional[int] = None
class MarketStatsResponse(BaseModel):
price: float
price_change_24h: float
daily_volume: float
daily_volume_btc: float
total_payments: int
pending_payments: int
class WalletBalanceResponse(BaseModel):
address: str
balance: float
unconfirmed_balance: float
total_received: float
total_sent: float
class WalletInfoResponse(BaseModel):
address: str
balance: float
unconfirmed_balance: float
total_received: float
total_sent: float
transactions: list
network: str
block_height: int
class JobCreate(BaseModel):
payload: Dict[str, Any]
@@ -213,6 +256,16 @@ class MarketplaceBidRequest(BaseModel):
notes: Optional[str] = Field(default=None, max_length=1024)
class MarketplaceBidView(BaseModel):
id: str
provider: str
capacity: int
price: float
notes: Optional[str] = None
status: str
submitted_at: datetime
class BlockSummary(BaseModel):
model_config = ConfigDict(populate_by_name=True)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import httpx
from collections import defaultdict, deque
from datetime import datetime
from typing import Optional
@@ -38,25 +39,51 @@ class ExplorerService:
self.session = session
def list_blocks(self, *, limit: int = 20, offset: int = 0) -> BlockListResponse:
statement = select(Job).order_by(Job.requested_at.desc())
jobs = self.session.exec(statement.offset(offset).limit(limit)).all()
# Fetch real blockchain data from RPC API
try:
# Use the blockchain RPC API running on localhost:8082
with httpx.Client(timeout=10.0) as client:
response = client.get("http://localhost:8082/rpc/blocks", params={"limit": limit, "offset": offset})
response.raise_for_status()
rpc_data = response.json()
items: list[BlockSummary] = []
for block in rpc_data.get("blocks", []):
items.append(
BlockSummary(
height=block["height"],
hash=block["hash"],
timestamp=datetime.fromisoformat(block["timestamp"]),
txCount=block["tx_count"],
proposer=block["proposer"],
)
)
next_offset: Optional[int] = offset + len(items) if len(items) == limit else None
return BlockListResponse(items=items, next_offset=next_offset)
except Exception as e:
# Fallback to fake data if RPC is unavailable
print(f"Warning: Failed to fetch blocks from RPC: {e}, falling back to fake data")
statement = select(Job).order_by(Job.requested_at.desc())
jobs = self.session.exec(statement.offset(offset).limit(limit)).all()
items: list[BlockSummary] = []
for index, job in enumerate(jobs):
height = _DEFAULT_HEIGHT_BASE + offset + index
proposer = job.assigned_miner_id or "unassigned"
items.append(
BlockSummary(
height=height,
hash=job.id,
timestamp=job.requested_at,
txCount=1,
proposer=proposer,
items: list[BlockSummary] = []
for index, job in enumerate(jobs):
height = _DEFAULT_HEIGHT_BASE + offset + index
proposer = job.assigned_miner_id or "unassigned"
items.append(
BlockSummary(
height=height,
hash=job.id,
timestamp=job.requested_at,
txCount=1,
proposer=proposer,
)
)
)
next_offset: Optional[int] = offset + len(items) if len(items) == limit else None
return BlockListResponse(items=items, next_offset=next_offset)
next_offset: Optional[int] = offset + len(items) if len(items) == limit else None
return BlockListResponse(items=items, next_offset=next_offset)
def list_transactions(self, *, limit: int = 50, offset: int = 0) -> TransactionListResponse:
statement = (

View File

@@ -10,6 +10,7 @@ from ..schemas import (
MarketplaceBidRequest,
MarketplaceOfferView,
MarketplaceStatsView,
MarketplaceBidView,
)
@@ -70,6 +71,47 @@ class MarketplaceService:
self.session.refresh(bid)
return bid
def list_bids(
self,
*,
status: Optional[str] = None,
provider: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> list[MarketplaceBidView]:
stmt = select(MarketplaceBid).order_by(MarketplaceBid.submitted_at.desc())
if status is not None:
normalised = status.strip().lower()
if normalised not in ("pending", "accepted", "rejected"):
raise ValueError(f"invalid status: {status}")
stmt = stmt.where(MarketplaceBid.status == normalised)
if provider is not None:
stmt = stmt.where(MarketplaceBid.provider == provider)
stmt = stmt.offset(offset).limit(limit)
bids = self.session.exec(stmt).all()
return [self._to_bid_view(bid) for bid in bids]
def get_bid(self, bid_id: str) -> Optional[MarketplaceBidView]:
bid = self.session.get(MarketplaceBid, bid_id)
if bid:
return self._to_bid_view(bid)
return None
@staticmethod
def _to_bid_view(bid: MarketplaceBid) -> MarketplaceBidView:
return MarketplaceBidView(
id=bid.id,
provider=bid.provider,
capacity=bid.capacity,
price=bid.price,
notes=bid.notes,
status=bid.status,
submitted_at=bid.submitted_at,
)
@staticmethod
def _to_offer_view(offer: MarketplaceOffer) -> MarketplaceOfferView:
status_val = offer.status.value if hasattr(offer.status, "value") else offer.status

View File

@@ -0,0 +1,470 @@
"""Tests for exchange API endpoints"""
import pytest
import time
import uuid
from fastapi.testclient import TestClient
from sqlmodel import Session, delete
from app.config import settings
from app.main import create_app
from app.routers.exchange import payments, BITCOIN_CONFIG
@pytest.fixture(scope="module", autouse=True)
def _init_db(tmp_path_factory):
db_file = tmp_path_factory.mktemp("data") / "exchange.db"
settings.database_url = f"sqlite:///{db_file}"
# Initialize database if needed
yield
@pytest.fixture()
def session():
# For this test, we'll use in-memory storage
yield None
@pytest.fixture()
def client():
app = create_app()
return TestClient(app)
@pytest.fixture(autouse=True)
def clear_payments():
"""Clear payments before each test"""
payments.clear()
yield
payments.clear()
class TestExchangeRatesEndpoint:
"""Test exchange rates endpoint"""
def test_get_exchange_rates_success(self, client: TestClient):
"""Test successful exchange rates retrieval"""
response = client.get("/v1/exchange/rates")
assert response.status_code == 200
data = response.json()
assert "btc_to_aitbc" in data
assert "aitbc_to_btc" in data
assert "fee_percent" in data
# Verify rates are reasonable
assert data["btc_to_aitbc"] > 0
assert data["aitbc_to_btc"] > 0
assert data["fee_percent"] >= 0
# Verify mathematical relationship
expected_aitbc_to_btc = 1.0 / data["btc_to_aitbc"]
assert abs(data["aitbc_to_btc"] - expected_aitbc_to_btc) < 0.00000001
class TestExchangeCreatePaymentEndpoint:
"""Test exchange create-payment endpoint"""
def test_create_payment_success(self, client: TestClient):
"""Test successful payment creation"""
payload = {
"user_id": "test_user_123",
"aitbc_amount": 1000,
"btc_amount": 0.01
}
response = client.post("/v1/exchange/create-payment", json=payload)
assert response.status_code == 200
data = response.json()
# Verify response structure
assert "payment_id" in data
assert data["user_id"] == payload["user_id"]
assert data["aitbc_amount"] == payload["aitbc_amount"]
assert data["btc_amount"] == payload["btc_amount"]
assert data["payment_address"] == BITCOIN_CONFIG['main_address']
assert data["status"] == "pending"
assert "created_at" in data
assert "expires_at" in data
# Verify payment was stored
assert data["payment_id"] in payments
stored_payment = payments[data["payment_id"]]
assert stored_payment["user_id"] == payload["user_id"]
assert stored_payment["aitbc_amount"] == payload["aitbc_amount"]
assert stored_payment["btc_amount"] == payload["btc_amount"]
def test_create_payment_invalid_amounts(self, client: TestClient):
"""Test payment creation with invalid amounts"""
# Test zero AITBC amount
payload1 = {
"user_id": "test_user",
"aitbc_amount": 0,
"btc_amount": 0.01
}
response1 = client.post("/v1/exchange/create-payment", json=payload1)
assert response1.status_code == 400
assert "Invalid amount" in response1.json()["detail"]
# Test negative BTC amount
payload2 = {
"user_id": "test_user",
"aitbc_amount": 1000,
"btc_amount": -0.01
}
response2 = client.post("/v1/exchange/create-payment", json=payload2)
assert response2.status_code == 400
assert "Invalid amount" in response2.json()["detail"]
def test_create_payment_amount_mismatch(self, client: TestClient):
"""Test payment creation with amount mismatch"""
payload = {
"user_id": "test_user",
"aitbc_amount": 1000, # Should be 0.01 BTC at 100000 rate
"btc_amount": 0.02 # This is double the expected amount
}
response = client.post("/v1/exchange/create-payment", json=payload)
assert response.status_code == 400
assert "Amount mismatch" in response.json()["detail"]
def test_create_payment_rounding_tolerance(self, client: TestClient):
"""Test payment creation with small rounding differences"""
payload = {
"user_id": "test_user",
"aitbc_amount": 1000,
"btc_amount": 0.01000000001 # Very small difference should be allowed
}
response = client.post("/v1/exchange/create-payment", json=payload)
assert response.status_code == 200
class TestExchangePaymentStatusEndpoint:
"""Test exchange payment-status endpoint"""
def test_get_payment_status_success(self, client: TestClient):
"""Test successful payment status retrieval"""
# First create a payment
create_payload = {
"user_id": "test_user",
"aitbc_amount": 500,
"btc_amount": 0.005
}
create_response = client.post("/v1/exchange/create-payment", json=create_payload)
payment_id = create_response.json()["payment_id"]
# Get payment status
response = client.get(f"/v1/exchange/payment-status/{payment_id}")
assert response.status_code == 200
data = response.json()
assert data["payment_id"] == payment_id
assert data["user_id"] == create_payload["user_id"]
assert data["aitbc_amount"] == create_payload["aitbc_amount"]
assert data["btc_amount"] == create_payload["btc_amount"]
assert data["status"] == "pending"
assert data["confirmations"] == 0
assert data["tx_hash"] is None
def test_get_payment_status_not_found(self, client: TestClient):
"""Test payment status for non-existent payment"""
fake_payment_id = "nonexistent_payment_id"
response = client.get(f"/v1/exchange/payment-status/{fake_payment_id}")
assert response.status_code == 404
assert "Payment not found" in response.json()["detail"]
def test_get_payment_status_expired(self, client: TestClient):
"""Test payment status for expired payment"""
# Create a payment with expired timestamp
payment_id = str(uuid.uuid4())
expired_payment = {
'payment_id': payment_id,
'user_id': 'test_user',
'aitbc_amount': 1000,
'btc_amount': 0.01,
'payment_address': BITCOIN_CONFIG['main_address'],
'status': 'pending',
'created_at': int(time.time()) - 7200, # 2 hours ago
'expires_at': int(time.time()) - 3600, # 1 hour ago (expired)
'confirmations': 0,
'tx_hash': None
}
payments[payment_id] = expired_payment
# Get payment status
response = client.get(f"/v1/exchange/payment-status/{payment_id}")
assert response.status_code == 200
data = response.json()
assert data["status"] == "expired"
class TestExchangeConfirmPaymentEndpoint:
"""Test exchange confirm-payment endpoint"""
def test_confirm_payment_success(self, client: TestClient):
"""Test successful payment confirmation"""
# First create a payment
create_payload = {
"user_id": "test_user",
"aitbc_amount": 1000,
"btc_amount": 0.01
}
create_response = client.post("/v1/exchange/create-payment", json=create_payload)
payment_id = create_response.json()["payment_id"]
# Confirm payment
confirm_payload = {"tx_hash": "test_transaction_hash_123"}
response = client.post(f"/v1/exchange/confirm-payment/{payment_id}",
json=confirm_payload)
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["payment_id"] == payment_id
assert data["aitbc_amount"] == create_payload["aitbc_amount"]
# Verify payment was updated
payment = payments[payment_id]
assert payment["status"] == "confirmed"
assert payment["tx_hash"] == confirm_payload["tx_hash"]
assert "confirmed_at" in payment
def test_confirm_payment_not_found(self, client: TestClient):
"""Test payment confirmation for non-existent payment"""
fake_payment_id = "nonexistent_payment_id"
confirm_payload = {"tx_hash": "test_tx_hash"}
response = client.post(f"/v1/exchange/confirm-payment/{fake_payment_id}",
json=confirm_payload)
assert response.status_code == 404
assert "Payment not found" in response.json()["detail"]
def test_confirm_payment_not_pending(self, client: TestClient):
"""Test payment confirmation for non-pending payment"""
# Create and confirm a payment
create_payload = {
"user_id": "test_user",
"aitbc_amount": 1000,
"btc_amount": 0.01
}
create_response = client.post("/v1/exchange/create-payment", json=create_payload)
payment_id = create_response.json()["payment_id"]
# First confirmation
confirm_payload = {"tx_hash": "test_tx_hash_1"}
client.post(f"/v1/exchange/confirm-payment/{payment_id}", json=confirm_payload)
# Try to confirm again
confirm_payload2 = {"tx_hash": "test_tx_hash_2"}
response = client.post(f"/v1/exchange/confirm-payment/{payment_id}",
json=confirm_payload2)
assert response.status_code == 400
assert "Payment not in pending state" in response.json()["detail"]
class TestExchangeMarketStatsEndpoint:
"""Test exchange market-stats endpoint"""
def test_get_market_stats_empty(self, client: TestClient):
"""Test market stats with no payments"""
response = client.get("/v1/exchange/market-stats")
assert response.status_code == 200
data = response.json()
assert "price" in data
assert "price_change_24h" in data
assert "daily_volume" in data
assert "daily_volume_btc" in data
assert "total_payments" in data
assert "pending_payments" in data
# With no payments, these should be 0
assert data["daily_volume"] == 0
assert data["daily_volume_btc"] == 0
assert data["total_payments"] == 0
assert data["pending_payments"] == 0
def test_get_market_stats_with_payments(self, client: TestClient):
"""Test market stats with payments"""
current_time = int(time.time())
# Create some confirmed payments (within 24h)
for i in range(3):
payment_id = str(uuid.uuid4())
payment = {
'payment_id': payment_id,
'user_id': f'user_{i}',
'aitbc_amount': 1000 * (i + 1),
'btc_amount': 0.01 * (i + 1),
'payment_address': BITCOIN_CONFIG['main_address'],
'status': 'confirmed',
'created_at': current_time - 3600, # 1 hour ago
'expires_at': current_time + 3600,
'confirmations': 1,
'tx_hash': f'tx_hash_{i}',
'confirmed_at': current_time - 1800 # 30 minutes ago
}
payments[payment_id] = payment
# Create some pending payments
for i in range(2):
payment_id = str(uuid.uuid4())
payment = {
'payment_id': payment_id,
'user_id': f'pending_user_{i}',
'aitbc_amount': 500 * (i + 1),
'btc_amount': 0.005 * (i + 1),
'payment_address': BITCOIN_CONFIG['main_address'],
'status': 'pending',
'created_at': current_time - 1800, # 30 minutes ago
'expires_at': current_time + 1800,
'confirmations': 0,
'tx_hash': None
}
payments[payment_id] = payment
# Create an old confirmed payment (older than 24h)
old_payment_id = str(uuid.uuid4())
old_payment = {
'payment_id': old_payment_id,
'user_id': 'old_user',
'aitbc_amount': 2000,
'btc_amount': 0.02,
'payment_address': BITCOIN_CONFIG['main_address'],
'status': 'confirmed',
'created_at': current_time - 86400 - 3600, # 25 hours ago
'expires_at': current_time - 86400 + 3600,
'confirmations': 1,
'tx_hash': 'old_tx_hash',
'confirmed_at': current_time - 86400 # 24 hours ago
}
payments[old_payment_id] = old_payment
response = client.get("/v1/exchange/market-stats")
assert response.status_code == 200
data = response.json()
# Verify calculations
# Confirmed payments: 1000 + 2000 + 3000 = 6000 AITBC
# Pending payments: 500 + 1000 = 1500 AITBC
# Daily volume should only include recent confirmed payments
expected_daily_volume = 1000 + 2000 + 3000 # 6000 AITBC
expected_daily_volume_btc = expected_daily_volume / BITCOIN_CONFIG['exchange_rate']
assert data["total_payments"] == 3 # Only confirmed payments
assert data["pending_payments"] == 2
assert data["daily_volume"] == expected_daily_volume
assert abs(data["daily_volume_btc"] - expected_daily_volume_btc) < 0.00000001
class TestExchangeWalletEndpoints:
"""Test exchange wallet endpoints"""
def test_wallet_balance_endpoint(self, client: TestClient):
"""Test wallet balance endpoint"""
# This test may fail if bitcoin_wallet service is not available
# We'll test the endpoint structure and error handling
response = client.get("/v1/exchange/wallet/balance")
# The endpoint should exist, but may return 500 if service is unavailable
assert response.status_code in [200, 500]
if response.status_code == 200:
data = response.json()
# Verify response structure if successful
expected_fields = ["address", "balance", "unconfirmed_balance",
"total_received", "total_sent"]
for field in expected_fields:
assert field in data
def test_wallet_info_endpoint(self, client: TestClient):
"""Test wallet info endpoint"""
response = client.get("/v1/exchange/wallet/info")
# The endpoint should exist, but may return 500 if service is unavailable
assert response.status_code in [200, 500]
if response.status_code == 200:
data = response.json()
# Verify response structure if successful
expected_fields = ["address", "balance", "unconfirmed_balance",
"total_received", "total_sent", "transactions",
"network", "block_height"]
for field in expected_fields:
assert field in data
class TestExchangeIntegration:
"""Test exchange integration scenarios"""
def test_complete_payment_lifecycle(self, client: TestClient):
"""Test complete payment lifecycle: create → check status → confirm"""
# Step 1: Create payment
create_payload = {
"user_id": "integration_user",
"aitbc_amount": 1500,
"btc_amount": 0.015
}
create_response = client.post("/v1/exchange/create-payment", json=create_payload)
assert create_response.status_code == 200
payment_id = create_response.json()["payment_id"]
# Step 2: Check initial status
status_response = client.get(f"/v1/exchange/payment-status/{payment_id}")
assert status_response.status_code == 200
status_data = status_response.json()
assert status_data["status"] == "pending"
assert status_data["confirmations"] == 0
# Step 3: Confirm payment
confirm_payload = {"tx_hash": "integration_tx_hash"}
confirm_response = client.post(f"/v1/exchange/confirm-payment/{payment_id}",
json=confirm_payload)
assert confirm_response.status_code == 200
# Step 4: Check final status
final_status_response = client.get(f"/v1/exchange/payment-status/{payment_id}")
assert final_status_response.status_code == 200
final_status_data = final_status_response.json()
assert final_status_data["status"] == "confirmed"
assert final_status_data["tx_hash"] == "integration_tx_hash"
assert "confirmed_at" in final_status_data
def test_market_stats_update_after_payment(self, client: TestClient):
"""Test that market stats update after payment confirmation"""
# Get initial stats
initial_stats_response = client.get("/v1/exchange/market-stats")
assert initial_stats_response.status_code == 200
initial_stats = initial_stats_response.json()
initial_total = initial_stats["total_payments"]
# Create and confirm payment
create_payload = {
"user_id": "stats_user",
"aitbc_amount": 2000,
"btc_amount": 0.02
}
create_response = client.post("/v1/exchange/create-payment", json=create_payload)
payment_id = create_response.json()["payment_id"]
confirm_payload = {"tx_hash": "stats_tx_hash"}
client.post(f"/v1/exchange/confirm-payment/{payment_id}", json=confirm_payload)
# Check updated stats
updated_stats_response = client.get("/v1/exchange/market-stats")
assert updated_stats_response.status_code == 200
updated_stats = updated_stats_response.json()
# Total payments should have increased
assert updated_stats["total_payments"] == initial_total + 1
assert updated_stats["daily_volume"] >= initial_stats["daily_volume"]

View File

@@ -3,7 +3,7 @@ from fastapi.testclient import TestClient
from sqlmodel import Session, delete
from app.config import settings
from app.domain import MarketplaceOffer, OfferStatus, MarketplaceBid
from app.domain import MarketplaceOffer, MarketplaceBid
from app.main import create_app
from app.services.marketplace import MarketplaceService
from app.storage.db import init_db, session_scope
@@ -33,8 +33,8 @@ def client():
def test_list_offers_filters_by_status(client: TestClient, session: Session):
open_offer = MarketplaceOffer(provider="Alpha", capacity=250, price=12.5, sla="99.9%", status=OfferStatus.open)
reserved_offer = MarketplaceOffer(provider="Beta", capacity=100, price=15.0, sla="99.5%", status=OfferStatus.reserved)
open_offer = MarketplaceOffer(provider="Alpha", capacity=250, price=12.5, sla="99.9%", status="open")
reserved_offer = MarketplaceOffer(provider="Beta", capacity=100, price=15.0, sla="99.5%", status="reserved")
session.add(open_offer)
session.add(reserved_offer)
session.commit()
@@ -99,9 +99,9 @@ def test_submit_bid_creates_record(client: TestClient, session: Session):
def test_marketplace_service_list_offers_handles_limit_offset(session: Session):
session.add_all(
[
MarketplaceOffer(provider="A", capacity=50, price=9.0, sla="99.0%", status=OfferStatus.open),
MarketplaceOffer(provider="B", capacity=70, price=11.0, sla="99.0%", status=OfferStatus.open),
MarketplaceOffer(provider="C", capacity=90, price=13.0, sla="99.0%", status=OfferStatus.open),
MarketplaceOffer(provider="A", capacity=50, price=9.0, sla="99.0%", status="open"),
MarketplaceOffer(provider="B", capacity=70, price=11.0, sla="99.0%", status="open"),
MarketplaceOffer(provider="C", capacity=90, price=13.0, sla="99.0%", status="open"),
]
)
session.commit()
@@ -111,3 +111,161 @@ def test_marketplace_service_list_offers_handles_limit_offset(session: Session):
assert len(limited) == 2
# Offers ordered by created_at descending → last inserted first
assert {offer.provider for offer in limited} == {"B", "A"}
def test_submit_bid_creates_record(client: TestClient, session: Session):
payload = {
"provider": "TestProvider",
"capacity": 150,
"price": 0.075,
"notes": "Test bid for GPU capacity"
}
resp = client.post("/v1/marketplace/bids", json=payload)
assert resp.status_code == 202
response_payload = resp.json()
assert "id" in response_payload
bid = session.get(MarketplaceBid, response_payload["id"])
assert bid is not None
assert bid.provider == payload["provider"]
assert bid.capacity == payload["capacity"]
assert bid.price == payload["price"]
assert bid.notes == payload["notes"]
assert bid.status == "pending"
def test_list_bids_filters_by_status_and_provider(client: TestClient, session: Session):
# Create test bids
pending_bid = MarketplaceBid(provider="ProviderA", capacity=100, price=0.05, notes="Pending bid")
accepted_bid = MarketplaceBid(provider="ProviderB", capacity=200, price=0.08, notes="Accepted bid", status="accepted")
rejected_bid = MarketplaceBid(provider="ProviderA", capacity=150, price=0.06, notes="Rejected bid", status="rejected")
session.add_all([pending_bid, accepted_bid, rejected_bid])
session.commit()
# List all bids
resp = client.get("/v1/marketplace/bids")
assert resp.status_code == 200
payload = resp.json()
assert len(payload) == 3
# Filter by status
resp_pending = client.get("/v1/marketplace/bids", params={"status": "pending"})
assert resp_pending.status_code == 200
pending_payload = resp_pending.json()
assert len(pending_payload) == 1
assert pending_payload[0]["provider"] == "ProviderA"
assert pending_payload[0]["status"] == "pending"
# Filter by provider
resp_provider = client.get("/v1/marketplace/bids", params={"provider": "ProviderA"})
assert resp_provider.status_code == 200
provider_payload = resp_provider.json()
assert len(provider_payload) == 2
assert all(bid["provider"] == "ProviderA" for bid in provider_payload)
# Filter by both status and provider
resp_both = client.get("/v1/marketplace/bids", params={"status": "pending", "provider": "ProviderA"})
assert resp_both.status_code == 200
both_payload = resp_both.json()
assert len(both_payload) == 1
assert both_payload[0]["provider"] == "ProviderA"
assert both_payload[0]["status"] == "pending"
# Invalid status yields 400
resp_invalid = client.get("/v1/marketplace/bids", params={"status": "invalid"})
assert resp_invalid.status_code == 400
def test_get_bid_details(client: TestClient, session: Session):
# Create a test bid
bid = MarketplaceBid(
provider="TestProvider",
capacity=100,
price=0.05,
notes="Test bid details",
status="pending"
)
session.add(bid)
session.commit()
session.refresh(bid)
# Get bid details
resp = client.get(f"/v1/marketplace/bids/{bid.id}")
assert resp.status_code == 200
payload = resp.json()
assert payload["id"] == bid.id
assert payload["provider"] == bid.provider
assert payload["capacity"] == bid.capacity
assert payload["price"] == bid.price
assert payload["notes"] == bid.notes
assert payload["status"] == bid.status
assert "submitted_at" in payload
# Non-existent bid yields 404
resp_not_found = client.get("/v1/marketplace/bids/nonexistent")
assert resp_not_found.status_code == 404
def test_marketplace_service_list_bids_handles_limit_offset(session: Session):
session.add_all(
[
MarketplaceBid(provider="A", capacity=50, price=0.05, notes="Bid A"),
MarketplaceBid(provider="B", capacity=70, price=0.07, notes="Bid B"),
MarketplaceBid(provider="C", capacity=90, price=0.09, notes="Bid C"),
]
)
session.commit()
service = MarketplaceService(session)
limited = service.list_bids(limit=2, offset=1)
assert len(limited) == 2
# Bids ordered by submitted_at descending → last inserted first
assert {bid.provider for bid in limited} == {"B", "A"}
def test_marketplace_stats_includes_bids(client: TestClient, session: Session):
# Create offers and bids
session.add_all(
[
MarketplaceOffer(provider="Alpha", capacity=200, price=10.0, sla="99.9%", status="open"),
MarketplaceOffer(provider="Beta", capacity=150, price=20.0, sla="99.5%", status="reserved"),
MarketplaceBid(provider="ProviderA", capacity=100, price=0.05, notes="Active bid 1"),
MarketplaceBid(provider="ProviderB", capacity=200, price=0.08, notes="Active bid 2"),
MarketplaceBid(provider="ProviderC", capacity=150, price=0.06, notes="Accepted bid", status="accepted"),
]
)
session.commit()
resp = client.get("/v1/marketplace/stats")
assert resp.status_code == 200
stats = resp.json()
assert stats["totalOffers"] == 2
assert stats["openCapacity"] == 200 # Only open offers
assert pytest.approx(stats["averagePrice"], rel=1e-3) == 10.0 # Only open offers
assert stats["activeBids"] == 2 # Only pending bids
def test_bid_validation(client: TestClient):
# Test invalid capacity (zero)
resp_zero_capacity = client.post("/v1/marketplace/bids", json={
"provider": "TestProvider",
"capacity": 0,
"price": 0.05
})
assert resp_zero_capacity.status_code == 400
# Test invalid price (negative)
resp_negative_price = client.post("/v1/marketplace/bids", json={
"provider": "TestProvider",
"capacity": 100,
"price": -0.05
})
assert resp_negative_price.status_code == 400
# Test missing required field
resp_missing_provider = client.post("/v1/marketplace/bids", json={
"capacity": 100,
"price": 0.05
})
assert resp_missing_provider.status_code == 422 # Validation error

View File

@@ -0,0 +1,224 @@
"""Exchange commands for AITBC CLI"""
import click
import httpx
from typing import Optional
from ..config import get_config
from ..utils import success, error, output
@click.group()
def exchange():
"""Bitcoin exchange operations"""
pass
@exchange.command()
@click.pass_context
def rates(ctx):
"""Get current exchange rates"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/exchange/rates",
timeout=10
)
if response.status_code == 200:
rates_data = response.json()
success("Current exchange rates:")
output(rates_data, ctx.obj['output_format'])
else:
error(f"Failed to get exchange rates: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@exchange.command()
@click.option("--aitbc-amount", type=float, help="Amount of AITBC to buy")
@click.option("--btc-amount", type=float, help="Amount of BTC to spend")
@click.option("--user-id", help="User ID for the payment")
@click.option("--notes", help="Additional notes for the payment")
@click.pass_context
def create_payment(ctx, aitbc_amount: Optional[float], btc_amount: Optional[float],
user_id: Optional[str], notes: Optional[str]):
"""Create a Bitcoin payment request for AITBC purchase"""
config = ctx.obj['config']
# Validate input
if aitbc_amount is not None and aitbc_amount <= 0:
error("AITBC amount must be greater than 0")
return
if btc_amount is not None and btc_amount <= 0:
error("BTC amount must be greater than 0")
return
if not aitbc_amount and not btc_amount:
error("Either --aitbc-amount or --btc-amount must be specified")
return
# Get exchange rates to calculate missing amount
try:
with httpx.Client() as client:
rates_response = client.get(
f"{config.coordinator_url}/v1/exchange/rates",
timeout=10
)
if rates_response.status_code != 200:
error("Failed to get exchange rates")
return
rates = rates_response.json()
btc_to_aitbc = rates.get('btc_to_aitbc', 100000)
# Calculate missing amount
if aitbc_amount and not btc_amount:
btc_amount = aitbc_amount / btc_to_aitbc
elif btc_amount and not aitbc_amount:
aitbc_amount = btc_amount * btc_to_aitbc
# Prepare payment request
payment_data = {
"user_id": user_id or "cli_user",
"aitbc_amount": aitbc_amount,
"btc_amount": btc_amount
}
if notes:
payment_data["notes"] = notes
# Create payment
response = client.post(
f"{config.coordinator_url}/v1/exchange/create-payment",
json=payment_data,
timeout=10
)
if response.status_code == 200:
payment = response.json()
success(f"Payment created: {payment.get('payment_id')}")
success(f"Send {btc_amount:.8f} BTC to: {payment.get('payment_address')}")
success(f"Expires at: {payment.get('expires_at')}")
output(payment, ctx.obj['output_format'])
else:
error(f"Failed to create payment: {response.status_code}")
if response.text:
error(f"Error details: {response.text}")
except Exception as e:
error(f"Network error: {e}")
@exchange.command()
@click.option("--payment-id", required=True, help="Payment ID to check")
@click.pass_context
def payment_status(ctx, payment_id: str):
"""Check payment confirmation status"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/exchange/payment-status/{payment_id}",
timeout=10
)
if response.status_code == 200:
status_data = response.json()
status = status_data.get('status', 'unknown')
if status == 'confirmed':
success(f"Payment {payment_id} is confirmed!")
success(f"AITBC amount: {status_data.get('aitbc_amount', 0)}")
elif status == 'pending':
success(f"Payment {payment_id} is pending confirmation")
elif status == 'expired':
error(f"Payment {payment_id} has expired")
else:
success(f"Payment {payment_id} status: {status}")
output(status_data, ctx.obj['output_format'])
else:
error(f"Failed to get payment status: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@exchange.command()
@click.pass_context
def market_stats(ctx):
"""Get exchange market statistics"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/exchange/market-stats",
timeout=10
)
if response.status_code == 200:
stats = response.json()
success("Exchange market statistics:")
output(stats, ctx.obj['output_format'])
else:
error(f"Failed to get market stats: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@exchange.group()
def wallet():
"""Bitcoin wallet operations"""
pass
@wallet.command()
@click.pass_context
def balance(ctx):
"""Get Bitcoin wallet balance"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/exchange/wallet/balance",
timeout=10
)
if response.status_code == 200:
balance_data = response.json()
success("Bitcoin wallet balance:")
output(balance_data, ctx.obj['output_format'])
else:
error(f"Failed to get wallet balance: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@wallet.command()
@click.pass_context
def info(ctx):
"""Get comprehensive Bitcoin wallet information"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/exchange/wallet/info",
timeout=10
)
if response.status_code == 200:
wallet_info = response.json()
success("Bitcoin wallet information:")
output(wallet_info, ctx.obj['output_format'])
else:
error(f"Failed to get wallet info: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")

View File

@@ -305,3 +305,164 @@ def review(ctx, gpu_id: str, rating: int, comment: Optional[str]):
error(f"Failed to add review: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@marketplace.group()
def bid():
"""Marketplace bid operations"""
pass
@bid.command()
@click.option("--provider", required=True, help="Provider ID (e.g., miner123)")
@click.option("--capacity", type=int, required=True, help="Bid capacity (number of units)")
@click.option("--price", type=float, required=True, help="Price per unit in AITBC")
@click.option("--notes", help="Additional notes for the bid")
@click.pass_context
def submit(ctx, provider: str, capacity: int, price: float, notes: Optional[str]):
"""Submit a bid to the marketplace"""
config = ctx.obj['config']
# Validate inputs
if capacity <= 0:
error("Capacity must be greater than 0")
return
if price <= 0:
error("Price must be greater than 0")
return
# Build bid data
bid_data = {
"provider": provider,
"capacity": capacity,
"price": price
}
if notes:
bid_data["notes"] = notes
try:
with httpx.Client() as client:
response = client.post(
f"{config.coordinator_url}/v1/marketplace/bids",
headers={
"Content-Type": "application/json",
"X-Api-Key": config.api_key or ""
},
json=bid_data
)
if response.status_code == 202:
result = response.json()
success(f"Bid submitted successfully: {result.get('id')}")
output(result, ctx.obj['output_format'])
else:
error(f"Failed to submit bid: {response.status_code}")
if response.text:
error(f"Error details: {response.text}")
except Exception as e:
error(f"Network error: {e}")
@bid.command()
@click.option("--status", help="Filter by bid status (pending, accepted, rejected)")
@click.option("--provider", help="Filter by provider ID")
@click.option("--limit", type=int, default=20, help="Maximum number of results")
@click.pass_context
def list(ctx, status: Optional[str], provider: Optional[str], limit: int):
"""List marketplace bids"""
config = ctx.obj['config']
# Build query params
params = {"limit": limit}
if status:
params["status"] = status
if provider:
params["provider"] = provider
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/marketplace/bids",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
bids = response.json()
output(bids, ctx.obj['output_format'])
else:
error(f"Failed to list bids: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@bid.command()
@click.argument("bid_id")
@click.pass_context
def details(ctx, bid_id: str):
"""Get bid details"""
config = ctx.obj['config']
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/marketplace/bids/{bid_id}",
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
bid_data = response.json()
output(bid_data, ctx.obj['output_format'])
else:
error(f"Bid not found: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")
@marketplace.group()
def offers():
"""Marketplace offers operations"""
pass
@offers.command()
@click.option("--status", help="Filter by offer status (open, reserved, closed)")
@click.option("--gpu-model", help="Filter by GPU model")
@click.option("--price-max", type=float, help="Maximum price per hour")
@click.option("--memory-min", type=int, help="Minimum memory in GB")
@click.option("--region", help="Filter by region")
@click.option("--limit", type=int, default=20, help="Maximum number of results")
@click.pass_context
def list(ctx, status: Optional[str], gpu_model: Optional[str], price_max: Optional[float],
memory_min: Optional[int], region: Optional[str], limit: int):
"""List marketplace offers"""
config = ctx.obj['config']
# Build query params
params = {"limit": limit}
if status:
params["status"] = status
if gpu_model:
params["gpu_model"] = gpu_model
if price_max:
params["price_max"] = price_max
if memory_min:
params["memory_min"] = memory_min
if region:
params["region"] = region
try:
with httpx.Client() as client:
response = client.get(
f"{config.coordinator_url}/v1/marketplace/offers",
params=params,
headers={"X-Api-Key": config.api_key or ""}
)
if response.status_code == 200:
offers = response.json()
output(offers, ctx.obj['output_format'])
else:
error(f"Failed to list offers: {response.status_code}")
except Exception as e:
error(f"Network error: {e}")

View File

@@ -21,6 +21,7 @@ from .commands.admin import admin
from .commands.config import config
from .commands.monitor import monitor
from .commands.governance import governance
from .commands.exchange import exchange
from .plugins import plugin, load_plugins
@@ -98,6 +99,7 @@ cli.add_command(admin)
cli.add_command(config)
cli.add_command(monitor)
cli.add_command(governance)
cli.add_command(exchange)
cli.add_command(plugin)
load_plugins(cli)

361
cli/test_exchange_e2e.py Normal file
View File

@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""
Exchange End-to-End Test
Tests complete Bitcoin exchange workflow: rates → payment creation → monitoring → confirmation.
"""
import argparse
import sys
import time
from typing import Optional
import httpx
DEFAULT_COORDINATOR = "http://localhost:8000"
DEFAULT_API_KEY = "${CLIENT_API_KEY}"
DEFAULT_USER_ID = "e2e_test_user"
DEFAULT_AITBC_AMOUNT = 1000
DEFAULT_TIMEOUT = 300
POLL_INTERVAL = 10
def get_exchange_rates(client: httpx.Client, base_url: str) -> Optional[dict]:
"""Get current exchange rates"""
response = client.get(
f"{base_url}/v1/exchange/rates",
timeout=10,
)
if response.status_code != 200:
print(f"❌ Failed to get exchange rates: {response.status_code} {response.text}")
return None
return response.json()
def create_payment(client: httpx.Client, base_url: str, user_id: str,
aitbc_amount: float, btc_amount: Optional[float] = None,
notes: Optional[str] = None) -> Optional[dict]:
"""Create a Bitcoin payment request"""
if not btc_amount:
# Get rates to calculate BTC amount
rates = get_exchange_rates(client, base_url)
if not rates:
return None
btc_amount = aitbc_amount / rates['btc_to_aitbc']
payload = {
"user_id": user_id,
"aitbc_amount": aitbc_amount,
"btc_amount": btc_amount
}
if notes:
payload["notes"] = notes
response = client.post(
f"{base_url}/v1/exchange/create-payment",
json=payload,
timeout=10,
)
if response.status_code != 200:
print(f"❌ Failed to create payment: {response.status_code} {response.text}")
return None
return response.json()
def get_payment_status(client: httpx.Client, base_url: str, payment_id: str) -> Optional[dict]:
"""Get payment status"""
response = client.get(
f"{base_url}/v1/exchange/payment-status/{payment_id}",
timeout=10,
)
if response.status_code != 200:
print(f"❌ Failed to get payment status: {response.status_code} {response.text}")
return None
return response.json()
def confirm_payment(client: httpx.Client, base_url: str, payment_id: str,
tx_hash: str) -> Optional[dict]:
"""Confirm payment (simulating blockchain confirmation)"""
response = client.post(
f"{base_url}/v1/exchange/confirm-payment/{payment_id}",
json={"tx_hash": tx_hash},
timeout=10,
)
if response.status_code != 200:
print(f"❌ Failed to confirm payment: {response.status_code} {response.text}")
return None
return response.json()
def get_market_stats(client: httpx.Client, base_url: str) -> Optional[dict]:
"""Get market statistics"""
response = client.get(
f"{base_url}/v1/exchange/market-stats",
timeout=10,
)
if response.status_code != 200:
print(f"❌ Failed to get market stats: {response.status_code} {response.text}")
return None
return response.json()
def get_wallet_balance(client: httpx.Client, base_url: str) -> Optional[dict]:
"""Get Bitcoin wallet balance"""
response = client.get(
f"{base_url}/v1/exchange/wallet/balance",
timeout=10,
)
if response.status_code != 200:
print(f"❌ Failed to get wallet balance: {response.status_code} {response.text}")
return None
return response.json()
def monitor_payment_confirmation(client: httpx.Client, base_url: str,
payment_id: str, timeout: int) -> Optional[str]:
"""Monitor payment until confirmed or timeout"""
deadline = time.time() + timeout
while time.time() < deadline:
status_data = get_payment_status(client, base_url, payment_id)
if not status_data:
return None
status = status_data.get("status")
print(f"⏳ Payment status: {status}")
if status == "confirmed":
return status
elif status == "expired":
print("❌ Payment expired")
return status
time.sleep(POLL_INTERVAL)
print("❌ Payment monitoring timed out")
return None
def test_basic_exchange_workflow(client: httpx.Client, base_url: str, user_id: str,
aitbc_amount: float) -> bool:
"""Test basic exchange workflow"""
print("🧪 Testing basic exchange workflow...")
# Step 1: Get exchange rates
print("💱 Getting exchange rates...")
rates = get_exchange_rates(client, base_url)
if not rates:
print("❌ Failed to get exchange rates")
return False
print(f"✅ Exchange rates: 1 BTC = {rates['btc_to_aitbc']:,} AITBC")
print(f" Fee: {rates['fee_percent']}%")
# Step 2: Create payment
print(f"💰 Creating payment for {aitbc_amount} AITBC...")
payment = create_payment(client, base_url, user_id, aitbc_amount,
notes="E2E test payment")
if not payment:
print("❌ Failed to create payment")
return False
print(f"✅ Payment created: {payment['payment_id']}")
print(f" Send {payment['btc_amount']:.8f} BTC to: {payment['payment_address']}")
print(f" Expires at: {payment['expires_at']}")
# Step 3: Check initial payment status
print("📋 Checking initial payment status...")
status = get_payment_status(client, base_url, payment['payment_id'])
if not status:
print("❌ Failed to get payment status")
return False
print(f"✅ Initial status: {status['status']}")
# Step 4: Simulate payment confirmation
print("🔗 Simulating blockchain confirmation...")
tx_hash = f"test_tx_{int(time.time())}"
confirmation = confirm_payment(client, base_url, payment['payment_id'], tx_hash)
if not confirmation:
print("❌ Failed to confirm payment")
return False
print(f"✅ Payment confirmed with transaction: {tx_hash}")
# Step 5: Verify final status
print("📄 Verifying final payment status...")
final_status = get_payment_status(client, base_url, payment['payment_id'])
if not final_status:
print("❌ Failed to get final payment status")
return False
if final_status['status'] != 'confirmed':
print(f"❌ Expected confirmed status, got: {final_status['status']}")
return False
print(f"✅ Payment confirmed! AITBC amount: {final_status['aitbc_amount']}")
return True
def test_market_statistics(client: httpx.Client, base_url: str) -> bool:
"""Test market statistics functionality"""
print("🧪 Testing market statistics...")
stats = get_market_stats(client, base_url)
if not stats:
print("❌ Failed to get market stats")
return False
print(f"📊 Market Statistics:")
print(f" Current price: ${stats['price']:.8f} per AITBC")
print(f" 24h change: {stats['price_change_24h']:+.2f}%")
print(f" Daily volume: {stats['daily_volume']:,} AITBC")
print(f" Daily volume (BTC): {stats['daily_volume_btc']:.8f} BTC")
print(f" Total payments: {stats['total_payments']}")
print(f" Pending payments: {stats['pending_payments']}")
return True
def test_wallet_operations(client: httpx.Client, base_url: str) -> bool:
"""Test wallet operations"""
print("🧪 Testing wallet operations...")
balance = get_wallet_balance(client, base_url)
if not balance:
print("❌ Failed to get wallet balance (service may be unavailable)")
return True # Don't fail test if wallet service is unavailable
print(f"💰 Wallet Balance:")
print(f" Address: {balance['address']}")
print(f" Balance: {balance['balance']:.8f} BTC")
print(f" Unconfirmed: {balance['unconfirmed_balance']:.8f} BTC")
print(f" Total received: {balance['total_received']:.8f} BTC")
print(f" Total sent: {balance['total_sent']:.8f} BTC")
return True
def test_multiple_payments_scenario(client: httpx.Client, base_url: str,
user_id: str) -> bool:
"""Test multiple payments scenario"""
print("🧪 Testing multiple payments scenario...")
# Create multiple payments
payment_amounts = [500, 1000, 1500]
payment_ids = []
for i, amount in enumerate(payment_amounts):
print(f"💰 Creating payment {i+1}: {amount} AITBC...")
payment = create_payment(client, base_url, f"{user_id}_{i}", amount,
notes=f"Multi-payment test {i+1}")
if not payment:
print(f"❌ Failed to create payment {i+1}")
return False
payment_ids.append(payment['payment_id'])
print(f"✅ Payment {i+1} created: {payment['payment_id']}")
time.sleep(1) # Small delay between payments
# Confirm all payments
for i, payment_id in enumerate(payment_ids):
print(f"🔗 Confirming payment {i+1}...")
tx_hash = f"multi_tx_{i}_{int(time.time())}"
confirmation = confirm_payment(client, base_url, payment_id, tx_hash)
if not confirmation:
print(f"❌ Failed to confirm payment {i+1}")
return False
print(f"✅ Payment {i+1} confirmed")
time.sleep(0.5)
# Check updated market stats
print("📊 Checking updated market statistics...")
final_stats = get_market_stats(client, base_url)
if final_stats:
print(f"✅ Final stats: {final_stats['total_payments']} total payments")
return True
def test_error_scenarios(client: httpx.Client, base_url: str) -> bool:
"""Test error handling scenarios"""
print("🧪 Testing error scenarios...")
# Test invalid payment creation
print("❌ Testing invalid payment creation...")
invalid_payment = create_payment(client, base_url, "test_user", -100)
if invalid_payment:
print("❌ Expected error for negative amount, but got success")
return False
print("✅ Correctly rejected negative amount")
# Test non-existent payment status
print("❌ Testing non-existent payment status...")
fake_status = get_payment_status(client, base_url, "fake_payment_id")
if fake_status:
print("❌ Expected error for fake payment ID, but got success")
return False
print("✅ Correctly rejected fake payment ID")
# Test invalid payment confirmation
print("❌ Testing invalid payment confirmation...")
fake_confirmation = confirm_payment(client, base_url, "fake_payment_id", "fake_tx")
if fake_confirmation:
print("❌ Expected error for fake payment confirmation, but got success")
return False
print("✅ Correctly rejected fake payment confirmation")
return True
def main() -> int:
parser = argparse.ArgumentParser(description="Exchange end-to-end test")
parser.add_argument("--url", default=DEFAULT_COORDINATOR, help="Coordinator base URL")
parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="Client API key")
parser.add_argument("--user-id", default=DEFAULT_USER_ID, help="User ID for payments")
parser.add_argument("--aitbc-amount", type=float, default=DEFAULT_AITBC_AMOUNT, help="AITBC amount for test payment")
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="Timeout in seconds")
parser.add_argument("--test", choices=["basic", "stats", "wallet", "multi", "errors", "all"],
default="all", help="Test scenario to run")
args = parser.parse_args()
with httpx.Client() as client:
print("🚀 Starting Exchange end-to-end test...")
print(f"📍 Coordinator: {args.url}")
print(f"🆔 User ID: {args.user_id}")
print(f"💰 Test amount: {args.aitbc_amount} AITBC")
print()
success = True
if args.test in ["basic", "all"]:
success &= test_basic_exchange_workflow(client, args.url, args.user_id, args.aitbc_amount)
print()
if args.test in ["stats", "all"]:
success &= test_market_statistics(client, args.url)
print()
if args.test in ["wallet", "all"]:
success &= test_wallet_operations(client, args.url)
print()
if args.test in ["multi", "all"]:
success &= test_multiple_payments_scenario(client, args.url, args.user_id)
print()
if args.test in ["errors", "all"]:
success &= test_error_scenarios(client, args.url)
print()
if success:
print("✅ All exchange tests completed successfully!")
return 0
else:
print("❌ Some exchange tests failed!")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""
GPU Marketplace Bids Test
Tests complete marketplace bid workflow: offers listing → bid submission → bid tracking.
"""
import argparse
import sys
import time
from typing import Optional
import httpx
DEFAULT_COORDINATOR = "http://localhost:8000"
DEFAULT_API_KEY = "${CLIENT_API_KEY}"
DEFAULT_PROVIDER = "test_miner_123"
DEFAULT_CAPACITY = 100
DEFAULT_PRICE = 0.05
DEFAULT_TIMEOUT = 300
POLL_INTERVAL = 5
def list_offers(client: httpx.Client, base_url: str, api_key: str,
status: Optional[str] = None, gpu_model: Optional[str] = None) -> Optional[dict]:
"""List marketplace offers with optional filters"""
params = {"limit": 20}
if status:
params["status"] = status
if gpu_model:
params["gpu_model"] = gpu_model
response = client.get(
f"{base_url}/v1/marketplace/offers",
headers={"X-Api-Key": api_key},
params=params,
timeout=10,
)
if response.status_code != 200:
print(f"❌ Failed to list offers: {response.status_code} {response.text}")
return None
return response.json()
def submit_bid(client: httpx.Client, base_url: str, api_key: str,
provider: str, capacity: int, price: float,
notes: Optional[str] = None) -> Optional[str]:
"""Submit a marketplace bid"""
payload = {
"provider": provider,
"capacity": capacity,
"price": price
}
if notes:
payload["notes"] = notes
response = client.post(
f"{base_url}/v1/marketplace/bids",
headers={"X-Api-Key": api_key, "Content-Type": "application/json"},
json=payload,
timeout=10,
)
if response.status_code != 202:
print(f"❌ Bid submission failed: {response.status_code} {response.text}")
return None
return response.json().get("id")
def list_bids(client: httpx.Client, base_url: str, api_key: str,
status: Optional[str] = None, provider: Optional[str] = None) -> Optional[dict]:
"""List marketplace bids with optional filters"""
params = {"limit": 20}
if status:
params["status"] = status
if provider:
params["provider"] = provider
response = client.get(
f"{base_url}/v1/marketplace/bids",
headers={"X-Api-Key": api_key},
params=params,
timeout=10,
)
if response.status_code != 200:
print(f"❌ Failed to list bids: {response.status_code} {response.text}")
return None
return response.json()
def get_bid_details(client: httpx.Client, base_url: str, api_key: str, bid_id: str) -> Optional[dict]:
"""Get detailed information about a specific bid"""
response = client.get(
f"{base_url}/v1/marketplace/bids/{bid_id}",
headers={"X-Api-Key": api_key},
timeout=10,
)
if response.status_code != 200:
print(f"❌ Failed to get bid details: {response.status_code} {response.text}")
return None
return response.json()
def get_marketplace_stats(client: httpx.Client, base_url: str, api_key: str) -> Optional[dict]:
"""Get marketplace statistics"""
response = client.get(
f"{base_url}/v1/marketplace/stats",
headers={"X-Api-Key": api_key},
timeout=10,
)
if response.status_code != 200:
print(f"❌ Failed to get marketplace stats: {response.status_code} {response.text}")
return None
return response.json()
def monitor_bid_status(client: httpx.Client, base_url: str, api_key: str,
bid_id: str, timeout: int) -> Optional[str]:
"""Monitor bid status until it's accepted/rejected or timeout"""
deadline = time.time() + timeout
while time.time() < deadline:
bid_details = get_bid_details(client, base_url, api_key, bid_id)
if not bid_details:
return None
status = bid_details.get("status")
print(f"⏳ Bid status: {status}")
if status in {"accepted", "rejected"}:
return status
time.sleep(POLL_INTERVAL)
print("❌ Bid status monitoring timed out")
return None
def test_basic_workflow(client: httpx.Client, base_url: str, api_key: str,
provider: str, capacity: int, price: float) -> bool:
"""Test basic marketplace bid workflow"""
print("🧪 Testing basic marketplace bid workflow...")
# Step 1: List available offers
print("📋 Listing marketplace offers...")
offers = list_offers(client, base_url, api_key, status="open")
if not offers:
print("❌ Failed to list offers")
return False
offers_list = offers.get("offers", [])
print(f"✅ Found {len(offers_list)} open offers")
if offers_list:
print("📊 Sample offers:")
for i, offer in enumerate(offers_list[:3]): # Show first 3 offers
print(f" {i+1}. {offer.get('gpu_model', 'Unknown')} - ${offer.get('price', 0):.4f}/hr - {offer.get('provider', 'Unknown')}")
# Step 2: Submit bid
print(f"💰 Submitting bid: {capacity} units at ${price:.4f}/unit from {provider}")
bid_id = submit_bid(client, base_url, api_key, provider, capacity, price,
notes="Test bid for GPU marketplace")
if not bid_id:
print("❌ Failed to submit bid")
return False
print(f"✅ Bid submitted: {bid_id}")
# Step 3: Get bid details
print("📄 Getting bid details...")
bid_details = get_bid_details(client, base_url, api_key, bid_id)
if not bid_details:
print("❌ Failed to get bid details")
return False
print(f"✅ Bid details: {bid_details['provider']} - {bid_details['capacity']} units - ${bid_details['price']:.4f}/unit - {bid_details['status']}")
# Step 4: List bids to verify it appears
print("📋 Listing bids to verify...")
bids = list_bids(client, base_url, api_key, provider=provider)
if not bids:
print("❌ Failed to list bids")
return False
bids_list = bids.get("bids", [])
our_bid = next((b for b in bids_list if b.get("id") == bid_id), None)
if not our_bid:
print("❌ Submitted bid not found in bid list")
return False
print(f"✅ Bid found in list: {our_bid['status']}")
return True
def test_competitive_bidding(client: httpx.Client, base_url: str, api_key: str) -> bool:
"""Test competitive bidding scenario with multiple providers"""
print("🧪 Testing competitive bidding scenario...")
# Submit multiple bids from different providers
providers = ["provider_alpha", "provider_beta", "provider_gamma"]
bid_ids = []
for i, provider in enumerate(providers):
price = 0.05 - (i * 0.01) # Decreasing prices
print(f"💰 {provider} submitting bid at ${price:.4f}/unit")
bid_id = submit_bid(client, base_url, api_key, provider, 50, price,
notes=f"Competitive bid from {provider}")
if not bid_id:
print(f"{provider} failed to submit bid")
return False
bid_ids.append((provider, bid_id))
time.sleep(1) # Small delay between submissions
print(f"✅ All {len(bid_ids)} competitive bids submitted")
# List all bids to see the competition
all_bids = list_bids(client, base_url, api_key)
if not all_bids:
print("❌ Failed to list all bids")
return False
bids_list = all_bids.get("bids", [])
competitive_bids = [b for b in bids_list if b.get("provider") in providers]
print(f"📊 Found {len(competitive_bids)} competitive bids:")
for bid in sorted(competitive_bids, key=lambda x: x.get("price", 0)):
print(f" {bid['provider']}: ${bid['price']:.4f}/unit - {bid['status']}")
return True
def test_marketplace_stats(client: httpx.Client, base_url: str, api_key: str) -> bool:
"""Test marketplace statistics functionality"""
print("🧪 Testing marketplace statistics...")
stats = get_marketplace_stats(client, base_url, api_key)
if not stats:
print("❌ Failed to get marketplace stats")
return False
print(f"📊 Marketplace Statistics:")
print(f" Total offers: {stats.get('totalOffers', 0)}")
print(f" Open capacity: {stats.get('openCapacity', 0)}")
print(f" Average price: ${stats.get('averagePrice', 0):.4f}")
print(f" Active bids: {stats.get('activeBids', 0)}")
return True
def main() -> int:
parser = argparse.ArgumentParser(description="GPU marketplace bids end-to-end test")
parser.add_argument("--url", default=DEFAULT_COORDINATOR, help="Coordinator base URL")
parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="Client API key")
parser.add_argument("--provider", default=DEFAULT_PROVIDER, help="Provider ID for bids")
parser.add_argument("--capacity", type=int, default=DEFAULT_CAPACITY, help="Bid capacity")
parser.add_argument("--price", type=float, default=DEFAULT_PRICE, help="Price per unit")
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="Timeout in seconds")
parser.add_argument("--test", choices=["basic", "competitive", "stats", "all"],
default="all", help="Test scenario to run")
args = parser.parse_args()
with httpx.Client() as client:
print("🚀 Starting GPU marketplace bids test...")
print(f"📍 Coordinator: {args.url}")
print(f"🆔 Provider: {args.provider}")
print(f"💰 Bid: {args.capacity} units at ${args.price:.4f}/unit")
print()
success = True
if args.test in ["basic", "all"]:
success &= test_basic_workflow(client, args.url, args.api_key,
args.provider, args.capacity, args.price)
print()
if args.test in ["competitive", "all"]:
success &= test_competitive_bidding(client, args.url, args.api_key)
print()
if args.test in ["stats", "all"]:
success &= test_marketplace_stats(client, args.url, args.api_key)
print()
if success:
print("✅ All marketplace bid tests completed successfully!")
return 0
else:
print("❌ Some marketplace bid tests failed!")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -40,6 +40,66 @@ Internet → aitbc.bubuit.net (HTTPS :443)
## Incus Host (localhost)
### Services (Host)
| Service | Port | Process | Python Version | Purpose | Status |
|---------|------|---------|----------------|---------|--------|
| Mock Coordinator | 8090 | python3 | 3.11+ | Development/testing API endpoint | systemd: aitbc-mock-coordinator.service |
| Blockchain Node | N/A | python3 | 3.11+ | Local blockchain node | systemd: aitbc-blockchain-node.service |
| Blockchain RPC API | 9080 | python3 | 3.11+ | RPC API for blockchain | systemd: aitbc-blockchain-rpc.service |
| GPU Miner Client | N/A | python3 | 3.11+ | GPU mining client | systemd: aitbc-gpu-miner.service |
| Local Development Tools | Varies | python3 | 3.11+ | CLI tools, scripts, testing | Manual/venv |
### Systemd Services (Host)
All services are configured as systemd units but currently inactive:
```bash
# Service files location: /etc/systemd/system/
aitbc-blockchain-node.service # Blockchain node main process
aitbc-blockchain-rpc.service # RPC API on port 9080
aitbc-gpu-miner.service # GPU mining client
aitbc-mock-coordinator.service # Mock coordinator on port 8090
```
**Service Details:**
- **Working Directory**: `/home/oib/windsurf/aitbc/apps/blockchain-node`
- **Python Environment**: `/home/oib/windsurf/aitbc/apps/blockchain-node/.venv/bin/python`
- **User**: oib
- **Restart Policy**: always (with 5s delay)
**Verification Commands:**
```bash
# Check service status
sc-status aitbc-blockchain-node.service aitbc-blockchain-rpc.service aitbc-gpu-miner.service aitbc-mock-coordinator.service
# Start services
sudo systemctl start aitbc-mock-coordinator.service
sudo systemctl start aitbc-blockchain-node.service
# Check logs
journalctl -u aitbc-mock-coordinator --no-pager -n 20
```
### Python Environment (Host)
Development and testing services on localhost use **Python 3.11+**:
```bash
# Localhost development workspace
/home/oib/windsurf/aitbc/ # Local development
├── .venv/ # Primary Python environment
├── cli/ # CLI tools (12 command groups)
├── scripts/ # Development scripts
└── tests/ # Pytest suites
```
**Verification Commands:**
```bash
python3 --version # Should show Python 3.11+
ls -la /home/oib/windsurf/aitbc/.venv/bin/python # Check venv
```
### Nginx Reverse Proxy
The host runs a simple reverse proxy that forwards all traffic to the container. SSL is terminated here via Let's Encrypt.
@@ -80,14 +140,32 @@ ssh aitbc-cascade # Direct SSH to container
### Services
| Service | Port | Process | Public URL |
|---------|------|---------|------------|
| Nginx (web) | 80 | nginx | https://aitbc.bubuit.net/ |
| Coordinator API | 8000 | python (uvicorn) | /api/ → /v1/ |
| Blockchain Node RPC | 8081 | python3 | /rpc/ |
| Wallet Daemon | 8002 | python | /wallet/ |
| Trade Exchange | 3002 | python (server.py) | /Exchange |
| Exchange API | 8085 | python | /api/trades/*, /api/orders/* |
| Service | Port | Process | Python Version | Public URL |
|---------|------|---------|----------------|------------|
| Nginx (web) | 80 | nginx | N/A | https://aitbc.bubuit.net/ |
| Coordinator API | 8000 | python (uvicorn) | 3.11+ | /api/ → /v1/ |
| Blockchain Node RPC | 8081 | python3 | 3.11+ | /rpc/ |
| Wallet Daemon | 8002 | python | 3.11+ | /wallet/ |
| Trade Exchange | 3002 | python (server.py) | 3.11+ | /Exchange |
| Exchange API | 8085 | python | 3.11+ | /api/trades/*, /api/orders/* |
### Python Environment Details
All Python services in the AITBC container run on **Python 3.11+** with isolated virtual environments:
```bash
# Container: aitbc (10.1.223.93)
/opt/coordinator-api/.venv/ # Coordinator API (uvicorn, FastAPI)
/opt/blockchain-node/.venv/ # Blockchain Node 1 (aitbc_chain)
/opt/blockchain-node-2/.venv/ # Blockchain Node 2 (aitbc_chain)
/opt/exchange/.venv/ # Exchange API (Flask/specific framework)
```
**Verification Commands:**
```bash
ssh aitbc-cascade "python3 --version" # Should show Python 3.11+
ssh aitbc-cascade "ls -la /opt/*/.venv/bin/python" # Check venv symlinks
```
### Nginx Routes (container)

View File

@@ -0,0 +1,104 @@
# Mock Coordinator Services Removal - RESOLVED
**Date:** February 16, 2026
**Status:** Resolved
**Severity:** Low
## Issue Description
Mock coordinator services were running on both localhost and AITBC server environments, creating potential confusion between development and production deployments. This could lead to testing against mock data instead of real production APIs.
## Affected Components
- **Localhost**: `aitbc-mock-coordinator.service`
- **AITBC Server**: `aitbc-coordinator.service` (mock version)
- **Production**: `aitbc-coordinator-api.service` (desired service)
## Root Cause Analysis
Historical development setup included mock coordinator services for testing purposes. These were never properly cleaned up when moving to production deployment, leading to:
- Multiple coordinator services running simultaneously
- Potential routing to mock endpoints instead of production
- Confusion about which service was handling requests
## Solution Implemented
### 1. Localhost Cleanup
```bash
# Stop and disable mock service
sudo systemctl stop aitbc-mock-coordinator.service
sudo systemctl disable aitbc-mock-coordinator.service
# Remove service file
sudo rm /etc/systemd/system/aitbc-mock-coordinator.service
sudo systemctl daemon-reload
```
### 2. AITBC Server Cleanup
```bash
# Stop and disable mock service
ssh aitbc-cascade "systemctl stop aitbc-coordinator.service"
ssh aitbc-cascade "systemctl disable aitbc-coordinator.service"
# Remove service file
ssh aitbc-cascade "rm /etc/systemd/system/aitbc-coordinator.service"
ssh aitbc-cascade "systemctl daemon-reload"
```
### 3. Production Service Verification
Confirmed production services running correctly:
- **Localhost**: `aitbc-coordinator-api.service` active on port 8000
- **AITBC Server**: `aitbc-coordinator-api.service` active in container
### 4. Database Configuration Fix
Fixed database configuration issue that was preventing localhost production service from starting:
- Added missing `effective_url` property to `DatabaseConfig` class
- Fixed module path in systemd service file
- Installed missing dependency (`python-json-logger`)
## Verification
Tested both production services:
```bash
# Localhost health check
curl -s http://localhost:8000/v1/health
# Response: {"status": "ok", "env": "dev"} ✅
# AITBC Server health check
curl -s https://aitbc.bubuit.net/api/health
# Response: {"status": "ok", "env": "dev"} ✅
```
## Service Configuration Differences
### Before Cleanup
- **Localhost**: Mock service + broken production service
- **AITBC Server**: Mock service + working production service
### After Cleanup
- **Localhost**: Working production service only
- **AITBC Server**: Working production service only
## Impact
- **Clarity**: Clear separation between development and production environments
- **Reliability**: Production requests no longer risk hitting mock endpoints
- **Maintenance**: Reduced service footprint and complexity
- **Performance**: Eliminated redundant services
## Lessons Learned
1. **Service Hygiene**: Always clean up mock/test services before production deployment
2. **Documentation**: Keep accurate inventory of running services
3. **Configuration**: Ensure production services have correct paths and dependencies
4. **Verification**: Test both environments after configuration changes
## Current Service Status
### Localhost Services
-`aitbc-coordinator-api.service` - Production API (active)
-`aitbc-mock-coordinator.service` - Mock API (removed)
### AITBC Server Services
-`aitbc-coordinator-api.service` - Production API (active)
-`aitbc-coordinator.service` - Mock API (removed)
## Related Documentation
- [Infrastructure Documentation](/docs/infrastructure.md)
- [Service Management Guidelines](/docs/operations/service-management.md)
- [Development vs Production Environments](/docs/development/environments.md)

View File

@@ -0,0 +1,92 @@
# Web Vitals 422 Error - RESOLVED
**Date:** February 16, 2026
**Status:** Resolved
**Severity:** Medium
## Issue Description
The `/api/web-vitals` endpoint was returning 422 Unprocessable Content errors when receiving performance metrics from the frontend. This prevented the collection of important web performance data.
## Affected Components
- **Backend**: `/apps/coordinator-api/src/app/routers/web_vitals.py` - API schema
- **Frontend**: `/website/assets/js/web-vitals.js` - Metrics collection script
- **Endpoint**: `/api/web-vitals` - POST endpoint for performance metrics
## Root Cause Analysis
The `WebVitalsEntry` Pydantic model in the backend only included three fields:
- `name` (required)
- `startTime` (optional)
- `duration` (optional)
However, the browser's Web Vitals library was sending additional fields for certain metrics:
- `value` - For CLS (Cumulative Layout Shift) metrics
- `hadRecentInput` - For CLS metrics to distinguish user-initiated shifts
When these extra fields were included in the JSON payload, Pydantic validation failed with a 422 error.
## Solution Implemented
### 1. Schema Enhancement
Updated the `WebVitalsEntry` model to include the missing optional fields:
```python
class WebVitalsEntry(BaseModel):
name: str
startTime: Optional[float] = None
duration: Optional[float] = None
value: Optional[float] = None # Added
hadRecentInput: Optional[bool] = None # Added
```
### 2. Defensive Processing
Added filtering logic to handle any unexpected fields that might be sent in the future:
```python
# Filter entries to only include supported fields
filtered_entries = []
for entry in metric.entries:
filtered_entry = {
"name": entry.name,
"startTime": entry.startTime,
"duration": entry.duration,
"value": entry.value,
"hadRecentInput": entry.hadRecentInput
}
# Remove None values
filtered_entry = {k: v for k, v in filtered_entry.items() if v is not None}
filtered_entries.append(filtered_entry)
```
### 3. Deployment
- Deployed changes to both localhost and AITBC server
- Restarted coordinator-api service on both systems
- Verified functionality with test requests
## Verification
Tested the fix with various Web Vitals payloads:
```bash
# Test with CLS metric (includes extra fields)
curl -X POST https://aitbc.bubuit.net/api/web-vitals \
-H "Content-Type: application/json" \
-d '{"name":"CLS","value":0.1,"id":"cls","delta":0.05,"entries":[{"name":"layout-shift","startTime":100,"duration":0,"value":0.1,"hadRecentInput":false}],"url":"https://aitbc.bubuit.net/","timestamp":"2026-02-16T20:00:00Z"}'
# Result: 200 OK ✅
```
## Impact
- **Before**: Web Vitals metrics collection was failing completely
- **After**: All Web Vitals metrics are now successfully collected and logged
- **Performance**: No performance impact on the API endpoint
- **Compatibility**: Backward compatible with existing frontend code
## Lessons Learned
1. **Schema Mismatch**: Always ensure backend schemas match frontend payloads exactly
2. **Optional Fields**: Web APIs often evolve with additional optional fields
3. **Defensive Programming**: Filter unknown fields to prevent future validation errors
4. **Testing**: Test with real frontend payloads, not just ideal ones
## Related Documentation
- [Web Vitals Documentation](https://web.dev/vitals/)
- [Pydantic Validation](https://pydantic-docs.helpmanual.io/)
- [FastAPI Error Handling](https://fastapi.tiangolo.com/tutorial/handling-errors/)

595
tests/cli/test_exchange.py Normal file
View File

@@ -0,0 +1,595 @@
"""Tests for exchange CLI commands"""
import pytest
import json
import time
from click.testing import CliRunner
from unittest.mock import Mock, patch
from aitbc_cli.commands.exchange import exchange
@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 TestExchangeRatesCommand:
"""Test exchange rates command"""
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_rates_success(self, mock_client_class, runner, mock_config):
"""Test successful exchange rates 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 = {
"btc_to_aitbc": 100000,
"aitbc_to_btc": 0.00001,
"fee_percent": 0.5
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(exchange, ['rates'],
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 JSON part
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['btc_to_aitbc'] == 100000
assert data['aitbc_to_btc'] == 0.00001
assert data['fee_percent'] == 0.5
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/exchange/rates',
timeout=10
)
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_rates_api_error(self, mock_client_class, runner, mock_config):
"""Test exchange rates with API error"""
# 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_client.get.return_value = mock_response
# Run command
result = runner.invoke(exchange, ['rates'],
obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Failed to get exchange rates: 500' in result.output
class TestExchangeCreatePaymentCommand:
"""Test exchange create-payment command"""
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_create_payment_with_aitbc_amount(self, mock_client_class, runner, mock_config):
"""Test creating payment with AITBC amount"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
# Mock rates response
rates_response = Mock()
rates_response.status_code = 200
rates_response.json.return_value = {
"btc_to_aitbc": 100000,
"aitbc_to_btc": 0.00001,
"fee_percent": 0.5
}
# Mock payment creation response
payment_response = Mock()
payment_response.status_code = 200
payment_response.json.return_value = {
"payment_id": "pay_123456",
"user_id": "cli_user",
"aitbc_amount": 1000,
"btc_amount": 0.01,
"payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"status": "pending",
"created_at": int(time.time()),
"expires_at": int(time.time()) + 3600
}
mock_client.get.return_value = rates_response
mock_client.post.return_value = payment_response
# Run command
result = runner.invoke(exchange, [
'create-payment',
'--aitbc-amount', '1000',
'--user-id', 'test_user',
'--notes', 'Test payment'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Payment created: pay_123456' in result.output
assert 'Send 0.01000000 BTC to:' in result.output
# Verify API calls
assert mock_client.get.call_count == 1 # Get rates
assert mock_client.post.call_count == 1 # Create payment
# Check payment creation call
payment_call = mock_client.post.call_args
assert payment_call[0][0] == 'http://test:8000/v1/exchange/create-payment'
payment_data = payment_call[1]['json']
assert payment_data['user_id'] == 'test_user'
assert payment_data['aitbc_amount'] == 1000
assert payment_data['btc_amount'] == 0.01
assert payment_data['notes'] == 'Test payment'
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_create_payment_with_btc_amount(self, mock_client_class, runner, mock_config):
"""Test creating payment with BTC amount"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
# Mock rates response
rates_response = Mock()
rates_response.status_code = 200
rates_response.json.return_value = {
"btc_to_aitbc": 100000,
"aitbc_to_btc": 0.00001,
"fee_percent": 0.5
}
# Mock payment creation response
payment_response = Mock()
payment_response.status_code = 200
payment_response.json.return_value = {
"payment_id": "pay_789012",
"user_id": "cli_user",
"aitbc_amount": 500,
"btc_amount": 0.005,
"payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"status": "pending",
"created_at": int(time.time()),
"expires_at": int(time.time()) + 3600
}
mock_client.get.return_value = rates_response
mock_client.post.return_value = payment_response
# Run command
result = runner.invoke(exchange, [
'create-payment',
'--btc-amount', '0.005'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Payment created: pay_789012' in result.output
# Check payment data
payment_call = mock_client.post.call_args
payment_data = payment_call[1]['json']
assert payment_data['aitbc_amount'] == 500
assert payment_data['btc_amount'] == 0.005
def test_create_payment_no_amount(self, runner, mock_config):
"""Test creating payment without specifying amount"""
# Run command without amount
result = runner.invoke(exchange, ['create-payment'],
obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Either --aitbc-amount or --btc-amount must be specified' in result.output
def test_create_payment_invalid_aitbc_amount(self, runner, mock_config):
"""Test creating payment with invalid AITBC amount"""
# Run command with invalid amount
result = runner.invoke(exchange, [
'create-payment',
'--aitbc-amount', '0'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'AITBC amount must be greater than 0' in result.output
def test_create_payment_invalid_btc_amount(self, runner, mock_config):
"""Test creating payment with invalid BTC amount"""
# Run command with invalid amount
result = runner.invoke(exchange, [
'create-payment',
'--btc-amount', '-0.01'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'BTC amount must be greater than 0' in result.output
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_create_payment_rates_error(self, mock_client_class, runner, mock_config):
"""Test creating payment when rates API fails"""
# Setup mock for rates error
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
rates_response = Mock()
rates_response.status_code = 500
mock_client.get.return_value = rates_response
# Run command
result = runner.invoke(exchange, [
'create-payment',
'--aitbc-amount', '1000'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Failed to get exchange rates' in result.output
class TestExchangePaymentStatusCommand:
"""Test exchange payment-status command"""
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_payment_status_pending(self, mock_client_class, runner, mock_config):
"""Test checking pending payment 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 = {
"payment_id": "pay_123456",
"user_id": "test_user",
"aitbc_amount": 1000,
"btc_amount": 0.01,
"payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"status": "pending",
"created_at": int(time.time()),
"expires_at": int(time.time()) + 3600,
"confirmations": 0
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(exchange, [
'payment-status',
'--payment-id', 'pay_123456'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Payment pay_123456 is pending confirmation' in result.output
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/exchange/payment-status/pay_123456',
timeout=10
)
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_payment_status_confirmed(self, mock_client_class, runner, mock_config):
"""Test checking confirmed payment 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 = {
"payment_id": "pay_123456",
"user_id": "test_user",
"aitbc_amount": 1000,
"btc_amount": 0.01,
"payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"status": "confirmed",
"created_at": int(time.time()),
"expires_at": int(time.time()) + 3600,
"confirmations": 1,
"confirmed_at": int(time.time())
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(exchange, [
'payment-status',
'--payment-id', 'pay_123456'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Payment pay_123456 is confirmed!' in result.output
assert 'AITBC amount: 1000' in result.output
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_payment_status_expired(self, mock_client_class, runner, mock_config):
"""Test checking expired payment 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 = {
"payment_id": "pay_123456",
"user_id": "test_user",
"aitbc_amount": 1000,
"btc_amount": 0.01,
"payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"status": "expired",
"created_at": int(time.time()),
"expires_at": int(time.time()) - 3600, # Expired
"confirmations": 0
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(exchange, [
'payment-status',
'--payment-id', 'pay_123456'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Payment pay_123456 has expired' in result.output
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_payment_status_not_found(self, mock_client_class, runner, mock_config):
"""Test checking status for non-existent payment"""
# Setup mock for 404 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(exchange, [
'payment-status',
'--payment-id', 'nonexistent'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Failed to get payment status: 404' in result.output
class TestExchangeMarketStatsCommand:
"""Test exchange market-stats command"""
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_market_stats_success(self, mock_client_class, runner, mock_config):
"""Test successful market stats 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 = {
"price": 0.00001,
"price_change_24h": 5.2,
"daily_volume": 50000,
"daily_volume_btc": 0.5,
"total_payments": 10,
"pending_payments": 2
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(exchange, ['market-stats'],
obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Exchange market statistics:' in result.output
# Extract and verify JSON
import re
clean_output = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
lines = clean_output.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.endswith('}'):
break
json_str = '\n'.join(json_lines)
assert json_str, "No JSON found in output"
data = json.loads(json_str)
assert data['price'] == 0.00001
assert data['price_change_24h'] == 5.2
assert data['daily_volume'] == 50000
assert data['total_payments'] == 10
assert data['pending_payments'] == 2
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/exchange/market-stats',
timeout=10
)
class TestExchangeWalletCommands:
"""Test exchange wallet commands"""
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_wallet_balance_success(self, mock_client_class, runner, mock_config):
"""Test successful wallet balance 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 = {
"address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"balance": 1.5,
"unconfirmed_balance": 0.1,
"total_received": 10.0,
"total_sent": 8.5
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(exchange, ['wallet', 'balance'],
obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Bitcoin wallet balance:' in result.output
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/exchange/wallet/balance',
timeout=10
)
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_wallet_info_success(self, mock_client_class, runner, mock_config):
"""Test successful wallet info 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 = {
"address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"balance": 1.5,
"unconfirmed_balance": 0.1,
"total_received": 10.0,
"total_sent": 8.5,
"transactions": [],
"network": "testnet",
"block_height": 2500000
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(exchange, ['wallet', 'info'],
obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Bitcoin wallet information:' in result.output
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/exchange/wallet/info',
timeout=10
)
class TestExchangeIntegration:
"""Test exchange integration workflows"""
@patch('aitbc_cli.commands.exchange.httpx.Client')
def test_complete_exchange_workflow(self, mock_client_class, runner, mock_config):
"""Test complete exchange workflow: rates → create payment → check status"""
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
# Step 1: Get rates
rates_response = Mock()
rates_response.status_code = 200
rates_response.json.return_value = {
"btc_to_aitbc": 100000,
"aitbc_to_btc": 0.00001,
"fee_percent": 0.5
}
# Step 2: Create payment
payment_response = Mock()
payment_response.status_code = 200
payment_response.json.return_value = {
"payment_id": "pay_workflow_123",
"user_id": "cli_user",
"aitbc_amount": 1000,
"btc_amount": 0.01,
"payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"status": "pending",
"created_at": int(time.time()),
"expires_at": int(time.time()) + 3600
}
# Step 3: Check payment status
status_response = Mock()
status_response.status_code = 200
status_response.json.return_value = {
"payment_id": "pay_workflow_123",
"user_id": "cli_user",
"aitbc_amount": 1000,
"btc_amount": 0.01,
"payment_address": "tb1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"status": "pending",
"created_at": int(time.time()),
"expires_at": int(time.time()) + 3600,
"confirmations": 0
}
# Configure mock to return different responses for different calls
mock_client.get.side_effect = [rates_response, status_response]
mock_client.post.return_value = payment_response
# Execute workflow
# Get rates
result1 = runner.invoke(exchange, ['rates'],
obj={'config': mock_config, 'output_format': 'json'})
assert result1.exit_code == 0
# Create payment
result2 = runner.invoke(exchange, [
'create-payment',
'--aitbc-amount', '1000'
], obj={'config': mock_config, 'output_format': 'json'})
assert result2.exit_code == 0
# Check payment status
result3 = runner.invoke(exchange, [
'payment-status',
'--payment-id', 'pay_workflow_123'
], obj={'config': mock_config, 'output_format': 'json'})
assert result3.exit_code == 0
# Verify all API calls were made
assert mock_client.get.call_count == 3 # rates (standalone) + rates (create-payment) + payment status
assert mock_client.post.call_count == 1 # create payment

View File

@@ -0,0 +1,497 @@
"""Tests for marketplace bid 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 TestMarketplaceBidCommands:
"""Test marketplace bid command group"""
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_bid_submit_success(self, mock_client_class, runner, mock_config):
"""Test successful bid submission"""
# Setup mock
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
mock_response = Mock()
mock_response.status_code = 202
mock_response.json.return_value = {
"id": "bid123",
"status": "pending"
}
mock_client.post.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'bid',
'submit',
'--provider', 'miner123',
'--capacity', '100',
'--price', '0.05',
'--notes', 'Need GPU capacity for AI training'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
# Extract JSON from output (success message + JSON)
# Remove ANSI escape codes and extract JSON part
import re
clean_output = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
lines = clean_output.strip().split('\n')
# Find JSON part (multiline JSON with ANSI codes removed)
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['id'] == 'bid123'
# Verify API call
mock_client.post.assert_called_once_with(
'http://test:8000/v1/marketplace/bids',
json={
"provider": "miner123",
"capacity": 100,
"price": 0.05,
"notes": "Need GPU capacity for AI training"
},
headers={
"Content-Type": "application/json",
"X-Api-Key": "test_api_key"
}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_bid_submit_validation_error(self, mock_client_class, runner, mock_config):
"""Test bid submission with invalid capacity"""
# Run command with invalid capacity
result = runner.invoke(marketplace, [
'bid',
'submit',
'--provider', 'miner123',
'--capacity', '0', # Invalid: must be > 0
'--price', '0.05'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Capacity must be greater than 0' in result.output
# Verify no API call was made
mock_client_class.assert_not_called()
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_bid_submit_price_validation_error(self, mock_client_class, runner, mock_config):
"""Test bid submission with invalid price"""
# Run command with invalid price
result = runner.invoke(marketplace, [
'bid',
'submit',
'--provider', 'miner123',
'--capacity', '100',
'--price', '-0.05' # Invalid: must be > 0
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Price must be greater than 0' in result.output
# Verify no API call was made
mock_client_class.assert_not_called()
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_bid_submit_api_error(self, mock_client_class, runner, mock_config):
"""Test bid submission with API error"""
# 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 = 400
mock_response.text = "Invalid provider"
mock_client.post.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'bid',
'submit',
'--provider', 'invalid_provider',
'--capacity', '100',
'--price', '0.05'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Failed to submit bid: 400' in result.output
assert 'Invalid provider' in result.output
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_bid_list_all(self, mock_client_class, runner, mock_config):
"""Test listing all bids"""
# 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 = {
"bids": [
{
"id": "bid1",
"provider": "miner1",
"capacity": 100,
"price": 0.05,
"status": "pending",
"submitted_at": "2024-01-01T00:00:00"
},
{
"id": "bid2",
"provider": "miner2",
"capacity": 50,
"price": 0.03,
"status": "accepted",
"submitted_at": "2024-01-01T01:00:00"
}
]
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'bid',
'list'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data['bids']) == 2
assert data['bids'][0]['provider'] == 'miner1'
assert data['bids'][0]['status'] == 'pending'
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/marketplace/bids',
params={"limit": 20},
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_bid_list_with_filters(self, mock_client_class, runner, mock_config):
"""Test listing bids 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 = {
"bids": [
{
"id": "bid1",
"provider": "miner123",
"capacity": 100,
"price": 0.05,
"status": "pending",
"submitted_at": "2024-01-01T00:00:00"
}
]
}
mock_client.get.return_value = mock_response
# Run command with filters
result = runner.invoke(marketplace, [
'bid',
'list',
'--status', 'pending',
'--provider', 'miner123',
'--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'] == 'pending'
assert call_args[1]['params']['provider'] == 'miner123'
assert call_args[1]['params']['limit'] == 10
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_bid_details(self, mock_client_class, runner, mock_config):
"""Test getting bid 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": "bid123",
"provider": "miner123",
"capacity": 100,
"price": 0.05,
"notes": "Need GPU capacity for AI training",
"status": "pending",
"submitted_at": "2024-01-01T00:00:00"
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'bid',
'details',
'bid123'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert data['id'] == 'bid123'
assert data['provider'] == 'miner123'
assert data['capacity'] == 100
assert data['price'] == 0.05
assert data['notes'] == 'Need GPU capacity for AI training'
assert data['status'] == 'pending'
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/marketplace/bids/bid123',
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_bid_details_not_found(self, mock_client_class, runner, mock_config):
"""Test getting details for non-existent bid"""
# Setup mock for 404 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, [
'bid',
'details',
'nonexistent'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
assert 'Bid not found: 404' in result.output
class TestMarketplaceOffersCommands:
"""Test marketplace offers command group"""
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_offers_list_all(self, mock_client_class, runner, mock_config):
"""Test listing all offers"""
# 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 = {
"offers": [
{
"id": "offer1",
"provider": "miner1",
"capacity": 200,
"price": 0.10,
"status": "open",
"gpu_model": "RTX4090",
"gpu_memory_gb": 24,
"region": "us-west"
},
{
"id": "offer2",
"provider": "miner2",
"capacity": 100,
"price": 0.08,
"status": "reserved",
"gpu_model": "RTX3080",
"gpu_memory_gb": 10,
"region": "us-east"
}
]
}
mock_client.get.return_value = mock_response
# Run command
result = runner.invoke(marketplace, [
'offers',
'list'
], obj={'config': mock_config, 'output_format': 'json'})
# Assertions
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data['offers']) == 2
assert data['offers'][0]['gpu_model'] == 'RTX4090'
assert data['offers'][0]['status'] == 'open'
# Verify API call
mock_client.get.assert_called_once_with(
'http://test:8000/v1/marketplace/offers',
params={"limit": 20},
headers={"X-Api-Key": "test_api_key"}
)
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_offers_list_with_filters(self, mock_client_class, runner, mock_config):
"""Test listing offers 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 = {
"offers": [
{
"id": "offer1",
"provider": "miner1",
"capacity": 200,
"price": 0.10,
"status": "open",
"gpu_model": "RTX4090",
"gpu_memory_gb": 24,
"region": "us-west"
}
]
}
mock_client.get.return_value = mock_response
# Run command with filters
result = runner.invoke(marketplace, [
'offers',
'list',
'--status', 'open',
'--gpu-model', 'RTX4090',
'--price-max', '0.15',
'--memory-min', '16',
'--region', 'us-west',
'--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
params = call_args[1]['params']
assert params['status'] == 'open'
assert params['gpu_model'] == 'RTX4090'
assert params['price_max'] == 0.15
assert params['memory_min'] == 16
assert params['region'] == 'us-west'
assert params['limit'] == 10
class TestMarketplaceBidIntegration:
"""Test marketplace bid integration workflows"""
@patch('aitbc_cli.commands.marketplace.httpx.Client')
def test_complete_bid_workflow(self, mock_client_class, runner, mock_config):
"""Test complete workflow: list offers -> submit bid -> track status"""
mock_client = Mock()
mock_client_class.return_value.__enter__.return_value = mock_client
# Step 1: List offers
offers_response = Mock()
offers_response.status_code = 200
offers_response.json.return_value = {
"offers": [
{
"id": "offer1",
"provider": "miner1",
"capacity": 200,
"price": 0.10,
"status": "open",
"gpu_model": "RTX4090"
}
]
}
# Step 2: Submit bid
bid_response = Mock()
bid_response.status_code = 202
bid_response.json.return_value = {
"id": "bid123",
"status": "pending"
}
# Step 3: Get bid details
bid_details_response = Mock()
bid_details_response.status_code = 200
bid_details_response.json.return_value = {
"id": "bid123",
"provider": "miner123",
"capacity": 100,
"price": 0.05,
"status": "pending",
"submitted_at": "2024-01-01T00:00:00"
}
# Configure mock to return different responses for different calls
mock_client.get.side_effect = [offers_response, bid_details_response]
mock_client.post.return_value = bid_response
# Execute workflow
# List offers
result1 = runner.invoke(marketplace, [
'offers',
'list',
'--status', 'open'
], obj={'config': mock_config, 'output_format': 'json'})
assert result1.exit_code == 0
# Submit bid
result2 = runner.invoke(marketplace, [
'bid',
'submit',
'--provider', 'miner123',
'--capacity', '100',
'--price', '0.05'
], obj={'config': mock_config, 'output_format': 'json'})
assert result2.exit_code == 0
# Check bid details
result3 = runner.invoke(marketplace, [
'bid',
'details',
'bid123'
], obj={'config': mock_config, 'output_format': 'json'})
assert result3.exit_code == 0
# Verify all API calls were made
assert mock_client.get.call_count == 2
assert mock_client.post.call_count == 1