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

@@ -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