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>
271 lines
8.6 KiB
Python
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
|