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>
This commit is contained in:
Cal Corum 2026-01-28 14:16:07 -06:00
parent 58349c126a
commit 3ec670753b
19 changed files with 3938 additions and 761 deletions

View File

@ -33,34 +33,80 @@ from sqlalchemy.ext.asyncio import AsyncSession # NO - DB dependency
---
## Dependency Injection Pattern
## Stateless Backend - Config from Request
**All services must use constructor-based dependency injection.** No service locator patterns.
**The backend is stateless. Rules and configuration come from the request, provided by the frontend.**
### Required Pattern
This is a key architectural principle: the frontend knows what context the user is in (campaign mode, freeplay mode, custom rules) and tells the API what rules to apply.
### Why Frontend Provides Config
1. **Simplicity**: No complex server-side config management
2. **Flexibility**: Same backend supports multiple game modes
3. **Testability**: Pure functions with explicit inputs
4. **Offline Fork**: No server-side config to replicate
### Pattern: Pure Functions with Config Parameter
```python
class DeckValidator:
"""Dependencies injected via constructor."""
def __init__(self, config: DeckConfig, card_service: CardService) -> None:
self._config = config
self._card_service = card_service
# CORRECT - Config comes from caller
def validate_deck(
cards: dict[str, int],
energy_cards: dict[str, int],
deck_config: DeckConfig, # Provided by frontend
card_lookup: Callable[[str], CardDefinition | None],
owned_cards: dict[str, int] | None = None,
) -> ValidationResult:
"""Pure function - all inputs from caller."""
...
```
### Forbidden Patterns
```python
# WRONG - Service locator pattern
# WRONG - Service with baked-in config
class DeckValidator:
def validate(self, cards):
service = get_card_service() # Hidden dependency!
...
def __init__(self, config: DeckConfig): # Config at construction
self._config = config
# WRONG - Default instantiation hides dependency
class DeckValidator:
def __init__(self, config: DeckConfig | None = None):
self.config = config or DeckConfig() # Hidden creation!
# WRONG - Default config hides dependency
def validate_deck(cards, config: DeckConfig | None = None):
config = config or DeckConfig() # Hidden creation!
```
---
## Dependency Injection for Services
**Services use constructor-based dependency injection for repositories and other services.**
Services still use DI for data access, but config comes from method parameters (request).
### Required Pattern
```python
class DeckService:
"""Dependencies injected via constructor. Config from method params."""
def __init__(
self,
deck_repository: DeckRepository,
card_service: CardService,
collection_repository: CollectionRepository | None = None,
) -> None:
self._deck_repo = deck_repository
self._card_service = card_service
self._collection_repo = collection_repository
async def create_deck(
self,
user_id: UUID,
cards: dict[str, int],
energy_cards: dict[str, int],
deck_config: DeckConfig, # Config from request!
max_decks: int,
) -> DeckEntry:
...
```
### Why This Matters
@ -68,7 +114,7 @@ class DeckValidator:
1. **Testability**: Dependencies can be mocked without patching globals
2. **Offline Fork**: Services can be swapped for local implementations
3. **Explicit Dependencies**: Constructor shows all requirements
4. **Composition Root**: All wiring happens at application startup, not scattered
4. **Stateless Operations**: Config comes from request, not server state
---
@ -104,41 +150,43 @@ class CollectionService:
---
## Configuration Injection
## Configuration Classes
Game rules come from `app/core/config.py` classes. These must be injected, not instantiated internally.
Game rules are defined in `app/core/config.py` as Pydantic models. These are passed from the frontend as request parameters.
### Pattern
### Available Config Classes
```python
# Inject config
class DeckValidator:
def __init__(self, config: DeckConfig, card_service: CardService):
self._config = config
from app.core.config import DeckConfig, RulesConfig
def validate(self, cards):
if len(cards) != self._config.min_size: # Use injected config
...
# DeckConfig - deck building rules
class DeckConfig(BaseModel):
min_size: int = 40
max_size: int = 40
max_copies_per_card: int = 4
min_basic_pokemon: int = 1
energy_deck_size: int = 20
# For validation functions that need config
def validate_starter_decks(config: DeckSizeConfig) -> dict[str, list[str]]:
"""Config is required parameter, not created internally."""
...
# Frontend can customize for different modes
freeplay_config = DeckConfig(max_copies_per_card=10) # Relaxed rules
campaign_config = DeckConfig() # Standard rules
```
### Protocol for Minimal Interface
When a function only needs specific config values, use a Protocol:
### API Endpoints Accept Config
```python
class DeckSizeConfig(Protocol):
"""Minimal interface for deck size validation."""
min_size: int
energy_deck_size: int
def validate_starter_decks(config: DeckSizeConfig) -> dict[str, list[str]]:
# Any object with min_size and energy_deck_size works
...
@router.post("/decks")
async def create_deck(
deck_in: DeckCreate, # Contains deck_config from frontend
current_user: User = Depends(get_current_user),
deck_service: DeckService = Depends(get_deck_service),
):
return await deck_service.create_deck(
user_id=current_user.id,
cards=deck_in.cards,
deck_config=deck_in.deck_config, # From request
...
)
```
---
@ -207,9 +255,9 @@ app/
### Creating a New Service
1. Define constructor with all dependencies as parameters
1. Define constructor with repository and service dependencies
2. Use repository protocols for data access
3. Inject CardService/DeckConfig rather than using `get_*` functions
3. Accept config (DeckConfig, RulesConfig) as **method parameters**, not constructor params
4. Keep business logic separate from data access
### Creating a Repository
@ -232,9 +280,10 @@ app/
| Mistake | Correct Approach |
|---------|------------------|
| `get_card_service()` in method body | Inject `CardService` via constructor |
| `config or DeckConfig()` default | Make config required parameter |
| `config or DeckConfig()` default | Make config required method parameter |
| Baking config into service constructor | Accept config as method parameter (from request) |
| Importing from `app.services` in `app.core` | Core must remain standalone |
| Hardcoded magic numbers | Use `DeckConfig` values |
| Hardcoded magic numbers | Use values from config parameter |
| Tests without docstrings | Always explain what and why |
| Unit tests in `tests/services/` | Use `tests/unit/` for no-DB tests |

View File

@ -0,0 +1,136 @@
"""Collections API router for Mantimon TCG.
This module provides REST endpoints for card collection management.
Users can view their collections and check individual card ownership.
Endpoints:
GET /collections/me - Get user's full collection
GET /collections/me/cards/{card_id} - Get quantity of a specific card
POST /collections/admin/{user_id}/add - Admin: Add cards to user (requires admin key)
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.api.deps import AdminAuth, CollectionServiceDep, CurrentUser
from app.schemas.collection import (
CollectionAddRequest,
CollectionCardResponse,
CollectionEntryResponse,
CollectionResponse,
)
router = APIRouter(prefix="/collections", tags=["collections"])
@router.get("/me", response_model=CollectionResponse)
async def get_my_collection(
current_user: CurrentUser,
collection_service: CollectionServiceDep,
) -> CollectionResponse:
"""Get the authenticated user's card collection.
Returns all cards the user owns with quantities and source information.
Returns:
CollectionResponse with summary stats and all entries.
"""
collection = await collection_service.get_collection(current_user.id)
entries = [
CollectionEntryResponse(
card_definition_id=entry.card_definition_id,
quantity=entry.quantity,
source=entry.source,
obtained_at=entry.obtained_at,
)
for entry in collection
]
total_cards = sum(entry.quantity for entry in collection)
return CollectionResponse(
total_unique_cards=len(collection),
total_card_count=total_cards,
entries=entries,
)
@router.get("/me/cards/{card_id}", response_model=CollectionCardResponse)
async def get_card_quantity(
card_id: str,
current_user: CurrentUser,
collection_service: CollectionServiceDep,
) -> CollectionCardResponse:
"""Get the quantity of a specific card in user's collection.
Args:
card_id: The card definition ID (e.g., "a1-001-bulbasaur").
Returns:
CollectionCardResponse with card_id and quantity.
Raises:
404: If card not in collection (quantity is 0).
"""
quantity = await collection_service.get_card_quantity(current_user.id, card_id)
if quantity == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Card '{card_id}' not in collection",
)
return CollectionCardResponse(
card_definition_id=card_id,
quantity=quantity,
)
@router.post(
"/admin/{user_id}/add",
response_model=CollectionEntryResponse,
status_code=status.HTTP_201_CREATED,
)
async def admin_add_cards(
user_id: UUID,
request: CollectionAddRequest,
_admin: AdminAuth,
collection_service: CollectionServiceDep,
) -> CollectionEntryResponse:
"""Add cards to a user's collection (admin endpoint).
This endpoint is for administrative purposes (testing, rewards, etc.).
Requires a valid admin API key in the X-Admin-API-Key header.
Args:
user_id: The target user's UUID.
request: Card add request with card_id, quantity, and source.
Returns:
The created/updated collection entry.
Raises:
400: If card_definition_id is invalid.
403: If admin API key is missing, invalid, or not configured.
"""
try:
entry = await collection_service.add_cards(
user_id=user_id,
card_definition_id=request.card_definition_id,
quantity=request.quantity,
source=request.source,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
return CollectionEntryResponse(
card_definition_id=entry.card_definition_id,
quantity=entry.quantity,
source=entry.source,
obtained_at=entry.obtained_at,
)

288
backend/app/api/decks.py Normal file
View File

@ -0,0 +1,288 @@
"""Decks API router for Mantimon TCG.
This module provides REST endpoints for deck management including
creating, reading, updating, and deleting decks, as well as validation.
The backend is stateless - deck rules come from the request via DeckConfig.
Frontend provides the rules appropriate for the game mode (campaign, freeplay, custom).
Endpoints:
GET /decks - List all user's decks
POST /decks - Create a new deck
GET /decks/{deck_id} - Get a specific deck
PUT /decks/{deck_id} - Update a deck
DELETE /decks/{deck_id} - Delete a deck
POST /decks/validate - Validate a deck without saving
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CardServiceDep, CollectionServiceDep, CurrentUser, DeckServiceDep
from app.schemas.deck import (
DeckCreateRequest,
DeckListResponse,
DeckResponse,
DeckUpdateRequest,
DeckValidateRequest,
DeckValidationResponse,
)
from app.services.deck_service import DeckLimitExceededError, DeckNotFoundError
from app.services.deck_validator import validate_deck
router = APIRouter(prefix="/decks", tags=["decks"])
@router.get("", response_model=DeckListResponse)
async def list_decks(
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckListResponse:
"""Get all decks for the authenticated user.
Returns a list of all user's decks along with count and limit information.
Premium users have unlimited decks (deck_limit=None).
Returns:
DeckListResponse with decks, count, and limit.
"""
decks = await deck_service.get_user_decks(current_user.id)
# Convert DeckEntry DTOs to DeckResponse
deck_responses = [
DeckResponse(
id=d.id,
name=d.name,
description=d.description,
cards=d.cards,
energy_cards=d.energy_cards,
is_valid=d.is_valid,
validation_errors=d.validation_errors,
is_starter=d.is_starter,
starter_type=d.starter_type,
created_at=d.created_at,
updated_at=d.updated_at,
)
for d in decks
]
# Premium users have unlimited decks
deck_limit = None if current_user.has_active_premium else current_user.max_decks
return DeckListResponse(
decks=deck_responses,
deck_count=len(decks),
deck_limit=deck_limit,
)
@router.post("", response_model=DeckResponse, status_code=status.HTTP_201_CREATED)
async def create_deck(
deck_in: DeckCreateRequest,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckResponse:
"""Create a new deck.
Validates the deck composition and stores it. Invalid decks CAN be saved
(with validation errors) to support work-in-progress deck building.
Args:
deck_in: Deck creation request with name, cards, energy, and config.
Returns:
The created deck with validation state.
Raises:
400: If user has reached their deck limit.
"""
try:
deck = await deck_service.create_deck(
user_id=current_user.id,
name=deck_in.name,
cards=deck_in.cards,
energy_cards=deck_in.energy_cards,
deck_config=deck_in.deck_config,
max_decks=current_user.max_decks,
validate_ownership=deck_in.validate_ownership,
description=deck_in.description,
)
except DeckLimitExceededError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
return DeckResponse(
id=deck.id,
name=deck.name,
description=deck.description,
cards=deck.cards,
energy_cards=deck.energy_cards,
is_valid=deck.is_valid,
validation_errors=deck.validation_errors,
is_starter=deck.is_starter,
starter_type=deck.starter_type,
created_at=deck.created_at,
updated_at=deck.updated_at,
)
@router.get("/{deck_id}", response_model=DeckResponse)
async def get_deck(
deck_id: UUID,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckResponse:
"""Get a specific deck by ID.
Only returns decks owned by the authenticated user.
Args:
deck_id: The deck's UUID.
Returns:
The deck details.
Raises:
404: If deck not found or not owned by user.
"""
try:
deck = await deck_service.get_deck(current_user.id, deck_id)
except DeckNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
) from None
return DeckResponse(
id=deck.id,
name=deck.name,
description=deck.description,
cards=deck.cards,
energy_cards=deck.energy_cards,
is_valid=deck.is_valid,
validation_errors=deck.validation_errors,
is_starter=deck.is_starter,
starter_type=deck.starter_type,
created_at=deck.created_at,
updated_at=deck.updated_at,
)
@router.put("/{deck_id}", response_model=DeckResponse)
async def update_deck(
deck_id: UUID,
deck_in: DeckUpdateRequest,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckResponse:
"""Update an existing deck.
Only provided fields are updated. If cards or energy_cards change,
the deck is re-validated with the provided deck_config.
Args:
deck_id: The deck's UUID.
deck_in: Update request with optional fields.
Returns:
The updated deck.
Raises:
404: If deck not found or not owned by user.
"""
try:
deck = await deck_service.update_deck(
user_id=current_user.id,
deck_id=deck_id,
deck_config=deck_in.deck_config,
name=deck_in.name,
cards=deck_in.cards,
energy_cards=deck_in.energy_cards,
validate_ownership=deck_in.validate_ownership,
description=deck_in.description,
)
except DeckNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
) from None
return DeckResponse(
id=deck.id,
name=deck.name,
description=deck.description,
cards=deck.cards,
energy_cards=deck.energy_cards,
is_valid=deck.is_valid,
validation_errors=deck.validation_errors,
is_starter=deck.is_starter,
starter_type=deck.starter_type,
created_at=deck.created_at,
updated_at=deck.updated_at,
)
@router.delete("/{deck_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_deck(
deck_id: UUID,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> None:
"""Delete a deck.
Only deletes decks owned by the authenticated user.
Args:
deck_id: The deck's UUID.
Raises:
404: If deck not found or not owned by user.
"""
try:
await deck_service.delete_deck(current_user.id, deck_id)
except DeckNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
) from None
@router.post("/validate", response_model=DeckValidationResponse)
async def validate_deck_endpoint(
request: DeckValidateRequest,
current_user: CurrentUser,
card_service: CardServiceDep,
collection_service: CollectionServiceDep,
) -> DeckValidationResponse:
"""Validate a deck composition without saving.
Useful for checking if a deck is valid before creating it.
Uses the provided deck_config rules from the frontend.
Args:
request: Validation request with cards, energy, and config.
Returns:
Validation result with is_valid and any errors.
"""
# Get owned cards if validating ownership (campaign mode)
owned_cards: dict[str, int] | None = None
if request.validate_ownership:
owned_cards = await collection_service.get_owned_cards_dict(current_user.id)
# Use the pure validation function with config from request
result = validate_deck(
cards=request.cards,
energy_cards=request.energy_cards,
deck_config=request.deck_config,
card_lookup=card_service.get_card,
owned_cards=owned_cards,
)
return DeckValidationResponse(
is_valid=result.is_valid,
errors=result.errors,
)

View File

@ -1,33 +1,43 @@
"""FastAPI dependencies for Mantimon TCG API.
This module provides dependency injection functions for authentication
and database access in API endpoints.
This module provides dependency injection functions for authentication,
database access, and service layer access in API endpoints.
Usage:
from app.api.deps import get_current_user, get_db
from app.api.deps import CurrentUser, DbSession, DeckServiceDep
@router.get("/me")
async def get_me(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
@router.get("/decks")
async def get_decks(
user: CurrentUser,
db: DbSession,
deck_service: DeckServiceDep,
):
return user
return await deck_service.get_user_decks(user.id)
Dependencies:
- get_db: Async database session
- get_current_user: Authenticated user from JWT (required)
- get_optional_user: Authenticated user or None
- get_current_premium_user: User with active premium
- verify_admin_token: Admin API key validation
- get_deck_service: DeckService with repositories
- get_collection_service: CollectionService with repository
"""
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db import get_session
from app.db.models import User
from app.repositories.postgres.collection import PostgresCollectionRepository
from app.repositories.postgres.deck import PostgresDeckRepository
from app.services.card_service import CardService, get_card_service
from app.services.collection_service import CollectionService
from app.services.deck_service import DeckService
from app.services.jwt_service import verify_access_token
from app.services.user_service import user_service
@ -43,6 +53,49 @@ oauth2_scheme_optional = OAuth2PasswordBearer(
auto_error=False, # Return None if no token
)
# Admin API key header for admin-only endpoints
admin_api_key_header = APIKeyHeader(
name="X-Admin-API-Key",
auto_error=True,
description="Admin API key for privileged operations",
)
async def verify_admin_token(
api_key: Annotated[str, Depends(admin_api_key_header)],
) -> None:
"""Verify the admin API key.
Validates the X-Admin-API-Key header against the configured admin key.
Admin key must be configured via ADMIN_API_KEY environment variable.
Args:
api_key: API key from X-Admin-API-Key header.
Raises:
HTTPException: 403 if admin key is not configured or doesn't match.
Example:
@router.post("/admin/grant-cards")
async def grant_cards(
_: None = Depends(verify_admin_token),
):
# Only accessible with valid admin key
...
"""
configured_key = settings.admin_api_key
if configured_key is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin API key not configured",
)
if api_key != configured_key.get_secret_value():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid admin API key",
)
async def get_db() -> AsyncSession:
"""Get async database session.
@ -165,8 +218,98 @@ async def get_current_premium_user(
return user
# Type aliases for cleaner endpoint signatures
# =============================================================================
# Service Dependencies
# =============================================================================
def get_collection_service(
db: Annotated[AsyncSession, Depends(get_db)],
) -> CollectionService:
"""Get CollectionService with PostgreSQL repository.
Creates a CollectionService instance with the current database session.
Args:
db: Database session from request.
Returns:
CollectionService configured for PostgreSQL.
Example:
@router.get("/collections/me")
async def get_collection(
service: CollectionService = Depends(get_collection_service),
):
...
"""
repo = PostgresCollectionRepository(db)
card_service = get_card_service()
return CollectionService(repo, card_service)
def get_deck_service(
db: Annotated[AsyncSession, Depends(get_db)],
) -> DeckService:
"""Get DeckService with PostgreSQL repositories.
Creates a DeckService instance with deck and collection repositories.
Args:
db: Database session from request.
Returns:
DeckService configured for PostgreSQL.
Example:
@router.post("/decks")
async def create_deck(
service: DeckService = Depends(get_deck_service),
):
...
"""
deck_repo = PostgresDeckRepository(db)
collection_repo = PostgresCollectionRepository(db)
card_service = get_card_service()
return DeckService(deck_repo, card_service, collection_repo)
def get_card_service_dep() -> CardService:
"""Get the CardService singleton.
CardService is a singleton that loads card definitions from JSON files.
This dependency provides consistent access for endpoints that need
direct card lookup functionality.
Returns:
The CardService singleton instance.
Example:
@router.post("/validate")
async def validate(
card_service: CardService = Depends(get_card_service_dep),
):
card = card_service.get_card("a1-001-bulbasaur")
"""
return get_card_service()
# =============================================================================
# Type Aliases for Cleaner Endpoint Signatures
# =============================================================================
# User dependencies
CurrentUser = Annotated[User, Depends(get_current_user)]
OptionalUser = Annotated[User | None, Depends(get_optional_user)]
PremiumUser = Annotated[User, Depends(get_current_premium_user)]
# Database session
DbSession = Annotated[AsyncSession, Depends(get_db)]
# Service dependencies
DeckServiceDep = Annotated[DeckService, Depends(get_deck_service)]
CollectionServiceDep = Annotated[CollectionService, Depends(get_collection_service)]
CardServiceDep = Annotated[CardService, Depends(get_card_service_dep)]
# Admin authentication
AdminAuth = Annotated[None, Depends(verify_admin_token)]

View File

@ -5,6 +5,7 @@ This module provides endpoints for user profile management:
- Update profile (display name, avatar)
- List linked OAuth accounts
- Session management
- Starter deck selection (one-time for new players)
All endpoints require authentication.
@ -16,13 +17,19 @@ Example:
# Update profile
PATCH /api/users/me
{"display_name": "NewName"}
# Select starter deck
POST /api/users/me/starter-deck
{"starter_type": "grass"}
"""
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from app.api.deps import CurrentUser, DbSession
from app.api.deps import CurrentUser, DbSession, DeckServiceDep
from app.schemas.deck import DeckResponse, StarterDeckSelectRequest, StarterStatusResponse
from app.schemas.user import UserResponse, UserUpdate
from app.services.deck_service import DeckLimitExceededError, StarterAlreadySelectedError
from app.services.token_store import token_store
from app.services.user_service import AccountLinkingError, user_service
@ -166,3 +173,94 @@ async def unlink_oauth_account(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
# =============================================================================
# Starter Deck Endpoints
# =============================================================================
@router.get("/me/starter-status", response_model=StarterStatusResponse)
async def get_starter_status(
user: CurrentUser,
deck_service: DeckServiceDep,
) -> StarterStatusResponse:
"""Check if user has already selected a starter deck.
New users need to select a starter deck before playing.
This endpoint checks if they've already made that selection.
Returns:
StarterStatusResponse with has_starter flag and starter_type.
"""
has_starter, starter_type = await deck_service.has_starter_deck(user.id)
return StarterStatusResponse(
has_starter=has_starter,
starter_type=starter_type,
)
@router.post("/me/starter-deck", response_model=DeckResponse, status_code=status.HTTP_201_CREATED)
async def select_starter_deck(
request: StarterDeckSelectRequest,
user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckResponse:
"""Select and create a starter deck for a new user.
This is a one-time operation that atomically:
1. Validates the starter type
2. Creates the starter deck (protected by unique constraint)
3. Grants all starter deck cards to the user's collection
Race conditions are handled by a database unique constraint that prevents
multiple starter decks per user.
Available starter types: grass, fire, water, psychic, lightning
Args:
request: Starter deck selection with type and optional config.
Returns:
The created starter deck.
Raises:
400: If starter already selected or invalid starter_type.
"""
try:
deck = await deck_service.select_and_grant_starter_deck(
user_id=user.id,
starter_type=request.starter_type,
deck_config=request.deck_config,
max_decks=user.max_decks,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
except StarterAlreadySelectedError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
except DeckLimitExceededError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
return DeckResponse(
id=deck.id,
name=deck.name,
description=deck.description,
cards=deck.cards,
energy_cards=deck.energy_cards,
is_valid=deck.is_valid,
validation_errors=deck.validation_errors,
is_starter=deck.is_starter,
starter_type=deck.starter_type,
created_at=deck.created_at,
updated_at=deck.updated_at,
)

View File

@ -180,6 +180,12 @@ class Settings(BaseSettings):
description="Path to bundled card JSON files",
)
# Admin
admin_api_key: SecretStr | None = Field(
default=None,
description="API key for admin endpoints (required in production)",
)
@field_validator("debug", mode="before")
@classmethod
def set_debug_from_environment(cls, v: bool, info) -> bool:

View File

@ -0,0 +1,42 @@
"""Add unique partial index for starter deck
Revision ID: 9ea744a4f451
Revises: 5ce887128ab1
Create Date: 2026-01-28 14:09:13.928446
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9ea744a4f451"
down_revision: str | Sequence[str] | None = "5ce887128ab1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(
"ix_decks_user_starter_unique",
"decks",
["user_id"],
unique=True,
postgresql_where=sa.text("is_starter = true"),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
"ix_decks_user_starter_unique",
table_name="decks",
postgresql_where=sa.text("is_starter = true"),
)
# ### end Alembic commands ###

View File

@ -16,7 +16,7 @@ Example:
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import Boolean, ForeignKey, Index, String, Text
from sqlalchemy import Boolean, ForeignKey, Index, String, Text, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -129,7 +129,16 @@ class Deck(Base):
)
# Indexes
__table_args__ = (Index("ix_decks_user_name", "user_id", "name"),)
__table_args__ = (
Index("ix_decks_user_name", "user_id", "name"),
# Partial unique index: only one starter deck per user
Index(
"ix_decks_user_starter_unique",
"user_id",
unique=True,
postgresql_where=text("is_starter = true"),
),
)
@property
def total_cards(self) -> int:

View File

@ -19,6 +19,8 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.auth import router as auth_router
from app.api.collections import router as collections_router
from app.api.decks import router as decks_router
from app.api.users import router as users_router
from app.config import settings
from app.db import close_db, init_db
@ -163,10 +165,11 @@ async def readiness_check() -> dict[str, str | int]:
# === API Routers ===
app.include_router(auth_router, prefix="/api")
app.include_router(users_router, prefix="/api")
app.include_router(collections_router, prefix="/api")
app.include_router(decks_router, prefix="/api")
# TODO: Add remaining routers in future phases
# from app.api import cards, decks, games, campaign
# from app.api import cards, games, campaign
# app.include_router(cards.router, prefix="/api/cards", tags=["cards"])
# app.include_router(decks.router, prefix="/api/decks", tags=["decks"])
# app.include_router(games.router, prefix="/api/games", tags=["games"])
# app.include_router(campaign.router, prefix="/api/campaign", tags=["campaign"])

View File

@ -3,6 +3,9 @@
This module defines Pydantic models for deck-related API requests
and responses. Decks contain card compositions for gameplay.
The backend is stateless - deck rules come from the request via DeckConfig.
Frontend provides the rules appropriate for the game mode (campaign, freeplay, custom).
Example:
deck = DeckResponse(
id=uuid4(),
@ -18,10 +21,63 @@ Example:
)
"""
import re
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from app.core.config import DeckConfig
# Card ID format: set-number-name (e.g., a1-001-bulbasaur, a1-094-pikachu-ex)
CARD_ID_PATTERN = re.compile(r"^[a-z0-9]+-\d{3}-[a-z0-9-]+$")
def validate_card_quantities(cards: dict[str, int], field_name: str) -> dict[str, int]:
"""Validate card quantities are within valid range.
Args:
cards: Mapping of card IDs to quantities.
field_name: Name of the field for error messages.
Returns:
The validated card dictionary.
Raises:
ValueError: If any quantity is invalid.
"""
for card_id, quantity in cards.items():
if quantity < 1:
raise ValueError(
f"{field_name}: quantity for '{card_id}' must be at least 1, got {quantity}"
)
if quantity > 99:
raise ValueError(
f"{field_name}: quantity for '{card_id}' cannot exceed 99, got {quantity}"
)
return cards
def validate_card_id_format(cards: dict[str, int], field_name: str) -> dict[str, int]:
"""Validate card IDs match expected format.
Args:
cards: Mapping of card IDs to quantities.
field_name: Name of the field for error messages.
Returns:
The validated card dictionary.
Raises:
ValueError: If any card ID has invalid format.
"""
for card_id in cards:
if not CARD_ID_PATTERN.match(card_id):
raise ValueError(
f"{field_name}: invalid card ID format '{card_id}'. "
f"Expected format: set-number-name (e.g., a1-001-bulbasaur)"
)
return cards
class DeckCreateRequest(BaseModel):
@ -29,24 +85,55 @@ class DeckCreateRequest(BaseModel):
Attributes:
name: Display name for the deck.
cards: Mapping of card IDs to quantities (40 cards total).
energy_cards: Mapping of energy types to quantities (20 total).
cards: Mapping of card IDs to quantities (40 cards total by default).
energy_cards: Mapping of energy types to quantities (20 total by default).
deck_config: Deck rules from the frontend (defaults to standard rules).
validate_ownership: If True, validates user owns all cards (campaign mode).
description: Optional deck description.
"""
name: str = Field(..., min_length=1, max_length=100, description="Deck name")
cards: dict[str, int] = Field(..., description="Card ID to quantity mapping")
energy_cards: dict[str, int] = Field(..., description="Energy type to quantity mapping")
deck_config: DeckConfig = Field(
default_factory=DeckConfig, description="Deck validation rules from frontend"
)
validate_ownership: bool = Field(
default=True, description="Validate card ownership (False for freeplay)"
)
description: str | None = Field(
default=None, max_length=500, description="Optional deck description"
)
@field_validator("cards")
@classmethod
def validate_cards(cls, v: dict[str, int]) -> dict[str, int]:
"""Validate card IDs and quantities."""
validate_card_id_format(v, "cards")
validate_card_quantities(v, "cards")
return v
@field_validator("energy_cards")
@classmethod
def validate_energy_cards(cls, v: dict[str, int]) -> dict[str, int]:
"""Validate energy card quantities."""
validate_card_quantities(v, "energy_cards")
return v
class DeckUpdateRequest(BaseModel):
"""Request model for updating a deck.
All fields are optional - only provided fields are updated.
If cards or energy_cards change, the deck is re-validated with deck_config.
Attributes:
name: New display name for the deck.
cards: New card composition.
energy_cards: New energy composition.
deck_config: Deck rules from the frontend (defaults to standard rules).
validate_ownership: If True, validates user owns all cards (campaign mode).
description: New deck description.
"""
name: str | None = Field(
@ -54,6 +141,30 @@ class DeckUpdateRequest(BaseModel):
)
cards: dict[str, int] | None = Field(default=None, description="New card composition")
energy_cards: dict[str, int] | None = Field(default=None, description="New energy composition")
deck_config: DeckConfig = Field(
default_factory=DeckConfig, description="Deck validation rules from frontend"
)
validate_ownership: bool = Field(
default=True, description="Validate card ownership (False for freeplay)"
)
description: str | None = Field(default=None, max_length=500, description="New description")
@field_validator("cards")
@classmethod
def validate_cards(cls, v: dict[str, int] | None) -> dict[str, int] | None:
"""Validate card IDs and quantities if provided."""
if v is not None:
validate_card_id_format(v, "cards")
validate_card_quantities(v, "cards")
return v
@field_validator("energy_cards")
@classmethod
def validate_energy_cards(cls, v: dict[str, int] | None) -> dict[str, int] | None:
"""Validate energy card quantities if provided."""
if v is not None:
validate_card_quantities(v, "energy_cards")
return v
class DeckResponse(BaseModel):
@ -64,6 +175,7 @@ class DeckResponse(BaseModel):
Attributes:
id: Unique deck identifier.
name: Display name of the deck.
description: Optional deck description or notes.
cards: Mapping of card IDs to quantities.
energy_cards: Mapping of energy types to quantities.
is_valid: Whether deck passes all validation rules.
@ -76,6 +188,7 @@ class DeckResponse(BaseModel):
id: UUID = Field(..., description="Deck ID")
name: str = Field(..., description="Deck name")
description: str | None = Field(default=None, description="Deck description")
cards: dict[str, int] = Field(..., description="Card ID to quantity mapping")
energy_cards: dict[str, int] = Field(..., description="Energy type to quantity mapping")
is_valid: bool = Field(..., description="Whether deck is valid")
@ -112,10 +225,33 @@ class DeckValidateRequest(BaseModel):
Attributes:
cards: Card ID to quantity mapping to validate.
energy_cards: Energy type to quantity mapping to validate.
deck_config: Deck rules from the frontend (defaults to standard rules).
validate_ownership: If True, validates user owns all cards (campaign mode).
"""
cards: dict[str, int] = Field(..., description="Card ID to quantity mapping")
energy_cards: dict[str, int] = Field(..., description="Energy type to quantity mapping")
deck_config: DeckConfig = Field(
default_factory=DeckConfig, description="Deck validation rules from frontend"
)
validate_ownership: bool = Field(
default=True, description="Validate card ownership (False for freeplay)"
)
@field_validator("cards")
@classmethod
def validate_cards(cls, v: dict[str, int]) -> dict[str, int]:
"""Validate card IDs and quantities."""
validate_card_id_format(v, "cards")
validate_card_quantities(v, "cards")
return v
@field_validator("energy_cards")
@classmethod
def validate_energy_cards(cls, v: dict[str, int]) -> dict[str, int]:
"""Validate energy card quantities."""
validate_card_quantities(v, "energy_cards")
return v
class DeckValidationResponse(BaseModel):
@ -135,12 +271,16 @@ class StarterDeckSelectRequest(BaseModel):
Attributes:
starter_type: Type of starter deck to select.
deck_config: Deck rules from the frontend (defaults to standard rules).
"""
starter_type: str = Field(
...,
description="Starter deck type (grass, fire, water, psychic, lightning)",
)
deck_config: DeckConfig = Field(
default_factory=DeckConfig, description="Deck validation rules from frontend"
)
class StarterStatusResponse(BaseModel):

View File

@ -196,8 +196,8 @@ class CollectionService:
Example:
owned = await service.get_owned_cards_dict(user_id)
# Pass to DeckValidator
result = validator.validate_deck(cards, energy, owned_cards=owned)
# Pass to validate_deck function
result = validate_deck(cards, energy, deck_config, card_lookup, owned_cards=owned)
"""
collection = await self._repo.get_all(user_id)
return {entry.card_definition_id: entry.quantity for entry in collection}

View File

@ -1,45 +1,49 @@
"""Deck service for Mantimon TCG.
This module provides business logic for deck management. It uses
the DeckRepository protocol for data access and DeckValidator for
validation logic.
the DeckRepository protocol for data access and pure validation functions.
The service layer handles:
- Deck slot limits (free vs premium users)
- Deck validation with optional ownership checking
- Starter deck creation
The backend is stateless - deck rules come from the request via DeckConfig.
Example:
from app.core.config import DeckConfig
from app.services.card_service import CardService
from app.services.deck_service import DeckService
from app.services.deck_validator import DeckValidator
from app.repositories.postgres import PostgresDeckRepository, PostgresCollectionRepository
# Create dependencies
card_service = CardService()
card_service.load_all()
deck_validator = DeckValidator(DeckConfig(), card_service)
# Create repositories
deck_repo = PostgresDeckRepository(db_session)
collection_repo = PostgresCollectionRepository(db_session)
# Create service with all dependencies
service = DeckService(deck_repo, deck_validator, card_service, collection_repo)
# Create service
service = DeckService(deck_repo, card_service, collection_repo)
# Create a deck
# Create a deck - rules provided by caller
deck = await service.create_deck(
user_id=user_id,
name="My Deck",
cards={"a1-001-bulbasaur": 4, ...},
energy_cards={"grass": 14, "colorless": 6},
deck_config=DeckConfig(), # Rules from frontend
max_decks=5, # From user.max_decks
)
"""
import logging
from uuid import UUID
from sqlalchemy.exc import IntegrityError
from app.core.config import DeckConfig
from app.core.models.card import CardDefinition
from app.repositories.protocols import (
CollectionRepository,
@ -47,7 +51,7 @@ from app.repositories.protocols import (
DeckRepository,
)
from app.services.card_service import CardService
from app.services.deck_validator import DeckValidationResult, DeckValidator
from app.services.deck_validator import ValidationResult, validate_deck
class DeckLimitExceededError(Exception):
@ -56,6 +60,12 @@ class DeckLimitExceededError(Exception):
pass
class StarterAlreadySelectedError(Exception):
"""Raised when user tries to select a starter deck they already have."""
pass
class DeckNotFoundError(Exception):
"""Raised when deck is not found or not owned by user."""
@ -70,17 +80,17 @@ class DeckService:
- Multiple storage backends
- Offline fork support
The backend is stateless - deck rules come from the request via DeckConfig.
Attributes:
_deck_repo: The deck repository implementation.
_collection_repo: The collection repository (for ownership checks).
_deck_validator: The deck validator for validation logic.
_card_service: The card service for card lookups.
"""
def __init__(
self,
deck_repository: DeckRepository,
deck_validator: DeckValidator,
card_service: CardService,
collection_repository: CollectionRepository | None = None,
) -> None:
@ -88,13 +98,11 @@ class DeckService:
Args:
deck_repository: Implementation of DeckRepository protocol.
deck_validator: Validator for deck compositions.
card_service: Card service for looking up card definitions.
collection_repository: Implementation of CollectionRepository protocol.
Required for ownership validation in campaign mode.
"""
self._deck_repo = deck_repository
self._deck_validator = deck_validator
self._card_service = card_service
self._collection_repo = collection_repository
@ -104,6 +112,7 @@ class DeckService:
name: str,
cards: dict[str, int],
energy_cards: dict[str, int],
deck_config: DeckConfig,
max_decks: int,
validate_ownership: bool = True,
is_starter: bool = False,
@ -120,6 +129,7 @@ class DeckService:
name: Display name for the deck.
cards: Card ID to quantity mapping.
energy_cards: Energy type to quantity mapping.
deck_config: Deck rules from the caller (frontend provides this).
max_decks: Maximum decks allowed (from user.max_decks).
validate_ownership: If True, checks card ownership (campaign mode).
is_starter: Whether this is a starter deck.
@ -138,6 +148,7 @@ class DeckService:
name="Grass Power",
cards={"a1-001-bulbasaur": 4, ...},
energy_cards={"grass": 14, "colorless": 6},
deck_config=DeckConfig(),
max_decks=5,
)
"""
@ -150,8 +161,8 @@ class DeckService:
)
# Validate deck
validation = await self.validate_deck(
cards, energy_cards, user_id if validate_ownership else None
validation = await self._validate_deck_internal(
cards, energy_cards, deck_config, user_id if validate_ownership else None
)
return await self._deck_repo.create(
@ -170,6 +181,7 @@ class DeckService:
self,
user_id: UUID,
deck_id: UUID,
deck_config: DeckConfig,
name: str | None = None,
cards: dict[str, int] | None = None,
energy_cards: dict[str, int] | None = None,
@ -183,6 +195,7 @@ class DeckService:
Args:
user_id: The user's UUID (for ownership verification).
deck_id: The deck's UUID.
deck_config: Deck rules from the caller (frontend provides this).
name: New name (optional).
cards: New card composition (optional).
energy_cards: New energy composition (optional).
@ -212,8 +225,11 @@ class DeckService:
validation_errors = deck.validation_errors
if needs_revalidation:
validation = await self.validate_deck(
final_cards, final_energy, user_id if validate_ownership else None
validation = await self._validate_deck_internal(
final_cards,
final_energy,
deck_config,
user_id if validate_ownership else None,
)
is_valid = validation.is_valid
validation_errors = validation.errors if validation.errors else None
@ -306,22 +322,24 @@ class DeckService:
"""
return await self._deck_repo.count_by_user(user_id)
async def validate_deck(
async def _validate_deck_internal(
self,
cards: dict[str, int],
energy_cards: dict[str, int],
deck_config: DeckConfig,
user_id: UUID | None = None,
) -> DeckValidationResult:
"""Validate a deck composition.
) -> ValidationResult:
"""Internal method to validate a deck composition.
Args:
cards: Card ID to quantity mapping.
energy_cards: Energy type to quantity mapping.
deck_config: Deck rules from the caller.
user_id: If provided, validates card ownership (campaign mode).
Pass None for freeplay mode.
Returns:
DeckValidationResult with is_valid and errors.
ValidationResult with is_valid and errors.
"""
owned_cards: dict[str, int] | None = None
if user_id is not None and self._collection_repo is not None:
@ -329,7 +347,13 @@ class DeckService:
collection = await self._collection_repo.get_all(user_id)
owned_cards = {entry.card_definition_id: entry.quantity for entry in collection}
return self._deck_validator.validate_deck(cards, energy_cards, owned_cards)
return validate_deck(
cards=cards,
energy_cards=energy_cards,
deck_config=deck_config,
card_lookup=self._card_service.get_card,
owned_cards=owned_cards,
)
async def get_deck_for_game(self, user_id: UUID, deck_id: UUID) -> list[CardDefinition]:
"""Expand a deck to a list of CardDefinitions for game use.
@ -372,6 +396,7 @@ class DeckService:
self,
user_id: UUID,
starter_type: str,
deck_config: DeckConfig,
max_decks: int,
) -> DeckEntry:
"""Create a starter deck for a user.
@ -382,6 +407,7 @@ class DeckService:
Args:
user_id: The user's UUID.
starter_type: Type of starter deck (grass, fire, water, etc.).
deck_config: Deck rules from the caller (frontend provides this).
max_decks: Maximum decks allowed (from user.max_decks).
Returns:
@ -406,9 +432,103 @@ class DeckService:
name=starter["name"],
cards=starter["cards"],
energy_cards=starter["energy_cards"],
deck_config=deck_config,
max_decks=max_decks,
validate_ownership=False, # Starter decks skip ownership check
is_starter=True,
starter_type=starter_type,
description=starter["description"],
)
async def select_and_grant_starter_deck(
self,
user_id: UUID,
starter_type: str,
deck_config: DeckConfig,
max_decks: int,
) -> DeckEntry:
"""Select a starter deck, granting cards and creating the deck atomically.
This is the preferred method for starter deck selection. It handles:
- Validation of starter type
- Race condition protection via database unique constraint
- Creating the deck and granting cards together
The database has a partial unique index on (user_id) WHERE is_starter=true,
which prevents duplicate starter decks even under concurrent requests.
Args:
user_id: The user's UUID.
starter_type: Type of starter deck (grass, fire, water, etc.).
deck_config: Deck rules from the caller (frontend provides this).
max_decks: Maximum decks allowed (from user.max_decks).
Returns:
The created starter DeckEntry.
Raises:
ValueError: If starter_type is invalid.
StarterAlreadySelectedError: If user already has a starter deck.
DeckLimitExceededError: If user has reached deck limit.
"""
from app.data.starter_decks import STARTER_TYPES, get_starter_deck
from app.db.models.collection import CardSource
logger = logging.getLogger(__name__)
# Validate starter type early
if starter_type not in STARTER_TYPES:
raise ValueError(
f"Invalid starter type: {starter_type}. "
f"Must be one of: {', '.join(STARTER_TYPES)}"
)
# Fast path: check if user already has a starter
has_starter, existing_type = await self.has_starter_deck(user_id)
if has_starter:
raise StarterAlreadySelectedError(f"Starter deck already selected: {existing_type}")
starter = get_starter_deck(starter_type)
# Try to create the deck first - protected by unique constraint
# If two concurrent requests try this, one will fail with IntegrityError
try:
deck = await self.create_deck(
user_id=user_id,
name=starter["name"],
cards=starter["cards"],
energy_cards=starter["energy_cards"],
deck_config=deck_config,
max_decks=max_decks,
validate_ownership=False,
is_starter=True,
starter_type=starter_type,
description=starter["description"],
)
except IntegrityError as e:
# Unique constraint violation - another request beat us
logger.info(f"Starter deck creation race condition for user {user_id}: {e}")
raise StarterAlreadySelectedError(
"Starter deck already selected (concurrent request)"
) from None
# Deck created successfully - now grant the cards
# This should never fail for valid starter types
if self._collection_repo is not None:
for card_id, quantity in starter["cards"].items():
await self._collection_repo.upsert(
user_id=user_id,
card_definition_id=card_id,
quantity_delta=quantity,
source=CardSource.STARTER,
)
for energy_type, quantity in starter["energy_cards"].items():
# Energy cards are stored as "energy-{type}" in collection
await self._collection_repo.upsert(
user_id=user_id,
card_definition_id=f"energy-{energy_type}",
quantity_delta=quantity,
source=CardSource.STARTER,
)
return deck

View File

@ -1,229 +1,184 @@
"""Deck validation service for Mantimon TCG.
"""Deck validation functions for Mantimon TCG.
This module provides standalone deck validation logic that can be used
without database dependencies. It validates deck compositions against
the game rules defined in DeckConfig.
The validator is separate from DeckService to allow:
- Unit testing without database
- Validation before saving (API /validate endpoint)
- Reuse across different contexts (import/export, AI deck building)
This module provides pure validation functions that validate deck compositions
against rules provided by the caller. The backend is stateless - rules come
from the request via DeckConfig.
Usage:
from app.core.config import DeckConfig
from app.services.card_service import CardService
from app.services.deck_validator import DeckValidator, DeckValidationResult
from app.services.deck_validator import validate_deck, ValidationResult
card_service = CardService()
card_service.load_all()
validator = DeckValidator(DeckConfig(), card_service)
result = validate_deck(
cards={"a1-001-bulbasaur": 4, ...},
energy_cards={"grass": 14, "colorless": 6},
deck_config=DeckConfig(), # Or custom rules from request
card_lookup=card_service.get_card,
owned_cards=user_collection, # None to skip ownership check
)
# Validate without ownership check (freeplay mode)
result = validator.validate_deck(cards, energy_cards)
# Validate with ownership check (campaign mode)
result = validator.validate_deck(cards, energy_cards, owned_cards=user_collection)
if not result.is_valid:
for error in result.errors:
print(error)
"""
from collections.abc import Callable
from dataclasses import dataclass, field
from app.core.config import DeckConfig
from app.services.card_service import CardService
from app.core.models.card import CardDefinition
@dataclass
class DeckValidationResult:
class ValidationResult:
"""Result of deck validation.
Contains validation status and all errors found. Multiple errors
can be returned to help the user fix all issues at once.
Attributes:
is_valid: Whether the deck passes all validation rules.
errors: List of human-readable error messages.
"""
is_valid: bool = True
errors: list[str] = field(default_factory=list)
def add_error(self, error: str) -> None:
"""Add an error and mark as invalid.
Args:
error: Human-readable error message.
"""
"""Add an error and mark as invalid."""
self.is_valid = False
self.errors.append(error)
class DeckValidator:
"""Validates deck compositions against game rules.
def validate_deck(
cards: dict[str, int],
energy_cards: dict[str, int],
deck_config: DeckConfig,
card_lookup: Callable[[str], CardDefinition | None],
owned_cards: dict[str, int] | None = None,
) -> ValidationResult:
"""Validate a deck composition against provided rules.
This validator checks:
1. Total card count (40 cards in main deck)
2. Total energy count (20 energy cards)
3. Maximum copies per card (4)
4. Minimum Basic Pokemon requirement (1)
5. Card ID validity (card must exist)
6. Card ownership (optional, for campaign mode)
This is a pure function - all inputs are provided by the caller,
including the rules to validate against via DeckConfig.
The validator uses DeckConfig for rule values, allowing different
game modes to have different rules if needed.
Args:
cards: Mapping of card IDs to quantities for the main deck.
energy_cards: Mapping of energy type names to quantities.
deck_config: Deck rules from the caller (DeckConfig from app.core.config).
card_lookup: Function to look up card definitions by ID.
owned_cards: If provided, validates that the user owns enough
copies of each card. Pass None to skip ownership validation.
Attributes:
_config: The deck configuration with validation rules.
_card_service: The card service for card lookups.
Returns:
ValidationResult with is_valid status and list of errors.
Example:
result = validate_deck(
cards={"a1-001-bulbasaur": 4},
energy_cards={"grass": 20},
deck_config=DeckConfig(min_size=40, energy_deck_size=20),
card_lookup=card_service.get_card,
)
"""
result = ValidationResult()
def __init__(self, config: DeckConfig, card_service: CardService) -> None:
"""Initialize the validator with dependencies.
# 1. Validate total card count
total_cards = sum(cards.values())
if total_cards != deck_config.min_size:
result.add_error(
f"Main deck must have exactly {deck_config.min_size} cards, got {total_cards}"
)
Args:
config: Deck configuration with validation rules.
card_service: Card service for looking up card definitions.
"""
self._config = config
self._card_service = card_service
# 2. Validate total energy count
total_energy = sum(energy_cards.values())
if total_energy != deck_config.energy_deck_size:
result.add_error(
f"Energy deck must have exactly {deck_config.energy_deck_size} cards, "
f"got {total_energy}"
)
@property
def config(self) -> DeckConfig:
"""Get the deck configuration."""
return self._config
def validate_deck(
self,
cards: dict[str, int],
energy_cards: dict[str, int],
owned_cards: dict[str, int] | None = None,
) -> DeckValidationResult:
"""Validate a deck composition.
Checks all validation rules and returns all errors found (not just
the first one). This helps users fix all issues at once.
Args:
cards: Mapping of card IDs to quantities for the main deck.
energy_cards: Mapping of energy type names to quantities.
owned_cards: If provided, validates that the user owns enough
copies of each card. Pass None to skip ownership validation
(for freeplay mode).
Returns:
DeckValidationResult with is_valid status and list of errors.
Example:
result = validator.validate_deck(
cards={"a1-001-bulbasaur": 4, "a1-002-ivysaur": 4, ...},
energy_cards={"grass": 14, "colorless": 6},
owned_cards={"a1-001-bulbasaur": 10, ...}
)
if not result.is_valid:
for error in result.errors:
print(error)
"""
result = DeckValidationResult()
# 1. Validate total card count
total_cards = sum(cards.values())
if total_cards != self._config.min_size:
# 3. Validate max copies per card
for card_id, quantity in cards.items():
if quantity > deck_config.max_copies_per_card:
result.add_error(
f"Main deck must have exactly {self._config.min_size} cards, " f"got {total_cards}"
f"Card '{card_id}' has {quantity} copies, "
f"max allowed is {deck_config.max_copies_per_card}"
)
# 2. Validate total energy count
total_energy = sum(energy_cards.values())
if total_energy != self._config.energy_deck_size:
result.add_error(
f"Energy deck must have exactly {self._config.energy_deck_size} cards, "
f"got {total_energy}"
)
# 4 & 5. Validate card IDs exist and count Basic Pokemon
basic_pokemon_count = 0
invalid_card_ids: list[str] = []
# 3. Validate max copies per card
for card_id, quantity in cards.items():
if quantity > self._config.max_copies_per_card:
result.add_error(
f"Card '{card_id}' has {quantity} copies, "
f"max allowed is {self._config.max_copies_per_card}"
)
for card_id in cards:
card_def = card_lookup(card_id)
if card_def is None:
invalid_card_ids.append(card_id)
elif card_def.is_basic_pokemon():
basic_pokemon_count += cards[card_id]
# 4 & 5. Validate card IDs exist and count Basic Pokemon
basic_pokemon_count = 0
invalid_card_ids: list[str] = []
if invalid_card_ids:
display_ids = invalid_card_ids[:5]
more = len(invalid_card_ids) - 5
error_msg = f"Invalid card IDs: {', '.join(display_ids)}"
if more > 0:
error_msg += f" (and {more} more)"
result.add_error(error_msg)
for card_id in cards:
card_def = self._card_service.get_card(card_id)
if card_def is None:
invalid_card_ids.append(card_id)
elif card_def.is_basic_pokemon():
basic_pokemon_count += cards[card_id]
# Check minimum Basic Pokemon requirement
if basic_pokemon_count < deck_config.min_basic_pokemon:
result.add_error(
f"Deck must have at least {deck_config.min_basic_pokemon} Basic Pokemon, "
f"got {basic_pokemon_count}"
)
if invalid_card_ids:
# Limit displayed invalid IDs to avoid huge error messages
display_ids = invalid_card_ids[:5]
more = len(invalid_card_ids) - 5
error_msg = f"Invalid card IDs: {', '.join(display_ids)}"
# 6. Validate ownership if owned_cards provided
if owned_cards is not None:
insufficient_cards: list[tuple[str, int, int]] = []
for card_id, required_qty in cards.items():
owned_qty = owned_cards.get(card_id, 0)
if owned_qty < required_qty:
insufficient_cards.append((card_id, required_qty, owned_qty))
if insufficient_cards:
display_cards = insufficient_cards[:5]
more = len(insufficient_cards) - 5
error_parts = [f"'{c[0]}' (need {c[1]}, own {c[2]})" for c in display_cards]
error_msg = f"Insufficient cards: {', '.join(error_parts)}"
if more > 0:
error_msg += f" (and {more} more)"
result.add_error(error_msg)
# Check minimum Basic Pokemon requirement
if basic_pokemon_count < self._config.min_basic_pokemon:
result.add_error(
f"Deck must have at least {self._config.min_basic_pokemon} Basic Pokemon, "
f"got {basic_pokemon_count}"
)
return result
# 6. Validate ownership if owned_cards provided (campaign mode)
if owned_cards is not None:
insufficient_cards: list[tuple[str, int, int]] = []
for card_id, required_qty in cards.items():
owned_qty = owned_cards.get(card_id, 0)
if owned_qty < required_qty:
insufficient_cards.append((card_id, required_qty, owned_qty))
if insufficient_cards:
# Limit displayed insufficient cards
display_cards = insufficient_cards[:5]
more = len(insufficient_cards) - 5
error_parts = [f"'{c[0]}' (need {c[1]}, own {c[2]})" for c in display_cards]
error_msg = f"Insufficient cards: {', '.join(error_parts)}"
if more > 0:
error_msg += f" (and {more} more)"
result.add_error(error_msg)
def validate_cards_exist(
card_ids: list[str],
card_lookup: Callable[[str], CardDefinition | None],
) -> list[str]:
"""Check which card IDs are invalid.
return result
Args:
card_ids: List of card IDs to check.
card_lookup: Function to look up card definitions.
def validate_cards_exist(self, card_ids: list[str]) -> list[str]:
"""Check which card IDs are invalid.
Returns:
List of invalid card IDs (empty if all valid).
"""
return [card_id for card_id in card_ids if card_lookup(card_id) is None]
Utility method to check card ID validity without full deck validation.
Args:
card_ids: List of card IDs to check.
def count_basic_pokemon(
cards: dict[str, int],
card_lookup: Callable[[str], CardDefinition | None],
) -> int:
"""Count Basic Pokemon in a deck.
Returns:
List of invalid card IDs (empty if all valid).
"""
invalid = []
for card_id in card_ids:
if self._card_service.get_card(card_id) is None:
invalid.append(card_id)
return invalid
Args:
cards: Mapping of card IDs to quantities.
card_lookup: Function to look up card definitions.
def count_basic_pokemon(self, cards: dict[str, int]) -> int:
"""Count Basic Pokemon in a deck.
Utility method to count Basic Pokemon without full validation.
Args:
cards: Mapping of card IDs to quantities.
Returns:
Total number of Basic Pokemon cards in the deck.
"""
count = 0
for card_id, quantity in cards.items():
card_def = self._card_service.get_card(card_id)
if card_def and card_def.is_basic_pokemon():
count += quantity
return count
Returns:
Total number of Basic Pokemon cards in the deck.
"""
count = 0
for card_id, quantity in cards.items():
card_def = card_lookup(card_id)
if card_def and card_def.is_basic_pokemon():
count += quantity
return count

View File

@ -0,0 +1,359 @@
"""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

View File

@ -0,0 +1,617 @@
"""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

View File

@ -0,0 +1,270 @@
"""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

View File

@ -0,0 +1,465 @@
"""Integration tests for CollectionService.
Tests collection management operations with real PostgreSQL database.
Uses dev containers (docker compose up -d) for database access.
These tests verify:
- Getting user collections
- Adding cards with upsert behavior
- Removing cards with quantity management
- Ownership checking for deck validation
- Starter deck card granting
"""
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.collection import CardSource
from app.repositories.postgres.collection import PostgresCollectionRepository
from app.services.card_service import CardService
from app.services.collection_service import CollectionService
from tests.factories import UserFactory
# =============================================================================
# Test card IDs - real cards from the loaded data
# =============================================================================
# These are real card IDs from the a1 set that exist in the data
TEST_CARD_1 = "a1-001-bulbasaur"
TEST_CARD_2 = "a1-002-ivysaur"
TEST_CARD_3 = "a1-033-charmander"
# =============================================================================
# Fixtures
# =============================================================================
@pytest_asyncio.fixture
async def card_service() -> CardService:
"""Create a CardService with real card data loaded.
Returns a CardService with actual card definitions from data/definitions/.
This is necessary because CollectionService validates card IDs.
"""
service = CardService()
await service.load_all()
return service
@pytest.fixture
def collection_service(db_session: AsyncSession, card_service: CardService) -> CollectionService:
"""Create a CollectionService with PostgreSQL repository."""
repo = PostgresCollectionRepository(db_session)
return CollectionService(repo, card_service)
# =============================================================================
# Get Collection Tests
# =============================================================================
class TestGetCollection:
"""Tests for retrieving user collections."""
@pytest.mark.asyncio
async def test_get_collection_empty_for_new_user(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that a new user has an empty collection.
New users should start with no cards until they select a starter deck
or receive cards through other means.
"""
user = await UserFactory.create(db_session)
collection = await collection_service.get_collection(user.id)
assert collection == []
@pytest.mark.asyncio
async def test_get_collection_returns_all_cards(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that get_collection returns all cards owned by user.
Each unique card should appear once with its quantity.
"""
user = await UserFactory.create(db_session)
# Add multiple different cards
await collection_service.add_cards(user.id, "a1-001-bulbasaur", 3, CardSource.BOOSTER)
await collection_service.add_cards(user.id, "a1-002-ivysaur", 1, CardSource.REWARD)
await collection_service.add_cards(user.id, "a1-033-charmander", 4, CardSource.STARTER)
collection = await collection_service.get_collection(user.id)
assert len(collection) == 3
card_ids = {entry.card_definition_id for entry in collection}
assert card_ids == {"a1-001-bulbasaur", "a1-002-ivysaur", "a1-033-charmander"}
# =============================================================================
# Add Cards Tests
# =============================================================================
class TestAddCards:
"""Tests for adding cards to collections."""
@pytest.mark.asyncio
async def test_add_cards_creates_new_entry(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that adding a card creates a new collection entry.
The entry should have the correct card ID, quantity, and source.
"""
user = await UserFactory.create(db_session)
entry = await collection_service.add_cards(
user.id, "a1-001-bulbasaur", 2, CardSource.BOOSTER
)
assert entry.card_definition_id == "a1-001-bulbasaur"
assert entry.quantity == 2
assert entry.source == CardSource.BOOSTER
assert entry.user_id == user.id
@pytest.mark.asyncio
async def test_add_cards_increments_existing_quantity(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that adding more of an existing card increases quantity.
The upsert pattern should increment quantity rather than creating
duplicate entries.
"""
user = await UserFactory.create(db_session)
# Add initial cards
await collection_service.add_cards(user.id, "a1-001-bulbasaur", 2, CardSource.BOOSTER)
# Add more of the same card
entry = await collection_service.add_cards(
user.id, "a1-001-bulbasaur", 3, CardSource.REWARD
)
assert entry.quantity == 5
# Source should remain the original source
assert entry.source == CardSource.BOOSTER
@pytest.mark.asyncio
async def test_add_cards_single_card(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test adding a single card (quantity=1).
This is the common case for single card rewards.
"""
user = await UserFactory.create(db_session)
entry = await collection_service.add_cards(user.id, "a1-001-bulbasaur", 1, CardSource.GIFT)
assert entry.quantity == 1
# =============================================================================
# Remove Cards Tests
# =============================================================================
class TestRemoveCards:
"""Tests for removing cards from collections."""
@pytest.mark.asyncio
async def test_remove_cards_decrements_quantity(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that removing cards decreases quantity.
Should retain the entry with reduced quantity.
"""
user = await UserFactory.create(db_session)
await collection_service.add_cards(user.id, "a1-001-bulbasaur", 5, CardSource.BOOSTER)
entry = await collection_service.remove_cards(user.id, "a1-001-bulbasaur", 2)
assert entry is not None
assert entry.quantity == 3
@pytest.mark.asyncio
async def test_remove_cards_deletes_entry_when_quantity_zero(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that removing all copies deletes the collection entry.
The card should no longer appear in the user's collection.
"""
user = await UserFactory.create(db_session)
await collection_service.add_cards(user.id, "a1-001-bulbasaur", 3, CardSource.BOOSTER)
result = await collection_service.remove_cards(user.id, "a1-001-bulbasaur", 3)
assert result is None # Entry was deleted
# Verify it's gone from collection
quantity = await collection_service.get_card_quantity(user.id, "a1-001-bulbasaur")
assert quantity == 0
@pytest.mark.asyncio
async def test_remove_cards_returns_none_if_not_owned(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that removing unowned cards returns None.
Should not raise an error, just return None.
"""
user = await UserFactory.create(db_session)
result = await collection_service.remove_cards(user.id, "nonexistent-card", 1)
assert result is None
@pytest.mark.asyncio
async def test_remove_cards_clamps_to_zero(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that removing more cards than owned removes all and deletes entry.
Should not go negative - just remove all and delete the entry.
"""
user = await UserFactory.create(db_session)
await collection_service.add_cards(user.id, "a1-001-bulbasaur", 2, CardSource.BOOSTER)
result = await collection_service.remove_cards(user.id, "a1-001-bulbasaur", 10)
assert result is None # Entry deleted
quantity = await collection_service.get_card_quantity(user.id, "a1-001-bulbasaur")
assert quantity == 0
# =============================================================================
# Card Quantity Tests
# =============================================================================
class TestGetCardQuantity:
"""Tests for checking individual card quantities."""
@pytest.mark.asyncio
async def test_get_card_quantity_returns_owned_amount(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that get_card_quantity returns correct quantity.
Should return the exact number of copies owned.
"""
user = await UserFactory.create(db_session)
await collection_service.add_cards(user.id, "a1-001-bulbasaur", 4, CardSource.BOOSTER)
quantity = await collection_service.get_card_quantity(user.id, "a1-001-bulbasaur")
assert quantity == 4
@pytest.mark.asyncio
async def test_get_card_quantity_returns_zero_if_not_owned(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that get_card_quantity returns 0 for unowned cards.
Should not raise an error, just return 0.
"""
user = await UserFactory.create(db_session)
quantity = await collection_service.get_card_quantity(user.id, "nonexistent-card")
assert quantity == 0
# =============================================================================
# Ownership Check Tests
# =============================================================================
class TestHasCards:
"""Tests for ownership validation (deck building support)."""
@pytest.mark.asyncio
async def test_has_cards_returns_true_when_owned(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that has_cards returns True when user owns enough cards.
Should verify all required cards are owned in sufficient quantity.
"""
user = await UserFactory.create(db_session)
await collection_service.add_cards(user.id, "a1-001-bulbasaur", 4, CardSource.BOOSTER)
await collection_service.add_cards(user.id, "a1-002-ivysaur", 2, CardSource.BOOSTER)
has_all = await collection_service.has_cards(
user.id, {"a1-001-bulbasaur": 3, "a1-002-ivysaur": 2}
)
assert has_all is True
@pytest.mark.asyncio
async def test_has_cards_returns_false_when_insufficient(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that has_cards returns False when user doesn't own enough.
Should fail if any single card is insufficient.
"""
user = await UserFactory.create(db_session)
await collection_service.add_cards(user.id, "a1-001-bulbasaur", 2, CardSource.BOOSTER)
has_all = await collection_service.has_cards(user.id, {"a1-001-bulbasaur": 4})
assert has_all is False
@pytest.mark.asyncio
async def test_has_cards_returns_false_for_unowned_card(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that has_cards returns False for cards not in collection.
Should fail if any required card is completely missing.
"""
user = await UserFactory.create(db_session)
await collection_service.add_cards(user.id, "a1-001-bulbasaur", 4, CardSource.BOOSTER)
has_all = await collection_service.has_cards(
user.id, {"a1-001-bulbasaur": 2, "a1-053-squirtle": 1} # squirtle not owned
)
assert has_all is False
@pytest.mark.asyncio
async def test_has_cards_empty_requirements_returns_true(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that has_cards returns True for empty requirements.
Edge case: no cards required means user "has" all of them.
"""
user = await UserFactory.create(db_session)
has_all = await collection_service.has_cards(user.id, {})
assert has_all is True
# =============================================================================
# Owned Cards Dict Tests
# =============================================================================
class TestGetOwnedCardsDict:
"""Tests for getting collection as dictionary (for deck validation)."""
@pytest.mark.asyncio
async def test_get_owned_cards_dict_returns_mapping(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that get_owned_cards_dict returns correct card->quantity mapping.
This is used by deck validation to check ownership efficiently.
"""
user = await UserFactory.create(db_session)
await collection_service.add_cards(user.id, "a1-001-bulbasaur", 4, CardSource.BOOSTER)
await collection_service.add_cards(user.id, "a1-002-ivysaur", 2, CardSource.REWARD)
owned = await collection_service.get_owned_cards_dict(user.id)
assert owned == {"a1-001-bulbasaur": 4, "a1-002-ivysaur": 2}
@pytest.mark.asyncio
async def test_get_owned_cards_dict_empty_for_new_user(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that get_owned_cards_dict returns empty dict for new user.
New users have no cards.
"""
user = await UserFactory.create(db_session)
owned = await collection_service.get_owned_cards_dict(user.id)
assert owned == {}
# =============================================================================
# Starter Deck Tests
# =============================================================================
class TestGrantStarterDeck:
"""Tests for starter deck card granting."""
@pytest.mark.asyncio
async def test_grant_starter_deck_adds_cards(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that grant_starter_deck adds all starter deck cards.
Should add all cards from the specified starter deck to collection
with STARTER source.
"""
user = await UserFactory.create(db_session)
entries = await collection_service.grant_starter_deck(user.id, "grass")
assert len(entries) > 0
# All entries should have STARTER source
assert all(entry.source == CardSource.STARTER for entry in entries)
@pytest.mark.asyncio
async def test_grant_starter_deck_invalid_type_raises(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that grant_starter_deck raises ValueError for invalid type.
Only valid starter types should be accepted.
"""
user = await UserFactory.create(db_session)
with pytest.raises(ValueError, match="Invalid starter type"):
await collection_service.grant_starter_deck(user.id, "invalid_type")
@pytest.mark.asyncio
async def test_grant_starter_deck_all_types(
self, db_session: AsyncSession, collection_service: CollectionService
):
"""
Test that all starter deck types can be granted.
Verifies all 5 starter types: grass, fire, water, psychic, lightning.
"""
starter_types = ["grass", "fire", "water", "psychic", "lightning"]
for starter_type in starter_types:
user = await UserFactory.create(db_session)
entries = await collection_service.grant_starter_deck(user.id, starter_type)
assert len(entries) > 0, f"Starter type {starter_type} should add cards"

View File

@ -0,0 +1,803 @@
"""Integration tests for DeckService.
Tests deck management operations with real PostgreSQL database.
Uses dev containers (docker compose up -d) for database access.
The backend is stateless - deck rules come from DeckConfig parameter.
These tests verify that the service correctly accepts and applies config
from the caller.
These tests verify:
- Creating decks with validation
- Updating decks with re-validation
- Deleting decks
- Deck slot limits (free vs premium)
- Ownership validation modes
- Starter deck creation
"""
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import DeckConfig
from app.db.models.collection import CardSource
from app.repositories.postgres.collection import PostgresCollectionRepository
from app.repositories.postgres.deck import PostgresDeckRepository
from app.services.card_service import CardService
from app.services.collection_service import CollectionService
from app.services.deck_service import (
DeckLimitExceededError,
DeckNotFoundError,
DeckService,
)
from tests.factories import DeckFactory, UserFactory
# =============================================================================
# Fixtures
# =============================================================================
@pytest_asyncio.fixture
async def card_service() -> CardService:
"""Create a CardService with real card data loaded."""
service = CardService()
await service.load_all()
return service
@pytest.fixture
def default_config() -> DeckConfig:
"""Standard deck config for testing."""
return DeckConfig()
@pytest.fixture
def deck_service(db_session: AsyncSession, card_service: CardService) -> DeckService:
"""Create a DeckService with PostgreSQL repositories."""
deck_repo = PostgresDeckRepository(db_session)
collection_repo = PostgresCollectionRepository(db_session)
return DeckService(deck_repo, card_service, collection_repo)
@pytest.fixture
def collection_service(db_session: AsyncSession, card_service: CardService) -> CollectionService:
"""Create a CollectionService for granting cards."""
repo = PostgresCollectionRepository(db_session)
return CollectionService(repo, card_service)
def make_valid_deck_cards() -> tuple[dict[str, int], dict[str, int]]:
"""Create card and energy dicts that form a valid 40+20 deck.
Uses real card IDs from the a1 set for proper validation testing.
"""
# 40 cards total - using real card IDs from a1 set
cards = {
# Basic Pokemon
"a1-001-bulbasaur": 4,
"a1-033-charmander": 4,
"a1-053-squirtle": 4,
"a1-094-pikachu": 4,
# Stage 1 Pokemon
"a1-002-ivysaur": 4,
"a1-034-charmeleon": 4,
"a1-054-wartortle": 4,
# Trainers
"a1-211-potion": 4,
"a1-213-professor-oaks-research": 4,
"a1-214-red": 4,
}
# 20 energy
energy_cards = {"grass": 10, "colorless": 10}
return cards, energy_cards
# =============================================================================
# Create Deck Tests
# =============================================================================
class TestCreateDeck:
"""Tests for deck creation."""
@pytest.mark.asyncio
async def test_create_deck_basic(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that create_deck creates a deck with provided data.
The deck should be persisted with all provided attributes.
"""
user = await UserFactory.create(db_session)
cards, energy = make_valid_deck_cards()
deck = await deck_service.create_deck(
user_id=user.id,
name="My Test Deck",
cards=cards,
energy_cards=energy,
deck_config=default_config,
max_decks=5,
validate_ownership=False, # Skip ownership for this test
description="A test deck",
)
assert deck.name == "My Test Deck"
assert deck.cards == cards
assert deck.energy_cards == energy
assert deck.description == "A test deck"
assert deck.user_id == user.id
assert deck.is_starter is False
@pytest.mark.asyncio
async def test_create_deck_stores_validation_errors(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that invalid decks are saved with validation errors.
Invalid decks CAN be saved (with errors) to support work-in-progress
deck building. The validation errors are stored for display.
"""
user = await UserFactory.create(db_session)
# Invalid deck: wrong counts
cards = {"a1-001-bulbasaur": 10} # Only 10 cards, not 40
energy = {"grass": 5} # Only 5 energy, not 20
deck = await deck_service.create_deck(
user_id=user.id,
name="Invalid Deck",
cards=cards,
energy_cards=energy,
deck_config=default_config,
max_decks=5,
validate_ownership=False,
)
assert deck.is_valid is False
assert deck.validation_errors is not None
assert len(deck.validation_errors) > 0
@pytest.mark.asyncio
async def test_create_deck_respects_custom_config(
self,
db_session: AsyncSession,
deck_service: DeckService,
):
"""
Test that create_deck uses the provided DeckConfig.
The backend is stateless - rules come from the request.
Custom config should be applied during validation.
"""
user = await UserFactory.create(db_session)
# Custom config with smaller deck sizes
custom_config = DeckConfig(
min_size=20,
max_size=20,
energy_deck_size=10,
max_copies_per_card=10,
)
# Deck that's valid for custom config but invalid for default
cards = {"a1-001-bulbasaur": 10, "a1-002-ivysaur": 10} # 20 cards
energy = {"grass": 10} # 10 energy
deck = await deck_service.create_deck(
user_id=user.id,
name="Custom Config Deck",
cards=cards,
energy_cards=energy,
deck_config=custom_config,
max_decks=5,
validate_ownership=False,
)
# Should have errors only for card IDs not existing, not for counts
# (since counts match custom config)
if deck.validation_errors:
# The only error should be about invalid card IDs, not counts
count_errors = [
e
for e in deck.validation_errors
if "cards" in e.lower() and "must have" in e.lower()
]
assert (
len(count_errors) == 0
), f"Should have no count errors with custom config: {deck.validation_errors}"
@pytest.mark.asyncio
async def test_create_deck_enforces_deck_limit(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that create_deck raises error when at deck limit.
Free users are limited to 5 decks by default.
"""
user = await UserFactory.create(db_session)
cards, energy = make_valid_deck_cards()
# Create 5 decks (the limit)
for i in range(5):
await DeckFactory.create_for_user(db_session, user, name=f"Deck {i}")
# Try to create a 6th deck
with pytest.raises(DeckLimitExceededError, match="Deck limit reached"):
await deck_service.create_deck(
user_id=user.id,
name="One Too Many",
cards=cards,
energy_cards=energy,
deck_config=default_config,
max_decks=5,
validate_ownership=False,
)
@pytest.mark.asyncio
async def test_create_deck_premium_unlimited(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that premium users can have more decks.
Premium users have max_decks=999 (effectively unlimited).
"""
user = await UserFactory.create_premium(db_session)
cards, energy = make_valid_deck_cards()
# Create many decks
for i in range(10):
await deck_service.create_deck(
user_id=user.id,
name=f"Deck {i}",
cards=cards,
energy_cards=energy,
deck_config=default_config,
max_decks=999, # Premium limit
validate_ownership=False,
)
# Should have 10 decks
decks = await deck_service.get_user_decks(user.id)
assert len(decks) == 10
# =============================================================================
# Update Deck Tests
# =============================================================================
class TestUpdateDeck:
"""Tests for deck updates."""
@pytest.mark.asyncio
async def test_update_deck_name_only(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that updating only the name doesn't re-validate.
Name changes should preserve existing validation state.
"""
user = await UserFactory.create(db_session)
deck = await DeckFactory.create_for_user(
db_session, user, is_valid=True, validation_errors=None
)
updated = await deck_service.update_deck(
user_id=user.id,
deck_id=deck.id,
deck_config=default_config,
name="New Name",
)
assert updated.name == "New Name"
assert updated.is_valid is True # Preserved
@pytest.mark.asyncio
async def test_update_deck_cards_revalidates(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that updating cards triggers re-validation.
Card changes should always re-validate the deck.
"""
user = await UserFactory.create(db_session)
deck = await DeckFactory.create_for_user(
db_session, user, is_valid=True, validation_errors=None
)
# Update with invalid cards
updated = await deck_service.update_deck(
user_id=user.id,
deck_id=deck.id,
deck_config=default_config,
cards={"a1-001-bulbasaur": 5}, # Invalid: only 5 cards
validate_ownership=False,
)
assert updated.is_valid is False
assert updated.validation_errors is not None
@pytest.mark.asyncio
async def test_update_deck_not_found(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that updating non-existent deck raises error.
Should raise DeckNotFoundError for invalid deck_id.
"""
user = await UserFactory.create(db_session)
from uuid import uuid4
with pytest.raises(DeckNotFoundError):
await deck_service.update_deck(
user_id=user.id,
deck_id=uuid4(), # Non-existent
deck_config=default_config,
name="New Name",
)
@pytest.mark.asyncio
async def test_update_deck_wrong_user(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that users can only update their own decks.
Should raise DeckNotFoundError when trying to update another user's deck.
"""
user1 = await UserFactory.create(db_session)
user2 = await UserFactory.create(db_session)
deck = await DeckFactory.create_for_user(db_session, user1)
with pytest.raises(DeckNotFoundError):
await deck_service.update_deck(
user_id=user2.id, # Wrong user
deck_id=deck.id,
deck_config=default_config,
name="Stolen Deck",
)
# =============================================================================
# Delete Deck Tests
# =============================================================================
class TestDeleteDeck:
"""Tests for deck deletion."""
@pytest.mark.asyncio
async def test_delete_deck_removes_deck(
self, db_session: AsyncSession, deck_service: DeckService
):
"""
Test that delete_deck removes the deck.
Deck should no longer be retrievable after deletion.
"""
user = await UserFactory.create(db_session)
deck = await DeckFactory.create_for_user(db_session, user)
result = await deck_service.delete_deck(user.id, deck.id)
assert result is True
with pytest.raises(DeckNotFoundError):
await deck_service.get_deck(user.id, deck.id)
@pytest.mark.asyncio
async def test_delete_deck_not_found(self, db_session: AsyncSession, deck_service: DeckService):
"""
Test that deleting non-existent deck raises error.
Should raise DeckNotFoundError for invalid deck_id.
"""
user = await UserFactory.create(db_session)
from uuid import uuid4
with pytest.raises(DeckNotFoundError):
await deck_service.delete_deck(user.id, uuid4())
@pytest.mark.asyncio
async def test_delete_deck_wrong_user(
self, db_session: AsyncSession, deck_service: DeckService
):
"""
Test that users can only delete their own decks.
Should raise DeckNotFoundError when trying to delete another user's deck.
"""
user1 = await UserFactory.create(db_session)
user2 = await UserFactory.create(db_session)
deck = await DeckFactory.create_for_user(db_session, user1)
with pytest.raises(DeckNotFoundError):
await deck_service.delete_deck(user2.id, deck.id)
# =============================================================================
# Get Deck Tests
# =============================================================================
class TestGetDeck:
"""Tests for retrieving decks."""
@pytest.mark.asyncio
async def test_get_deck_returns_owned_deck(
self, db_session: AsyncSession, deck_service: DeckService
):
"""
Test that get_deck returns a deck owned by user.
Should return complete deck details.
"""
user = await UserFactory.create(db_session)
deck = await DeckFactory.create_for_user(db_session, user, name="My Deck")
result = await deck_service.get_deck(user.id, deck.id)
assert result.id == deck.id
assert result.name == "My Deck"
assert result.user_id == user.id
@pytest.mark.asyncio
async def test_get_deck_not_found(self, db_session: AsyncSession, deck_service: DeckService):
"""
Test that get_deck raises error for non-existent deck.
Should raise DeckNotFoundError for invalid deck_id.
"""
user = await UserFactory.create(db_session)
from uuid import uuid4
with pytest.raises(DeckNotFoundError):
await deck_service.get_deck(user.id, uuid4())
@pytest.mark.asyncio
async def test_get_deck_wrong_user(self, db_session: AsyncSession, deck_service: DeckService):
"""
Test that users can only get their own decks.
Should raise DeckNotFoundError for another user's deck.
"""
user1 = await UserFactory.create(db_session)
user2 = await UserFactory.create(db_session)
deck = await DeckFactory.create_for_user(db_session, user1)
with pytest.raises(DeckNotFoundError):
await deck_service.get_deck(user2.id, deck.id)
# =============================================================================
# Get User Decks Tests
# =============================================================================
class TestGetUserDecks:
"""Tests for listing user's decks."""
@pytest.mark.asyncio
async def test_get_user_decks_returns_all(
self, db_session: AsyncSession, deck_service: DeckService
):
"""
Test that get_user_decks returns all user's decks.
Should return complete list of decks.
"""
user = await UserFactory.create(db_session)
await DeckFactory.create_for_user(db_session, user, name="Deck 1")
await DeckFactory.create_for_user(db_session, user, name="Deck 2")
await DeckFactory.create_for_user(db_session, user, name="Deck 3")
decks = await deck_service.get_user_decks(user.id)
assert len(decks) == 3
names = {d.name for d in decks}
assert names == {"Deck 1", "Deck 2", "Deck 3"}
@pytest.mark.asyncio
async def test_get_user_decks_empty(self, db_session: AsyncSession, deck_service: DeckService):
"""
Test that get_user_decks returns empty list for new user.
New users should have no decks.
"""
user = await UserFactory.create(db_session)
decks = await deck_service.get_user_decks(user.id)
assert decks == []
@pytest.mark.asyncio
async def test_get_user_decks_only_own_decks(
self, db_session: AsyncSession, deck_service: DeckService
):
"""
Test that get_user_decks only returns own decks.
Should not include other users' decks.
"""
user1 = await UserFactory.create(db_session)
user2 = await UserFactory.create(db_session)
await DeckFactory.create_for_user(db_session, user1, name="User1 Deck")
await DeckFactory.create_for_user(db_session, user2, name="User2 Deck")
decks = await deck_service.get_user_decks(user1.id)
assert len(decks) == 1
assert decks[0].name == "User1 Deck"
# =============================================================================
# Can Create Deck Tests
# =============================================================================
class TestCanCreateDeck:
"""Tests for deck limit checking."""
@pytest.mark.asyncio
async def test_can_create_deck_under_limit(
self, db_session: AsyncSession, deck_service: DeckService
):
"""
Test that can_create_deck returns True under limit.
User with fewer decks than max should be able to create more.
"""
user = await UserFactory.create(db_session)
await DeckFactory.create_for_user(db_session, user)
can_create = await deck_service.can_create_deck(user.id, max_decks=5)
assert can_create is True
@pytest.mark.asyncio
async def test_can_create_deck_at_limit(
self, db_session: AsyncSession, deck_service: DeckService
):
"""
Test that can_create_deck returns False at limit.
User at max decks should not be able to create more.
"""
user = await UserFactory.create(db_session)
for i in range(5):
await DeckFactory.create_for_user(db_session, user, name=f"Deck {i}")
can_create = await deck_service.can_create_deck(user.id, max_decks=5)
assert can_create is False
# =============================================================================
# Starter Deck Tests
# =============================================================================
class TestStarterDeck:
"""Tests for starter deck creation."""
@pytest.mark.asyncio
async def test_has_starter_deck_false_for_new_user(
self, db_session: AsyncSession, deck_service: DeckService
):
"""
Test that has_starter_deck returns False for new user.
New users haven't selected a starter yet.
"""
user = await UserFactory.create(db_session)
has_starter, starter_type = await deck_service.has_starter_deck(user.id)
assert has_starter is False
assert starter_type is None
@pytest.mark.asyncio
async def test_has_starter_deck_true_after_selection(
self, db_session: AsyncSession, deck_service: DeckService
):
"""
Test that has_starter_deck returns True after selecting starter.
Should return the type of starter selected.
"""
user = await UserFactory.create(db_session)
await DeckFactory.create_starter_deck(db_session, user, starter_type="grass")
has_starter, starter_type = await deck_service.has_starter_deck(user.id)
assert has_starter is True
assert starter_type == "grass"
@pytest.mark.asyncio
async def test_create_starter_deck(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that create_starter_deck creates a proper starter deck.
Should create deck with is_starter=True and starter_type set.
"""
user = await UserFactory.create(db_session)
deck = await deck_service.create_starter_deck(
user_id=user.id,
starter_type="fire",
deck_config=default_config,
max_decks=5,
)
assert deck.is_starter is True
assert deck.starter_type == "fire"
# Starter deck names are defined in starter_decks.py and may vary
assert deck.name is not None and len(deck.name) > 0
@pytest.mark.asyncio
async def test_create_starter_deck_invalid_type(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that create_starter_deck rejects invalid type.
Should raise ValueError for unknown starter type.
"""
user = await UserFactory.create(db_session)
with pytest.raises(ValueError, match="Invalid starter type"):
await deck_service.create_starter_deck(
user_id=user.id,
starter_type="invalid",
deck_config=default_config,
max_decks=5,
)
# =============================================================================
# Ownership Validation Tests
# =============================================================================
class TestOwnershipValidation:
"""Tests for card ownership validation in campaign mode."""
@pytest.mark.asyncio
async def test_validate_ownership_passes_when_owned(
self,
db_session: AsyncSession,
deck_service: DeckService,
collection_service: CollectionService,
default_config: DeckConfig,
):
"""
Test that ownership validation passes when user owns cards.
In campaign mode, user must own all cards in the deck.
"""
user = await UserFactory.create(db_session)
# Grant cards to user
await collection_service.add_cards(user.id, "a1-001-bulbasaur", 4, CardSource.BOOSTER)
cards = {"a1-001-bulbasaur": 4}
energy = {"grass": 20}
# Create deck with ownership validation
deck = await deck_service.create_deck(
user_id=user.id,
name="Owned Cards Deck",
cards=cards,
energy_cards=energy,
deck_config=default_config,
max_decks=5,
validate_ownership=True,
)
# Should have errors for card counts/IDs but NOT for ownership
ownership_errors = [e for e in (deck.validation_errors or []) if "Insufficient" in e]
assert len(ownership_errors) == 0
@pytest.mark.asyncio
async def test_validate_ownership_fails_when_not_owned(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that ownership validation fails when user doesn't own cards.
Should include ownership errors in validation_errors.
"""
user = await UserFactory.create(db_session)
# User has no cards
cards = {"a1-001-bulbasaur": 4}
energy = {"grass": 20}
deck = await deck_service.create_deck(
user_id=user.id,
name="Unowned Cards Deck",
cards=cards,
energy_cards=energy,
deck_config=default_config,
max_decks=5,
validate_ownership=True,
)
assert deck.is_valid is False
ownership_errors = [e for e in (deck.validation_errors or []) if "Insufficient" in e]
assert len(ownership_errors) > 0
@pytest.mark.asyncio
async def test_skip_ownership_validation_freeplay(
self,
db_session: AsyncSession,
deck_service: DeckService,
default_config: DeckConfig,
):
"""
Test that ownership validation can be skipped for freeplay.
In freeplay mode, validate_ownership=False skips ownership checks.
"""
user = await UserFactory.create(db_session)
# User has no cards
cards = {"a1-001-bulbasaur": 4}
energy = {"grass": 20}
deck = await deck_service.create_deck(
user_id=user.id,
name="Freeplay Deck",
cards=cards,
energy_cards=energy,
deck_config=default_config,
max_decks=5,
validate_ownership=False, # Freeplay mode
)
# Should NOT have ownership errors
ownership_errors = [e for e in (deck.validation_errors or []) if "Insufficient" in e]
assert len(ownership_errors) == 0

View File

@ -1,28 +1,27 @@
"""Tests for the DeckValidator service.
"""Tests for the deck validation functions.
These tests verify deck validation logic without database dependencies.
CardService is mocked and injected to isolate the validation logic and
enable fast, deterministic unit tests.
Card lookup is mocked to isolate the validation logic and enable fast,
deterministic unit tests.
The DeckValidator enforces Mantimon TCG house rules:
The validation functions enforce Mantimon TCG house rules:
- 40 cards in main deck
- 20 energy cards in separate energy deck
- Max 4 copies of any single card
- At least 1 Basic Pokemon required
- Campaign mode: must own all cards in deck
- Freeplay mode: ownership validation skipped
- Ownership check when owned_cards is provided
"""
from unittest.mock import MagicMock
import pytest
from app.core.config import DeckConfig
from app.core.enums import CardType, EnergyType, PokemonStage
from app.core.models.card import CardDefinition
from app.services.deck_validator import (
DeckValidationResult,
DeckValidator,
ValidationResult,
count_basic_pokemon,
validate_cards_exist,
validate_deck,
)
# =============================================================================
@ -30,89 +29,59 @@ from app.services.deck_validator import (
# =============================================================================
@pytest.fixture
def mock_card_service():
"""Create a mock CardService with test cards.
Provides a set of test cards:
- 3 Basic Pokemon (pikachu, bulbasaur, charmander)
- 2 Stage 1 Pokemon (raichu, ivysaur)
- 2 Trainer cards (potion, professor-oak)
This allows testing deck validation without loading real card data.
"""
service = MagicMock()
# Define test cards
cards = {
"test-001-pikachu": CardDefinition(
id="test-001-pikachu",
name="Pikachu",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
),
"test-002-bulbasaur": CardDefinition(
id="test-002-bulbasaur",
name="Bulbasaur",
card_type=CardType.POKEMON,
hp=70,
pokemon_type=EnergyType.GRASS,
stage=PokemonStage.BASIC,
),
"test-003-charmander": CardDefinition(
id="test-003-charmander",
name="Charmander",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.FIRE,
stage=PokemonStage.BASIC,
),
"test-004-raichu": CardDefinition(
id="test-004-raichu",
name="Raichu",
card_type=CardType.POKEMON,
hp=100,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.STAGE_1,
evolves_from="Pikachu",
),
"test-005-ivysaur": CardDefinition(
id="test-005-ivysaur",
name="Ivysaur",
card_type=CardType.POKEMON,
hp=90,
pokemon_type=EnergyType.GRASS,
stage=PokemonStage.STAGE_1,
evolves_from="Bulbasaur",
),
"test-101-potion": CardDefinition(
id="test-101-potion",
name="Potion",
card_type=CardType.TRAINER,
trainer_type="item",
),
"test-102-professor-oak": CardDefinition(
id="test-102-professor-oak",
name="Professor Oak",
card_type=CardType.TRAINER,
trainer_type="supporter",
),
}
service.get_card = lambda card_id: cards.get(card_id)
return service
def make_basic_pokemon(card_id: str) -> CardDefinition:
"""Create a Basic Pokemon card definition."""
return CardDefinition(
id=card_id,
name=f"Pokemon {card_id}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
@pytest.fixture
def valid_energy_deck() -> dict[str, int]:
"""Create a valid 20-card energy deck."""
return {
"lightning": 10,
"grass": 6,
"colorless": 4,
}
def make_stage1_pokemon(card_id: str) -> CardDefinition:
"""Create a Stage 1 Pokemon card definition."""
return CardDefinition(
id=card_id,
name=f"Pokemon {card_id}",
card_type=CardType.POKEMON,
hp=90,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.STAGE_1,
evolves_from="SomeBasic",
)
def make_trainer(card_id: str) -> CardDefinition:
"""Create a Trainer card definition."""
return CardDefinition(
id=card_id,
name=f"Trainer {card_id}",
card_type=CardType.TRAINER,
trainer_type="item",
)
def basic_pokemon_lookup(card_id: str) -> CardDefinition | None:
"""Card lookup that returns Basic Pokemon for any ID."""
return make_basic_pokemon(card_id)
def stage1_pokemon_lookup(card_id: str) -> CardDefinition | None:
"""Card lookup that returns Stage 1 Pokemon for any ID."""
return make_stage1_pokemon(card_id)
def trainer_lookup(card_id: str) -> CardDefinition | None:
"""Card lookup that returns Trainers for any ID."""
return make_trainer(card_id)
def null_lookup(card_id: str) -> CardDefinition | None:
"""Card lookup that returns None for any ID."""
return None
@pytest.fixture
@ -121,75 +90,38 @@ def default_config() -> DeckConfig:
return DeckConfig()
@pytest.fixture
def validator(default_config, mock_card_service) -> DeckValidator:
"""Create a DeckValidator with default config and mock card service."""
return DeckValidator(default_config, mock_card_service)
def create_basic_pokemon_mock():
"""Create a mock that returns Basic Pokemon for any card ID.
Useful for tests that need all cards to be valid Basic Pokemon.
"""
mock = MagicMock()
mock.get_card = lambda cid: CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
return mock
# =============================================================================
# DeckValidationResult Tests
# ValidationResult Tests
# =============================================================================
class TestDeckValidationResult:
"""Tests for the DeckValidationResult dataclass."""
class TestValidationResult:
"""Tests for the ValidationResult dataclass."""
def test_default_is_valid(self):
"""Test that a new result starts as valid with no errors.
A fresh DeckValidationResult should indicate validity until
errors are explicitly added.
"""
result = DeckValidationResult()
"""Test that a new result starts as valid with no errors."""
result = ValidationResult()
assert result.is_valid is True
assert result.errors == []
def test_add_error_marks_invalid(self):
"""Test that adding an error marks the result as invalid.
Once any error is added, is_valid should be False.
"""
result = DeckValidationResult()
"""Test that adding an error marks the result as invalid."""
result = ValidationResult()
result.add_error("Test error")
assert result.is_valid is False
assert "Test error" in result.errors
def test_add_multiple_errors(self):
"""Test that multiple errors can be accumulated.
All errors should be collected, not just the first one,
to give users complete feedback on what needs fixing.
"""
result = DeckValidationResult()
"""Test that multiple errors can be accumulated."""
result = ValidationResult()
result.add_error("Error 1")
result.add_error("Error 2")
result.add_error("Error 3")
assert result.is_valid is False
assert len(result.errors) == 3
assert "Error 1" in result.errors
assert "Error 2" in result.errors
assert "Error 3" in result.errors
# =============================================================================
@ -201,64 +133,42 @@ class TestCardCountValidation:
"""Tests for main deck card count validation (40 cards required)."""
def test_valid_card_count_passes(self, default_config):
"""Test that exactly 40 cards passes validation.
The main deck must have exactly 40 cards per Mantimon house rules.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
"""Test that exactly 40 cards passes validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
# Should pass card count check
assert "must have exactly 40 cards" not in str(result.errors)
def test_39_cards_fails(self, default_config):
"""Test that 39 cards fails validation.
One card short of the required 40 should produce an error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
"""Test that 39 cards fails validation."""
cards = {f"card-{i:03d}": 4 for i in range(9)} # 36 cards
cards["card-extra"] = 3 # 39 total
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("must have exactly 40 cards" in e and "got 39" in e for e in result.errors)
assert any("got 39" in e for e in result.errors)
def test_41_cards_fails(self, default_config):
"""Test that 41 cards fails validation.
One card over the required 40 should produce an error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
"""Test that 41 cards fails validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
cards["card-extra"] = 1 # 41 total
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("must have exactly 40 cards" in e and "got 41" in e for e in result.errors)
assert any("got 41" in e for e in result.errors)
def test_empty_deck_fails(self, default_config):
"""Test that an empty deck fails validation.
A deck with no cards should fail the card count check.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
result = validator.validate_deck({}, {"lightning": 20})
"""Test that an empty deck fails validation."""
result = validate_deck({}, {"lightning": 20}, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("must have exactly 40 cards" in e and "got 0" in e for e in result.errors)
assert any("got 0" in e for e in result.errors)
# =============================================================================
@ -270,59 +180,33 @@ class TestEnergyCountValidation:
"""Tests for energy deck card count validation (20 energy required)."""
def test_valid_energy_count_passes(self, default_config):
"""Test that exactly 20 energy cards passes validation.
The energy deck must have exactly 20 cards per Mantimon house rules.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
"""Test that exactly 20 energy cards passes validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 14, "colorless": 6} # 20 total
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert "Energy deck must have exactly 20" not in str(result.errors)
def test_19_energy_fails(self, default_config):
"""Test that 19 energy cards fails validation.
One energy short of the required 20 should produce an error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
"""Test that 19 energy cards fails validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 19}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("Energy deck must have exactly 20" in e and "got 19" in e for e in result.errors)
assert any("got 19" in e for e in result.errors)
def test_21_energy_fails(self, default_config):
"""Test that 21 energy cards fails validation.
One energy over the required 20 should produce an error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
"""Test that 21 energy cards fails validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 21}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("Energy deck must have exactly 20" in e and "got 21" in e for e in result.errors)
def test_empty_energy_deck_fails(self, default_config):
"""Test that an empty energy deck fails validation."""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)}
result = validator.validate_deck(cards, {})
assert result.is_valid is False
assert any("Energy deck must have exactly 20" in e and "got 0" in e for e in result.errors)
assert any("got 21" in e for e in result.errors)
# =============================================================================
@ -334,67 +218,36 @@ class TestMaxCopiesValidation:
"""Tests for maximum copies per card validation (4 max)."""
def test_4_copies_allowed(self, default_config):
"""Test that 4 copies of a card is allowed.
The maximum of 4 copies per card should pass validation.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {
"test-001-pikachu": 4, # Max allowed
}
# Pad to 40 cards
for i in range(9):
cards[f"filler-{i:03d}"] = 4
"""Test that 4 copies of a card is allowed."""
cards = {f"card-{i:03d}": 4 for i in range(10)} # 4 copies each
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert "max allowed is 4" not in str(result.errors)
def test_5_copies_fails(self, default_config):
"""Test that 5 copies of a card fails validation.
One copy over the maximum should produce an error identifying the card.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {
"test-001-pikachu": 5, # One over max
}
# Pad to 40 (5 + 35)
"""Test that 5 copies of a card fails validation."""
cards = {"over-limit": 5}
# Pad to 40 cards
for i in range(7):
cards[f"filler-{i:03d}"] = 5
cards[f"card-{i:03d}"] = 5
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any(
"test-001-pikachu" in e and "5 copies" in e and "max allowed is 4" in e
for e in result.errors
)
assert any("over-limit" in e and "5 copies" in e for e in result.errors)
def test_multiple_cards_over_limit(self, default_config):
"""Test that multiple cards over limit all get reported.
Each card exceeding the limit should generate its own error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
cards = {
"card-a": 5,
"card-b": 6,
"card-c": 4, # OK
}
# Pad to 40
"""Test that multiple cards over limit all get reported."""
cards = {"card-a": 5, "card-b": 6, "card-c": 4} # a and b over
cards["filler"] = 25
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert result.is_valid is False
# Both card-a and card-b should be reported
error_str = str(result.errors)
assert "card-a" in error_str
assert "card-b" in error_str
@ -409,63 +262,30 @@ class TestBasicPokemonRequirement:
"""Tests for minimum Basic Pokemon requirement (at least 1)."""
def test_deck_with_basic_pokemon_passes(self, default_config):
"""Test that a deck with Basic Pokemon passes validation.
Having at least 1 Basic Pokemon satisfies this requirement.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
"""Test that a deck with Basic Pokemon passes validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert "at least 1 Basic Pokemon" not in str(result.errors)
def test_deck_without_basic_pokemon_fails(self, default_config):
"""Test that a deck without Basic Pokemon fails validation.
A deck composed entirely of Stage 1/2 Pokemon and Trainers
cannot start a game and should fail validation.
"""
# Create a mock that returns Stage 1 pokemon for all IDs
mock_service = MagicMock()
mock_service.get_card = lambda cid: CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.STAGE_1,
evolves_from="SomeBasic",
)
validator = DeckValidator(default_config, mock_service)
"""Test that a deck without Basic Pokemon fails validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, stage1_pokemon_lookup)
assert result.is_valid is False
assert any("at least 1 Basic Pokemon" in e for e in result.errors)
def test_deck_with_only_trainers_fails(self, default_config):
"""Test that a deck with only Trainers fails Basic Pokemon check.
Trainers don't count toward the Basic Pokemon requirement.
"""
# Create a mock that returns Trainers for all IDs
mock_service = MagicMock()
mock_service.get_card = lambda cid: CardDefinition(
id=cid,
name=f"Trainer {cid}",
card_type=CardType.TRAINER,
trainer_type="item",
)
validator = DeckValidator(default_config, mock_service)
"""Test that a deck with only Trainers fails Basic Pokemon check."""
cards = {f"trainer-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, trainer_lookup)
assert result.is_valid is False
assert any("at least 1 Basic Pokemon" in e for e in result.errors)
@ -480,88 +300,44 @@ class TestCardIdValidation:
"""Tests for card ID existence validation."""
def test_valid_card_ids_pass(self, default_config):
"""Test that valid card IDs pass validation.
All card IDs in the deck should exist in the CardService.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
"""Test that valid card IDs pass validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, basic_pokemon_lookup)
assert "Invalid card IDs" not in str(result.errors)
def test_invalid_card_id_fails(self, default_config):
"""Test that an invalid card ID fails validation.
"""Test that an invalid card ID fails validation."""
Card IDs not found in CardService should produce an error.
"""
# Create a mock that returns None for specific cards
mock_service = MagicMock()
def mock_get_card(cid):
if cid == "nonexistent-card":
def partial_lookup(card_id: str) -> CardDefinition | None:
if card_id == "bad-card":
return None
return CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
return make_basic_pokemon(card_id)
mock_service.get_card = mock_get_card
validator = DeckValidator(default_config, mock_service)
cards = {
"valid-card": 4,
"nonexistent-card": 4,
}
# Pad to 40
cards = {"good-card": 4, "bad-card": 4}
for i in range(8):
cards[f"card-{i:03d}"] = 4
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, partial_lookup)
assert result.is_valid is False
assert any("Invalid card IDs" in e and "nonexistent-card" in e for e in result.errors)
assert any("Invalid card IDs" in e and "bad-card" in e for e in result.errors)
def test_multiple_invalid_ids_reported(self, default_config):
"""Test that multiple invalid IDs are reported together.
"""Test that multiple invalid IDs are reported together."""
The error message should list multiple invalid IDs (up to a limit).
"""
# Create a mock that returns None for "bad-*" cards
mock_service = MagicMock()
def mock_get_card(cid):
if cid.startswith("bad"):
def partial_lookup(card_id: str) -> CardDefinition | None:
if card_id.startswith("bad"):
return None
return CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
return make_basic_pokemon(card_id)
mock_service.get_card = mock_get_card
validator = DeckValidator(default_config, mock_service)
cards = {
"bad-1": 4,
"bad-2": 4,
"bad-3": 4,
"good": 28,
}
cards = {"bad-1": 4, "bad-2": 4, "bad-3": 4, "good": 28}
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, default_config, partial_lookup)
assert result.is_valid is False
error_str = str(result.errors)
@ -576,77 +352,55 @@ class TestCardIdValidation:
class TestOwnershipValidation:
"""Tests for card ownership validation (campaign mode)."""
"""Tests for card ownership validation."""
def test_owned_cards_pass(self, default_config):
"""Test that deck passes when user owns all cards.
In campaign mode, user must own sufficient copies of each card.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
"""Test that deck passes when user owns all cards."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
# User owns 10 copies of each card
owned = {f"card-{i:03d}": 10 for i in range(10)}
result = validator.validate_deck(cards, energy, owned_cards=owned)
result = validate_deck(
cards, energy, default_config, basic_pokemon_lookup, owned_cards=owned
)
assert "Insufficient cards" not in str(result.errors)
def test_insufficient_ownership_fails(self, default_config):
"""Test that deck fails when user doesn't own enough copies.
Needing 4 copies but only owning 2 should produce an error.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
"""Test that deck fails when user doesn't own enough copies."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
# User owns only 2 copies of first card
owned = {f"card-{i:03d}": 10 for i in range(10)}
owned["card-000"] = 2 # Need 4, only have 2
result = validator.validate_deck(cards, energy, owned_cards=owned)
result = validate_deck(
cards, energy, default_config, basic_pokemon_lookup, owned_cards=owned
)
assert result.is_valid is False
assert any(
"Insufficient cards" in e and "card-000" in e and "need 4" in e and "own 2" in e
for e in result.errors
)
assert any("card-000" in e and "need 4" in e and "own 2" in e for e in result.errors)
def test_unowned_card_fails(self, default_config):
"""Test that deck fails when user doesn't own a card at all.
A card with 0 owned copies should fail ownership validation.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
"""Test that deck fails when user doesn't own a card at all."""
cards = {"owned-card": 20, "unowned-card": 20}
energy = {"lightning": 20}
owned = {"owned-card": 20} # Missing unowned-card entirely
owned = {"owned-card": 20} # Missing unowned-card
result = validator.validate_deck(cards, energy, owned_cards=owned)
assert result.is_valid is False
assert any(
"Insufficient cards" in e and "unowned-card" in e and "own 0" in e
for e in result.errors
result = validate_deck(
cards, energy, default_config, basic_pokemon_lookup, owned_cards=owned
)
def test_freeplay_skips_ownership(self, default_config):
"""Test that passing None for owned_cards skips ownership validation.
assert result.is_valid is False
assert any("unowned-card" in e and "own 0" in e for e in result.errors)
In freeplay mode, users have access to all cards regardless of
their actual collection.
"""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
def test_none_owned_cards_skips_ownership_check(self, default_config):
"""Test that passing None for owned_cards skips ownership validation."""
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
# owned_cards=None means freeplay mode
result = validator.validate_deck(cards, energy, owned_cards=None)
result = validate_deck(
cards, energy, default_config, basic_pokemon_lookup, owned_cards=None
)
assert "Insufficient cards" not in str(result.errors)
@ -660,35 +414,15 @@ class TestMultipleErrors:
"""Tests for returning all errors at once."""
def test_multiple_errors_returned_together(self, default_config):
"""Test that multiple validation errors are all returned.
When a deck has multiple issues, all should be reported so the
user can fix everything at once rather than iteratively.
"""
# Create a mock that returns None for all cards
mock_service = MagicMock()
mock_service.get_card = lambda cid: None
validator = DeckValidator(default_config, mock_service)
cards = {
"bad-card": 5, # Invalid ID + over copy limit
}
# Total is 5 (not 40)
"""Test that multiple validation errors are all returned."""
cards = {"bad-card": 5} # Invalid ID + over copy limit + wrong count
energy = {"lightning": 10} # Only 10 (not 20)
owned = {} # Empty ownership
owned = {}
result = validator.validate_deck(cards, energy, owned_cards=owned)
result = validate_deck(cards, energy, default_config, null_lookup, owned_cards=owned)
assert result.is_valid is False
# Should have multiple errors
assert len(result.errors) >= 3
error_str = str(result.errors)
# Card count error
assert "40 cards" in error_str
# Energy count error
assert "20" in error_str
# Invalid card ID or max copies
assert "bad-card" in error_str
# =============================================================================
@ -700,17 +434,12 @@ class TestCustomConfig:
"""Tests for using custom DeckConfig values."""
def test_custom_deck_size(self):
"""Test that custom deck size is respected.
Different game modes might use different deck sizes (e.g., 60-card).
"""
"""Test that custom deck size is respected."""
custom_config = DeckConfig(min_size=60, max_size=60)
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(custom_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, custom_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("must have exactly 60 cards" in e for e in result.errors)
@ -718,12 +447,10 @@ class TestCustomConfig:
def test_custom_energy_size(self):
"""Test that custom energy deck size is respected."""
custom_config = DeckConfig(energy_deck_size=30)
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(custom_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
energy = {"lightning": 20} # 20, but need 30
cards = {f"card-{i:03d}": 4 for i in range(10)}
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, custom_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("must have exactly 30" in e for e in result.errors)
@ -731,111 +458,58 @@ class TestCustomConfig:
def test_custom_max_copies(self):
"""Test that custom max copies per card is respected."""
custom_config = DeckConfig(max_copies_per_card=2)
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(custom_config, mock_service)
cards = {f"card-{i:03d}": 4 for i in range(10)} # 4 copies each
energy = {"lightning": 20}
result = validator.validate_deck(cards, energy)
result = validate_deck(cards, energy, custom_config, basic_pokemon_lookup)
assert result.is_valid is False
assert any("max allowed is 2" in e for e in result.errors)
# =============================================================================
# Utility Method Tests
# Utility Function Tests
# =============================================================================
class TestUtilityMethods:
"""Tests for utility methods on DeckValidator."""
class TestUtilityFunctions:
"""Tests for utility functions."""
def test_validate_cards_exist_all_valid(self, default_config):
def test_validate_cards_exist_all_valid(self):
"""Test validate_cards_exist returns empty list when all valid."""
mock_service = create_basic_pokemon_mock()
validator = DeckValidator(default_config, mock_service)
card_ids = ["card-1", "card-2", "card-3"]
invalid = validator.validate_cards_exist(card_ids)
invalid = validate_cards_exist(card_ids, basic_pokemon_lookup)
assert invalid == []
def test_validate_cards_exist_some_invalid(self, default_config):
def test_validate_cards_exist_some_invalid(self):
"""Test validate_cards_exist returns invalid IDs."""
mock_service = MagicMock()
def mock_get(cid):
if cid.startswith("bad"):
def partial_lookup(card_id: str) -> CardDefinition | None:
if card_id.startswith("bad"):
return None
return CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
return make_basic_pokemon(card_id)
mock_service.get_card = mock_get
validator = DeckValidator(default_config, mock_service)
card_ids = ["good-1", "bad-1", "good-2", "bad-2"]
invalid = validator.validate_cards_exist(card_ids)
invalid = validate_cards_exist(card_ids, partial_lookup)
assert set(invalid) == {"bad-1", "bad-2"}
def test_count_basic_pokemon(self, default_config):
def test_count_basic_pokemon(self):
"""Test count_basic_pokemon returns correct count."""
mock_service = MagicMock()
def mock_get(cid):
if cid.startswith("basic"):
return CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.BASIC,
)
elif cid.startswith("stage1"):
return CardDefinition(
id=cid,
name=f"Card {cid}",
card_type=CardType.POKEMON,
hp=90,
pokemon_type=EnergyType.LIGHTNING,
stage=PokemonStage.STAGE_1,
evolves_from="SomeBasic",
)
def mixed_lookup(card_id: str) -> CardDefinition | None:
if card_id.startswith("basic"):
return make_basic_pokemon(card_id)
elif card_id.startswith("stage1"):
return make_stage1_pokemon(card_id)
else:
return CardDefinition(
id=cid,
name=f"Trainer {cid}",
card_type=CardType.TRAINER,
trainer_type="item",
)
return make_trainer(card_id)
mock_service.get_card = mock_get
cards = {"basic-1": 4, "basic-2": 3, "stage1-1": 4, "trainer-1": 4}
validator = DeckValidator(default_config, mock_service)
cards = {
"basic-1": 4,
"basic-2": 3,
"stage1-1": 4,
"trainer-1": 4,
}
count = count_basic_pokemon(cards, mixed_lookup)
count = validator.count_basic_pokemon(cards)
# basic-1: 4 + basic-2: 3 = 7
assert count == 7
def test_config_property(self, default_config, mock_card_service):
"""Test that config property returns the injected DeckConfig."""
validator = DeckValidator(default_config, mock_card_service)
assert validator.config is default_config
assert validator.config.min_size == 40
assert validator.config.energy_deck_size == 20
assert count == 7 # basic-1: 4 + basic-2: 3