"""Tests for starter deck API endpoints. Tests the starter deck selection endpoints in the users API with proper mocking. """ from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock 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.users import router as users_router from app.db.models import User from app.repositories.protocols import DeckEntry from app.services.deck_service import DeckService 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 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) def make_deck_entry( name: str = "Starter Deck", starter_type: str = "grass", ) -> DeckEntry: """Create a DeckEntry for testing.""" return DeckEntry( id=uuid4(), user_id=uuid4(), name=name, cards={"a1-001-bulbasaur": 4}, energy_cards={"grass": 20}, is_valid=True, validation_errors=None, is_starter=True, starter_type=starter_type, description="A starter deck", created_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) @pytest.fixture def app(test_user, mock_deck_service): """Create a test FastAPI app with mocked dependencies.""" test_app = FastAPI() test_app.include_router(users_router, prefix="/api") # Override dependencies async def override_get_current_user(): return test_user def override_get_deck_service(): return mock_deck_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 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(users_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) # ============================================================================= # GET /users/me/starter-status Tests # ============================================================================= class TestGetStarterStatus: """Tests for GET /api/users/me/starter-status endpoint.""" def test_returns_no_starter_for_new_user( self, client: TestClient, auth_headers, mock_deck_service ): """ Test that new users have no starter deck selected. has_starter should be False and starter_type should be None. """ mock_deck_service.has_starter_deck = AsyncMock(return_value=(False, None)) response = client.get("/api/users/me/starter-status", headers=auth_headers) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["has_starter"] is False assert data["starter_type"] is None def test_returns_starter_type_when_selected( self, client: TestClient, auth_headers, mock_deck_service ): """ Test that endpoint returns starter type when user has selected one. has_starter should be True with the selected starter_type. """ mock_deck_service.has_starter_deck = AsyncMock(return_value=(True, "grass")) response = client.get("/api/users/me/starter-status", headers=auth_headers) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["has_starter"] is True assert data["starter_type"] == "grass" def test_requires_authentication(self, unauthenticated_client: TestClient): """Test that endpoint returns 401 without authentication.""" response = unauthenticated_client.get("/api/users/me/starter-status") assert response.status_code == status.HTTP_401_UNAUTHORIZED # ============================================================================= # POST /users/me/starter-deck Tests # ============================================================================= class TestSelectStarterDeck: """Tests for POST /api/users/me/starter-deck endpoint.""" def test_creates_starter_deck(self, client: TestClient, auth_headers, mock_deck_service): """ Test that endpoint creates starter deck and grants cards atomically. Uses the combined select_and_grant_starter_deck method. """ mock_deck_service.select_and_grant_starter_deck = AsyncMock( return_value=make_deck_entry("Forest Guardian", "grass") ) response = client.post( "/api/users/me/starter-deck", headers=auth_headers, json={"starter_type": "grass"}, ) assert response.status_code == status.HTTP_201_CREATED data = response.json() assert data["is_starter"] is True assert data["starter_type"] == "grass" def test_returns_400_if_already_selected( self, client: TestClient, auth_headers, mock_deck_service ): """ Test that endpoint returns 400 if starter already selected. Users can only select a starter deck once. """ from app.services.deck_service import StarterAlreadySelectedError mock_deck_service.select_and_grant_starter_deck = AsyncMock( side_effect=StarterAlreadySelectedError("Starter deck already selected: fire") ) response = client.post( "/api/users/me/starter-deck", headers=auth_headers, json={"starter_type": "grass"}, ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert "already selected" in response.json()["detail"].lower() def test_returns_400_for_invalid_starter_type( self, client: TestClient, auth_headers, mock_deck_service ): """ Test that endpoint returns 400 for invalid starter type. Only valid starter types should be accepted. """ mock_deck_service.select_and_grant_starter_deck = AsyncMock( side_effect=ValueError("Invalid starter type: invalid") ) response = client.post( "/api/users/me/starter-deck", headers=auth_headers, json={"starter_type": "invalid"}, ) assert response.status_code == status.HTTP_400_BAD_REQUEST 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. """ mock_deck_service.select_and_grant_starter_deck = AsyncMock(return_value=make_deck_entry()) response = client.post( "/api/users/me/starter-deck", headers=auth_headers, json={ "starter_type": "grass", "deck_config": {"min_size": 40, "energy_deck_size": 20}, }, ) assert response.status_code == status.HTTP_201_CREATED def test_requires_authentication(self, unauthenticated_client: TestClient): """Test that endpoint returns 401 without authentication.""" response = unauthenticated_client.post( "/api/users/me/starter-deck", json={"starter_type": "grass"}, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED