arbret/backend/tests/test_exchange.py
counterweight 41e158376c
Step 6: Update exchange creation logic to use new pricing config
- Update ExchangeService to load pricing config from database
- Update validate_eur_amount to use direction-specific limits
- Update apply_premium_for_direction to calculate base + extra premium
- Update create_exchange to use new premium calculation
- Add tests for premium calculation (small trade extra, large trade base only, direction-specific)
- Update existing tests to account for new premium calculation
2025-12-26 20:24:13 +01:00

1457 lines
57 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 (
BitcoinTransferMethod,
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, price, and pricing config 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 and pricing config
async with client_factory.get_db_session() as db:
await create_price_in_db(db, price=20000.0, minutes_ago=0)
from repositories.pricing import PricingRepository
repo = PricingRepository(db)
await repo.create_or_update(
premium_buy=5,
premium_sell=5,
small_trade_threshold_eur=20000,
small_trade_extra_premium=2,
eur_min_buy=10000,
eur_max_buy=300000,
eur_min_sell=10000,
eur_max_sell=300000,
)
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 and pricing config
async with client_factory.get_db_session() as db:
await create_price_in_db(db, price=20000.0, minutes_ago=0)
from repositories.pricing import PricingRepository
repo = PricingRepository(db)
await repo.create_or_update(
premium_buy=5,
premium_sell=5,
small_trade_threshold_eur=20000,
small_trade_extra_premium=2,
eur_min_buy=10000,
eur_max_buy=300000,
eur_min_sell=10000,
eur_max_sell=300000,
)
# 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
# Note: premium is now in config, agreed_price is calculated on frontend
@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) and pricing config
async with client_factory.get_db_session() as db:
await create_price_in_db(db, price=19000.0, minutes_ago=6)
from repositories.pricing import PricingRepository
repo = PricingRepository(db)
await repo.create_or_update(
premium_buy=5,
premium_sell=5,
small_trade_threshold_eur=20000,
small_trade_extra_premium=2,
eur_min_buy=10000,
eur_max_buy=300000,
eur_min_sell=10000,
eur_max_sell=300000,
)
# 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)
from repositories.pricing import PricingRepository
repo = PricingRepository(db)
await repo.create_or_update(
premium_buy=5,
premium_sell=6,
small_trade_threshold_eur=20000,
small_trade_extra_premium=2,
eur_min_buy=10000,
eur_max_buy=300000,
eur_min_sell=12000,
eur_max_sell=320000,
)
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_buy" in config
assert "eur_max_buy" in config
assert "eur_min_sell" in config
assert "eur_max_sell" in config
assert "eur_increment" in config
assert "premium_buy" in config
assert "premium_sell" in config
assert "small_trade_threshold_eur" in config
assert "small_trade_extra_premium" in config
assert config["eur_min_buy"] == 10000
assert config["eur_max_buy"] == 300000
assert config["eur_min_sell"] == 12000
assert config["eur_max_sell"] == 320000
assert config["eur_increment"] == 20
assert config["premium_buy"] == 5
assert config["premium_sell"] == 6
# =============================================================================
# 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",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000, # €100 in cents
},
)
assert response.status_code == 200
data = response.json()
assert data["direction"] == "buy"
assert data["bitcoin_transfer_method"] == "onchain"
assert data["eur_amount"] == 10000
assert data["status"] == "booked"
assert data["sats_amount"] > 0
# For buy with €100 (small trade): base 5% + extra 2% = 7%
# Agreed price is market * 1.07
assert data["agreed_price_eur"] == pytest.approx(21400.0, rel=0.001)
assert data["premium_percentage"] == 7
@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",
"bitcoin_transfer_method": "lightning",
"eur_amount": 20000, # €200 in cents
},
)
assert response.status_code == 200
data = response.json()
assert data["direction"] == "sell"
assert data["bitcoin_transfer_method"] == "lightning"
assert data["eur_amount"] == 20000
# For sell with €200 (at threshold): base 5% + extra 2% = 7%
# Agreed price is market * 0.93
assert data["agreed_price_eur"] == pytest.approx(18600.0, rel=0.001)
assert data["premium_percentage"] == 7
@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",
"bitcoin_transfer_method": "onchain",
"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",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
assert response.status_code == 409
assert "already been booked" in response.json()["detail"]
@pytest.mark.asyncio
async def test_cannot_book_two_trades_same_day(
self, client_factory, regular_user, admin_user
):
"""Cannot book two trades on the same day."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
# First trade
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",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
assert response.status_code == 200
# Try to book another trade on the same day
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",
"bitcoin_transfer_method": "lightning",
"eur_amount": 20000,
},
)
assert response.status_code == 400
assert "already have a trade booked" in response.json()["detail"]
assert "Trade ID:" 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",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
assert response.status_code == 400
assert "Invalid direction" in response.json()["detail"]
@pytest.mark.asyncio
async def test_missing_bitcoin_transfer_method_rejected(
self, client_factory, regular_user, admin_user
):
"""Missing bitcoin_transfer_method 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": 10000,
},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_invalid_bitcoin_transfer_method_rejected(
self, client_factory, regular_user, admin_user
):
"""Invalid bitcoin_transfer_method 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",
"bitcoin_transfer_method": "invalid",
"eur_amount": 10000,
},
)
assert response.status_code == 400
assert "Invalid bitcoin_transfer_method" in response.json()["detail"]
@pytest.mark.asyncio
async def test_lightning_above_threshold_rejected(
self, client_factory, regular_user, admin_user
):
"""Lightning payment above threshold 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:
# Try Lightning with amount above threshold (€1000 = 100000 cents)
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "lightning",
"eur_amount": 110000, # €1100, above €1000 threshold
},
)
assert response.status_code == 400
assert "Lightning payments are only allowed" in response.json()["detail"]
@pytest.mark.asyncio
async def test_lightning_below_threshold_accepted(
self, client_factory, regular_user, admin_user
):
"""Lightning payment below threshold is accepted."""
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:
# Lightning with amount at threshold
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "lightning",
"eur_amount": 100000, # €1000, exactly at threshold
},
)
assert response.status_code == 200
data = response.json()
assert data["bitcoin_transfer_method"] == "lightning"
@pytest.mark.asyncio
async def test_onchain_above_threshold_accepted(
self, client_factory, regular_user, admin_user
):
"""Onchain payment above threshold is accepted."""
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:
# Onchain with amount above Lightning threshold
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "onchain",
"eur_amount": 110000, # €1100, above €1000 threshold
},
)
assert response.status_code == 200
data = response.json()
assert data["bitcoin_transfer_method"] == "onchain"
@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",
"bitcoin_transfer_method": "onchain",
"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",
"bitcoin_transfer_method": "onchain",
"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_premium_calculation_small_trade_extra(
self, client_factory, regular_user, admin_user
):
"""Premium includes extra for small trades (amount <= threshold)."""
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:
# Trade below threshold: should get base + extra premium
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "onchain",
"eur_amount": 14000, # €140, below €200 threshold, multiple of €20
},
)
assert response.status_code == 200
data = response.json()
# Base 5% + extra 2% = 7% total
assert data["premium_percentage"] == 7
# Market 20000 * 1.07 = 21400
assert data["agreed_price_eur"] == pytest.approx(21400.0, rel=0.001)
@pytest.mark.asyncio
async def test_premium_calculation_large_trade_base_only(
self, client_factory, regular_user, admin_user
):
"""Premium is base only for large trades (amount > threshold)."""
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:
# Trade above threshold: should get base premium only
response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T10:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "onchain",
"eur_amount": 24000, # €240, above €200 threshold, multiple of €20
},
)
assert response.status_code == 200
data = response.json()
# Base 5% only (no extra)
assert data["premium_percentage"] == 5
# Market 20000 * 1.05 = 21000
assert data["agreed_price_eur"] == pytest.approx(21000.0, rel=0.001)
@pytest.mark.asyncio
async def test_premium_calculation_direction_specific(
self, client_factory, regular_user, admin_user
):
"""Premium uses direction-specific base values."""
target_date = await setup_availability_and_price(client_factory, admin_user)
target_date_2 = await setup_availability_and_price(
client_factory, admin_user, target_date=in_days(2)
)
# Update pricing config with different premiums for buy/sell
async with client_factory.get_db_session() as db:
from repositories.pricing import PricingRepository
repo = PricingRepository(db)
await repo.create_or_update(
premium_buy=10,
premium_sell=8,
small_trade_threshold_eur=20000,
small_trade_extra_premium=2,
eur_min_buy=10000,
eur_max_buy=300000,
eur_min_sell=10000,
eur_max_sell=300000,
)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
# Buy trade
buy_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "onchain",
"eur_amount": 14000, # €140, multiple of €20
},
)
assert buy_response.status_code == 200
buy_data = buy_response.json()
# Buy: base 10% + extra 2% = 12%
assert buy_data["premium_percentage"] == 12
# Sell trade (different day to avoid one-trade-per-day limit)
sell_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date_2}T10:00:00Z",
"direction": "sell",
"bitcoin_transfer_method": "onchain",
"eur_amount": 14000, # €140, multiple of €20
},
)
assert sell_response.status_code == 200
sell_data = sell_response.json()
# Sell: base 8% + extra 2% = 10%
assert sell_data["premium_percentage"] == 10
@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",
"bitcoin_transfer_method": "onchain",
"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_slot_not_on_minute_boundary_rejected(
self, client_factory, regular_user, admin_user
):
"""Slot start time not on slot duration boundary 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:07:00Z", # 07 is not on 15-min boundary
"direction": "buy",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
assert response.status_code == 400
assert "boundary" in response.json()["detail"].lower()
@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 and pricing config
async with client_factory.get_db_session() as db:
await create_price_in_db(db, price=20000.0, minutes_ago=10)
from repositories.pricing import PricingRepository
repo = PricingRepository(db)
await repo.create_or_update(
premium_buy=5,
premium_sell=5,
small_trade_threshold_eur=20000,
small_trade_extra_premium=2,
eur_min_buy=10000,
eur_max_buy=300000,
eur_min_sell=10000,
eur_max_sell=300000,
)
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",
"bitcoin_transfer_method": "onchain",
"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",
"bitcoin_transfer_method": "onchain",
"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",
"bitcoin_transfer_method": "onchain",
"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)
target_date_2 = await setup_availability_and_price(
client_factory, admin_user, target_date=in_days(2)
)
with mock_price_fetcher(20000.0):
async with client_factory.create(cookies=regular_user["cookies"]) as client:
# Book two trades on different days
await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date_2}T10:00:00Z",
"direction": "sell",
"bitcoin_transfer_method": "lightning",
"eur_amount": 20000,
},
)
# Get trades
response = await client.get("/api/trades")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
class TestGetMyTrade:
"""Test getting a single trade by ID."""
@pytest.mark.asyncio
async def test_get_my_trade_success(self, client_factory, regular_user, admin_user):
"""Can get own trade by ID."""
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:
# Create a trade
create_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
assert create_response.status_code == 200
public_id = create_response.json()["public_id"]
# Get the trade
get_response = await client.get(f"/api/trades/{public_id}")
assert get_response.status_code == 200
data = get_response.json()
assert data["public_id"] == public_id
assert data["direction"] == "buy"
assert data["bitcoin_transfer_method"] == "onchain"
@pytest.mark.asyncio
async def test_cannot_get_other_user_trade(
self, client_factory, regular_user, alt_regular_user, admin_user
):
"""Cannot get another user's trade."""
target_date = await setup_availability_and_price(client_factory, admin_user)
with mock_price_fetcher(20000.0):
# First user creates a trade
async with client_factory.create(cookies=regular_user["cookies"]) as client:
create_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
assert create_response.status_code == 200
public_id = create_response.json()["public_id"]
# Second user tries to get it
async with client_factory.create(
cookies=alt_regular_user["cookies"]
) as client:
get_response = await client.get(f"/api/trades/{public_id}")
assert get_response.status_code == 404
@pytest.mark.asyncio
async def test_get_nonexistent_trade_returns_404(
self, client_factory, regular_user
):
"""Getting a nonexistent trade returns 404."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
# Use a valid UUID format but non-existent
response = await client.get(
"/api/trades/00000000-0000-0000-0000-000000000000"
)
assert response.status_code == 404
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",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
public_id = book_response.json()["public_id"]
# Cancel
response = await client.post(f"/api/trades/{public_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_cancelled_slot_becomes_available_again(
self, client_factory, regular_user, admin_user
):
"""When a trade is cancelled, the slot becomes available for booking again."""
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 a slot
book_response = await client.post(
"/api/exchange",
json={
"slot_start": f"{target_date}T09:00:00Z",
"direction": "buy",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
assert book_response.status_code == 200
public_id = book_response.json()["public_id"]
# Verify the slot is NOT available
slots_response = await client.get(
f"/api/exchange/slots?date={target_date}"
)
assert slots_response.status_code == 200
slots = slots_response.json()["slots"]
slot_starts = [s["start_time"] for s in slots]
assert f"{target_date}T09:00:00Z" not in slot_starts
# Cancel the trade
cancel_response = await client.post(f"/api/trades/{public_id}/cancel")
assert cancel_response.status_code == 200
# Verify the slot IS available again
slots_response = await client.get(
f"/api/exchange/slots?date={target_date}"
)
assert slots_response.status_code == 200
slots = slots_response.json()["slots"]
slot_starts = [s["start_time"] for s in slots]
assert f"{target_date}T09:00:00Z" in slot_starts
@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",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
public_id = book_response.json()["public_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/{public_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/00000000-0000-0000-0000-000000000000/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",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
public_id = book_response.json()["public_id"]
await client.post(f"/api/trades/{public_id}/cancel")
# Try to cancel again
response = await client.post(f"/api/trades/{public_id}/cancel")
assert response.status_code == 400
assert "cancelled_by_user" in response.json()["detail"]
@pytest.mark.asyncio
async def test_cannot_cancel_past_trade(
self, client_factory, regular_user, admin_user
):
"""Cannot cancel a trade after the slot time has passed."""
# 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=1)
exchange = Exchange(
user_id=regular_user["user"]["id"],
slot_start=past_time,
slot_end=past_time + timedelta(minutes=15),
direction=TradeDirection.BUY,
bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN,
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)
public_id = exchange.public_id
# User tries to cancel
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(f"/api/trades/{public_id}/cancel")
assert response.status_code == 400
assert "already passed" 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",
"bitcoin_transfer_method": "onchain",
"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,
bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN,
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,
bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN,
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)
public_id = exchange.public_id
# Admin completes
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{public_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",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
public_id = book_response.json()["public_id"]
# Admin tries to complete
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{public_id}/complete")
assert response.status_code == 400
assert "not yet started" in response.json()["detail"]
@pytest.mark.asyncio
async def test_regular_user_cannot_complete_trade(
self, client_factory, regular_user, admin_user
):
"""Regular user cannot complete trades (requires COMPLETE_EXCHANGE permission)."""
# 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,
bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN,
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)
public_id = exchange.public_id
# Regular user tries to complete
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{public_id}/complete")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_regular_user_cannot_mark_no_show(
self, client_factory, regular_user, admin_user
):
"""Regular user cannot mark trades as no-show (requires COMPLETE_EXCHANGE permission)."""
# 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,
bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN,
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)
public_id = exchange.public_id
# Regular user tries to mark as no-show
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{public_id}/no-show")
assert response.status_code == 403
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,
bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN,
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)
public_id = exchange.public_id
# Admin marks no-show
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{public_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",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
public_id = book_response.json()["public_id"]
# Admin cancels
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post(f"/api/admin/trades/{public_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",
"bitcoin_transfer_method": "onchain",
"eur_amount": 10000,
},
)
public_id = book_response.json()["public_id"]
# User tries admin cancel
response = await client.post(f"/api/admin/trades/{public_id}/cancel")
assert response.status_code == 403
# =============================================================================
# User Search Tests
# =============================================================================
class TestAdminUserSearch:
"""Test admin user search endpoint."""
@pytest.mark.asyncio
async def test_admin_can_search_users(
self, client_factory, admin_user, regular_user
):
"""Admin can search for users by email."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
# Search for the regular user
response = await client.get(
f"/api/admin/users/search?q={regular_user['user']['email'][:5]}"
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
# Should find the regular user
emails = [u["email"] for u in data]
assert regular_user["user"]["email"] in emails
@pytest.mark.asyncio
async def test_search_returns_limited_results(self, client_factory, admin_user):
"""Search results are limited to 10."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
# Search with a common pattern
response = await client.get("/api/admin/users/search?q=@")
assert response.status_code == 200
data = response.json()
assert len(data) <= 10
@pytest.mark.asyncio
async def test_regular_user_cannot_search(self, client_factory, regular_user):
"""Regular user cannot access user search."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/admin/users/search?q=test")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_search_requires_query(self, client_factory, admin_user):
"""Search requires a query parameter."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.get("/api/admin/users/search")
assert response.status_code == 422 # Validation error