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