"""Tests for decks API endpoints. Tests the deck management endpoints with mocked services. The backend is stateless - DeckConfig comes from the request. """ from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest from fastapi import FastAPI, status from fastapi.testclient import TestClient from app.api import deps as api_deps from app.api.decks import router as decks_router from app.db.models import User from app.repositories.protocols import DeckEntry from app.services.card_service import CardService from app.services.collection_service import CollectionService from app.services.deck_service import ( DeckLimitExceededError, DeckNotFoundError, DeckService, ) from app.services.deck_validator import ValidationResult from app.services.jwt_service import create_access_token # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture def test_user(): """Create a test user object.""" user = User( email="test@example.com", display_name="Test User", avatar_url="https://example.com/avatar.jpg", oauth_provider="google", oauth_id="google-123", is_premium=False, premium_until=None, ) user.id = uuid4() user.created_at = datetime.now(UTC) user.updated_at = datetime.now(UTC) user.last_login = None user.linked_accounts = [] return user @pytest.fixture def premium_user(test_user): """Create a premium test user.""" from datetime import timedelta test_user.is_premium = True test_user.premium_until = datetime.now(UTC) + timedelta(days=30) return test_user @pytest.fixture def access_token(test_user): """Create a valid access token for the test user.""" return create_access_token(test_user.id) @pytest.fixture def auth_headers(access_token): """Create Authorization headers with Bearer token.""" return {"Authorization": f"Bearer {access_token}"} @pytest.fixture def mock_deck_service(): """Create a mock DeckService.""" return MagicMock(spec=DeckService) @pytest.fixture def mock_collection_service(): """Create a mock CollectionService.""" return MagicMock(spec=CollectionService) @pytest.fixture def mock_card_service(): """Create a mock CardService.""" service = MagicMock(spec=CardService) service.get_card.return_value = MagicMock() # Card exists return service @pytest.fixture def app(test_user, mock_deck_service, mock_collection_service, mock_card_service): """Create a test FastAPI app with mocked dependencies.""" test_app = FastAPI() test_app.include_router(decks_router, prefix="/api") # Override dependencies async def override_get_current_user(): return test_user def override_get_deck_service(): return mock_deck_service def override_get_collection_service(): return mock_collection_service test_app.dependency_overrides[api_deps.get_current_user] = override_get_current_user test_app.dependency_overrides[api_deps.get_deck_service] = override_get_deck_service test_app.dependency_overrides[api_deps.get_collection_service] = override_get_collection_service yield test_app test_app.dependency_overrides.clear() @pytest.fixture def unauthenticated_app(): """Create a test FastAPI app without auth override (for 401 tests).""" test_app = FastAPI() test_app.include_router(decks_router, prefix="/api") yield test_app @pytest.fixture def client(app): """Create a test client for the app.""" return TestClient(app) @pytest.fixture def unauthenticated_client(unauthenticated_app): """Create a test client without auth for 401 tests.""" return TestClient(unauthenticated_app) def make_deck_entry( name: str = "Test Deck", is_valid: bool = True, is_starter: bool = False, ) -> DeckEntry: """Create a DeckEntry for testing.""" return DeckEntry( id=uuid4(), user_id=uuid4(), name=name, cards={"a1-001-bulbasaur": 4, "a1-033-charmander": 4}, energy_cards={"grass": 10, "fire": 10}, is_valid=is_valid, validation_errors=None if is_valid else ["Invalid deck"], is_starter=is_starter, starter_type="grass" if is_starter else None, description="A test deck", created_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) # ============================================================================= # GET /decks Tests # ============================================================================= class TestListDecks: """Tests for GET /api/decks endpoint.""" def test_returns_empty_list_for_new_user( self, client: TestClient, auth_headers, mock_deck_service ): """ Test that endpoint returns empty list for new user. New users should have no decks. """ mock_deck_service.get_user_decks = AsyncMock(return_value=[]) response = client.get("/api/decks", headers=auth_headers) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["decks"] == [] assert data["deck_count"] == 0 def test_returns_user_decks(self, client: TestClient, auth_headers, mock_deck_service): """ Test that endpoint returns all user's decks. Should include deck details and count. """ decks = [make_deck_entry("Deck 1"), make_deck_entry("Deck 2")] mock_deck_service.get_user_decks = AsyncMock(return_value=decks) response = client.get("/api/decks", headers=auth_headers) assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data["decks"]) == 2 assert data["deck_count"] == 2 def test_includes_deck_limit_for_free_user( self, client: TestClient, auth_headers, mock_deck_service, test_user ): """ Test that deck_limit is included for free users. Free users have a deck limit of 5. """ mock_deck_service.get_user_decks = AsyncMock(return_value=[]) response = client.get("/api/decks", headers=auth_headers) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["deck_limit"] == 5 # Free user default def test_requires_authentication(self, unauthenticated_client: TestClient): """ Test that endpoint requires authentication. Unauthenticated requests should return 401. """ response = unauthenticated_client.get("/api/decks") assert response.status_code == status.HTTP_401_UNAUTHORIZED # ============================================================================= # POST /decks Tests # ============================================================================= class TestCreateDeck: """Tests for POST /api/decks endpoint.""" def test_creates_deck(self, client: TestClient, auth_headers, mock_deck_service): """ Test that endpoint creates a new deck. Should return the created deck with 201 status. """ deck = make_deck_entry("My New Deck") mock_deck_service.create_deck = AsyncMock(return_value=deck) response = client.post( "/api/decks", headers=auth_headers, json={ "name": "My New Deck", "cards": {"a1-001-bulbasaur": 4}, "energy_cards": {"grass": 20}, }, ) assert response.status_code == status.HTTP_201_CREATED data = response.json() assert data["name"] == "My New Deck" def test_accepts_custom_deck_config(self, client: TestClient, auth_headers, mock_deck_service): """ Test that endpoint accepts custom DeckConfig from frontend. The backend is stateless - rules come from the request. """ deck = make_deck_entry() mock_deck_service.create_deck = AsyncMock(return_value=deck) response = client.post( "/api/decks", headers=auth_headers, json={ "name": "Custom Rules Deck", "cards": {"a1-001-bulbasaur": 10}, "energy_cards": {"grass": 10}, "deck_config": { "min_size": 20, "max_size": 20, "energy_deck_size": 10, "max_copies_per_card": 10, }, }, ) assert response.status_code == status.HTTP_201_CREATED def test_returns_400_at_deck_limit(self, client: TestClient, auth_headers, mock_deck_service): """ Test that endpoint returns 400 when at deck limit. Users cannot create more decks than their limit allows. """ mock_deck_service.create_deck = AsyncMock( side_effect=DeckLimitExceededError("Deck limit reached (5/5)") ) response = client.post( "/api/decks", headers=auth_headers, json={ "name": "One Too Many", "cards": {"a1-001-bulbasaur": 4}, "energy_cards": {"grass": 20}, }, ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert "limit" in response.json()["detail"].lower() def test_requires_authentication(self, unauthenticated_client: TestClient): """ Test that endpoint requires authentication. Unauthenticated requests should return 401. """ response = unauthenticated_client.post( "/api/decks", json={ "name": "Test Deck", "cards": {"a1-001-bulbasaur": 4}, "energy_cards": {"grass": 20}, }, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED # ============================================================================= # GET /decks/{deck_id} Tests # ============================================================================= class TestGetDeck: """Tests for GET /api/decks/{deck_id} endpoint.""" def test_returns_deck(self, client: TestClient, auth_headers, mock_deck_service): """ Test that endpoint returns the requested deck. Should return full deck details. """ deck = make_deck_entry("My Deck") mock_deck_service.get_deck = AsyncMock(return_value=deck) response = client.get(f"/api/decks/{deck.id}", headers=auth_headers) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["name"] == "My Deck" def test_returns_404_for_nonexistent(self, client: TestClient, auth_headers, mock_deck_service): """ Test that endpoint returns 404 for non-existent deck. Should return 404 when deck doesn't exist. """ mock_deck_service.get_deck = AsyncMock(side_effect=DeckNotFoundError()) response = client.get(f"/api/decks/{uuid4()}", headers=auth_headers) assert response.status_code == status.HTTP_404_NOT_FOUND def test_returns_404_for_other_user_deck( self, client: TestClient, auth_headers, mock_deck_service ): """ Test that endpoint returns 404 for other user's deck. Users cannot access decks they don't own. """ mock_deck_service.get_deck = AsyncMock(side_effect=DeckNotFoundError()) response = client.get(f"/api/decks/{uuid4()}", headers=auth_headers) assert response.status_code == status.HTTP_404_NOT_FOUND def test_requires_authentication(self, unauthenticated_client: TestClient): """ Test that endpoint requires authentication. Unauthenticated requests should return 401. """ response = unauthenticated_client.get(f"/api/decks/{uuid4()}") assert response.status_code == status.HTTP_401_UNAUTHORIZED # ============================================================================= # PUT /decks/{deck_id} Tests # ============================================================================= class TestUpdateDeck: """Tests for PUT /api/decks/{deck_id} endpoint.""" def test_updates_deck(self, client: TestClient, auth_headers, mock_deck_service): """ Test that endpoint updates the deck. Should return the updated deck. """ deck = make_deck_entry("Updated Name") mock_deck_service.update_deck = AsyncMock(return_value=deck) response = client.put( f"/api/decks/{deck.id}", headers=auth_headers, json={"name": "Updated Name"}, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["name"] == "Updated Name" def test_returns_404_for_nonexistent(self, client: TestClient, auth_headers, mock_deck_service): """ Test that endpoint returns 404 for non-existent deck. Cannot update a deck that doesn't exist. """ mock_deck_service.update_deck = AsyncMock(side_effect=DeckNotFoundError()) response = client.put( f"/api/decks/{uuid4()}", headers=auth_headers, json={"name": "New Name"}, ) assert response.status_code == status.HTTP_404_NOT_FOUND def test_requires_authentication(self, unauthenticated_client: TestClient): """ Test that endpoint requires authentication. Unauthenticated requests should return 401. """ response = unauthenticated_client.put( f"/api/decks/{uuid4()}", json={"name": "New Name"}, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED # ============================================================================= # DELETE /decks/{deck_id} Tests # ============================================================================= class TestDeleteDeck: """Tests for DELETE /api/decks/{deck_id} endpoint.""" def test_deletes_deck(self, client: TestClient, auth_headers, mock_deck_service): """ Test that endpoint deletes the deck. Should return 204 No Content on success. """ mock_deck_service.delete_deck = AsyncMock(return_value=True) response = client.delete(f"/api/decks/{uuid4()}", headers=auth_headers) assert response.status_code == status.HTTP_204_NO_CONTENT def test_returns_404_for_nonexistent(self, client: TestClient, auth_headers, mock_deck_service): """ Test that endpoint returns 404 for non-existent deck. Cannot delete a deck that doesn't exist. """ mock_deck_service.delete_deck = AsyncMock(side_effect=DeckNotFoundError()) response = client.delete(f"/api/decks/{uuid4()}", headers=auth_headers) assert response.status_code == status.HTTP_404_NOT_FOUND def test_requires_authentication(self, unauthenticated_client: TestClient): """ Test that endpoint requires authentication. Unauthenticated requests should return 401. """ response = unauthenticated_client.delete(f"/api/decks/{uuid4()}") assert response.status_code == status.HTTP_401_UNAUTHORIZED # ============================================================================= # POST /decks/validate Tests # ============================================================================= class TestValidateDeck: """Tests for POST /api/decks/validate endpoint.""" def test_returns_valid_result( self, client: TestClient, auth_headers, mock_collection_service, mock_card_service, ): """ Test that endpoint validates deck and returns result. Valid decks should return is_valid=True with empty errors. """ mock_collection_service.get_owned_cards_dict = AsyncMock( return_value={"a1-001-bulbasaur": 10} ) # Mock the validate_deck function with patch("app.api.decks.validate_deck") as mock_validate: mock_validate.return_value = ValidationResult(is_valid=True, errors=[]) with patch("app.services.card_service.get_card_service") as mock_get_cs: mock_get_cs.return_value = mock_card_service response = client.post( "/api/decks/validate", headers=auth_headers, json={ "cards": {"a1-001-bulbasaur": 4}, "energy_cards": {"grass": 20}, }, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["is_valid"] is True assert data["errors"] == [] def test_returns_validation_errors( self, client: TestClient, auth_headers, mock_collection_service, mock_card_service, ): """ Test that endpoint returns validation errors for invalid deck. Invalid decks should return is_valid=False with error messages. """ mock_collection_service.get_owned_cards_dict = AsyncMock(return_value={}) with patch("app.api.decks.validate_deck") as mock_validate: mock_validate.return_value = ValidationResult( is_valid=False, errors=["Deck must have 40 cards"] ) with patch("app.services.card_service.get_card_service") as mock_get_cs: mock_get_cs.return_value = mock_card_service response = client.post( "/api/decks/validate", headers=auth_headers, json={ "cards": {"a1-001-bulbasaur": 4}, "energy_cards": {"grass": 5}, }, ) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["is_valid"] is False assert len(data["errors"]) > 0 def test_accepts_custom_deck_config( self, client: TestClient, auth_headers, mock_collection_service, mock_card_service, ): """ Test that endpoint accepts custom DeckConfig from frontend. Custom rules should be passed to validation. """ mock_collection_service.get_owned_cards_dict = AsyncMock(return_value={}) with patch("app.api.decks.validate_deck") as mock_validate: mock_validate.return_value = ValidationResult(is_valid=True, errors=[]) with patch("app.services.card_service.get_card_service") as mock_get_cs: mock_get_cs.return_value = mock_card_service response = client.post( "/api/decks/validate", headers=auth_headers, json={ "cards": {"a1-001-bulbasaur": 10}, "energy_cards": {"grass": 10}, "deck_config": {"min_size": 20, "energy_deck_size": 10}, "validate_ownership": False, }, ) assert response.status_code == status.HTTP_200_OK def test_requires_authentication(self, unauthenticated_client: TestClient): """ Test that endpoint requires authentication. Unauthenticated requests should return 401. """ response = unauthenticated_client.post( "/api/decks/validate", json={ "cards": {"a1-001-bulbasaur": 4}, "energy_cards": {"grass": 20}, }, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED