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:
parent
58349c126a
commit
3ec670753b
@ -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 |
|
||||
|
||||
|
||||
136
backend/app/api/collections.py
Normal file
136
backend/app/api/collections.py
Normal 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
288
backend/app/api/decks.py
Normal 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,
|
||||
)
|
||||
@ -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)]
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 ###
|
||||
@ -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:
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
359
backend/tests/api/test_collections_api.py
Normal file
359
backend/tests/api/test_collections_api.py
Normal 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
|
||||
617
backend/tests/api/test_decks_api.py
Normal file
617
backend/tests/api/test_decks_api.py
Normal 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
|
||||
270
backend/tests/api/test_starter_deck_api.py
Normal file
270
backend/tests/api/test_starter_deck_api.py
Normal 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
|
||||
465
backend/tests/services/test_collection_service.py
Normal file
465
backend/tests/services/test_collection_service.py
Normal 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"
|
||||
803
backend/tests/services/test_deck_service.py
Normal file
803
backend/tests/services/test_deck_service.py
Normal 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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user