mantimon-tcg/backend/tests/api/test_starter_deck_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

271 lines
8.6 KiB
Python

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