""" Exchange API Tests Tests for the Bitcoin trading exchange endpoints. """ from datetime import UTC, date, datetime, timedelta from unittest.mock import patch import pytest from models import ( BitcoinTransferMethod, Exchange, ExchangeStatus, PriceHistory, TradeDirection, ) from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX def tomorrow() -> date: return date.today() + timedelta(days=1) def in_days(n: int) -> date: return date.today() + timedelta(days=n) # ============================================================================= # Fixtures # ============================================================================= async def create_price_in_db(db, price: float = 20000.0, minutes_ago: int = 0): """Create a price record in the database.""" timestamp = datetime.now(UTC) - timedelta(minutes=minutes_ago) price_record = PriceHistory( source=SOURCE_BITFINEX, pair=PAIR_BTC_EUR, price=price, timestamp=timestamp, ) db.add(price_record) await db.commit() await db.refresh(price_record) return price_record def mock_price_fetcher(price: float = 20000.0): """Return a mock that returns the given price.""" async def mock_fetch(): return (price, datetime.now(UTC)) return patch("routes.exchange.fetch_btc_eur_price", mock_fetch) async def setup_availability_and_price(client_factory, admin_user, target_date=None): """Helper to set up availability, price, and pricing config for tests.""" if target_date is None: target_date = tomorrow() # Admin sets availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(target_date), "slots": [{"start_time": "09:00:00", "end_time": "17:00:00"}], }, ) # Create fresh price in DB and pricing config async with client_factory.get_db_session() as db: await create_price_in_db(db, price=20000.0, minutes_ago=0) from repositories.pricing import PricingRepository repo = PricingRepository(db) await repo.create_or_update( premium_buy=5, premium_sell=5, small_trade_threshold_eur=20000, small_trade_extra_premium=2, eur_min_buy=10000, eur_max_buy=300000, eur_min_sell=10000, eur_max_sell=300000, ) return target_date # ============================================================================= # Price Endpoint Tests # ============================================================================= class TestExchangePriceEndpoint: """Test the /api/exchange/price endpoint.""" @pytest.mark.asyncio async def test_regular_user_can_get_price(self, client_factory, regular_user): """Regular user can access price endpoint.""" # Create fresh price in DB and pricing config async with client_factory.get_db_session() as db: await create_price_in_db(db, price=20000.0, minutes_ago=0) from repositories.pricing import PricingRepository repo = PricingRepository(db) await repo.create_or_update( premium_buy=5, premium_sell=5, small_trade_threshold_eur=20000, small_trade_extra_premium=2, eur_min_buy=10000, eur_max_buy=300000, eur_min_sell=10000, eur_max_sell=300000, ) # Mock the price fetcher to prevent real API calls with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/exchange/price") assert response.status_code == 200 data = response.json() assert "price" in data assert "config" in data assert data["price"]["market_price"] == 20000.0 # Note: premium is now in config, agreed_price is calculated on frontend @pytest.mark.asyncio async def test_admin_cannot_get_price(self, client_factory, admin_user): """Admin cannot access price endpoint.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.get("/api/exchange/price") assert response.status_code == 403 @pytest.mark.asyncio async def test_unauthenticated_cannot_get_price(self, client): """Unauthenticated user cannot access price endpoint.""" response = await client.get("/api/exchange/price") assert response.status_code == 401 @pytest.mark.asyncio async def test_stale_price_triggers_fetch(self, client_factory, regular_user): """When cached price is stale, a fresh price is fetched.""" # Create stale price (6 minutes old, threshold is 5 minutes) and pricing config async with client_factory.get_db_session() as db: await create_price_in_db(db, price=19000.0, minutes_ago=6) from repositories.pricing import PricingRepository repo = PricingRepository(db) await repo.create_or_update( premium_buy=5, premium_sell=5, small_trade_threshold_eur=20000, small_trade_extra_premium=2, eur_min_buy=10000, eur_max_buy=300000, eur_min_sell=10000, eur_max_sell=300000, ) # Mock fetcher returns new price with mock_price_fetcher(21000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/exchange/price") assert response.status_code == 200 data = response.json() # Should have the fresh price, not the stale one assert data["price"]["market_price"] == 21000.0 @pytest.mark.asyncio async def test_config_contains_expected_fields(self, client_factory, regular_user): """Config section contains all required fields.""" async with client_factory.get_db_session() as db: await create_price_in_db(db) from repositories.pricing import PricingRepository repo = PricingRepository(db) await repo.create_or_update( premium_buy=5, premium_sell=6, small_trade_threshold_eur=20000, small_trade_extra_premium=2, eur_min_buy=10000, eur_max_buy=300000, eur_min_sell=12000, eur_max_sell=320000, ) with mock_price_fetcher(): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/exchange/price") assert response.status_code == 200 config = response.json()["config"] assert "eur_min_buy" in config assert "eur_max_buy" in config assert "eur_min_sell" in config assert "eur_max_sell" in config assert "eur_increment" in config assert "premium_buy" in config assert "premium_sell" in config assert "small_trade_threshold_eur" in config assert "small_trade_extra_premium" in config assert config["eur_min_buy"] == 10000 assert config["eur_max_buy"] == 300000 assert config["eur_min_sell"] == 12000 assert config["eur_max_sell"] == 320000 assert config["eur_increment"] == 20 assert config["premium_buy"] == 5 assert config["premium_sell"] == 6 # ============================================================================= # Create Exchange Tests # ============================================================================= class TestCreateExchange: """Test creating exchange trades.""" @pytest.mark.asyncio async def test_create_exchange_buy_success( self, client_factory, regular_user, admin_user ): """Can successfully create a buy exchange trade.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, # €100 in cents }, ) assert response.status_code == 200 data = response.json() assert data["direction"] == "buy" assert data["bitcoin_transfer_method"] == "onchain" assert data["eur_amount"] == 10000 assert data["status"] == "booked" assert data["sats_amount"] > 0 # For buy with €100 (small trade): base 5% + extra 2% = 7% # Agreed price is market * 1.07 assert data["agreed_price_eur"] == pytest.approx(21400.0, rel=0.001) assert data["premium_percentage"] == 7 @pytest.mark.asyncio async def test_create_exchange_sell_success( self, client_factory, regular_user, admin_user ): """Can successfully create a sell exchange trade.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T10:00:00Z", "direction": "sell", "bitcoin_transfer_method": "lightning", "eur_amount": 20000, # €200 in cents }, ) assert response.status_code == 200 data = response.json() assert data["direction"] == "sell" assert data["bitcoin_transfer_method"] == "lightning" assert data["eur_amount"] == 20000 # For sell with €200 (at threshold): base 5% + extra 2% = 7% # Agreed price is market * 0.93 assert data["agreed_price_eur"] == pytest.approx(18600.0, rel=0.001) assert data["premium_percentage"] == 7 @pytest.mark.asyncio async def test_cannot_book_same_slot_twice( self, client_factory, regular_user, alt_regular_user, admin_user ): """Cannot book the same slot twice.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): # First user books async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) assert response.status_code == 200 # Second user tries same slot async with client_factory.create( cookies=alt_regular_user["cookies"] ) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) assert response.status_code == 409 assert "already been booked" in response.json()["detail"] @pytest.mark.asyncio async def test_cannot_book_two_trades_same_day( self, client_factory, regular_user, admin_user ): """Cannot book two trades on the same day.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): # First trade async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) assert response.status_code == 200 # Try to book another trade on the same day async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T10:00:00Z", "direction": "sell", "bitcoin_transfer_method": "lightning", "eur_amount": 20000, }, ) assert response.status_code == 400 assert "already have a trade booked" in response.json()["detail"] assert "Trade ID:" in response.json()["detail"] @pytest.mark.asyncio async def test_invalid_direction_rejected( self, client_factory, regular_user, admin_user ): """Invalid direction is rejected.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "invalid", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) assert response.status_code == 400 assert "Invalid direction" in response.json()["detail"] @pytest.mark.asyncio async def test_missing_bitcoin_transfer_method_rejected( self, client_factory, regular_user, admin_user ): """Missing bitcoin_transfer_method is rejected.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "eur_amount": 10000, }, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_invalid_bitcoin_transfer_method_rejected( self, client_factory, regular_user, admin_user ): """Invalid bitcoin_transfer_method is rejected.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "invalid", "eur_amount": 10000, }, ) assert response.status_code == 400 assert "Invalid bitcoin_transfer_method" in response.json()["detail"] @pytest.mark.asyncio async def test_lightning_above_threshold_rejected( self, client_factory, regular_user, admin_user ): """Lightning payment above threshold is rejected.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: # Try Lightning with amount above threshold (€1000 = 100000 cents) response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "lightning", "eur_amount": 110000, # €1100, above €1000 threshold }, ) assert response.status_code == 400 assert "Lightning payments are only allowed" in response.json()["detail"] @pytest.mark.asyncio async def test_lightning_below_threshold_accepted( self, client_factory, regular_user, admin_user ): """Lightning payment below threshold is accepted.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: # Lightning with amount at threshold response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "lightning", "eur_amount": 100000, # €1000, exactly at threshold }, ) assert response.status_code == 200 data = response.json() assert data["bitcoin_transfer_method"] == "lightning" @pytest.mark.asyncio async def test_onchain_above_threshold_accepted( self, client_factory, regular_user, admin_user ): """Onchain payment above threshold is accepted.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: # Onchain with amount above Lightning threshold response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 110000, # €1100, above €1000 threshold }, ) assert response.status_code == 200 data = response.json() assert data["bitcoin_transfer_method"] == "onchain" @pytest.mark.asyncio async def test_amount_below_minimum_rejected( self, client_factory, regular_user, admin_user ): """EUR amount below minimum is rejected.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 5000, # €50, below min of €100 }, ) assert response.status_code == 400 assert "at least" in response.json()["detail"] @pytest.mark.asyncio async def test_amount_above_maximum_rejected( self, client_factory, regular_user, admin_user ): """EUR amount above maximum is rejected.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 400000, # €4000, above max of €3000 }, ) assert response.status_code == 400 assert "at most" in response.json()["detail"] @pytest.mark.asyncio async def test_premium_calculation_small_trade_extra( self, client_factory, regular_user, admin_user ): """Premium includes extra for small trades (amount <= threshold).""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: # Trade below threshold: should get base + extra premium response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 14000, # €140, below €200 threshold, multiple of €20 }, ) assert response.status_code == 200 data = response.json() # Base 5% + extra 2% = 7% total assert data["premium_percentage"] == 7 # Market 20000 * 1.07 = 21400 assert data["agreed_price_eur"] == pytest.approx(21400.0, rel=0.001) @pytest.mark.asyncio async def test_premium_calculation_large_trade_base_only( self, client_factory, regular_user, admin_user ): """Premium is base only for large trades (amount > threshold).""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: # Trade above threshold: should get base premium only response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T10:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 24000, # €240, above €200 threshold, multiple of €20 }, ) assert response.status_code == 200 data = response.json() # Base 5% only (no extra) assert data["premium_percentage"] == 5 # Market 20000 * 1.05 = 21000 assert data["agreed_price_eur"] == pytest.approx(21000.0, rel=0.001) @pytest.mark.asyncio async def test_premium_calculation_direction_specific( self, client_factory, regular_user, admin_user ): """Premium uses direction-specific base values.""" target_date = await setup_availability_and_price(client_factory, admin_user) target_date_2 = await setup_availability_and_price( client_factory, admin_user, target_date=in_days(2) ) # Update pricing config with different premiums for buy/sell async with client_factory.get_db_session() as db: from repositories.pricing import PricingRepository repo = PricingRepository(db) await repo.create_or_update( premium_buy=10, premium_sell=8, small_trade_threshold_eur=20000, small_trade_extra_premium=2, eur_min_buy=10000, eur_max_buy=300000, eur_min_sell=10000, eur_max_sell=300000, ) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: # Buy trade buy_response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 14000, # €140, multiple of €20 }, ) assert buy_response.status_code == 200 buy_data = buy_response.json() # Buy: base 10% + extra 2% = 12% assert buy_data["premium_percentage"] == 12 # Sell trade (different day to avoid one-trade-per-day limit) sell_response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date_2}T10:00:00Z", "direction": "sell", "bitcoin_transfer_method": "onchain", "eur_amount": 14000, # €140, multiple of €20 }, ) assert sell_response.status_code == 200 sell_data = sell_response.json() # Sell: base 8% + extra 2% = 10% assert sell_data["premium_percentage"] == 10 @pytest.mark.asyncio async def test_amount_not_multiple_of_increment_rejected( self, client_factory, regular_user, admin_user ): """EUR amount not a multiple of increment is rejected.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 11500, # €115, not multiple of €20 }, ) assert response.status_code == 400 assert "multiple" in response.json()["detail"] @pytest.mark.asyncio async def test_slot_not_on_minute_boundary_rejected( self, client_factory, regular_user, admin_user ): """Slot start time not on slot duration boundary is rejected.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:07:00Z", # 07 is not on 15-min boundary "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) assert response.status_code == 400 assert "boundary" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_stale_price_blocks_booking( self, client_factory, regular_user, admin_user ): """Cannot book when price is stale.""" # Set up availability async with client_factory.create(cookies=admin_user["cookies"]) as admin_client: await admin_client.put( "/api/admin/availability", json={ "date": str(tomorrow()), "slots": [{"start_time": "09:00:00", "end_time": "17:00:00"}], }, ) # Create stale price and pricing config async with client_factory.get_db_session() as db: await create_price_in_db(db, price=20000.0, minutes_ago=10) from repositories.pricing import PricingRepository repo = PricingRepository(db) await repo.create_or_update( premium_buy=5, premium_sell=5, small_trade_threshold_eur=20000, small_trade_extra_premium=2, eur_min_buy=10000, eur_max_buy=300000, eur_min_sell=10000, eur_max_sell=300000, ) async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{tomorrow()}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) assert response.status_code == 503 assert "stale" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_slot_outside_availability_rejected( self, client_factory, regular_user, admin_user ): """Slot outside availability is rejected.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T20:00:00Z", # Outside 09-17 "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) assert response.status_code == 400 assert "not available" in response.json()["detail"] @pytest.mark.asyncio async def test_admin_cannot_create_exchange(self, client_factory, admin_user): """Admin cannot create exchange.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.post( "/api/exchange", json={ "slot_start": f"{tomorrow()}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) assert response.status_code == 403 # ============================================================================= # User Trades Tests # ============================================================================= class TestUserTrades: """Test user's trades endpoints.""" @pytest.mark.asyncio async def test_get_my_trades_empty(self, client_factory, regular_user): """Returns empty list when user has no trades.""" async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/trades") assert response.status_code == 200 assert response.json() == [] @pytest.mark.asyncio async def test_get_my_trades_with_exchanges( self, client_factory, regular_user, admin_user ): """Returns user's trades.""" target_date = await setup_availability_and_price(client_factory, admin_user) target_date_2 = await setup_availability_and_price( client_factory, admin_user, target_date=in_days(2) ) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: # Book two trades on different days await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) await client.post( "/api/exchange", json={ "slot_start": f"{target_date_2}T10:00:00Z", "direction": "sell", "bitcoin_transfer_method": "lightning", "eur_amount": 20000, }, ) # Get trades response = await client.get("/api/trades") assert response.status_code == 200 data = response.json() assert len(data) == 2 class TestGetMyTrade: """Test getting a single trade by ID.""" @pytest.mark.asyncio async def test_get_my_trade_success(self, client_factory, regular_user, admin_user): """Can get own trade by ID.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: # Create a trade create_response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) assert create_response.status_code == 200 public_id = create_response.json()["public_id"] # Get the trade get_response = await client.get(f"/api/trades/{public_id}") assert get_response.status_code == 200 data = get_response.json() assert data["public_id"] == public_id assert data["direction"] == "buy" assert data["bitcoin_transfer_method"] == "onchain" @pytest.mark.asyncio async def test_cannot_get_other_user_trade( self, client_factory, regular_user, alt_regular_user, admin_user ): """Cannot get another user's trade.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): # First user creates a trade async with client_factory.create(cookies=regular_user["cookies"]) as client: create_response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) assert create_response.status_code == 200 public_id = create_response.json()["public_id"] # Second user tries to get it async with client_factory.create( cookies=alt_regular_user["cookies"] ) as client: get_response = await client.get(f"/api/trades/{public_id}") assert get_response.status_code == 404 @pytest.mark.asyncio async def test_get_nonexistent_trade_returns_404( self, client_factory, regular_user ): """Getting a nonexistent trade returns 404.""" async with client_factory.create(cookies=regular_user["cookies"]) as client: # Use a valid UUID format but non-existent response = await client.get( "/api/trades/00000000-0000-0000-0000-000000000000" ) assert response.status_code == 404 class TestCancelTrade: """Test cancelling trades.""" @pytest.mark.asyncio async def test_cancel_own_trade(self, client_factory, regular_user, admin_user): """User can cancel their own trade.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: # Book trade book_response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) public_id = book_response.json()["public_id"] # Cancel response = await client.post(f"/api/trades/{public_id}/cancel") assert response.status_code == 200 data = response.json() assert data["status"] == "cancelled_by_user" assert data["cancelled_at"] is not None @pytest.mark.asyncio async def test_cancelled_slot_becomes_available_again( self, client_factory, regular_user, admin_user ): """When a trade is cancelled, the slot becomes available for booking again.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: # Book a slot book_response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) assert book_response.status_code == 200 public_id = book_response.json()["public_id"] # Verify the slot is NOT available slots_response = await client.get( f"/api/exchange/slots?date={target_date}" ) assert slots_response.status_code == 200 slots = slots_response.json()["slots"] slot_starts = [s["start_time"] for s in slots] assert f"{target_date}T09:00:00Z" not in slot_starts # Cancel the trade cancel_response = await client.post(f"/api/trades/{public_id}/cancel") assert cancel_response.status_code == 200 # Verify the slot IS available again slots_response = await client.get( f"/api/exchange/slots?date={target_date}" ) assert slots_response.status_code == 200 slots = slots_response.json()["slots"] slot_starts = [s["start_time"] for s in slots] assert f"{target_date}T09:00:00Z" in slot_starts @pytest.mark.asyncio async def test_cannot_cancel_others_trade( self, client_factory, regular_user, alt_regular_user, admin_user ): """User cannot cancel another user's trade.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): # First user books async with client_factory.create(cookies=regular_user["cookies"]) as client: book_response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) public_id = book_response.json()["public_id"] # Second user tries to cancel (no mock needed for this) async with client_factory.create(cookies=alt_regular_user["cookies"]) as client: response = await client.post(f"/api/trades/{public_id}/cancel") assert response.status_code == 403 @pytest.mark.asyncio async def test_cannot_cancel_nonexistent_trade(self, client_factory, regular_user): """Returns 404 for non-existent trade.""" async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post( "/api/trades/00000000-0000-0000-0000-000000000000/cancel" ) assert response.status_code == 404 @pytest.mark.asyncio async def test_cannot_cancel_already_cancelled( self, client_factory, regular_user, admin_user ): """Cannot cancel an already cancelled trade.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): async with client_factory.create(cookies=regular_user["cookies"]) as client: # Book and cancel book_response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) public_id = book_response.json()["public_id"] await client.post(f"/api/trades/{public_id}/cancel") # Try to cancel again response = await client.post(f"/api/trades/{public_id}/cancel") assert response.status_code == 400 assert "cancelled_by_user" in response.json()["detail"] @pytest.mark.asyncio async def test_cannot_cancel_past_trade( self, client_factory, regular_user, admin_user ): """Cannot cancel a trade after the slot time has passed.""" # Create a past trade directly in DB async with client_factory.get_db_session() as db: await create_price_in_db(db, price=20000.0) past_time = datetime.now(UTC) - timedelta(hours=1) exchange = Exchange( user_id=regular_user["user"]["id"], slot_start=past_time, slot_end=past_time + timedelta(minutes=15), direction=TradeDirection.BUY, bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN, eur_amount=10000, sats_amount=500000, market_price_eur=20000.0, agreed_price_eur=21000.0, premium_percentage=5, status=ExchangeStatus.BOOKED, ) db.add(exchange) await db.commit() await db.refresh(exchange) public_id = exchange.public_id # User tries to cancel async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post(f"/api/trades/{public_id}/cancel") assert response.status_code == 400 assert "already passed" in response.json()["detail"] # ============================================================================= # Admin Trades Tests # ============================================================================= class TestAdminUpcomingTrades: """Test admin viewing upcoming trades.""" @pytest.mark.asyncio async def test_admin_can_view_upcoming_trades( self, client_factory, regular_user, admin_user ): """Admin can view upcoming trades.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): # User books async with client_factory.create(cookies=regular_user["cookies"]) as client: await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) # Admin views upcoming trades async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.get("/api/admin/trades/upcoming") assert response.status_code == 200 data = response.json() assert len(data) >= 1 # Check user contact info is present assert "user_contact" in data[0] assert "email" in data[0]["user_contact"] @pytest.mark.asyncio async def test_regular_user_cannot_view_upcoming( self, client_factory, regular_user ): """Regular user cannot access admin endpoint.""" async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/admin/trades/upcoming") assert response.status_code == 403 class TestAdminPastTrades: """Test admin viewing past trades.""" @pytest.mark.asyncio async def test_admin_can_view_past_trades( self, client_factory, regular_user, admin_user ): """Admin can view past trades.""" # Create a past trade directly in DB async with client_factory.get_db_session() as db: await create_price_in_db(db, price=20000.0) past_time = datetime.now(UTC) - timedelta(hours=2) exchange = Exchange( user_id=regular_user["user"]["id"], slot_start=past_time, slot_end=past_time + timedelta(minutes=15), direction=TradeDirection.BUY, bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN, eur_amount=10000, sats_amount=500000, market_price_eur=20000.0, agreed_price_eur=21000.0, premium_percentage=5, status=ExchangeStatus.BOOKED, ) db.add(exchange) await db.commit() # Admin views past trades async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.get("/api/admin/trades/past") assert response.status_code == 200 data = response.json() assert len(data) >= 1 class TestAdminCompleteTrade: """Test admin completing trades.""" @pytest.mark.asyncio async def test_admin_can_complete_trade( self, client_factory, regular_user, admin_user ): """Admin can mark a past trade as completed.""" # Create a past trade in DB async with client_factory.get_db_session() as db: past_time = datetime.now(UTC) - timedelta(hours=1) exchange = Exchange( user_id=regular_user["user"]["id"], slot_start=past_time, slot_end=past_time + timedelta(minutes=15), direction=TradeDirection.BUY, bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN, eur_amount=10000, sats_amount=500000, market_price_eur=20000.0, agreed_price_eur=21000.0, premium_percentage=5, status=ExchangeStatus.BOOKED, ) db.add(exchange) await db.commit() await db.refresh(exchange) public_id = exchange.public_id # Admin completes async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.post(f"/api/admin/trades/{public_id}/complete") assert response.status_code == 200 data = response.json() assert data["status"] == "completed" assert data["completed_at"] is not None @pytest.mark.asyncio async def test_cannot_complete_future_trade( self, client_factory, regular_user, admin_user ): """Cannot complete a trade that hasn't started yet.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): # User books future trade async with client_factory.create(cookies=regular_user["cookies"]) as client: book_response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) public_id = book_response.json()["public_id"] # Admin tries to complete async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.post(f"/api/admin/trades/{public_id}/complete") assert response.status_code == 400 assert "not yet started" in response.json()["detail"] @pytest.mark.asyncio async def test_regular_user_cannot_complete_trade( self, client_factory, regular_user, admin_user ): """Regular user cannot complete trades (requires COMPLETE_EXCHANGE permission).""" # Create a past trade in DB async with client_factory.get_db_session() as db: past_time = datetime.now(UTC) - timedelta(hours=1) exchange = Exchange( user_id=regular_user["user"]["id"], slot_start=past_time, slot_end=past_time + timedelta(minutes=15), direction=TradeDirection.BUY, bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN, eur_amount=10000, sats_amount=500000, market_price_eur=20000.0, agreed_price_eur=21000.0, premium_percentage=5, status=ExchangeStatus.BOOKED, ) db.add(exchange) await db.commit() await db.refresh(exchange) public_id = exchange.public_id # Regular user tries to complete async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post(f"/api/admin/trades/{public_id}/complete") assert response.status_code == 403 @pytest.mark.asyncio async def test_regular_user_cannot_mark_no_show( self, client_factory, regular_user, admin_user ): """Regular user cannot mark trades as no-show (requires COMPLETE_EXCHANGE permission).""" # Create a past trade in DB async with client_factory.get_db_session() as db: past_time = datetime.now(UTC) - timedelta(hours=1) exchange = Exchange( user_id=regular_user["user"]["id"], slot_start=past_time, slot_end=past_time + timedelta(minutes=15), direction=TradeDirection.BUY, bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN, eur_amount=10000, sats_amount=500000, market_price_eur=20000.0, agreed_price_eur=21000.0, premium_percentage=5, status=ExchangeStatus.BOOKED, ) db.add(exchange) await db.commit() await db.refresh(exchange) public_id = exchange.public_id # Regular user tries to mark as no-show async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post(f"/api/admin/trades/{public_id}/no-show") assert response.status_code == 403 class TestAdminNoShowTrade: """Test admin marking trades as no-show.""" @pytest.mark.asyncio async def test_admin_can_mark_no_show( self, client_factory, regular_user, admin_user ): """Admin can mark a past trade as no-show.""" # Create a past trade in DB async with client_factory.get_db_session() as db: past_time = datetime.now(UTC) - timedelta(hours=1) exchange = Exchange( user_id=regular_user["user"]["id"], slot_start=past_time, slot_end=past_time + timedelta(minutes=15), direction=TradeDirection.BUY, bitcoin_transfer_method=BitcoinTransferMethod.ONCHAIN, eur_amount=10000, sats_amount=500000, market_price_eur=20000.0, agreed_price_eur=21000.0, premium_percentage=5, status=ExchangeStatus.BOOKED, ) db.add(exchange) await db.commit() await db.refresh(exchange) public_id = exchange.public_id # Admin marks no-show async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.post(f"/api/admin/trades/{public_id}/no-show") assert response.status_code == 200 data = response.json() assert data["status"] == "no_show" class TestAdminCancelTrade: """Test admin cancelling trades.""" @pytest.mark.asyncio async def test_admin_can_cancel_trade( self, client_factory, regular_user, admin_user ): """Admin can cancel any trade.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): # User books async with client_factory.create(cookies=regular_user["cookies"]) as client: book_response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) public_id = book_response.json()["public_id"] # Admin cancels async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.post(f"/api/admin/trades/{public_id}/cancel") assert response.status_code == 200 data = response.json() assert data["status"] == "cancelled_by_admin" @pytest.mark.asyncio async def test_regular_user_cannot_admin_cancel( self, client_factory, regular_user, admin_user ): """Regular user cannot use admin cancel endpoint.""" target_date = await setup_availability_and_price(client_factory, admin_user) with mock_price_fetcher(20000.0): # User books async with client_factory.create(cookies=regular_user["cookies"]) as client: book_response = await client.post( "/api/exchange", json={ "slot_start": f"{target_date}T09:00:00Z", "direction": "buy", "bitcoin_transfer_method": "onchain", "eur_amount": 10000, }, ) public_id = book_response.json()["public_id"] # User tries admin cancel response = await client.post(f"/api/admin/trades/{public_id}/cancel") assert response.status_code == 403 # ============================================================================= # User Search Tests # ============================================================================= class TestAdminUserSearch: """Test admin user search endpoint.""" @pytest.mark.asyncio async def test_admin_can_search_users( self, client_factory, admin_user, regular_user ): """Admin can search for users by email.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: # Search for the regular user response = await client.get( f"/api/admin/users/search?q={regular_user['user']['email'][:5]}" ) assert response.status_code == 200 data = response.json() assert isinstance(data, list) # Should find the regular user emails = [u["email"] for u in data] assert regular_user["user"]["email"] in emails @pytest.mark.asyncio async def test_search_returns_limited_results(self, client_factory, admin_user): """Search results are limited to 10.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: # Search with a common pattern response = await client.get("/api/admin/users/search?q=@") assert response.status_code == 200 data = response.json() assert len(data) <= 10 @pytest.mark.asyncio async def test_regular_user_cannot_search(self, client_factory, regular_user): """Regular user cannot access user search.""" async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.get("/api/admin/users/search?q=test") assert response.status_code == 403 @pytest.mark.asyncio async def test_search_requires_query(self, client_factory, admin_user): """Search requires a query parameter.""" async with client_factory.create(cookies=admin_user["cookies"]) as client: response = await client.get("/api/admin/users/search") assert response.status_code == 422 # Validation error