arbret/backend/tests/test_exchange.py
counterweight 811fdf2663
Phase 2.5: Add exchange endpoint tests
Comprehensive test coverage for exchange endpoints:
- Price endpoint: permission checks, price retrieval, staleness, config
- Create exchange: buy/sell, double booking, validation, stale price
- User trades: list trades, cancel own trade, cancel restrictions
- Admin trades: view upcoming/past, complete, no-show, cancel

Tests mock the Bitfinex price fetcher to ensure deterministic results.
2025-12-22 18:48:23 +01:00

789 lines
29 KiB
Python

"""
Exchange API Tests
Tests for the Bitcoin trading exchange endpoints.
"""
from datetime import UTC, date, datetime, timedelta
from unittest.mock import patch
import pytest
from models import Exchange, ExchangeStatus, PriceHistory, TradeDirection
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX
def tomorrow() -> date:
return date.today() + timedelta(days=1)
def in_days(n: int) -> date:
return date.today() + timedelta(days=n)
# =============================================================================
# Fixtures
# =============================================================================
async def create_price_in_db(db, price: float = 20000.0, minutes_ago: int = 0):
"""Create a price record in the database."""
timestamp = datetime.now(UTC) - timedelta(minutes=minutes_ago)
price_record = PriceHistory(
source=SOURCE_BITFINEX,
pair=PAIR_BTC_EUR,
price=price,
timestamp=timestamp,
)
db.add(price_record)
await db.commit()
await db.refresh(price_record)
return price_record
def mock_price_fetcher(price: float = 20000.0):
"""Return a mock that returns the given price."""
async def mock_fetch():
return (price, datetime.now(UTC))
return patch("routes.exchange.fetch_btc_eur_price", mock_fetch)
async def setup_availability_and_price(client_factory, admin_user, target_date=None):
"""Helper to set up availability and price for tests."""
if target_date is None:
target_date = tomorrow()
# Admin sets availability
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
await admin_client.put(
"/api/admin/availability",
json={
"date": str(target_date),
"slots": [{"start_time": "09:00:00", "end_time": "17:00:00"}],
},
)
# Create fresh price in DB
async with client_factory.get_db_session() as db:
await create_price_in_db(db, price=20000.0, minutes_ago=0)
return target_date
# =============================================================================
# Price Endpoint Tests
# =============================================================================
class TestExchangePriceEndpoint:
"""Test the /api/exchange/price endpoint."""
@pytest.mark.asyncio
async def test_regular_user_can_get_price(self, client_factory, regular_user):
"""Regular user can access price endpoint."""
# Create fresh price in DB
async with client_factory.get_db_session() as db:
await create_price_in_db(db, price=20000.0, minutes_ago=0)
# Mock the price fetcher to prevent real API calls
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/exchange/price")
assert response.status_code == 200
data = response.json()
assert "price" in data
assert "config" in data
assert data["price"]["market_price"] == 20000.0
assert data["price"]["premium_percentage"] == 5
# Agreed price should be market * 1.05 (5% premium)
assert data["price"]["agreed_price"] == pytest.approx(21000.0, rel=0.001)
@pytest.mark.asyncio
async def test_admin_cannot_get_price(self, client_factory, admin_user):
"""Admin cannot access price endpoint."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/exchange/price")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_cannot_get_price(self, client):
"""Unauthenticated user cannot access price endpoint."""
response = await client.get("/api/exchange/price")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_stale_price_triggers_fetch(self, client_factory, regular_user):
"""When cached price is stale, a fresh price is fetched."""
# Create stale price (6 minutes old, threshold is 5 minutes)
async with client_factory.get_db_session() as db:
await create_price_in_db(db, price=19000.0, minutes_ago=6)
# Mock fetcher returns new price
with mock_price_fetcher(21000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/exchange/price")
assert response.status_code == 200
data = response.json()
# Should have the fresh price, not the stale one
assert data["price"]["market_price"] == 21000.0
@pytest.mark.asyncio
async def test_config_contains_expected_fields(self, client_factory, regular_user):
"""Config section contains all required fields."""
async with client_factory.get_db_session() as db:
await create_price_in_db(db)
with mock_price_fetcher():
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/exchange/price")
assert response.status_code == 200
config = response.json()["config"]
assert "eur_min" in config
assert "eur_max" in config
assert "eur_increment" in config
assert "premium_percentage" in config
assert config["eur_min"] == 100
assert config["eur_max"] == 3000
assert config["eur_increment"] == 20
# =============================================================================
# Create Exchange Tests
# =============================================================================
class TestCreateExchange:
"""Test creating exchange trades."""
@pytest.mark.asyncio
async def test_create_exchange_buy_success(
self, client_factory, regular_user, admin_user
):
"""Can successfully create a buy exchange trade."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000, # €100 in cents
},
)
assert response.status_code == 200
data = response.json()
assert data["direction"] == "buy"
assert data["eur_amount"] == 10000
assert data["status"] == "booked"
assert data["sats_amount"] > 0
# For buy, agreed price is market * 1.05
assert data["agreed_price_eur"] == pytest.approx(21000.0, rel=0.001)
@pytest.mark.asyncio
async def test_create_exchange_sell_success(
self, client_factory, regular_user, admin_user
):
"""Can successfully create a sell exchange trade."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T10:00:00Z",
"direction": "sell",
"eur_amount": 20000, # €200 in cents
},
)
assert response.status_code == 200
data = response.json()
assert data["direction"] == "sell"
assert data["eur_amount"] == 20000
# For sell, agreed price is market * 0.95
assert data["agreed_price_eur"] == pytest.approx(19000.0, rel=0.001)
@pytest.mark.asyncio
async def test_cannot_book_same_slot_twice(
self, client_factory, regular_user, alt_regular_user, admin_user
):
"""Cannot book the same slot twice."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
# First user books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
assert response.status_code == 200
# Second user tries same slot
async with client_factory.create(
cookies=alt_regular_user["cookies"]
) as client:
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
assert response.status_code == 409
assert "already been booked" in response.json()["detail"]
@pytest.mark.asyncio
async def test_invalid_direction_rejected(
self, client_factory, regular_user, admin_user
):
"""Invalid direction is rejected."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "invalid",
"eur_amount": 10000,
},
)
assert response.status_code == 400
assert "Invalid direction" in response.json()["detail"]
@pytest.mark.asyncio
async def test_amount_below_minimum_rejected(
self, client_factory, regular_user, admin_user
):
"""EUR amount below minimum is rejected."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 5000, # €50, below min of €100
},
)
assert response.status_code == 400
assert "at least" in response.json()["detail"]
@pytest.mark.asyncio
async def test_amount_above_maximum_rejected(
self, client_factory, regular_user, admin_user
):
"""EUR amount above maximum is rejected."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 400000, # €4000, above max of €3000
},
)
assert response.status_code == 400
assert "at most" in response.json()["detail"]
@pytest.mark.asyncio
async def test_amount_not_multiple_of_increment_rejected(
self, client_factory, regular_user, admin_user
):
"""EUR amount not a multiple of increment is rejected."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 11500, # €115, not multiple of €20
},
)
assert response.status_code == 400
assert "multiple" in response.json()["detail"]
@pytest.mark.asyncio
async def test_stale_price_blocks_booking(
self, client_factory, regular_user, admin_user
):
"""Cannot book when price is stale."""
# Set up availability
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
await admin_client.put(
"/api/admin/availability",
json={
"date": str(tomorrow()),
"slots": [{"start_time": "09:00:00", "end_time": "17:00:00"}],
},
)
# Create stale price (create_exchange doesn't fetch, just reads from DB)
async with client_factory.get_db_session() as db:
await create_price_in_db(db, price=20000.0, minutes_ago=10)
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{tomorrow()}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
assert response.status_code == 503
assert "stale" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_slot_outside_availability_rejected(
self, client_factory, regular_user, admin_user
):
"""Slot outside availability is rejected."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T20:00:00Z", # Outside 09-17
"direction": "buy",
"eur_amount": 10000,
},
)
assert response.status_code == 400
assert "not available" in response.json()["detail"]
@pytest.mark.asyncio
async def test_admin_cannot_create_exchange(self, client_factory, admin_user):
"""Admin cannot create exchange."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{tomorrow()}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
assert response.status_code == 403
# =============================================================================
# User Trades Tests
# =============================================================================
class TestUserTrades:
"""Test user's trades endpoints."""
@pytest.mark.asyncio
async def test_get_my_trades_empty(self, client_factory, regular_user):
"""Returns empty list when user has no trades."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/trades")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
async def test_get_my_trades_with_exchanges(
self, client_factory, regular_user, admin_user
):
"""Returns user's trades."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
# Book two trades
await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T10:00:00Z",
"direction": "sell",
"eur_amount": 20000,
},
)
# Get trades
response = await client.get("/api/trades")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
class TestCancelTrade:
"""Test cancelling trades."""
@pytest.mark.asyncio
async def test_cancel_own_trade(self, client_factory, regular_user, admin_user):
"""User can cancel their own trade."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
# Book trade
book_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
# Cancel
response = await client.post(f"/api/trades/{trade_id}/cancel")
assert response.status_code == 200
data = response.json()
assert data["status"] == "cancelled_by_user"
assert data["cancelled_at"] is not None
@pytest.mark.asyncio
async def test_cannot_cancel_others_trade(
self, client_factory, regular_user, alt_regular_user, admin_user
):
"""User cannot cancel another user's trade."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
# First user books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
# Second user tries to cancel (no mock needed for this)
async with client_factory.create(cookies=alt_regular_user["cookies"]) as client:
response = await client.post(f"/api/trades/{trade_id}/cancel")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_cannot_cancel_nonexistent_trade(self, client_factory, regular_user):
"""Returns 404 for non-existent trade."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post("/api/trades/99999/cancel")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_cannot_cancel_already_cancelled(
self, client_factory, regular_user, admin_user
):
"""Cannot cancel an already cancelled trade."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
# Book and cancel
book_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
await client.post(f"/api/trades/{trade_id}/cancel")
# Try to cancel again
response = await client.post(f"/api/trades/{trade_id}/cancel")
assert response.status_code == 400
assert "cancelled_by_user" in response.json()["detail"]
# =============================================================================
# Admin Trades Tests
# =============================================================================
class TestAdminUpcomingTrades:
"""Test admin viewing upcoming trades."""
@pytest.mark.asyncio
async def test_admin_can_view_upcoming_trades(
self, client_factory, regular_user, admin_user
):
"""Admin can view upcoming trades."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
# User books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
# Admin views upcoming trades
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/admin/trades/upcoming")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
# Check user contact info is present
assert "user_contact" in data[0]
assert "email" in data[0]["user_contact"]
@pytest.mark.asyncio
async def test_regular_user_cannot_view_upcoming(
self, client_factory, regular_user
):
"""Regular user cannot access admin endpoint."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/admin/trades/upcoming")
assert response.status_code == 403
class TestAdminPastTrades:
"""Test admin viewing past trades."""
@pytest.mark.asyncio
async def test_admin_can_view_past_trades(
self, client_factory, regular_user, admin_user
):
"""Admin can view past trades."""
# Create a past trade directly in DB
async with client_factory.get_db_session() as db:
await create_price_in_db(db, price=20000.0)
past_time = datetime.now(UTC) - timedelta(hours=2)
exchange = Exchange(
user_id=regular_user["user"]["id"],
slot_start=past_time,
slot_end=past_time + timedelta(minutes=15),
direction=TradeDirection.BUY,
eur_amount=10000,
sats_amount=500000,
market_price_eur=20000.0,
agreed_price_eur=21000.0,
premium_percentage=5,
status=ExchangeStatus.BOOKED,
)
db.add(exchange)
await db.commit()
# Admin views past trades
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/admin/trades/past")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
class TestAdminCompleteTrade:
"""Test admin completing trades."""
@pytest.mark.asyncio
async def test_admin_can_complete_trade(
self, client_factory, regular_user, admin_user
):
"""Admin can mark a past trade as completed."""
# Create a past trade in DB
async with client_factory.get_db_session() as db:
past_time = datetime.now(UTC) - timedelta(hours=1)
exchange = Exchange(
user_id=regular_user["user"]["id"],
slot_start=past_time,
slot_end=past_time + timedelta(minutes=15),
direction=TradeDirection.BUY,
eur_amount=10000,
sats_amount=500000,
market_price_eur=20000.0,
agreed_price_eur=21000.0,
premium_percentage=5,
status=ExchangeStatus.BOOKED,
)
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
# Admin completes
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/complete")
assert response.status_code == 200
data = response.json()
assert data["status"] == "completed"
assert data["completed_at"] is not None
@pytest.mark.asyncio
async def test_cannot_complete_future_trade(
self, client_factory, regular_user, admin_user
):
"""Cannot complete a trade that hasn't started yet."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
# User books future trade
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
# Admin tries to complete
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/complete")
assert response.status_code == 400
assert "not yet started" in response.json()["detail"]
class TestAdminNoShowTrade:
"""Test admin marking trades as no-show."""
@pytest.mark.asyncio
async def test_admin_can_mark_no_show(
self, client_factory, regular_user, admin_user
):
"""Admin can mark a past trade as no-show."""
# Create a past trade in DB
async with client_factory.get_db_session() as db:
past_time = datetime.now(UTC) - timedelta(hours=1)
exchange = Exchange(
user_id=regular_user["user"]["id"],
slot_start=past_time,
slot_end=past_time + timedelta(minutes=15),
direction=TradeDirection.BUY,
eur_amount=10000,
sats_amount=500000,
market_price_eur=20000.0,
agreed_price_eur=21000.0,
premium_percentage=5,
status=ExchangeStatus.BOOKED,
)
db.add(exchange)
await db.commit()
await db.refresh(exchange)
trade_id = exchange.id
# Admin marks no-show
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/no-show")
assert response.status_code == 200
data = response.json()
assert data["status"] == "no_show"
class TestAdminCancelTrade:
"""Test admin cancelling trades."""
@pytest.mark.asyncio
async def test_admin_can_cancel_trade(
self, client_factory, regular_user, admin_user
):
"""Admin can cancel any trade."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
# User books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
# Admin cancels
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{trade_id}/cancel")
assert response.status_code == 200
data = response.json()
assert data["status"] == "cancelled_by_admin"
@pytest.mark.asyncio
async def test_regular_user_cannot_admin_cancel(
self, client_factory, regular_user, admin_user
):
"""Regular user cannot use admin cancel endpoint."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
# User books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"eur_amount": 10000,
},
)
trade_id = book_response.json()["id"]
# User tries admin cancel
response = await client.post(f"/api/admin/trades/{trade_id}/cancel")
assert response.status_code == 403