1268 lines
49 KiB
Python
1268 lines
49 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 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
|
|
# Note: agreed_price is calculated on frontend based on direction (buy/sell)
|
|
|
|
@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",
|
|
"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, 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",
|
|
"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, 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",
|
|
"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_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 (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",
|
|
"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
|