The agreed_price depends on trade direction (buy/sell) and must be calculated on the frontend. Returning a buy-side-only agreed_price from the API was misleading and unused. Frontend already calculates the direction-aware price correctly.
788 lines
29 KiB
Python
788 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
|
|
# 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",
|
|
"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
|