""" 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) 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", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) await client.post( "/api/exchange", json={ "slot_start": f"{target_date}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 trade_id = create_response.json()["id"] # Get the trade get_response = await client.get(f"/api/trades/{trade_id}") assert get_response.status_code == 200 data = get_response.json() assert data["id"] == trade_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 trade_id = create_response.json()["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/{trade_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: response = await client.get("/api/trades/99999") 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, }, ) 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_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 trade_id = book_response.json()["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/{trade_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, }, ) 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", "bitcoin_transfer_method": "onchain", "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"] @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) trade_id = exchange.id # User tries to cancel async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post(f"/api/trades/{trade_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) 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", "bitcoin_transfer_method": "onchain", "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"] @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) trade_id = exchange.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/{trade_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) trade_id = exchange.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/{trade_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) 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", "bitcoin_transfer_method": "onchain", "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", "bitcoin_transfer_method": "onchain", "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 # ============================================================================= # 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