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:
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
470
apps/coordinator-api/tests/test_exchange.py
Normal file
470
apps/coordinator-api/tests/test_exchange.py
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user