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>
618 lines
20 KiB
Python
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
|