"""Tests for collections API endpoints. Tests the card collection management endpoints with mocked services. """ 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.collections import router as collections_router from app.db.models import User from app.db.models.collection import CardSource from app.repositories.protocols import CollectionEntry from app.services.card_service import CardService from app.services.collection_service import CollectionService 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_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_collection_service, mock_card_service): """Create a test FastAPI app with mocked dependencies.""" test_app = FastAPI() test_app.include_router(collections_router, prefix="/api") # Override dependencies async def override_get_current_user(): return test_user 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_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(collections_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_collection_entry( card_id: str = "a1-001-bulbasaur", quantity: int = 4, source: CardSource = CardSource.BOOSTER, ) -> CollectionEntry: """Create a CollectionEntry for testing.""" return CollectionEntry( id=uuid4(), user_id=uuid4(), card_definition_id=card_id, quantity=quantity, source=source, obtained_at=datetime.now(UTC), created_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) # ============================================================================= # GET /collections/me Tests # ============================================================================= class TestGetMyCollection: """Tests for GET /api/collections/me endpoint.""" def test_returns_empty_collection_for_new_user( self, client: TestClient, auth_headers, mock_collection_service ): """ Test that endpoint returns empty collection for new user. New users should have no cards until they select a starter. """ mock_collection_service.get_collection = AsyncMock(return_value=[]) response = client.get("/api/collections/me", headers=auth_headers) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_unique_cards"] == 0 assert data["total_card_count"] == 0 assert data["entries"] == [] def test_returns_collection_with_cards( self, client: TestClient, auth_headers, mock_collection_service ): """ Test that endpoint returns cards in collection. Should include all cards with quantities and totals. """ entries = [ make_collection_entry("a1-001-bulbasaur", 4), make_collection_entry("a1-033-charmander", 2), ] mock_collection_service.get_collection = AsyncMock(return_value=entries) response = client.get("/api/collections/me", headers=auth_headers) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_unique_cards"] == 2 assert data["total_card_count"] == 6 # 4 + 2 assert len(data["entries"]) == 2 def test_requires_authentication(self, unauthenticated_client: TestClient): """ Test that endpoint requires authentication. Unauthenticated requests should return 401. """ response = unauthenticated_client.get("/api/collections/me") assert response.status_code == status.HTTP_401_UNAUTHORIZED # ============================================================================= # GET /collections/me/cards/{card_id} Tests # ============================================================================= class TestGetCardQuantity: """Tests for GET /api/collections/me/cards/{card_id} endpoint.""" def test_returns_card_quantity(self, client: TestClient, auth_headers, mock_collection_service): """ Test that endpoint returns quantity for owned card. Should return the card ID and quantity. """ mock_collection_service.get_card_quantity = AsyncMock(return_value=4) response = client.get("/api/collections/me/cards/a1-001-bulbasaur", headers=auth_headers) assert response.status_code == status.HTTP_200_OK data = response.json() assert data["card_definition_id"] == "a1-001-bulbasaur" assert data["quantity"] == 4 def test_returns_404_for_unowned_card( self, client: TestClient, auth_headers, mock_collection_service ): """ Test that endpoint returns 404 for cards not in collection. Cards with quantity 0 should return 404. """ mock_collection_service.get_card_quantity = AsyncMock(return_value=0) response = client.get("/api/collections/me/cards/unowned-card", 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("/api/collections/me/cards/a1-001-bulbasaur") assert response.status_code == status.HTTP_401_UNAUTHORIZED # ============================================================================= # POST /collections/admin/{user_id}/add Tests # ============================================================================= class TestAdminAddCards: """Tests for POST /api/collections/admin/{user_id}/add endpoint. Admin endpoint requires X-Admin-API-Key header instead of user authentication. """ @pytest.fixture def admin_app(self, mock_collection_service): """Create a test app with admin auth bypassed.""" from app.api import deps as api_deps test_app = FastAPI() test_app.include_router(collections_router, prefix="/api") # Override admin auth to allow access async def override_verify_admin_token(): return None def override_get_collection_service(): return mock_collection_service test_app.dependency_overrides[api_deps.verify_admin_token] = override_verify_admin_token test_app.dependency_overrides[api_deps.get_collection_service] = ( override_get_collection_service ) yield test_app test_app.dependency_overrides.clear() @pytest.fixture def admin_client(self, admin_app): """Create a test client with admin auth bypassed.""" return TestClient(admin_app) def test_adds_cards_to_collection(self, admin_client: TestClient, mock_collection_service): """ Test that admin can add cards to user's collection. Should create/update collection entry and return it. """ entry = make_collection_entry("a1-001-bulbasaur", 5, CardSource.GIFT) mock_collection_service.add_cards = AsyncMock(return_value=entry) user_id = str(uuid4()) response = admin_client.post( f"/api/collections/admin/{user_id}/add", json={ "card_definition_id": "a1-001-bulbasaur", "quantity": 5, "source": "gift", }, ) assert response.status_code == status.HTTP_201_CREATED data = response.json() assert data["card_definition_id"] == "a1-001-bulbasaur" assert data["quantity"] == 5 def test_returns_400_for_invalid_card(self, admin_client: TestClient, mock_collection_service): """ Test that endpoint returns 400 for invalid card ID. Non-existent card IDs should be rejected. """ mock_collection_service.add_cards = AsyncMock(side_effect=ValueError("Invalid card ID")) user_id = str(uuid4()) response = admin_client.post( f"/api/collections/admin/{user_id}/add", json={ "card_definition_id": "invalid-card", "quantity": 1, "source": "gift", }, ) assert response.status_code == status.HTTP_400_BAD_REQUEST def test_requires_admin_api_key(self, unauthenticated_client: TestClient): """ Test that endpoint returns 401 without admin API key header. Requests without X-Admin-API-Key header are rejected with 401. """ user_id = str(uuid4()) response = unauthenticated_client.post( f"/api/collections/admin/{user_id}/add", json={ "card_definition_id": "a1-001-bulbasaur", "quantity": 1, "source": "gift", }, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED assert "not authenticated" in response.json()["detail"].lower() def test_rejects_invalid_admin_api_key(self, unauthenticated_client: TestClient): """ Test that endpoint returns 403 for invalid admin API key. Requests with wrong X-Admin-API-Key are rejected with 403. """ user_id = str(uuid4()) response = unauthenticated_client.post( f"/api/collections/admin/{user_id}/add", headers={"X-Admin-API-Key": "wrong-key"}, json={ "card_definition_id": "a1-001-bulbasaur", "quantity": 1, "source": "gift", }, ) # Returns 403 because the key is invalid (or not configured in test env) assert response.status_code == status.HTTP_403_FORBIDDEN