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
|
```python
|
||||||
class DeckValidator:
|
# CORRECT - Config comes from caller
|
||||||
"""Dependencies injected via constructor."""
|
def validate_deck(
|
||||||
|
cards: dict[str, int],
|
||||||
def __init__(self, config: DeckConfig, card_service: CardService) -> None:
|
energy_cards: dict[str, int],
|
||||||
self._config = config
|
deck_config: DeckConfig, # Provided by frontend
|
||||||
self._card_service = card_service
|
card_lookup: Callable[[str], CardDefinition | None],
|
||||||
|
owned_cards: dict[str, int] | None = None,
|
||||||
|
) -> ValidationResult:
|
||||||
|
"""Pure function - all inputs from caller."""
|
||||||
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Forbidden Patterns
|
### Forbidden Patterns
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# WRONG - Service locator pattern
|
# WRONG - Service with baked-in config
|
||||||
class DeckValidator:
|
class DeckValidator:
|
||||||
def validate(self, cards):
|
def __init__(self, config: DeckConfig): # Config at construction
|
||||||
service = get_card_service() # Hidden dependency!
|
self._config = config
|
||||||
...
|
|
||||||
|
|
||||||
# WRONG - Default instantiation hides dependency
|
# WRONG - Default config hides dependency
|
||||||
class DeckValidator:
|
def validate_deck(cards, config: DeckConfig | None = None):
|
||||||
def __init__(self, config: DeckConfig | None = None):
|
config = config or DeckConfig() # Hidden creation!
|
||||||
self.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
|
### Why This Matters
|
||||||
@ -68,7 +114,7 @@ class DeckValidator:
|
|||||||
1. **Testability**: Dependencies can be mocked without patching globals
|
1. **Testability**: Dependencies can be mocked without patching globals
|
||||||
2. **Offline Fork**: Services can be swapped for local implementations
|
2. **Offline Fork**: Services can be swapped for local implementations
|
||||||
3. **Explicit Dependencies**: Constructor shows all requirements
|
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
|
```python
|
||||||
# Inject config
|
from app.core.config import DeckConfig, RulesConfig
|
||||||
class DeckValidator:
|
|
||||||
def __init__(self, config: DeckConfig, card_service: CardService):
|
|
||||||
self._config = config
|
|
||||||
|
|
||||||
def validate(self, cards):
|
# DeckConfig - deck building rules
|
||||||
if len(cards) != self._config.min_size: # Use injected config
|
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
|
# Frontend can customize for different modes
|
||||||
def validate_starter_decks(config: DeckSizeConfig) -> dict[str, list[str]]:
|
freeplay_config = DeckConfig(max_copies_per_card=10) # Relaxed rules
|
||||||
"""Config is required parameter, not created internally."""
|
campaign_config = DeckConfig() # Standard rules
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Protocol for Minimal Interface
|
### API Endpoints Accept Config
|
||||||
|
|
||||||
When a function only needs specific config values, use a Protocol:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class DeckSizeConfig(Protocol):
|
@router.post("/decks")
|
||||||
"""Minimal interface for deck size validation."""
|
async def create_deck(
|
||||||
min_size: int
|
deck_in: DeckCreate, # Contains deck_config from frontend
|
||||||
energy_deck_size: int
|
current_user: User = Depends(get_current_user),
|
||||||
|
deck_service: DeckService = Depends(get_deck_service),
|
||||||
def validate_starter_decks(config: DeckSizeConfig) -> dict[str, list[str]]:
|
):
|
||||||
# Any object with min_size and energy_deck_size works
|
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
|
### 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
|
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
|
4. Keep business logic separate from data access
|
||||||
|
|
||||||
### Creating a Repository
|
### Creating a Repository
|
||||||
@ -232,9 +280,10 @@ app/
|
|||||||
| Mistake | Correct Approach |
|
| Mistake | Correct Approach |
|
||||||
|---------|------------------|
|
|---------|------------------|
|
||||||
| `get_card_service()` in method body | Inject `CardService` via constructor |
|
| `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 |
|
| 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 |
|
| Tests without docstrings | Always explain what and why |
|
||||||
| Unit tests in `tests/services/` | Use `tests/unit/` for no-DB tests |
|
| 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.
|
"""FastAPI dependencies for Mantimon TCG API.
|
||||||
|
|
||||||
This module provides dependency injection functions for authentication
|
This module provides dependency injection functions for authentication,
|
||||||
and database access in API endpoints.
|
database access, and service layer access in API endpoints.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from app.api.deps import get_current_user, get_db
|
from app.api.deps import CurrentUser, DbSession, DeckServiceDep
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/decks")
|
||||||
async def get_me(
|
async def get_decks(
|
||||||
user: User = Depends(get_current_user),
|
user: CurrentUser,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: DbSession,
|
||||||
|
deck_service: DeckServiceDep,
|
||||||
):
|
):
|
||||||
return user
|
return await deck_service.get_user_decks(user.id)
|
||||||
|
|
||||||
Dependencies:
|
Dependencies:
|
||||||
- get_db: Async database session
|
- get_db: Async database session
|
||||||
- get_current_user: Authenticated user from JWT (required)
|
- get_current_user: Authenticated user from JWT (required)
|
||||||
- get_optional_user: Authenticated user or None
|
- get_optional_user: Authenticated user or None
|
||||||
- get_current_premium_user: User with active premium
|
- 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 typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.db.models import User
|
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.jwt_service import verify_access_token
|
||||||
from app.services.user_service import user_service
|
from app.services.user_service import user_service
|
||||||
|
|
||||||
@ -43,6 +53,49 @@ oauth2_scheme_optional = OAuth2PasswordBearer(
|
|||||||
auto_error=False, # Return None if no token
|
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:
|
async def get_db() -> AsyncSession:
|
||||||
"""Get async database session.
|
"""Get async database session.
|
||||||
@ -165,8 +218,98 @@ async def get_current_premium_user(
|
|||||||
return 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)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
OptionalUser = Annotated[User | None, Depends(get_optional_user)]
|
OptionalUser = Annotated[User | None, Depends(get_optional_user)]
|
||||||
PremiumUser = Annotated[User, Depends(get_current_premium_user)]
|
PremiumUser = Annotated[User, Depends(get_current_premium_user)]
|
||||||
|
|
||||||
|
# Database session
|
||||||
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
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)
|
- Update profile (display name, avatar)
|
||||||
- List linked OAuth accounts
|
- List linked OAuth accounts
|
||||||
- Session management
|
- Session management
|
||||||
|
- Starter deck selection (one-time for new players)
|
||||||
|
|
||||||
All endpoints require authentication.
|
All endpoints require authentication.
|
||||||
|
|
||||||
@ -16,13 +17,19 @@ Example:
|
|||||||
# Update profile
|
# Update profile
|
||||||
PATCH /api/users/me
|
PATCH /api/users/me
|
||||||
{"display_name": "NewName"}
|
{"display_name": "NewName"}
|
||||||
|
|
||||||
|
# Select starter deck
|
||||||
|
POST /api/users/me/starter-deck
|
||||||
|
{"starter_type": "grass"}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
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.schemas.user import UserResponse, UserUpdate
|
||||||
|
from app.services.deck_service import DeckLimitExceededError, StarterAlreadySelectedError
|
||||||
from app.services.token_store import token_store
|
from app.services.token_store import token_store
|
||||||
from app.services.user_service import AccountLinkingError, user_service
|
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,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=str(e),
|
detail=str(e),
|
||||||
) from None
|
) 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",
|
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")
|
@field_validator("debug", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_debug_from_environment(cls, v: bool, info) -> bool:
|
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 typing import TYPE_CHECKING
|
||||||
from uuid import UUID
|
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.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@ -129,7 +129,16 @@ class Deck(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Indexes
|
# 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
|
@property
|
||||||
def total_cards(self) -> int:
|
def total_cards(self) -> int:
|
||||||
|
|||||||
@ -19,6 +19,8 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.auth import router as auth_router
|
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.api.users import router as users_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import close_db, init_db
|
from app.db import close_db, init_db
|
||||||
@ -163,10 +165,11 @@ async def readiness_check() -> dict[str, str | int]:
|
|||||||
# === API Routers ===
|
# === API Routers ===
|
||||||
app.include_router(auth_router, prefix="/api")
|
app.include_router(auth_router, prefix="/api")
|
||||||
app.include_router(users_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
|
# 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(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(games.router, prefix="/api/games", tags=["games"])
|
||||||
# app.include_router(campaign.router, prefix="/api/campaign", tags=["campaign"])
|
# app.include_router(campaign.router, prefix="/api/campaign", tags=["campaign"])
|
||||||
|
|||||||
@ -3,6 +3,9 @@
|
|||||||
This module defines Pydantic models for deck-related API requests
|
This module defines Pydantic models for deck-related API requests
|
||||||
and responses. Decks contain card compositions for gameplay.
|
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:
|
Example:
|
||||||
deck = DeckResponse(
|
deck = DeckResponse(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
@ -18,10 +21,63 @@ Example:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
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):
|
class DeckCreateRequest(BaseModel):
|
||||||
@ -29,24 +85,55 @@ class DeckCreateRequest(BaseModel):
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
name: Display name for the deck.
|
name: Display name for the deck.
|
||||||
cards: Mapping of card IDs to quantities (40 cards total).
|
cards: Mapping of card IDs to quantities (40 cards total by default).
|
||||||
energy_cards: Mapping of energy types to quantities (20 total).
|
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")
|
name: str = Field(..., min_length=1, max_length=100, description="Deck name")
|
||||||
cards: dict[str, int] = Field(..., description="Card ID to quantity mapping")
|
cards: dict[str, int] = Field(..., description="Card ID to quantity mapping")
|
||||||
energy_cards: dict[str, int] = Field(..., description="Energy type 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):
|
class DeckUpdateRequest(BaseModel):
|
||||||
"""Request model for updating a deck.
|
"""Request model for updating a deck.
|
||||||
|
|
||||||
All fields are optional - only provided fields are updated.
|
All fields are optional - only provided fields are updated.
|
||||||
|
If cards or energy_cards change, the deck is re-validated with deck_config.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
name: New display name for the deck.
|
name: New display name for the deck.
|
||||||
cards: New card composition.
|
cards: New card composition.
|
||||||
energy_cards: New energy 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(
|
name: str | None = Field(
|
||||||
@ -54,6 +141,30 @@ class DeckUpdateRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
cards: dict[str, int] | None = Field(default=None, description="New card composition")
|
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")
|
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):
|
class DeckResponse(BaseModel):
|
||||||
@ -64,6 +175,7 @@ class DeckResponse(BaseModel):
|
|||||||
Attributes:
|
Attributes:
|
||||||
id: Unique deck identifier.
|
id: Unique deck identifier.
|
||||||
name: Display name of the deck.
|
name: Display name of the deck.
|
||||||
|
description: Optional deck description or notes.
|
||||||
cards: Mapping of card IDs to quantities.
|
cards: Mapping of card IDs to quantities.
|
||||||
energy_cards: Mapping of energy types to quantities.
|
energy_cards: Mapping of energy types to quantities.
|
||||||
is_valid: Whether deck passes all validation rules.
|
is_valid: Whether deck passes all validation rules.
|
||||||
@ -76,6 +188,7 @@ class DeckResponse(BaseModel):
|
|||||||
|
|
||||||
id: UUID = Field(..., description="Deck ID")
|
id: UUID = Field(..., description="Deck ID")
|
||||||
name: str = Field(..., description="Deck name")
|
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")
|
cards: dict[str, int] = Field(..., description="Card ID to quantity mapping")
|
||||||
energy_cards: dict[str, int] = Field(..., description="Energy type to quantity mapping")
|
energy_cards: dict[str, int] = Field(..., description="Energy type to quantity mapping")
|
||||||
is_valid: bool = Field(..., description="Whether deck is valid")
|
is_valid: bool = Field(..., description="Whether deck is valid")
|
||||||
@ -112,10 +225,33 @@ class DeckValidateRequest(BaseModel):
|
|||||||
Attributes:
|
Attributes:
|
||||||
cards: Card ID to quantity mapping to validate.
|
cards: Card ID to quantity mapping to validate.
|
||||||
energy_cards: Energy type 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")
|
cards: dict[str, int] = Field(..., description="Card ID to quantity mapping")
|
||||||
energy_cards: dict[str, int] = Field(..., description="Energy type 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):
|
class DeckValidationResponse(BaseModel):
|
||||||
@ -135,12 +271,16 @@ class StarterDeckSelectRequest(BaseModel):
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
starter_type: Type of starter deck to select.
|
starter_type: Type of starter deck to select.
|
||||||
|
deck_config: Deck rules from the frontend (defaults to standard rules).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
starter_type: str = Field(
|
starter_type: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Starter deck type (grass, fire, water, psychic, lightning)",
|
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):
|
class StarterStatusResponse(BaseModel):
|
||||||
|
|||||||
@ -196,8 +196,8 @@ class CollectionService:
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
owned = await service.get_owned_cards_dict(user_id)
|
owned = await service.get_owned_cards_dict(user_id)
|
||||||
# Pass to DeckValidator
|
# Pass to validate_deck function
|
||||||
result = validator.validate_deck(cards, energy, owned_cards=owned)
|
result = validate_deck(cards, energy, deck_config, card_lookup, owned_cards=owned)
|
||||||
"""
|
"""
|
||||||
collection = await self._repo.get_all(user_id)
|
collection = await self._repo.get_all(user_id)
|
||||||
return {entry.card_definition_id: entry.quantity for entry in collection}
|
return {entry.card_definition_id: entry.quantity for entry in collection}
|
||||||
|
|||||||
@ -1,45 +1,49 @@
|
|||||||
"""Deck service for Mantimon TCG.
|
"""Deck service for Mantimon TCG.
|
||||||
|
|
||||||
This module provides business logic for deck management. It uses
|
This module provides business logic for deck management. It uses
|
||||||
the DeckRepository protocol for data access and DeckValidator for
|
the DeckRepository protocol for data access and pure validation functions.
|
||||||
validation logic.
|
|
||||||
|
|
||||||
The service layer handles:
|
The service layer handles:
|
||||||
- Deck slot limits (free vs premium users)
|
- Deck slot limits (free vs premium users)
|
||||||
- Deck validation with optional ownership checking
|
- Deck validation with optional ownership checking
|
||||||
- Starter deck creation
|
- Starter deck creation
|
||||||
|
|
||||||
|
The backend is stateless - deck rules come from the request via DeckConfig.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
from app.core.config import DeckConfig
|
from app.core.config import DeckConfig
|
||||||
from app.services.card_service import CardService
|
from app.services.card_service import CardService
|
||||||
from app.services.deck_service import DeckService
|
from app.services.deck_service import DeckService
|
||||||
from app.services.deck_validator import DeckValidator
|
|
||||||
from app.repositories.postgres import PostgresDeckRepository, PostgresCollectionRepository
|
from app.repositories.postgres import PostgresDeckRepository, PostgresCollectionRepository
|
||||||
|
|
||||||
# Create dependencies
|
# Create dependencies
|
||||||
card_service = CardService()
|
card_service = CardService()
|
||||||
card_service.load_all()
|
card_service.load_all()
|
||||||
deck_validator = DeckValidator(DeckConfig(), card_service)
|
|
||||||
|
|
||||||
# Create repositories
|
# Create repositories
|
||||||
deck_repo = PostgresDeckRepository(db_session)
|
deck_repo = PostgresDeckRepository(db_session)
|
||||||
collection_repo = PostgresCollectionRepository(db_session)
|
collection_repo = PostgresCollectionRepository(db_session)
|
||||||
|
|
||||||
# Create service with all dependencies
|
# Create service
|
||||||
service = DeckService(deck_repo, deck_validator, card_service, collection_repo)
|
service = DeckService(deck_repo, card_service, collection_repo)
|
||||||
|
|
||||||
# Create a deck
|
# Create a deck - rules provided by caller
|
||||||
deck = await service.create_deck(
|
deck = await service.create_deck(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name="My Deck",
|
name="My Deck",
|
||||||
cards={"a1-001-bulbasaur": 4, ...},
|
cards={"a1-001-bulbasaur": 4, ...},
|
||||||
energy_cards={"grass": 14, "colorless": 6},
|
energy_cards={"grass": 14, "colorless": 6},
|
||||||
|
deck_config=DeckConfig(), # Rules from frontend
|
||||||
max_decks=5, # From user.max_decks
|
max_decks=5, # From user.max_decks
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.core.config import DeckConfig
|
||||||
from app.core.models.card import CardDefinition
|
from app.core.models.card import CardDefinition
|
||||||
from app.repositories.protocols import (
|
from app.repositories.protocols import (
|
||||||
CollectionRepository,
|
CollectionRepository,
|
||||||
@ -47,7 +51,7 @@ from app.repositories.protocols import (
|
|||||||
DeckRepository,
|
DeckRepository,
|
||||||
)
|
)
|
||||||
from app.services.card_service import CardService
|
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):
|
class DeckLimitExceededError(Exception):
|
||||||
@ -56,6 +60,12 @@ class DeckLimitExceededError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StarterAlreadySelectedError(Exception):
|
||||||
|
"""Raised when user tries to select a starter deck they already have."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DeckNotFoundError(Exception):
|
class DeckNotFoundError(Exception):
|
||||||
"""Raised when deck is not found or not owned by user."""
|
"""Raised when deck is not found or not owned by user."""
|
||||||
|
|
||||||
@ -70,17 +80,17 @@ class DeckService:
|
|||||||
- Multiple storage backends
|
- Multiple storage backends
|
||||||
- Offline fork support
|
- Offline fork support
|
||||||
|
|
||||||
|
The backend is stateless - deck rules come from the request via DeckConfig.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
_deck_repo: The deck repository implementation.
|
_deck_repo: The deck repository implementation.
|
||||||
_collection_repo: The collection repository (for ownership checks).
|
_collection_repo: The collection repository (for ownership checks).
|
||||||
_deck_validator: The deck validator for validation logic.
|
|
||||||
_card_service: The card service for card lookups.
|
_card_service: The card service for card lookups.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
deck_repository: DeckRepository,
|
deck_repository: DeckRepository,
|
||||||
deck_validator: DeckValidator,
|
|
||||||
card_service: CardService,
|
card_service: CardService,
|
||||||
collection_repository: CollectionRepository | None = None,
|
collection_repository: CollectionRepository | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -88,13 +98,11 @@ class DeckService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
deck_repository: Implementation of DeckRepository protocol.
|
deck_repository: Implementation of DeckRepository protocol.
|
||||||
deck_validator: Validator for deck compositions.
|
|
||||||
card_service: Card service for looking up card definitions.
|
card_service: Card service for looking up card definitions.
|
||||||
collection_repository: Implementation of CollectionRepository protocol.
|
collection_repository: Implementation of CollectionRepository protocol.
|
||||||
Required for ownership validation in campaign mode.
|
Required for ownership validation in campaign mode.
|
||||||
"""
|
"""
|
||||||
self._deck_repo = deck_repository
|
self._deck_repo = deck_repository
|
||||||
self._deck_validator = deck_validator
|
|
||||||
self._card_service = card_service
|
self._card_service = card_service
|
||||||
self._collection_repo = collection_repository
|
self._collection_repo = collection_repository
|
||||||
|
|
||||||
@ -104,6 +112,7 @@ class DeckService:
|
|||||||
name: str,
|
name: str,
|
||||||
cards: dict[str, int],
|
cards: dict[str, int],
|
||||||
energy_cards: dict[str, int],
|
energy_cards: dict[str, int],
|
||||||
|
deck_config: DeckConfig,
|
||||||
max_decks: int,
|
max_decks: int,
|
||||||
validate_ownership: bool = True,
|
validate_ownership: bool = True,
|
||||||
is_starter: bool = False,
|
is_starter: bool = False,
|
||||||
@ -120,6 +129,7 @@ class DeckService:
|
|||||||
name: Display name for the deck.
|
name: Display name for the deck.
|
||||||
cards: Card ID to quantity mapping.
|
cards: Card ID to quantity mapping.
|
||||||
energy_cards: Energy type 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).
|
max_decks: Maximum decks allowed (from user.max_decks).
|
||||||
validate_ownership: If True, checks card ownership (campaign mode).
|
validate_ownership: If True, checks card ownership (campaign mode).
|
||||||
is_starter: Whether this is a starter deck.
|
is_starter: Whether this is a starter deck.
|
||||||
@ -138,6 +148,7 @@ class DeckService:
|
|||||||
name="Grass Power",
|
name="Grass Power",
|
||||||
cards={"a1-001-bulbasaur": 4, ...},
|
cards={"a1-001-bulbasaur": 4, ...},
|
||||||
energy_cards={"grass": 14, "colorless": 6},
|
energy_cards={"grass": 14, "colorless": 6},
|
||||||
|
deck_config=DeckConfig(),
|
||||||
max_decks=5,
|
max_decks=5,
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
@ -150,8 +161,8 @@ class DeckService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Validate deck
|
# Validate deck
|
||||||
validation = await self.validate_deck(
|
validation = await self._validate_deck_internal(
|
||||||
cards, energy_cards, user_id if validate_ownership else None
|
cards, energy_cards, deck_config, user_id if validate_ownership else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return await self._deck_repo.create(
|
return await self._deck_repo.create(
|
||||||
@ -170,6 +181,7 @@ class DeckService:
|
|||||||
self,
|
self,
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
deck_id: UUID,
|
deck_id: UUID,
|
||||||
|
deck_config: DeckConfig,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
cards: dict[str, int] | None = None,
|
cards: dict[str, int] | None = None,
|
||||||
energy_cards: dict[str, int] | None = None,
|
energy_cards: dict[str, int] | None = None,
|
||||||
@ -183,6 +195,7 @@ class DeckService:
|
|||||||
Args:
|
Args:
|
||||||
user_id: The user's UUID (for ownership verification).
|
user_id: The user's UUID (for ownership verification).
|
||||||
deck_id: The deck's UUID.
|
deck_id: The deck's UUID.
|
||||||
|
deck_config: Deck rules from the caller (frontend provides this).
|
||||||
name: New name (optional).
|
name: New name (optional).
|
||||||
cards: New card composition (optional).
|
cards: New card composition (optional).
|
||||||
energy_cards: New energy composition (optional).
|
energy_cards: New energy composition (optional).
|
||||||
@ -212,8 +225,11 @@ class DeckService:
|
|||||||
validation_errors = deck.validation_errors
|
validation_errors = deck.validation_errors
|
||||||
|
|
||||||
if needs_revalidation:
|
if needs_revalidation:
|
||||||
validation = await self.validate_deck(
|
validation = await self._validate_deck_internal(
|
||||||
final_cards, final_energy, user_id if validate_ownership else None
|
final_cards,
|
||||||
|
final_energy,
|
||||||
|
deck_config,
|
||||||
|
user_id if validate_ownership else None,
|
||||||
)
|
)
|
||||||
is_valid = validation.is_valid
|
is_valid = validation.is_valid
|
||||||
validation_errors = validation.errors if validation.errors else None
|
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)
|
return await self._deck_repo.count_by_user(user_id)
|
||||||
|
|
||||||
async def validate_deck(
|
async def _validate_deck_internal(
|
||||||
self,
|
self,
|
||||||
cards: dict[str, int],
|
cards: dict[str, int],
|
||||||
energy_cards: dict[str, int],
|
energy_cards: dict[str, int],
|
||||||
|
deck_config: DeckConfig,
|
||||||
user_id: UUID | None = None,
|
user_id: UUID | None = None,
|
||||||
) -> DeckValidationResult:
|
) -> ValidationResult:
|
||||||
"""Validate a deck composition.
|
"""Internal method to validate a deck composition.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cards: Card ID to quantity mapping.
|
cards: Card ID to quantity mapping.
|
||||||
energy_cards: Energy type 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).
|
user_id: If provided, validates card ownership (campaign mode).
|
||||||
Pass None for freeplay mode.
|
Pass None for freeplay mode.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DeckValidationResult with is_valid and errors.
|
ValidationResult with is_valid and errors.
|
||||||
"""
|
"""
|
||||||
owned_cards: dict[str, int] | None = None
|
owned_cards: dict[str, int] | None = None
|
||||||
if user_id is not None and self._collection_repo is not 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)
|
collection = await self._collection_repo.get_all(user_id)
|
||||||
owned_cards = {entry.card_definition_id: entry.quantity for entry in collection}
|
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]:
|
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.
|
"""Expand a deck to a list of CardDefinitions for game use.
|
||||||
@ -372,6 +396,7 @@ class DeckService:
|
|||||||
self,
|
self,
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
starter_type: str,
|
starter_type: str,
|
||||||
|
deck_config: DeckConfig,
|
||||||
max_decks: int,
|
max_decks: int,
|
||||||
) -> DeckEntry:
|
) -> DeckEntry:
|
||||||
"""Create a starter deck for a user.
|
"""Create a starter deck for a user.
|
||||||
@ -382,6 +407,7 @@ class DeckService:
|
|||||||
Args:
|
Args:
|
||||||
user_id: The user's UUID.
|
user_id: The user's UUID.
|
||||||
starter_type: Type of starter deck (grass, fire, water, etc.).
|
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).
|
max_decks: Maximum decks allowed (from user.max_decks).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@ -406,9 +432,103 @@ class DeckService:
|
|||||||
name=starter["name"],
|
name=starter["name"],
|
||||||
cards=starter["cards"],
|
cards=starter["cards"],
|
||||||
energy_cards=starter["energy_cards"],
|
energy_cards=starter["energy_cards"],
|
||||||
|
deck_config=deck_config,
|
||||||
max_decks=max_decks,
|
max_decks=max_decks,
|
||||||
validate_ownership=False, # Starter decks skip ownership check
|
validate_ownership=False, # Starter decks skip ownership check
|
||||||
is_starter=True,
|
is_starter=True,
|
||||||
starter_type=starter_type,
|
starter_type=starter_type,
|
||||||
description=starter["description"],
|
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
|
This module provides pure validation functions that validate deck compositions
|
||||||
without database dependencies. It validates deck compositions against
|
against rules provided by the caller. The backend is stateless - rules come
|
||||||
the game rules defined in DeckConfig.
|
from the request via 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)
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from app.core.config import DeckConfig
|
from app.core.config import DeckConfig
|
||||||
from app.services.card_service import CardService
|
from app.services.deck_validator import validate_deck, ValidationResult
|
||||||
from app.services.deck_validator import DeckValidator, DeckValidationResult
|
|
||||||
|
|
||||||
card_service = CardService()
|
result = validate_deck(
|
||||||
card_service.load_all()
|
cards={"a1-001-bulbasaur": 4, ...},
|
||||||
validator = DeckValidator(DeckConfig(), card_service)
|
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)
|
if not result.is_valid:
|
||||||
result = validator.validate_deck(cards, energy_cards)
|
for error in result.errors:
|
||||||
|
print(error)
|
||||||
# Validate with ownership check (campaign mode)
|
|
||||||
result = validator.validate_deck(cards, energy_cards, owned_cards=user_collection)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from app.core.config import DeckConfig
|
from app.core.config import DeckConfig
|
||||||
from app.services.card_service import CardService
|
from app.core.models.card import CardDefinition
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DeckValidationResult:
|
class ValidationResult:
|
||||||
"""Result of deck validation.
|
"""Result of deck validation.
|
||||||
|
|
||||||
Contains validation status and all errors found. Multiple errors
|
Contains validation status and all errors found. Multiple errors
|
||||||
can be returned to help the user fix all issues at once.
|
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
|
is_valid: bool = True
|
||||||
errors: list[str] = field(default_factory=list)
|
errors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
def add_error(self, error: str) -> None:
|
def add_error(self, error: str) -> None:
|
||||||
"""Add an error and mark as invalid.
|
"""Add an error and mark as invalid."""
|
||||||
|
|
||||||
Args:
|
|
||||||
error: Human-readable error message.
|
|
||||||
"""
|
|
||||||
self.is_valid = False
|
self.is_valid = False
|
||||||
self.errors.append(error)
|
self.errors.append(error)
|
||||||
|
|
||||||
|
|
||||||
class DeckValidator:
|
def validate_deck(
|
||||||
"""Validates deck compositions against game rules.
|
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:
|
This is a pure function - all inputs are provided by the caller,
|
||||||
1. Total card count (40 cards in main deck)
|
including the rules to validate against via DeckConfig.
|
||||||
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)
|
|
||||||
|
|
||||||
The validator uses DeckConfig for rule values, allowing different
|
Args:
|
||||||
game modes to have different rules if needed.
|
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:
|
Returns:
|
||||||
_config: The deck configuration with validation rules.
|
ValidationResult with is_valid status and list of errors.
|
||||||
_card_service: The card service for card lookups.
|
|
||||||
|
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:
|
# 1. Validate total card count
|
||||||
"""Initialize the validator with dependencies.
|
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:
|
# 2. Validate total energy count
|
||||||
config: Deck configuration with validation rules.
|
total_energy = sum(energy_cards.values())
|
||||||
card_service: Card service for looking up card definitions.
|
if total_energy != deck_config.energy_deck_size:
|
||||||
"""
|
result.add_error(
|
||||||
self._config = config
|
f"Energy deck must have exactly {deck_config.energy_deck_size} cards, "
|
||||||
self._card_service = card_service
|
f"got {total_energy}"
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
# 3. Validate max copies per card
|
||||||
def config(self) -> DeckConfig:
|
for card_id, quantity in cards.items():
|
||||||
"""Get the deck configuration."""
|
if quantity > deck_config.max_copies_per_card:
|
||||||
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:
|
|
||||||
result.add_error(
|
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
|
# 4 & 5. Validate card IDs exist and count Basic Pokemon
|
||||||
total_energy = sum(energy_cards.values())
|
basic_pokemon_count = 0
|
||||||
if total_energy != self._config.energy_deck_size:
|
invalid_card_ids: list[str] = []
|
||||||
result.add_error(
|
|
||||||
f"Energy deck must have exactly {self._config.energy_deck_size} cards, "
|
|
||||||
f"got {total_energy}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Validate max copies per card
|
for card_id in cards:
|
||||||
for card_id, quantity in cards.items():
|
card_def = card_lookup(card_id)
|
||||||
if quantity > self._config.max_copies_per_card:
|
if card_def is None:
|
||||||
result.add_error(
|
invalid_card_ids.append(card_id)
|
||||||
f"Card '{card_id}' has {quantity} copies, "
|
elif card_def.is_basic_pokemon():
|
||||||
f"max allowed is {self._config.max_copies_per_card}"
|
basic_pokemon_count += cards[card_id]
|
||||||
)
|
|
||||||
|
|
||||||
# 4 & 5. Validate card IDs exist and count Basic Pokemon
|
if invalid_card_ids:
|
||||||
basic_pokemon_count = 0
|
display_ids = invalid_card_ids[:5]
|
||||||
invalid_card_ids: list[str] = []
|
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:
|
# Check minimum Basic Pokemon requirement
|
||||||
card_def = self._card_service.get_card(card_id)
|
if basic_pokemon_count < deck_config.min_basic_pokemon:
|
||||||
if card_def is None:
|
result.add_error(
|
||||||
invalid_card_ids.append(card_id)
|
f"Deck must have at least {deck_config.min_basic_pokemon} Basic Pokemon, "
|
||||||
elif card_def.is_basic_pokemon():
|
f"got {basic_pokemon_count}"
|
||||||
basic_pokemon_count += cards[card_id]
|
)
|
||||||
|
|
||||||
if invalid_card_ids:
|
# 6. Validate ownership if owned_cards provided
|
||||||
# Limit displayed invalid IDs to avoid huge error messages
|
if owned_cards is not None:
|
||||||
display_ids = invalid_card_ids[:5]
|
insufficient_cards: list[tuple[str, int, int]] = []
|
||||||
more = len(invalid_card_ids) - 5
|
for card_id, required_qty in cards.items():
|
||||||
error_msg = f"Invalid card IDs: {', '.join(display_ids)}"
|
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:
|
if more > 0:
|
||||||
error_msg += f" (and {more} more)"
|
error_msg += f" (and {more} more)"
|
||||||
result.add_error(error_msg)
|
result.add_error(error_msg)
|
||||||
|
|
||||||
# Check minimum Basic Pokemon requirement
|
return result
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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:
|
def validate_cards_exist(
|
||||||
# Limit displayed insufficient cards
|
card_ids: list[str],
|
||||||
display_cards = insufficient_cards[:5]
|
card_lookup: Callable[[str], CardDefinition | None],
|
||||||
more = len(insufficient_cards) - 5
|
) -> list[str]:
|
||||||
error_parts = [f"'{c[0]}' (need {c[1]}, own {c[2]})" for c in display_cards]
|
"""Check which card IDs are invalid.
|
||||||
error_msg = f"Insufficient cards: {', '.join(error_parts)}"
|
|
||||||
if more > 0:
|
|
||||||
error_msg += f" (and {more} more)"
|
|
||||||
result.add_error(error_msg)
|
|
||||||
|
|
||||||
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]:
|
Returns:
|
||||||
"""Check which card IDs are invalid.
|
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:
|
def count_basic_pokemon(
|
||||||
card_ids: List of card IDs to check.
|
cards: dict[str, int],
|
||||||
|
card_lookup: Callable[[str], CardDefinition | None],
|
||||||
|
) -> int:
|
||||||
|
"""Count Basic Pokemon in a deck.
|
||||||
|
|
||||||
Returns:
|
Args:
|
||||||
List of invalid card IDs (empty if all valid).
|
cards: Mapping of card IDs to quantities.
|
||||||
"""
|
card_lookup: Function to look up card definitions.
|
||||||
invalid = []
|
|
||||||
for card_id in card_ids:
|
|
||||||
if self._card_service.get_card(card_id) is None:
|
|
||||||
invalid.append(card_id)
|
|
||||||
return invalid
|
|
||||||
|
|
||||||
def count_basic_pokemon(self, cards: dict[str, int]) -> int:
|
Returns:
|
||||||
"""Count Basic Pokemon in a deck.
|
Total number of Basic Pokemon cards in the deck.
|
||||||
|
"""
|
||||||
Utility method to count Basic Pokemon without full validation.
|
count = 0
|
||||||
|
for card_id, quantity in cards.items():
|
||||||
Args:
|
card_def = card_lookup(card_id)
|
||||||
cards: Mapping of card IDs to quantities.
|
if card_def and card_def.is_basic_pokemon():
|
||||||
|
count += quantity
|
||||||
Returns:
|
return count
|
||||||
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
|
|
||||||
|
|||||||
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.
|
These tests verify deck validation logic without database dependencies.
|
||||||
CardService is mocked and injected to isolate the validation logic and
|
Card lookup is mocked to isolate the validation logic and enable fast,
|
||||||
enable fast, deterministic unit tests.
|
deterministic unit tests.
|
||||||
|
|
||||||
The DeckValidator enforces Mantimon TCG house rules:
|
The validation functions enforce Mantimon TCG house rules:
|
||||||
- 40 cards in main deck
|
- 40 cards in main deck
|
||||||
- 20 energy cards in separate energy deck
|
- 20 energy cards in separate energy deck
|
||||||
- Max 4 copies of any single card
|
- Max 4 copies of any single card
|
||||||
- At least 1 Basic Pokemon required
|
- At least 1 Basic Pokemon required
|
||||||
- Campaign mode: must own all cards in deck
|
- Ownership check when owned_cards is provided
|
||||||
- Freeplay mode: ownership validation skipped
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.core.config import DeckConfig
|
from app.core.config import DeckConfig
|
||||||
from app.core.enums import CardType, EnergyType, PokemonStage
|
from app.core.enums import CardType, EnergyType, PokemonStage
|
||||||
from app.core.models.card import CardDefinition
|
from app.core.models.card import CardDefinition
|
||||||
from app.services.deck_validator import (
|
from app.services.deck_validator import (
|
||||||
DeckValidationResult,
|
ValidationResult,
|
||||||
DeckValidator,
|
count_basic_pokemon,
|
||||||
|
validate_cards_exist,
|
||||||
|
validate_deck,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -30,89 +29,59 @@ from app.services.deck_validator import (
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def make_basic_pokemon(card_id: str) -> CardDefinition:
|
||||||
def mock_card_service():
|
"""Create a Basic Pokemon card definition."""
|
||||||
"""Create a mock CardService with test cards.
|
return CardDefinition(
|
||||||
|
id=card_id,
|
||||||
Provides a set of test cards:
|
name=f"Pokemon {card_id}",
|
||||||
- 3 Basic Pokemon (pikachu, bulbasaur, charmander)
|
card_type=CardType.POKEMON,
|
||||||
- 2 Stage 1 Pokemon (raichu, ivysaur)
|
hp=60,
|
||||||
- 2 Trainer cards (potion, professor-oak)
|
pokemon_type=EnergyType.LIGHTNING,
|
||||||
|
stage=PokemonStage.BASIC,
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def make_stage1_pokemon(card_id: str) -> CardDefinition:
|
||||||
def valid_energy_deck() -> dict[str, int]:
|
"""Create a Stage 1 Pokemon card definition."""
|
||||||
"""Create a valid 20-card energy deck."""
|
return CardDefinition(
|
||||||
return {
|
id=card_id,
|
||||||
"lightning": 10,
|
name=f"Pokemon {card_id}",
|
||||||
"grass": 6,
|
card_type=CardType.POKEMON,
|
||||||
"colorless": 4,
|
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
|
@pytest.fixture
|
||||||
@ -121,75 +90,38 @@ def default_config() -> DeckConfig:
|
|||||||
return 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:
|
class TestValidationResult:
|
||||||
"""Tests for the DeckValidationResult dataclass."""
|
"""Tests for the ValidationResult dataclass."""
|
||||||
|
|
||||||
def test_default_is_valid(self):
|
def test_default_is_valid(self):
|
||||||
"""Test that a new result starts as valid with no errors.
|
"""Test that a new result starts as valid with no errors."""
|
||||||
|
result = ValidationResult()
|
||||||
A fresh DeckValidationResult should indicate validity until
|
|
||||||
errors are explicitly added.
|
|
||||||
"""
|
|
||||||
result = DeckValidationResult()
|
|
||||||
|
|
||||||
assert result.is_valid is True
|
assert result.is_valid is True
|
||||||
assert result.errors == []
|
assert result.errors == []
|
||||||
|
|
||||||
def test_add_error_marks_invalid(self):
|
def test_add_error_marks_invalid(self):
|
||||||
"""Test that adding an error marks the result as invalid.
|
"""Test that adding an error marks the result as invalid."""
|
||||||
|
result = ValidationResult()
|
||||||
Once any error is added, is_valid should be False.
|
|
||||||
"""
|
|
||||||
result = DeckValidationResult()
|
|
||||||
result.add_error("Test error")
|
result.add_error("Test error")
|
||||||
|
|
||||||
assert result.is_valid is False
|
assert result.is_valid is False
|
||||||
assert "Test error" in result.errors
|
assert "Test error" in result.errors
|
||||||
|
|
||||||
def test_add_multiple_errors(self):
|
def test_add_multiple_errors(self):
|
||||||
"""Test that multiple errors can be accumulated.
|
"""Test that multiple errors can be accumulated."""
|
||||||
|
result = ValidationResult()
|
||||||
All errors should be collected, not just the first one,
|
|
||||||
to give users complete feedback on what needs fixing.
|
|
||||||
"""
|
|
||||||
result = DeckValidationResult()
|
|
||||||
result.add_error("Error 1")
|
result.add_error("Error 1")
|
||||||
result.add_error("Error 2")
|
result.add_error("Error 2")
|
||||||
result.add_error("Error 3")
|
result.add_error("Error 3")
|
||||||
|
|
||||||
assert result.is_valid is False
|
assert result.is_valid is False
|
||||||
assert len(result.errors) == 3
|
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)."""
|
"""Tests for main deck card count validation (40 cards required)."""
|
||||||
|
|
||||||
def test_valid_card_count_passes(self, default_config):
|
def test_valid_card_count_passes(self, default_config):
|
||||||
"""Test that exactly 40 cards passes validation.
|
"""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)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
|
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
|
||||||
energy = {"lightning": 20}
|
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)
|
assert "must have exactly 40 cards" not in str(result.errors)
|
||||||
|
|
||||||
def test_39_cards_fails(self, default_config):
|
def test_39_cards_fails(self, default_config):
|
||||||
"""Test that 39 cards fails validation.
|
"""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)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(9)} # 36 cards
|
cards = {f"card-{i:03d}": 4 for i in range(9)} # 36 cards
|
||||||
cards["card-extra"] = 3 # 39 total
|
cards["card-extra"] = 3 # 39 total
|
||||||
energy = {"lightning": 20}
|
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 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):
|
def test_41_cards_fails(self, default_config):
|
||||||
"""Test that 41 cards fails validation.
|
"""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)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
|
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
|
||||||
cards["card-extra"] = 1 # 41 total
|
cards["card-extra"] = 1 # 41 total
|
||||||
energy = {"lightning": 20}
|
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 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):
|
def test_empty_deck_fails(self, default_config):
|
||||||
"""Test that an empty deck fails validation.
|
"""Test that an empty deck fails validation."""
|
||||||
|
result = validate_deck({}, {"lightning": 20}, default_config, basic_pokemon_lookup)
|
||||||
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})
|
|
||||||
|
|
||||||
assert result.is_valid is False
|
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)."""
|
"""Tests for energy deck card count validation (20 energy required)."""
|
||||||
|
|
||||||
def test_valid_energy_count_passes(self, default_config):
|
def test_valid_energy_count_passes(self, default_config):
|
||||||
"""Test that exactly 20 energy cards passes validation.
|
"""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)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
||||||
energy = {"lightning": 14, "colorless": 6} # 20 total
|
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)
|
assert "Energy deck must have exactly 20" not in str(result.errors)
|
||||||
|
|
||||||
def test_19_energy_fails(self, default_config):
|
def test_19_energy_fails(self, default_config):
|
||||||
"""Test that 19 energy cards fails validation.
|
"""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)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
||||||
energy = {"lightning": 19}
|
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 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):
|
def test_21_energy_fails(self, default_config):
|
||||||
"""Test that 21 energy cards fails validation.
|
"""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)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
||||||
energy = {"lightning": 21}
|
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 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)
|
assert any("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)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -334,67 +218,36 @@ class TestMaxCopiesValidation:
|
|||||||
"""Tests for maximum copies per card validation (4 max)."""
|
"""Tests for maximum copies per card validation (4 max)."""
|
||||||
|
|
||||||
def test_4_copies_allowed(self, default_config):
|
def test_4_copies_allowed(self, default_config):
|
||||||
"""Test that 4 copies of a card is allowed.
|
"""Test that 4 copies of a card is allowed."""
|
||||||
|
cards = {f"card-{i:03d}": 4 for i in range(10)} # 4 copies each
|
||||||
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
|
|
||||||
energy = {"lightning": 20}
|
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)
|
assert "max allowed is 4" not in str(result.errors)
|
||||||
|
|
||||||
def test_5_copies_fails(self, default_config):
|
def test_5_copies_fails(self, default_config):
|
||||||
"""Test that 5 copies of a card fails validation.
|
"""Test that 5 copies of a card fails validation."""
|
||||||
|
cards = {"over-limit": 5}
|
||||||
One copy over the maximum should produce an error identifying the card.
|
# Pad to 40 cards
|
||||||
"""
|
|
||||||
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)
|
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
cards[f"filler-{i:03d}"] = 5
|
cards[f"card-{i:03d}"] = 5
|
||||||
energy = {"lightning": 20}
|
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 result.is_valid is False
|
||||||
assert any(
|
assert any("over-limit" in e and "5 copies" in e for e in result.errors)
|
||||||
"test-001-pikachu" in e and "5 copies" in e and "max allowed is 4" in e
|
|
||||||
for e in result.errors
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_multiple_cards_over_limit(self, default_config):
|
def test_multiple_cards_over_limit(self, default_config):
|
||||||
"""Test that multiple cards over limit all get reported.
|
"""Test that multiple cards over limit all get reported."""
|
||||||
|
cards = {"card-a": 5, "card-b": 6, "card-c": 4} # a and b over
|
||||||
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
|
|
||||||
cards["filler"] = 25
|
cards["filler"] = 25
|
||||||
energy = {"lightning": 20}
|
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 result.is_valid is False
|
||||||
# Both card-a and card-b should be reported
|
|
||||||
error_str = str(result.errors)
|
error_str = str(result.errors)
|
||||||
assert "card-a" in error_str
|
assert "card-a" in error_str
|
||||||
assert "card-b" in error_str
|
assert "card-b" in error_str
|
||||||
@ -409,63 +262,30 @@ class TestBasicPokemonRequirement:
|
|||||||
"""Tests for minimum Basic Pokemon requirement (at least 1)."""
|
"""Tests for minimum Basic Pokemon requirement (at least 1)."""
|
||||||
|
|
||||||
def test_deck_with_basic_pokemon_passes(self, default_config):
|
def test_deck_with_basic_pokemon_passes(self, default_config):
|
||||||
"""Test that a deck with Basic Pokemon passes validation.
|
"""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)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
||||||
energy = {"lightning": 20}
|
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)
|
assert "at least 1 Basic Pokemon" not in str(result.errors)
|
||||||
|
|
||||||
def test_deck_without_basic_pokemon_fails(self, default_config):
|
def test_deck_without_basic_pokemon_fails(self, default_config):
|
||||||
"""Test that a deck without Basic Pokemon fails validation.
|
"""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)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
||||||
energy = {"lightning": 20}
|
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 result.is_valid is False
|
||||||
assert any("at least 1 Basic Pokemon" in e for e in result.errors)
|
assert any("at least 1 Basic Pokemon" in e for e in result.errors)
|
||||||
|
|
||||||
def test_deck_with_only_trainers_fails(self, default_config):
|
def test_deck_with_only_trainers_fails(self, default_config):
|
||||||
"""Test that a deck with only Trainers fails Basic Pokemon check.
|
"""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)
|
|
||||||
cards = {f"trainer-{i:03d}": 4 for i in range(10)}
|
cards = {f"trainer-{i:03d}": 4 for i in range(10)}
|
||||||
energy = {"lightning": 20}
|
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 result.is_valid is False
|
||||||
assert any("at least 1 Basic Pokemon" in e for e in result.errors)
|
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."""
|
"""Tests for card ID existence validation."""
|
||||||
|
|
||||||
def test_valid_card_ids_pass(self, default_config):
|
def test_valid_card_ids_pass(self, default_config):
|
||||||
"""Test that valid card IDs pass validation.
|
"""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)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
||||||
energy = {"lightning": 20}
|
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)
|
assert "Invalid card IDs" not in str(result.errors)
|
||||||
|
|
||||||
def test_invalid_card_id_fails(self, default_config):
|
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.
|
def partial_lookup(card_id: str) -> CardDefinition | None:
|
||||||
"""
|
if card_id == "bad-card":
|
||||||
# Create a mock that returns None for specific cards
|
|
||||||
mock_service = MagicMock()
|
|
||||||
|
|
||||||
def mock_get_card(cid):
|
|
||||||
if cid == "nonexistent-card":
|
|
||||||
return None
|
return None
|
||||||
return CardDefinition(
|
return make_basic_pokemon(card_id)
|
||||||
id=cid,
|
|
||||||
name=f"Card {cid}",
|
|
||||||
card_type=CardType.POKEMON,
|
|
||||||
hp=60,
|
|
||||||
pokemon_type=EnergyType.LIGHTNING,
|
|
||||||
stage=PokemonStage.BASIC,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_service.get_card = mock_get_card
|
cards = {"good-card": 4, "bad-card": 4}
|
||||||
|
|
||||||
validator = DeckValidator(default_config, mock_service)
|
|
||||||
cards = {
|
|
||||||
"valid-card": 4,
|
|
||||||
"nonexistent-card": 4,
|
|
||||||
}
|
|
||||||
# Pad to 40
|
|
||||||
for i in range(8):
|
for i in range(8):
|
||||||
cards[f"card-{i:03d}"] = 4
|
cards[f"card-{i:03d}"] = 4
|
||||||
energy = {"lightning": 20}
|
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 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):
|
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).
|
def partial_lookup(card_id: str) -> CardDefinition | None:
|
||||||
"""
|
if card_id.startswith("bad"):
|
||||||
# Create a mock that returns None for "bad-*" cards
|
|
||||||
mock_service = MagicMock()
|
|
||||||
|
|
||||||
def mock_get_card(cid):
|
|
||||||
if cid.startswith("bad"):
|
|
||||||
return None
|
return None
|
||||||
return CardDefinition(
|
return make_basic_pokemon(card_id)
|
||||||
id=cid,
|
|
||||||
name=f"Card {cid}",
|
|
||||||
card_type=CardType.POKEMON,
|
|
||||||
hp=60,
|
|
||||||
pokemon_type=EnergyType.LIGHTNING,
|
|
||||||
stage=PokemonStage.BASIC,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_service.get_card = mock_get_card
|
cards = {"bad-1": 4, "bad-2": 4, "bad-3": 4, "good": 28}
|
||||||
|
|
||||||
validator = DeckValidator(default_config, mock_service)
|
|
||||||
cards = {
|
|
||||||
"bad-1": 4,
|
|
||||||
"bad-2": 4,
|
|
||||||
"bad-3": 4,
|
|
||||||
"good": 28,
|
|
||||||
}
|
|
||||||
energy = {"lightning": 20}
|
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 result.is_valid is False
|
||||||
error_str = str(result.errors)
|
error_str = str(result.errors)
|
||||||
@ -576,77 +352,55 @@ class TestCardIdValidation:
|
|||||||
|
|
||||||
|
|
||||||
class TestOwnershipValidation:
|
class TestOwnershipValidation:
|
||||||
"""Tests for card ownership validation (campaign mode)."""
|
"""Tests for card ownership validation."""
|
||||||
|
|
||||||
def test_owned_cards_pass(self, default_config):
|
def test_owned_cards_pass(self, default_config):
|
||||||
"""Test that deck passes when user owns all cards.
|
"""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)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
||||||
energy = {"lightning": 20}
|
energy = {"lightning": 20}
|
||||||
# User owns 10 copies of each card
|
|
||||||
owned = {f"card-{i:03d}": 10 for i in range(10)}
|
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)
|
assert "Insufficient cards" not in str(result.errors)
|
||||||
|
|
||||||
def test_insufficient_ownership_fails(self, default_config):
|
def test_insufficient_ownership_fails(self, default_config):
|
||||||
"""Test that deck fails when user doesn't own enough copies.
|
"""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)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
||||||
energy = {"lightning": 20}
|
energy = {"lightning": 20}
|
||||||
# User owns only 2 copies of first card
|
|
||||||
owned = {f"card-{i:03d}": 10 for i in range(10)}
|
owned = {f"card-{i:03d}": 10 for i in range(10)}
|
||||||
owned["card-000"] = 2 # Need 4, only have 2
|
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 result.is_valid is False
|
||||||
assert any(
|
assert any("card-000" in e and "need 4" in e and "own 2" in e for e in result.errors)
|
||||||
"Insufficient cards" in e and "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):
|
def test_unowned_card_fails(self, default_config):
|
||||||
"""Test that deck fails when user doesn't own a card at all.
|
"""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)
|
|
||||||
cards = {"owned-card": 20, "unowned-card": 20}
|
cards = {"owned-card": 20, "unowned-card": 20}
|
||||||
energy = {"lightning": 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)
|
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 "unowned-card" in e and "own 0" in e
|
|
||||||
for e in result.errors
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_freeplay_skips_ownership(self, default_config):
|
assert result.is_valid is False
|
||||||
"""Test that passing None for owned_cards skips ownership validation.
|
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
|
def test_none_owned_cards_skips_ownership_check(self, default_config):
|
||||||
their actual collection.
|
"""Test that passing None for owned_cards skips ownership validation."""
|
||||||
"""
|
|
||||||
mock_service = create_basic_pokemon_mock()
|
|
||||||
validator = DeckValidator(default_config, mock_service)
|
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
||||||
energy = {"lightning": 20}
|
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)
|
assert "Insufficient cards" not in str(result.errors)
|
||||||
|
|
||||||
@ -660,35 +414,15 @@ class TestMultipleErrors:
|
|||||||
"""Tests for returning all errors at once."""
|
"""Tests for returning all errors at once."""
|
||||||
|
|
||||||
def test_multiple_errors_returned_together(self, default_config):
|
def test_multiple_errors_returned_together(self, default_config):
|
||||||
"""Test that multiple validation errors are all returned.
|
"""Test that multiple validation errors are all returned."""
|
||||||
|
cards = {"bad-card": 5} # Invalid ID + over copy limit + wrong count
|
||||||
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)
|
|
||||||
energy = {"lightning": 10} # Only 10 (not 20)
|
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
|
assert result.is_valid is False
|
||||||
# Should have multiple errors
|
|
||||||
assert len(result.errors) >= 3
|
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."""
|
"""Tests for using custom DeckConfig values."""
|
||||||
|
|
||||||
def test_custom_deck_size(self):
|
def test_custom_deck_size(self):
|
||||||
"""Test that custom deck size is respected.
|
"""Test that custom deck size is respected."""
|
||||||
|
|
||||||
Different game modes might use different deck sizes (e.g., 60-card).
|
|
||||||
"""
|
|
||||||
custom_config = DeckConfig(min_size=60, max_size=60)
|
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
|
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
|
||||||
energy = {"lightning": 20}
|
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 result.is_valid is False
|
||||||
assert any("must have exactly 60 cards" in e for e in result.errors)
|
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):
|
def test_custom_energy_size(self):
|
||||||
"""Test that custom energy deck size is respected."""
|
"""Test that custom energy deck size is respected."""
|
||||||
custom_config = DeckConfig(energy_deck_size=30)
|
custom_config = DeckConfig(energy_deck_size=30)
|
||||||
mock_service = create_basic_pokemon_mock()
|
cards = {f"card-{i:03d}": 4 for i in range(10)}
|
||||||
validator = DeckValidator(custom_config, mock_service)
|
energy = {"lightning": 20}
|
||||||
cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards
|
|
||||||
energy = {"lightning": 20} # 20, but need 30
|
|
||||||
|
|
||||||
result = validator.validate_deck(cards, energy)
|
result = validate_deck(cards, energy, custom_config, basic_pokemon_lookup)
|
||||||
|
|
||||||
assert result.is_valid is False
|
assert result.is_valid is False
|
||||||
assert any("must have exactly 30" in e for e in result.errors)
|
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):
|
def test_custom_max_copies(self):
|
||||||
"""Test that custom max copies per card is respected."""
|
"""Test that custom max copies per card is respected."""
|
||||||
custom_config = DeckConfig(max_copies_per_card=2)
|
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
|
cards = {f"card-{i:03d}": 4 for i in range(10)} # 4 copies each
|
||||||
energy = {"lightning": 20}
|
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 result.is_valid is False
|
||||||
assert any("max allowed is 2" in e for e in result.errors)
|
assert any("max allowed is 2" in e for e in result.errors)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Utility Method Tests
|
# Utility Function Tests
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class TestUtilityMethods:
|
class TestUtilityFunctions:
|
||||||
"""Tests for utility methods on DeckValidator."""
|
"""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."""
|
"""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"]
|
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 == []
|
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."""
|
"""Test validate_cards_exist returns invalid IDs."""
|
||||||
mock_service = MagicMock()
|
|
||||||
|
|
||||||
def mock_get(cid):
|
def partial_lookup(card_id: str) -> CardDefinition | None:
|
||||||
if cid.startswith("bad"):
|
if card_id.startswith("bad"):
|
||||||
return None
|
return None
|
||||||
return CardDefinition(
|
return make_basic_pokemon(card_id)
|
||||||
id=cid,
|
|
||||||
name=f"Card {cid}",
|
|
||||||
card_type=CardType.POKEMON,
|
|
||||||
hp=60,
|
|
||||||
pokemon_type=EnergyType.LIGHTNING,
|
|
||||||
stage=PokemonStage.BASIC,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_service.get_card = mock_get
|
|
||||||
|
|
||||||
validator = DeckValidator(default_config, mock_service)
|
|
||||||
card_ids = ["good-1", "bad-1", "good-2", "bad-2"]
|
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"}
|
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."""
|
"""Test count_basic_pokemon returns correct count."""
|
||||||
mock_service = MagicMock()
|
|
||||||
|
|
||||||
def mock_get(cid):
|
def mixed_lookup(card_id: str) -> CardDefinition | None:
|
||||||
if cid.startswith("basic"):
|
if card_id.startswith("basic"):
|
||||||
return CardDefinition(
|
return make_basic_pokemon(card_id)
|
||||||
id=cid,
|
elif card_id.startswith("stage1"):
|
||||||
name=f"Card {cid}",
|
return make_stage1_pokemon(card_id)
|
||||||
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",
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
return CardDefinition(
|
return make_trainer(card_id)
|
||||||
id=cid,
|
|
||||||
name=f"Trainer {cid}",
|
|
||||||
card_type=CardType.TRAINER,
|
|
||||||
trainer_type="item",
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
count = count_basic_pokemon(cards, mixed_lookup)
|
||||||
cards = {
|
|
||||||
"basic-1": 4,
|
|
||||||
"basic-2": 3,
|
|
||||||
"stage1-1": 4,
|
|
||||||
"trainer-1": 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
count = validator.count_basic_pokemon(cards)
|
assert count == 7 # basic-1: 4 + basic-2: 3
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user