Critical fixes: - Add admin API key authentication for admin endpoints - Add race condition protection via unique partial index for starter decks - Make starter deck selection atomic with combined method Moderate fixes: - Fix DI pattern violation in validate_deck_endpoint - Add card ID format validation (regex pattern) - Add card quantity validation (1-99 range) - Fix exception chaining with from None (B904) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
360 lines
12 KiB
Python
360 lines
12 KiB
Python
"""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
|