mantimon-tcg/backend/tests/api/test_decks_api.py
Cal Corum 3ec670753b Fix security and validation issues from code review
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>
2026-01-28 14:16:07 -06:00

618 lines
20 KiB
Python

"""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