From 3ec670753b77da7c037c42e1cc21f17194d5b1d2 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 28 Jan 2026 14:16:07 -0600 Subject: [PATCH] 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 --- backend/CLAUDE.md | 143 +++- backend/app/api/collections.py | 136 +++ backend/app/api/decks.py | 288 +++++++ backend/app/api/deps.py | 163 +++- backend/app/api/users.py | 100 ++- backend/app/config.py | 6 + ...1_add_unique_partial_index_for_starter_.py | 42 + backend/app/db/models/deck.py | 13 +- backend/app/main.py | 7 +- backend/app/schemas/deck.py | 146 +++- backend/app/services/collection_service.py | 4 +- backend/app/services/deck_service.py | 162 +++- backend/app/services/deck_validator.py | 305 +++---- backend/tests/api/test_collections_api.py | 359 ++++++++ backend/tests/api/test_decks_api.py | 617 ++++++++++++++ backend/tests/api/test_starter_deck_api.py | 270 ++++++ .../tests/services/test_collection_service.py | 465 ++++++++++ backend/tests/services/test_deck_service.py | 803 ++++++++++++++++++ .../unit/services/test_deck_validator.py | 670 ++++----------- 19 files changed, 3938 insertions(+), 761 deletions(-) create mode 100644 backend/app/api/collections.py create mode 100644 backend/app/api/decks.py create mode 100644 backend/app/db/migrations/versions/9ea744a4f451_add_unique_partial_index_for_starter_.py create mode 100644 backend/tests/api/test_collections_api.py create mode 100644 backend/tests/api/test_decks_api.py create mode 100644 backend/tests/api/test_starter_deck_api.py create mode 100644 backend/tests/services/test_collection_service.py create mode 100644 backend/tests/services/test_deck_service.py diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index d25f690..c00365f 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -33,34 +33,80 @@ from sqlalchemy.ext.asyncio import AsyncSession # NO - DB dependency --- -## Dependency Injection Pattern +## Stateless Backend - Config from Request -**All services must use constructor-based dependency injection.** No service locator patterns. +**The backend is stateless. Rules and configuration come from the request, provided by the frontend.** -### Required Pattern +This is a key architectural principle: the frontend knows what context the user is in (campaign mode, freeplay mode, custom rules) and tells the API what rules to apply. + +### Why Frontend Provides Config + +1. **Simplicity**: No complex server-side config management +2. **Flexibility**: Same backend supports multiple game modes +3. **Testability**: Pure functions with explicit inputs +4. **Offline Fork**: No server-side config to replicate + +### Pattern: Pure Functions with Config Parameter ```python -class DeckValidator: - """Dependencies injected via constructor.""" - - def __init__(self, config: DeckConfig, card_service: CardService) -> None: - self._config = config - self._card_service = card_service +# CORRECT - Config comes from caller +def validate_deck( + cards: dict[str, int], + energy_cards: dict[str, int], + deck_config: DeckConfig, # Provided by frontend + card_lookup: Callable[[str], CardDefinition | None], + owned_cards: dict[str, int] | None = None, +) -> ValidationResult: + """Pure function - all inputs from caller.""" + ... ``` ### Forbidden Patterns ```python -# WRONG - Service locator pattern +# WRONG - Service with baked-in config class DeckValidator: - def validate(self, cards): - service = get_card_service() # Hidden dependency! - ... + def __init__(self, config: DeckConfig): # Config at construction + self._config = config -# WRONG - Default instantiation hides dependency -class DeckValidator: - def __init__(self, config: DeckConfig | None = None): - self.config = config or DeckConfig() # Hidden creation! +# WRONG - Default config hides dependency +def validate_deck(cards, config: DeckConfig | None = None): + config = config or DeckConfig() # Hidden creation! +``` + +--- + +## Dependency Injection for Services + +**Services use constructor-based dependency injection for repositories and other services.** + +Services still use DI for data access, but config comes from method parameters (request). + +### Required Pattern + +```python +class DeckService: + """Dependencies injected via constructor. Config from method params.""" + + def __init__( + self, + deck_repository: DeckRepository, + card_service: CardService, + collection_repository: CollectionRepository | None = None, + ) -> None: + self._deck_repo = deck_repository + self._card_service = card_service + self._collection_repo = collection_repository + + async def create_deck( + self, + user_id: UUID, + cards: dict[str, int], + energy_cards: dict[str, int], + deck_config: DeckConfig, # Config from request! + max_decks: int, + ) -> DeckEntry: + ... ``` ### Why This Matters @@ -68,7 +114,7 @@ class DeckValidator: 1. **Testability**: Dependencies can be mocked without patching globals 2. **Offline Fork**: Services can be swapped for local implementations 3. **Explicit Dependencies**: Constructor shows all requirements -4. **Composition Root**: All wiring happens at application startup, not scattered +4. **Stateless Operations**: Config comes from request, not server state --- @@ -104,41 +150,43 @@ class CollectionService: --- -## Configuration Injection +## Configuration Classes -Game rules come from `app/core/config.py` classes. These must be injected, not instantiated internally. +Game rules are defined in `app/core/config.py` as Pydantic models. These are passed from the frontend as request parameters. -### Pattern +### Available Config Classes ```python -# Inject config -class DeckValidator: - def __init__(self, config: DeckConfig, card_service: CardService): - self._config = config +from app.core.config import DeckConfig, RulesConfig - def validate(self, cards): - if len(cards) != self._config.min_size: # Use injected config - ... +# DeckConfig - deck building rules +class DeckConfig(BaseModel): + min_size: int = 40 + max_size: int = 40 + max_copies_per_card: int = 4 + min_basic_pokemon: int = 1 + energy_deck_size: int = 20 -# For validation functions that need config -def validate_starter_decks(config: DeckSizeConfig) -> dict[str, list[str]]: - """Config is required parameter, not created internally.""" - ... +# Frontend can customize for different modes +freeplay_config = DeckConfig(max_copies_per_card=10) # Relaxed rules +campaign_config = DeckConfig() # Standard rules ``` -### Protocol for Minimal Interface - -When a function only needs specific config values, use a Protocol: +### API Endpoints Accept Config ```python -class DeckSizeConfig(Protocol): - """Minimal interface for deck size validation.""" - min_size: int - energy_deck_size: int - -def validate_starter_decks(config: DeckSizeConfig) -> dict[str, list[str]]: - # Any object with min_size and energy_deck_size works - ... +@router.post("/decks") +async def create_deck( + deck_in: DeckCreate, # Contains deck_config from frontend + current_user: User = Depends(get_current_user), + deck_service: DeckService = Depends(get_deck_service), +): + return await deck_service.create_deck( + user_id=current_user.id, + cards=deck_in.cards, + deck_config=deck_in.deck_config, # From request + ... + ) ``` --- @@ -207,9 +255,9 @@ app/ ### Creating a New Service -1. Define constructor with all dependencies as parameters +1. Define constructor with repository and service dependencies 2. Use repository protocols for data access -3. Inject CardService/DeckConfig rather than using `get_*` functions +3. Accept config (DeckConfig, RulesConfig) as **method parameters**, not constructor params 4. Keep business logic separate from data access ### Creating a Repository @@ -232,9 +280,10 @@ app/ | Mistake | Correct Approach | |---------|------------------| | `get_card_service()` in method body | Inject `CardService` via constructor | -| `config or DeckConfig()` default | Make config required parameter | +| `config or DeckConfig()` default | Make config required method parameter | +| Baking config into service constructor | Accept config as method parameter (from request) | | Importing from `app.services` in `app.core` | Core must remain standalone | -| Hardcoded magic numbers | Use `DeckConfig` values | +| Hardcoded magic numbers | Use values from config parameter | | Tests without docstrings | Always explain what and why | | Unit tests in `tests/services/` | Use `tests/unit/` for no-DB tests | diff --git a/backend/app/api/collections.py b/backend/app/api/collections.py new file mode 100644 index 0000000..6215b9a --- /dev/null +++ b/backend/app/api/collections.py @@ -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, + ) diff --git a/backend/app/api/decks.py b/backend/app/api/decks.py new file mode 100644 index 0000000..57d7e2e --- /dev/null +++ b/backend/app/api/decks.py @@ -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, + ) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 743afae..4d9992e 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,33 +1,43 @@ """FastAPI dependencies for Mantimon TCG API. -This module provides dependency injection functions for authentication -and database access in API endpoints. +This module provides dependency injection functions for authentication, +database access, and service layer access in API endpoints. Usage: - from app.api.deps import get_current_user, get_db + from app.api.deps import CurrentUser, DbSession, DeckServiceDep - @router.get("/me") - async def get_me( - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), + @router.get("/decks") + async def get_decks( + user: CurrentUser, + db: DbSession, + deck_service: DeckServiceDep, ): - return user + return await deck_service.get_user_decks(user.id) Dependencies: - get_db: Async database session - get_current_user: Authenticated user from JWT (required) - get_optional_user: Authenticated user or None - get_current_premium_user: User with active premium + - verify_admin_token: Admin API key validation + - get_deck_service: DeckService with repositories + - get_collection_service: CollectionService with repository """ from typing import Annotated from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer +from fastapi.security import APIKeyHeader, OAuth2PasswordBearer from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.db import get_session from app.db.models import User +from app.repositories.postgres.collection import PostgresCollectionRepository +from app.repositories.postgres.deck import PostgresDeckRepository +from app.services.card_service import CardService, get_card_service +from app.services.collection_service import CollectionService +from app.services.deck_service import DeckService from app.services.jwt_service import verify_access_token from app.services.user_service import user_service @@ -43,6 +53,49 @@ oauth2_scheme_optional = OAuth2PasswordBearer( auto_error=False, # Return None if no token ) +# Admin API key header for admin-only endpoints +admin_api_key_header = APIKeyHeader( + name="X-Admin-API-Key", + auto_error=True, + description="Admin API key for privileged operations", +) + + +async def verify_admin_token( + api_key: Annotated[str, Depends(admin_api_key_header)], +) -> None: + """Verify the admin API key. + + Validates the X-Admin-API-Key header against the configured admin key. + Admin key must be configured via ADMIN_API_KEY environment variable. + + Args: + api_key: API key from X-Admin-API-Key header. + + Raises: + HTTPException: 403 if admin key is not configured or doesn't match. + + Example: + @router.post("/admin/grant-cards") + async def grant_cards( + _: None = Depends(verify_admin_token), + ): + # Only accessible with valid admin key + ... + """ + configured_key = settings.admin_api_key + if configured_key is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin API key not configured", + ) + + if api_key != configured_key.get_secret_value(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid admin API key", + ) + async def get_db() -> AsyncSession: """Get async database session. @@ -165,8 +218,98 @@ async def get_current_premium_user( return user -# Type aliases for cleaner endpoint signatures +# ============================================================================= +# Service Dependencies +# ============================================================================= + + +def get_collection_service( + db: Annotated[AsyncSession, Depends(get_db)], +) -> CollectionService: + """Get CollectionService with PostgreSQL repository. + + Creates a CollectionService instance with the current database session. + + Args: + db: Database session from request. + + Returns: + CollectionService configured for PostgreSQL. + + Example: + @router.get("/collections/me") + async def get_collection( + service: CollectionService = Depends(get_collection_service), + ): + ... + """ + repo = PostgresCollectionRepository(db) + card_service = get_card_service() + return CollectionService(repo, card_service) + + +def get_deck_service( + db: Annotated[AsyncSession, Depends(get_db)], +) -> DeckService: + """Get DeckService with PostgreSQL repositories. + + Creates a DeckService instance with deck and collection repositories. + + Args: + db: Database session from request. + + Returns: + DeckService configured for PostgreSQL. + + Example: + @router.post("/decks") + async def create_deck( + service: DeckService = Depends(get_deck_service), + ): + ... + """ + deck_repo = PostgresDeckRepository(db) + collection_repo = PostgresCollectionRepository(db) + card_service = get_card_service() + return DeckService(deck_repo, card_service, collection_repo) + + +def get_card_service_dep() -> CardService: + """Get the CardService singleton. + + CardService is a singleton that loads card definitions from JSON files. + This dependency provides consistent access for endpoints that need + direct card lookup functionality. + + Returns: + The CardService singleton instance. + + Example: + @router.post("/validate") + async def validate( + card_service: CardService = Depends(get_card_service_dep), + ): + card = card_service.get_card("a1-001-bulbasaur") + """ + return get_card_service() + + +# ============================================================================= +# Type Aliases for Cleaner Endpoint Signatures +# ============================================================================= + +# User dependencies CurrentUser = Annotated[User, Depends(get_current_user)] OptionalUser = Annotated[User | None, Depends(get_optional_user)] PremiumUser = Annotated[User, Depends(get_current_premium_user)] + +# Database session DbSession = Annotated[AsyncSession, Depends(get_db)] + +# Service dependencies +DeckServiceDep = Annotated[DeckService, Depends(get_deck_service)] +CollectionServiceDep = Annotated[CollectionService, Depends(get_collection_service)] +CardServiceDep = Annotated[CardService, Depends(get_card_service_dep)] + +# Admin authentication +AdminAuth = Annotated[None, Depends(verify_admin_token)] diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 3a0e182..ed371f5 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -5,6 +5,7 @@ This module provides endpoints for user profile management: - Update profile (display name, avatar) - List linked OAuth accounts - Session management +- Starter deck selection (one-time for new players) All endpoints require authentication. @@ -16,13 +17,19 @@ Example: # Update profile PATCH /api/users/me {"display_name": "NewName"} + + # Select starter deck + POST /api/users/me/starter-deck + {"starter_type": "grass"} """ from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field -from app.api.deps import CurrentUser, DbSession +from app.api.deps import CurrentUser, DbSession, DeckServiceDep +from app.schemas.deck import DeckResponse, StarterDeckSelectRequest, StarterStatusResponse from app.schemas.user import UserResponse, UserUpdate +from app.services.deck_service import DeckLimitExceededError, StarterAlreadySelectedError from app.services.token_store import token_store from app.services.user_service import AccountLinkingError, user_service @@ -166,3 +173,94 @@ async def unlink_oauth_account( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) from None + + +# ============================================================================= +# Starter Deck Endpoints +# ============================================================================= + + +@router.get("/me/starter-status", response_model=StarterStatusResponse) +async def get_starter_status( + user: CurrentUser, + deck_service: DeckServiceDep, +) -> StarterStatusResponse: + """Check if user has already selected a starter deck. + + New users need to select a starter deck before playing. + This endpoint checks if they've already made that selection. + + Returns: + StarterStatusResponse with has_starter flag and starter_type. + """ + has_starter, starter_type = await deck_service.has_starter_deck(user.id) + + return StarterStatusResponse( + has_starter=has_starter, + starter_type=starter_type, + ) + + +@router.post("/me/starter-deck", response_model=DeckResponse, status_code=status.HTTP_201_CREATED) +async def select_starter_deck( + request: StarterDeckSelectRequest, + user: CurrentUser, + deck_service: DeckServiceDep, +) -> DeckResponse: + """Select and create a starter deck for a new user. + + This is a one-time operation that atomically: + 1. Validates the starter type + 2. Creates the starter deck (protected by unique constraint) + 3. Grants all starter deck cards to the user's collection + + Race conditions are handled by a database unique constraint that prevents + multiple starter decks per user. + + Available starter types: grass, fire, water, psychic, lightning + + Args: + request: Starter deck selection with type and optional config. + + Returns: + The created starter deck. + + Raises: + 400: If starter already selected or invalid starter_type. + """ + try: + deck = await deck_service.select_and_grant_starter_deck( + user_id=user.id, + starter_type=request.starter_type, + deck_config=request.deck_config, + max_decks=user.max_decks, + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from None + except StarterAlreadySelectedError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from None + except DeckLimitExceededError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from None + + return DeckResponse( + id=deck.id, + name=deck.name, + description=deck.description, + cards=deck.cards, + energy_cards=deck.energy_cards, + is_valid=deck.is_valid, + validation_errors=deck.validation_errors, + is_starter=deck.is_starter, + starter_type=deck.starter_type, + created_at=deck.created_at, + updated_at=deck.updated_at, + ) diff --git a/backend/app/config.py b/backend/app/config.py index 1f03008..cf62f48 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -180,6 +180,12 @@ class Settings(BaseSettings): description="Path to bundled card JSON files", ) + # Admin + admin_api_key: SecretStr | None = Field( + default=None, + description="API key for admin endpoints (required in production)", + ) + @field_validator("debug", mode="before") @classmethod def set_debug_from_environment(cls, v: bool, info) -> bool: diff --git a/backend/app/db/migrations/versions/9ea744a4f451_add_unique_partial_index_for_starter_.py b/backend/app/db/migrations/versions/9ea744a4f451_add_unique_partial_index_for_starter_.py new file mode 100644 index 0000000..1a51850 --- /dev/null +++ b/backend/app/db/migrations/versions/9ea744a4f451_add_unique_partial_index_for_starter_.py @@ -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 ### diff --git a/backend/app/db/models/deck.py b/backend/app/db/models/deck.py index 72b83cf..2335443 100644 --- a/backend/app/db/models/deck.py +++ b/backend/app/db/models/deck.py @@ -16,7 +16,7 @@ Example: from typing import TYPE_CHECKING from uuid import UUID -from sqlalchemy import Boolean, ForeignKey, Index, String, Text +from sqlalchemy import Boolean, ForeignKey, Index, String, Text, text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -129,7 +129,16 @@ class Deck(Base): ) # Indexes - __table_args__ = (Index("ix_decks_user_name", "user_id", "name"),) + __table_args__ = ( + Index("ix_decks_user_name", "user_id", "name"), + # Partial unique index: only one starter deck per user + Index( + "ix_decks_user_starter_unique", + "user_id", + unique=True, + postgresql_where=text("is_starter = true"), + ), + ) @property def total_cards(self) -> int: diff --git a/backend/app/main.py b/backend/app/main.py index 0255b76..529e250 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,8 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.auth import router as auth_router +from app.api.collections import router as collections_router +from app.api.decks import router as decks_router from app.api.users import router as users_router from app.config import settings from app.db import close_db, init_db @@ -163,10 +165,11 @@ async def readiness_check() -> dict[str, str | int]: # === API Routers === app.include_router(auth_router, prefix="/api") app.include_router(users_router, prefix="/api") +app.include_router(collections_router, prefix="/api") +app.include_router(decks_router, prefix="/api") # TODO: Add remaining routers in future phases -# from app.api import cards, decks, games, campaign +# from app.api import cards, games, campaign # app.include_router(cards.router, prefix="/api/cards", tags=["cards"]) -# app.include_router(decks.router, prefix="/api/decks", tags=["decks"]) # app.include_router(games.router, prefix="/api/games", tags=["games"]) # app.include_router(campaign.router, prefix="/api/campaign", tags=["campaign"]) diff --git a/backend/app/schemas/deck.py b/backend/app/schemas/deck.py index ed5e918..6e42381 100644 --- a/backend/app/schemas/deck.py +++ b/backend/app/schemas/deck.py @@ -3,6 +3,9 @@ This module defines Pydantic models for deck-related API requests and responses. Decks contain card compositions for gameplay. +The backend is stateless - deck rules come from the request via DeckConfig. +Frontend provides the rules appropriate for the game mode (campaign, freeplay, custom). + Example: deck = DeckResponse( id=uuid4(), @@ -18,10 +21,63 @@ Example: ) """ +import re from datetime import datetime from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator + +from app.core.config import DeckConfig + +# Card ID format: set-number-name (e.g., a1-001-bulbasaur, a1-094-pikachu-ex) +CARD_ID_PATTERN = re.compile(r"^[a-z0-9]+-\d{3}-[a-z0-9-]+$") + + +def validate_card_quantities(cards: dict[str, int], field_name: str) -> dict[str, int]: + """Validate card quantities are within valid range. + + Args: + cards: Mapping of card IDs to quantities. + field_name: Name of the field for error messages. + + Returns: + The validated card dictionary. + + Raises: + ValueError: If any quantity is invalid. + """ + for card_id, quantity in cards.items(): + if quantity < 1: + raise ValueError( + f"{field_name}: quantity for '{card_id}' must be at least 1, got {quantity}" + ) + if quantity > 99: + raise ValueError( + f"{field_name}: quantity for '{card_id}' cannot exceed 99, got {quantity}" + ) + return cards + + +def validate_card_id_format(cards: dict[str, int], field_name: str) -> dict[str, int]: + """Validate card IDs match expected format. + + Args: + cards: Mapping of card IDs to quantities. + field_name: Name of the field for error messages. + + Returns: + The validated card dictionary. + + Raises: + ValueError: If any card ID has invalid format. + """ + for card_id in cards: + if not CARD_ID_PATTERN.match(card_id): + raise ValueError( + f"{field_name}: invalid card ID format '{card_id}'. " + f"Expected format: set-number-name (e.g., a1-001-bulbasaur)" + ) + return cards class DeckCreateRequest(BaseModel): @@ -29,24 +85,55 @@ class DeckCreateRequest(BaseModel): Attributes: name: Display name for the deck. - cards: Mapping of card IDs to quantities (40 cards total). - energy_cards: Mapping of energy types to quantities (20 total). + cards: Mapping of card IDs to quantities (40 cards total by default). + energy_cards: Mapping of energy types to quantities (20 total by default). + deck_config: Deck rules from the frontend (defaults to standard rules). + validate_ownership: If True, validates user owns all cards (campaign mode). + description: Optional deck description. """ name: str = Field(..., min_length=1, max_length=100, description="Deck name") cards: dict[str, int] = Field(..., description="Card ID to quantity mapping") energy_cards: dict[str, int] = Field(..., description="Energy type to quantity mapping") + deck_config: DeckConfig = Field( + default_factory=DeckConfig, description="Deck validation rules from frontend" + ) + validate_ownership: bool = Field( + default=True, description="Validate card ownership (False for freeplay)" + ) + description: str | None = Field( + default=None, max_length=500, description="Optional deck description" + ) + + @field_validator("cards") + @classmethod + def validate_cards(cls, v: dict[str, int]) -> dict[str, int]: + """Validate card IDs and quantities.""" + validate_card_id_format(v, "cards") + validate_card_quantities(v, "cards") + return v + + @field_validator("energy_cards") + @classmethod + def validate_energy_cards(cls, v: dict[str, int]) -> dict[str, int]: + """Validate energy card quantities.""" + validate_card_quantities(v, "energy_cards") + return v class DeckUpdateRequest(BaseModel): """Request model for updating a deck. All fields are optional - only provided fields are updated. + If cards or energy_cards change, the deck is re-validated with deck_config. Attributes: name: New display name for the deck. cards: New card composition. energy_cards: New energy composition. + deck_config: Deck rules from the frontend (defaults to standard rules). + validate_ownership: If True, validates user owns all cards (campaign mode). + description: New deck description. """ name: str | None = Field( @@ -54,6 +141,30 @@ class DeckUpdateRequest(BaseModel): ) cards: dict[str, int] | None = Field(default=None, description="New card composition") energy_cards: dict[str, int] | None = Field(default=None, description="New energy composition") + deck_config: DeckConfig = Field( + default_factory=DeckConfig, description="Deck validation rules from frontend" + ) + validate_ownership: bool = Field( + default=True, description="Validate card ownership (False for freeplay)" + ) + description: str | None = Field(default=None, max_length=500, description="New description") + + @field_validator("cards") + @classmethod + def validate_cards(cls, v: dict[str, int] | None) -> dict[str, int] | None: + """Validate card IDs and quantities if provided.""" + if v is not None: + validate_card_id_format(v, "cards") + validate_card_quantities(v, "cards") + return v + + @field_validator("energy_cards") + @classmethod + def validate_energy_cards(cls, v: dict[str, int] | None) -> dict[str, int] | None: + """Validate energy card quantities if provided.""" + if v is not None: + validate_card_quantities(v, "energy_cards") + return v class DeckResponse(BaseModel): @@ -64,6 +175,7 @@ class DeckResponse(BaseModel): Attributes: id: Unique deck identifier. name: Display name of the deck. + description: Optional deck description or notes. cards: Mapping of card IDs to quantities. energy_cards: Mapping of energy types to quantities. is_valid: Whether deck passes all validation rules. @@ -76,6 +188,7 @@ class DeckResponse(BaseModel): id: UUID = Field(..., description="Deck ID") name: str = Field(..., description="Deck name") + description: str | None = Field(default=None, description="Deck description") cards: dict[str, int] = Field(..., description="Card ID to quantity mapping") energy_cards: dict[str, int] = Field(..., description="Energy type to quantity mapping") is_valid: bool = Field(..., description="Whether deck is valid") @@ -112,10 +225,33 @@ class DeckValidateRequest(BaseModel): Attributes: cards: Card ID to quantity mapping to validate. energy_cards: Energy type to quantity mapping to validate. + deck_config: Deck rules from the frontend (defaults to standard rules). + validate_ownership: If True, validates user owns all cards (campaign mode). """ cards: dict[str, int] = Field(..., description="Card ID to quantity mapping") energy_cards: dict[str, int] = Field(..., description="Energy type to quantity mapping") + deck_config: DeckConfig = Field( + default_factory=DeckConfig, description="Deck validation rules from frontend" + ) + validate_ownership: bool = Field( + default=True, description="Validate card ownership (False for freeplay)" + ) + + @field_validator("cards") + @classmethod + def validate_cards(cls, v: dict[str, int]) -> dict[str, int]: + """Validate card IDs and quantities.""" + validate_card_id_format(v, "cards") + validate_card_quantities(v, "cards") + return v + + @field_validator("energy_cards") + @classmethod + def validate_energy_cards(cls, v: dict[str, int]) -> dict[str, int]: + """Validate energy card quantities.""" + validate_card_quantities(v, "energy_cards") + return v class DeckValidationResponse(BaseModel): @@ -135,12 +271,16 @@ class StarterDeckSelectRequest(BaseModel): Attributes: starter_type: Type of starter deck to select. + deck_config: Deck rules from the frontend (defaults to standard rules). """ starter_type: str = Field( ..., description="Starter deck type (grass, fire, water, psychic, lightning)", ) + deck_config: DeckConfig = Field( + default_factory=DeckConfig, description="Deck validation rules from frontend" + ) class StarterStatusResponse(BaseModel): diff --git a/backend/app/services/collection_service.py b/backend/app/services/collection_service.py index 6562c5e..50fbb7d 100644 --- a/backend/app/services/collection_service.py +++ b/backend/app/services/collection_service.py @@ -196,8 +196,8 @@ class CollectionService: Example: owned = await service.get_owned_cards_dict(user_id) - # Pass to DeckValidator - result = validator.validate_deck(cards, energy, owned_cards=owned) + # Pass to validate_deck function + result = validate_deck(cards, energy, deck_config, card_lookup, owned_cards=owned) """ collection = await self._repo.get_all(user_id) return {entry.card_definition_id: entry.quantity for entry in collection} diff --git a/backend/app/services/deck_service.py b/backend/app/services/deck_service.py index 2807efb..08790b8 100644 --- a/backend/app/services/deck_service.py +++ b/backend/app/services/deck_service.py @@ -1,45 +1,49 @@ """Deck service for Mantimon TCG. This module provides business logic for deck management. It uses -the DeckRepository protocol for data access and DeckValidator for -validation logic. +the DeckRepository protocol for data access and pure validation functions. The service layer handles: - Deck slot limits (free vs premium users) - Deck validation with optional ownership checking - Starter deck creation +The backend is stateless - deck rules come from the request via DeckConfig. + Example: from app.core.config import DeckConfig from app.services.card_service import CardService from app.services.deck_service import DeckService - from app.services.deck_validator import DeckValidator from app.repositories.postgres import PostgresDeckRepository, PostgresCollectionRepository # Create dependencies card_service = CardService() card_service.load_all() - deck_validator = DeckValidator(DeckConfig(), card_service) # Create repositories deck_repo = PostgresDeckRepository(db_session) collection_repo = PostgresCollectionRepository(db_session) - # Create service with all dependencies - service = DeckService(deck_repo, deck_validator, card_service, collection_repo) + # Create service + service = DeckService(deck_repo, card_service, collection_repo) - # Create a deck + # Create a deck - rules provided by caller deck = await service.create_deck( user_id=user_id, name="My Deck", cards={"a1-001-bulbasaur": 4, ...}, energy_cards={"grass": 14, "colorless": 6}, + deck_config=DeckConfig(), # Rules from frontend max_decks=5, # From user.max_decks ) """ +import logging from uuid import UUID +from sqlalchemy.exc import IntegrityError + +from app.core.config import DeckConfig from app.core.models.card import CardDefinition from app.repositories.protocols import ( CollectionRepository, @@ -47,7 +51,7 @@ from app.repositories.protocols import ( DeckRepository, ) from app.services.card_service import CardService -from app.services.deck_validator import DeckValidationResult, DeckValidator +from app.services.deck_validator import ValidationResult, validate_deck class DeckLimitExceededError(Exception): @@ -56,6 +60,12 @@ class DeckLimitExceededError(Exception): pass +class StarterAlreadySelectedError(Exception): + """Raised when user tries to select a starter deck they already have.""" + + pass + + class DeckNotFoundError(Exception): """Raised when deck is not found or not owned by user.""" @@ -70,17 +80,17 @@ class DeckService: - Multiple storage backends - Offline fork support + The backend is stateless - deck rules come from the request via DeckConfig. + Attributes: _deck_repo: The deck repository implementation. _collection_repo: The collection repository (for ownership checks). - _deck_validator: The deck validator for validation logic. _card_service: The card service for card lookups. """ def __init__( self, deck_repository: DeckRepository, - deck_validator: DeckValidator, card_service: CardService, collection_repository: CollectionRepository | None = None, ) -> None: @@ -88,13 +98,11 @@ class DeckService: Args: deck_repository: Implementation of DeckRepository protocol. - deck_validator: Validator for deck compositions. card_service: Card service for looking up card definitions. collection_repository: Implementation of CollectionRepository protocol. Required for ownership validation in campaign mode. """ self._deck_repo = deck_repository - self._deck_validator = deck_validator self._card_service = card_service self._collection_repo = collection_repository @@ -104,6 +112,7 @@ class DeckService: name: str, cards: dict[str, int], energy_cards: dict[str, int], + deck_config: DeckConfig, max_decks: int, validate_ownership: bool = True, is_starter: bool = False, @@ -120,6 +129,7 @@ class DeckService: name: Display name for the deck. cards: Card ID to quantity mapping. energy_cards: Energy type to quantity mapping. + deck_config: Deck rules from the caller (frontend provides this). max_decks: Maximum decks allowed (from user.max_decks). validate_ownership: If True, checks card ownership (campaign mode). is_starter: Whether this is a starter deck. @@ -138,6 +148,7 @@ class DeckService: name="Grass Power", cards={"a1-001-bulbasaur": 4, ...}, energy_cards={"grass": 14, "colorless": 6}, + deck_config=DeckConfig(), max_decks=5, ) """ @@ -150,8 +161,8 @@ class DeckService: ) # Validate deck - validation = await self.validate_deck( - cards, energy_cards, user_id if validate_ownership else None + validation = await self._validate_deck_internal( + cards, energy_cards, deck_config, user_id if validate_ownership else None ) return await self._deck_repo.create( @@ -170,6 +181,7 @@ class DeckService: self, user_id: UUID, deck_id: UUID, + deck_config: DeckConfig, name: str | None = None, cards: dict[str, int] | None = None, energy_cards: dict[str, int] | None = None, @@ -183,6 +195,7 @@ class DeckService: Args: user_id: The user's UUID (for ownership verification). deck_id: The deck's UUID. + deck_config: Deck rules from the caller (frontend provides this). name: New name (optional). cards: New card composition (optional). energy_cards: New energy composition (optional). @@ -212,8 +225,11 @@ class DeckService: validation_errors = deck.validation_errors if needs_revalidation: - validation = await self.validate_deck( - final_cards, final_energy, user_id if validate_ownership else None + validation = await self._validate_deck_internal( + final_cards, + final_energy, + deck_config, + user_id if validate_ownership else None, ) is_valid = validation.is_valid validation_errors = validation.errors if validation.errors else None @@ -306,22 +322,24 @@ class DeckService: """ return await self._deck_repo.count_by_user(user_id) - async def validate_deck( + async def _validate_deck_internal( self, cards: dict[str, int], energy_cards: dict[str, int], + deck_config: DeckConfig, user_id: UUID | None = None, - ) -> DeckValidationResult: - """Validate a deck composition. + ) -> ValidationResult: + """Internal method to validate a deck composition. Args: cards: Card ID to quantity mapping. energy_cards: Energy type to quantity mapping. + deck_config: Deck rules from the caller. user_id: If provided, validates card ownership (campaign mode). Pass None for freeplay mode. Returns: - DeckValidationResult with is_valid and errors. + ValidationResult with is_valid and errors. """ owned_cards: dict[str, int] | None = None if user_id is not None and self._collection_repo is not None: @@ -329,7 +347,13 @@ class DeckService: collection = await self._collection_repo.get_all(user_id) owned_cards = {entry.card_definition_id: entry.quantity for entry in collection} - return self._deck_validator.validate_deck(cards, energy_cards, owned_cards) + return validate_deck( + cards=cards, + energy_cards=energy_cards, + deck_config=deck_config, + card_lookup=self._card_service.get_card, + owned_cards=owned_cards, + ) async def get_deck_for_game(self, user_id: UUID, deck_id: UUID) -> list[CardDefinition]: """Expand a deck to a list of CardDefinitions for game use. @@ -372,6 +396,7 @@ class DeckService: self, user_id: UUID, starter_type: str, + deck_config: DeckConfig, max_decks: int, ) -> DeckEntry: """Create a starter deck for a user. @@ -382,6 +407,7 @@ class DeckService: Args: user_id: The user's UUID. starter_type: Type of starter deck (grass, fire, water, etc.). + deck_config: Deck rules from the caller (frontend provides this). max_decks: Maximum decks allowed (from user.max_decks). Returns: @@ -406,9 +432,103 @@ class DeckService: name=starter["name"], cards=starter["cards"], energy_cards=starter["energy_cards"], + deck_config=deck_config, max_decks=max_decks, validate_ownership=False, # Starter decks skip ownership check is_starter=True, starter_type=starter_type, description=starter["description"], ) + + async def select_and_grant_starter_deck( + self, + user_id: UUID, + starter_type: str, + deck_config: DeckConfig, + max_decks: int, + ) -> DeckEntry: + """Select a starter deck, granting cards and creating the deck atomically. + + This is the preferred method for starter deck selection. It handles: + - Validation of starter type + - Race condition protection via database unique constraint + - Creating the deck and granting cards together + + The database has a partial unique index on (user_id) WHERE is_starter=true, + which prevents duplicate starter decks even under concurrent requests. + + Args: + user_id: The user's UUID. + starter_type: Type of starter deck (grass, fire, water, etc.). + deck_config: Deck rules from the caller (frontend provides this). + max_decks: Maximum decks allowed (from user.max_decks). + + Returns: + The created starter DeckEntry. + + Raises: + ValueError: If starter_type is invalid. + StarterAlreadySelectedError: If user already has a starter deck. + DeckLimitExceededError: If user has reached deck limit. + """ + from app.data.starter_decks import STARTER_TYPES, get_starter_deck + from app.db.models.collection import CardSource + + logger = logging.getLogger(__name__) + + # Validate starter type early + if starter_type not in STARTER_TYPES: + raise ValueError( + f"Invalid starter type: {starter_type}. " + f"Must be one of: {', '.join(STARTER_TYPES)}" + ) + + # Fast path: check if user already has a starter + has_starter, existing_type = await self.has_starter_deck(user_id) + if has_starter: + raise StarterAlreadySelectedError(f"Starter deck already selected: {existing_type}") + + starter = get_starter_deck(starter_type) + + # Try to create the deck first - protected by unique constraint + # If two concurrent requests try this, one will fail with IntegrityError + try: + deck = await self.create_deck( + user_id=user_id, + name=starter["name"], + cards=starter["cards"], + energy_cards=starter["energy_cards"], + deck_config=deck_config, + max_decks=max_decks, + validate_ownership=False, + is_starter=True, + starter_type=starter_type, + description=starter["description"], + ) + except IntegrityError as e: + # Unique constraint violation - another request beat us + logger.info(f"Starter deck creation race condition for user {user_id}: {e}") + raise StarterAlreadySelectedError( + "Starter deck already selected (concurrent request)" + ) from None + + # Deck created successfully - now grant the cards + # This should never fail for valid starter types + if self._collection_repo is not None: + for card_id, quantity in starter["cards"].items(): + await self._collection_repo.upsert( + user_id=user_id, + card_definition_id=card_id, + quantity_delta=quantity, + source=CardSource.STARTER, + ) + for energy_type, quantity in starter["energy_cards"].items(): + # Energy cards are stored as "energy-{type}" in collection + await self._collection_repo.upsert( + user_id=user_id, + card_definition_id=f"energy-{energy_type}", + quantity_delta=quantity, + source=CardSource.STARTER, + ) + + return deck diff --git a/backend/app/services/deck_validator.py b/backend/app/services/deck_validator.py index aa67f9a..4e2ac11 100644 --- a/backend/app/services/deck_validator.py +++ b/backend/app/services/deck_validator.py @@ -1,229 +1,184 @@ -"""Deck validation service for Mantimon TCG. +"""Deck validation functions for Mantimon TCG. -This module provides standalone deck validation logic that can be used -without database dependencies. It validates deck compositions against -the game rules defined in DeckConfig. - -The validator is separate from DeckService to allow: -- Unit testing without database -- Validation before saving (API /validate endpoint) -- Reuse across different contexts (import/export, AI deck building) +This module provides pure validation functions that validate deck compositions +against rules provided by the caller. The backend is stateless - rules come +from the request via DeckConfig. Usage: from app.core.config import DeckConfig - from app.services.card_service import CardService - from app.services.deck_validator import DeckValidator, DeckValidationResult + from app.services.deck_validator import validate_deck, ValidationResult - card_service = CardService() - card_service.load_all() - validator = DeckValidator(DeckConfig(), card_service) + result = validate_deck( + cards={"a1-001-bulbasaur": 4, ...}, + energy_cards={"grass": 14, "colorless": 6}, + deck_config=DeckConfig(), # Or custom rules from request + card_lookup=card_service.get_card, + owned_cards=user_collection, # None to skip ownership check + ) - # Validate without ownership check (freeplay mode) - result = validator.validate_deck(cards, energy_cards) - - # Validate with ownership check (campaign mode) - result = validator.validate_deck(cards, energy_cards, owned_cards=user_collection) + if not result.is_valid: + for error in result.errors: + print(error) """ +from collections.abc import Callable from dataclasses import dataclass, field from app.core.config import DeckConfig -from app.services.card_service import CardService +from app.core.models.card import CardDefinition @dataclass -class DeckValidationResult: +class ValidationResult: """Result of deck validation. Contains validation status and all errors found. Multiple errors can be returned to help the user fix all issues at once. - - Attributes: - is_valid: Whether the deck passes all validation rules. - errors: List of human-readable error messages. """ is_valid: bool = True errors: list[str] = field(default_factory=list) def add_error(self, error: str) -> None: - """Add an error and mark as invalid. - - Args: - error: Human-readable error message. - """ + """Add an error and mark as invalid.""" self.is_valid = False self.errors.append(error) -class DeckValidator: - """Validates deck compositions against game rules. +def validate_deck( + cards: dict[str, int], + energy_cards: dict[str, int], + deck_config: DeckConfig, + card_lookup: Callable[[str], CardDefinition | None], + owned_cards: dict[str, int] | None = None, +) -> ValidationResult: + """Validate a deck composition against provided rules. - This validator checks: - 1. Total card count (40 cards in main deck) - 2. Total energy count (20 energy cards) - 3. Maximum copies per card (4) - 4. Minimum Basic Pokemon requirement (1) - 5. Card ID validity (card must exist) - 6. Card ownership (optional, for campaign mode) + This is a pure function - all inputs are provided by the caller, + including the rules to validate against via DeckConfig. - The validator uses DeckConfig for rule values, allowing different - game modes to have different rules if needed. + Args: + cards: Mapping of card IDs to quantities for the main deck. + energy_cards: Mapping of energy type names to quantities. + deck_config: Deck rules from the caller (DeckConfig from app.core.config). + card_lookup: Function to look up card definitions by ID. + owned_cards: If provided, validates that the user owns enough + copies of each card. Pass None to skip ownership validation. - Attributes: - _config: The deck configuration with validation rules. - _card_service: The card service for card lookups. + Returns: + ValidationResult with is_valid status and list of errors. + + Example: + result = validate_deck( + cards={"a1-001-bulbasaur": 4}, + energy_cards={"grass": 20}, + deck_config=DeckConfig(min_size=40, energy_deck_size=20), + card_lookup=card_service.get_card, + ) """ + result = ValidationResult() - def __init__(self, config: DeckConfig, card_service: CardService) -> None: - """Initialize the validator with dependencies. + # 1. Validate total card count + total_cards = sum(cards.values()) + if total_cards != deck_config.min_size: + result.add_error( + f"Main deck must have exactly {deck_config.min_size} cards, got {total_cards}" + ) - Args: - config: Deck configuration with validation rules. - card_service: Card service for looking up card definitions. - """ - self._config = config - self._card_service = card_service + # 2. Validate total energy count + total_energy = sum(energy_cards.values()) + if total_energy != deck_config.energy_deck_size: + result.add_error( + f"Energy deck must have exactly {deck_config.energy_deck_size} cards, " + f"got {total_energy}" + ) - @property - def config(self) -> DeckConfig: - """Get the deck configuration.""" - return self._config - - def validate_deck( - self, - cards: dict[str, int], - energy_cards: dict[str, int], - owned_cards: dict[str, int] | None = None, - ) -> DeckValidationResult: - """Validate a deck composition. - - Checks all validation rules and returns all errors found (not just - the first one). This helps users fix all issues at once. - - Args: - cards: Mapping of card IDs to quantities for the main deck. - energy_cards: Mapping of energy type names to quantities. - owned_cards: If provided, validates that the user owns enough - copies of each card. Pass None to skip ownership validation - (for freeplay mode). - - Returns: - DeckValidationResult with is_valid status and list of errors. - - Example: - result = validator.validate_deck( - cards={"a1-001-bulbasaur": 4, "a1-002-ivysaur": 4, ...}, - energy_cards={"grass": 14, "colorless": 6}, - owned_cards={"a1-001-bulbasaur": 10, ...} - ) - if not result.is_valid: - for error in result.errors: - print(error) - """ - result = DeckValidationResult() - - # 1. Validate total card count - total_cards = sum(cards.values()) - if total_cards != self._config.min_size: + # 3. Validate max copies per card + for card_id, quantity in cards.items(): + if quantity > deck_config.max_copies_per_card: result.add_error( - f"Main deck must have exactly {self._config.min_size} cards, " f"got {total_cards}" + f"Card '{card_id}' has {quantity} copies, " + f"max allowed is {deck_config.max_copies_per_card}" ) - # 2. Validate total energy count - total_energy = sum(energy_cards.values()) - if total_energy != self._config.energy_deck_size: - result.add_error( - f"Energy deck must have exactly {self._config.energy_deck_size} cards, " - f"got {total_energy}" - ) + # 4 & 5. Validate card IDs exist and count Basic Pokemon + basic_pokemon_count = 0 + invalid_card_ids: list[str] = [] - # 3. Validate max copies per card - for card_id, quantity in cards.items(): - if quantity > self._config.max_copies_per_card: - result.add_error( - f"Card '{card_id}' has {quantity} copies, " - f"max allowed is {self._config.max_copies_per_card}" - ) + for card_id in cards: + card_def = card_lookup(card_id) + if card_def is None: + invalid_card_ids.append(card_id) + elif card_def.is_basic_pokemon(): + basic_pokemon_count += cards[card_id] - # 4 & 5. Validate card IDs exist and count Basic Pokemon - basic_pokemon_count = 0 - invalid_card_ids: list[str] = [] + if invalid_card_ids: + display_ids = invalid_card_ids[:5] + more = len(invalid_card_ids) - 5 + error_msg = f"Invalid card IDs: {', '.join(display_ids)}" + if more > 0: + error_msg += f" (and {more} more)" + result.add_error(error_msg) - for card_id in cards: - card_def = self._card_service.get_card(card_id) - if card_def is None: - invalid_card_ids.append(card_id) - elif card_def.is_basic_pokemon(): - basic_pokemon_count += cards[card_id] + # Check minimum Basic Pokemon requirement + if basic_pokemon_count < deck_config.min_basic_pokemon: + result.add_error( + f"Deck must have at least {deck_config.min_basic_pokemon} Basic Pokemon, " + f"got {basic_pokemon_count}" + ) - if invalid_card_ids: - # Limit displayed invalid IDs to avoid huge error messages - display_ids = invalid_card_ids[:5] - more = len(invalid_card_ids) - 5 - error_msg = f"Invalid card IDs: {', '.join(display_ids)}" + # 6. Validate ownership if owned_cards provided + if owned_cards is not None: + insufficient_cards: list[tuple[str, int, int]] = [] + for card_id, required_qty in cards.items(): + owned_qty = owned_cards.get(card_id, 0) + if owned_qty < required_qty: + insufficient_cards.append((card_id, required_qty, owned_qty)) + + if insufficient_cards: + display_cards = insufficient_cards[:5] + more = len(insufficient_cards) - 5 + error_parts = [f"'{c[0]}' (need {c[1]}, own {c[2]})" for c in display_cards] + error_msg = f"Insufficient cards: {', '.join(error_parts)}" if more > 0: error_msg += f" (and {more} more)" result.add_error(error_msg) - # Check minimum Basic Pokemon requirement - if basic_pokemon_count < self._config.min_basic_pokemon: - result.add_error( - f"Deck must have at least {self._config.min_basic_pokemon} Basic Pokemon, " - f"got {basic_pokemon_count}" - ) + return result - # 6. Validate ownership if owned_cards provided (campaign mode) - if owned_cards is not None: - insufficient_cards: list[tuple[str, int, int]] = [] - for card_id, required_qty in cards.items(): - owned_qty = owned_cards.get(card_id, 0) - if owned_qty < required_qty: - insufficient_cards.append((card_id, required_qty, owned_qty)) - if insufficient_cards: - # Limit displayed insufficient cards - display_cards = insufficient_cards[:5] - more = len(insufficient_cards) - 5 - error_parts = [f"'{c[0]}' (need {c[1]}, own {c[2]})" for c in display_cards] - error_msg = f"Insufficient cards: {', '.join(error_parts)}" - if more > 0: - error_msg += f" (and {more} more)" - result.add_error(error_msg) +def validate_cards_exist( + card_ids: list[str], + card_lookup: Callable[[str], CardDefinition | None], +) -> list[str]: + """Check which card IDs are invalid. - return result + Args: + card_ids: List of card IDs to check. + card_lookup: Function to look up card definitions. - def validate_cards_exist(self, card_ids: list[str]) -> list[str]: - """Check which card IDs are invalid. + Returns: + List of invalid card IDs (empty if all valid). + """ + return [card_id for card_id in card_ids if card_lookup(card_id) is None] - Utility method to check card ID validity without full deck validation. - Args: - card_ids: List of card IDs to check. +def count_basic_pokemon( + cards: dict[str, int], + card_lookup: Callable[[str], CardDefinition | None], +) -> int: + """Count Basic Pokemon in a deck. - Returns: - List of invalid card IDs (empty if all valid). - """ - invalid = [] - for card_id in card_ids: - if self._card_service.get_card(card_id) is None: - invalid.append(card_id) - return invalid + Args: + cards: Mapping of card IDs to quantities. + card_lookup: Function to look up card definitions. - def count_basic_pokemon(self, cards: dict[str, int]) -> int: - """Count Basic Pokemon in a deck. - - Utility method to count Basic Pokemon without full validation. - - Args: - cards: Mapping of card IDs to quantities. - - Returns: - Total number of Basic Pokemon cards in the deck. - """ - count = 0 - for card_id, quantity in cards.items(): - card_def = self._card_service.get_card(card_id) - if card_def and card_def.is_basic_pokemon(): - count += quantity - return count + Returns: + Total number of Basic Pokemon cards in the deck. + """ + count = 0 + for card_id, quantity in cards.items(): + card_def = card_lookup(card_id) + if card_def and card_def.is_basic_pokemon(): + count += quantity + return count diff --git a/backend/tests/api/test_collections_api.py b/backend/tests/api/test_collections_api.py new file mode 100644 index 0000000..86933a8 --- /dev/null +++ b/backend/tests/api/test_collections_api.py @@ -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 diff --git a/backend/tests/api/test_decks_api.py b/backend/tests/api/test_decks_api.py new file mode 100644 index 0000000..25c8946 --- /dev/null +++ b/backend/tests/api/test_decks_api.py @@ -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 diff --git a/backend/tests/api/test_starter_deck_api.py b/backend/tests/api/test_starter_deck_api.py new file mode 100644 index 0000000..40798b1 --- /dev/null +++ b/backend/tests/api/test_starter_deck_api.py @@ -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 diff --git a/backend/tests/services/test_collection_service.py b/backend/tests/services/test_collection_service.py new file mode 100644 index 0000000..fca0015 --- /dev/null +++ b/backend/tests/services/test_collection_service.py @@ -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" diff --git a/backend/tests/services/test_deck_service.py b/backend/tests/services/test_deck_service.py new file mode 100644 index 0000000..e83628c --- /dev/null +++ b/backend/tests/services/test_deck_service.py @@ -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 diff --git a/backend/tests/unit/services/test_deck_validator.py b/backend/tests/unit/services/test_deck_validator.py index 2ca15fa..4e6c888 100644 --- a/backend/tests/unit/services/test_deck_validator.py +++ b/backend/tests/unit/services/test_deck_validator.py @@ -1,28 +1,27 @@ -"""Tests for the DeckValidator service. +"""Tests for the deck validation functions. These tests verify deck validation logic without database dependencies. -CardService is mocked and injected to isolate the validation logic and -enable fast, deterministic unit tests. +Card lookup is mocked to isolate the validation logic and enable fast, +deterministic unit tests. -The DeckValidator enforces Mantimon TCG house rules: +The validation functions enforce Mantimon TCG house rules: - 40 cards in main deck - 20 energy cards in separate energy deck - Max 4 copies of any single card - At least 1 Basic Pokemon required -- Campaign mode: must own all cards in deck -- Freeplay mode: ownership validation skipped +- Ownership check when owned_cards is provided """ -from unittest.mock import MagicMock - import pytest from app.core.config import DeckConfig from app.core.enums import CardType, EnergyType, PokemonStage from app.core.models.card import CardDefinition from app.services.deck_validator import ( - DeckValidationResult, - DeckValidator, + ValidationResult, + count_basic_pokemon, + validate_cards_exist, + validate_deck, ) # ============================================================================= @@ -30,89 +29,59 @@ from app.services.deck_validator import ( # ============================================================================= -@pytest.fixture -def mock_card_service(): - """Create a mock CardService with test cards. - - Provides a set of test cards: - - 3 Basic Pokemon (pikachu, bulbasaur, charmander) - - 2 Stage 1 Pokemon (raichu, ivysaur) - - 2 Trainer cards (potion, professor-oak) - - This allows testing deck validation without loading real card data. - """ - service = MagicMock() - - # Define test cards - cards = { - "test-001-pikachu": CardDefinition( - id="test-001-pikachu", - name="Pikachu", - card_type=CardType.POKEMON, - hp=60, - pokemon_type=EnergyType.LIGHTNING, - stage=PokemonStage.BASIC, - ), - "test-002-bulbasaur": CardDefinition( - id="test-002-bulbasaur", - name="Bulbasaur", - card_type=CardType.POKEMON, - hp=70, - pokemon_type=EnergyType.GRASS, - stage=PokemonStage.BASIC, - ), - "test-003-charmander": CardDefinition( - id="test-003-charmander", - name="Charmander", - card_type=CardType.POKEMON, - hp=60, - pokemon_type=EnergyType.FIRE, - stage=PokemonStage.BASIC, - ), - "test-004-raichu": CardDefinition( - id="test-004-raichu", - name="Raichu", - card_type=CardType.POKEMON, - hp=100, - pokemon_type=EnergyType.LIGHTNING, - stage=PokemonStage.STAGE_1, - evolves_from="Pikachu", - ), - "test-005-ivysaur": CardDefinition( - id="test-005-ivysaur", - name="Ivysaur", - card_type=CardType.POKEMON, - hp=90, - pokemon_type=EnergyType.GRASS, - stage=PokemonStage.STAGE_1, - evolves_from="Bulbasaur", - ), - "test-101-potion": CardDefinition( - id="test-101-potion", - name="Potion", - card_type=CardType.TRAINER, - trainer_type="item", - ), - "test-102-professor-oak": CardDefinition( - id="test-102-professor-oak", - name="Professor Oak", - card_type=CardType.TRAINER, - trainer_type="supporter", - ), - } - - service.get_card = lambda card_id: cards.get(card_id) - return service +def make_basic_pokemon(card_id: str) -> CardDefinition: + """Create a Basic Pokemon card definition.""" + return CardDefinition( + id=card_id, + name=f"Pokemon {card_id}", + card_type=CardType.POKEMON, + hp=60, + pokemon_type=EnergyType.LIGHTNING, + stage=PokemonStage.BASIC, + ) -@pytest.fixture -def valid_energy_deck() -> dict[str, int]: - """Create a valid 20-card energy deck.""" - return { - "lightning": 10, - "grass": 6, - "colorless": 4, - } +def make_stage1_pokemon(card_id: str) -> CardDefinition: + """Create a Stage 1 Pokemon card definition.""" + return CardDefinition( + id=card_id, + name=f"Pokemon {card_id}", + card_type=CardType.POKEMON, + hp=90, + pokemon_type=EnergyType.LIGHTNING, + stage=PokemonStage.STAGE_1, + evolves_from="SomeBasic", + ) + + +def make_trainer(card_id: str) -> CardDefinition: + """Create a Trainer card definition.""" + return CardDefinition( + id=card_id, + name=f"Trainer {card_id}", + card_type=CardType.TRAINER, + trainer_type="item", + ) + + +def basic_pokemon_lookup(card_id: str) -> CardDefinition | None: + """Card lookup that returns Basic Pokemon for any ID.""" + return make_basic_pokemon(card_id) + + +def stage1_pokemon_lookup(card_id: str) -> CardDefinition | None: + """Card lookup that returns Stage 1 Pokemon for any ID.""" + return make_stage1_pokemon(card_id) + + +def trainer_lookup(card_id: str) -> CardDefinition | None: + """Card lookup that returns Trainers for any ID.""" + return make_trainer(card_id) + + +def null_lookup(card_id: str) -> CardDefinition | None: + """Card lookup that returns None for any ID.""" + return None @pytest.fixture @@ -121,75 +90,38 @@ def default_config() -> DeckConfig: return DeckConfig() -@pytest.fixture -def validator(default_config, mock_card_service) -> DeckValidator: - """Create a DeckValidator with default config and mock card service.""" - return DeckValidator(default_config, mock_card_service) - - -def create_basic_pokemon_mock(): - """Create a mock that returns Basic Pokemon for any card ID. - - Useful for tests that need all cards to be valid Basic Pokemon. - """ - mock = MagicMock() - mock.get_card = lambda cid: CardDefinition( - id=cid, - name=f"Card {cid}", - card_type=CardType.POKEMON, - hp=60, - pokemon_type=EnergyType.LIGHTNING, - stage=PokemonStage.BASIC, - ) - return mock - - # ============================================================================= -# DeckValidationResult Tests +# ValidationResult Tests # ============================================================================= -class TestDeckValidationResult: - """Tests for the DeckValidationResult dataclass.""" +class TestValidationResult: + """Tests for the ValidationResult dataclass.""" def test_default_is_valid(self): - """Test that a new result starts as valid with no errors. - - A fresh DeckValidationResult should indicate validity until - errors are explicitly added. - """ - result = DeckValidationResult() + """Test that a new result starts as valid with no errors.""" + result = ValidationResult() assert result.is_valid is True assert result.errors == [] def test_add_error_marks_invalid(self): - """Test that adding an error marks the result as invalid. - - Once any error is added, is_valid should be False. - """ - result = DeckValidationResult() + """Test that adding an error marks the result as invalid.""" + result = ValidationResult() result.add_error("Test error") assert result.is_valid is False assert "Test error" in result.errors def test_add_multiple_errors(self): - """Test that multiple errors can be accumulated. - - All errors should be collected, not just the first one, - to give users complete feedback on what needs fixing. - """ - result = DeckValidationResult() + """Test that multiple errors can be accumulated.""" + result = ValidationResult() result.add_error("Error 1") result.add_error("Error 2") result.add_error("Error 3") assert result.is_valid is False assert len(result.errors) == 3 - assert "Error 1" in result.errors - assert "Error 2" in result.errors - assert "Error 3" in result.errors # ============================================================================= @@ -201,64 +133,42 @@ class TestCardCountValidation: """Tests for main deck card count validation (40 cards required).""" def test_valid_card_count_passes(self, default_config): - """Test that exactly 40 cards passes validation. - - The main deck must have exactly 40 cards per Mantimon house rules. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + """Test that exactly 40 cards passes validation.""" cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, basic_pokemon_lookup) - # Should pass card count check assert "must have exactly 40 cards" not in str(result.errors) def test_39_cards_fails(self, default_config): - """Test that 39 cards fails validation. - - One card short of the required 40 should produce an error. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + """Test that 39 cards fails validation.""" cards = {f"card-{i:03d}": 4 for i in range(9)} # 36 cards cards["card-extra"] = 3 # 39 total energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, basic_pokemon_lookup) assert result.is_valid is False - assert any("must have exactly 40 cards" in e and "got 39" in e for e in result.errors) + assert any("got 39" in e for e in result.errors) def test_41_cards_fails(self, default_config): - """Test that 41 cards fails validation. - - One card over the required 40 should produce an error. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + """Test that 41 cards fails validation.""" cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards cards["card-extra"] = 1 # 41 total energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, basic_pokemon_lookup) assert result.is_valid is False - assert any("must have exactly 40 cards" in e and "got 41" in e for e in result.errors) + assert any("got 41" in e for e in result.errors) def test_empty_deck_fails(self, default_config): - """Test that an empty deck fails validation. - - A deck with no cards should fail the card count check. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) - - result = validator.validate_deck({}, {"lightning": 20}) + """Test that an empty deck fails validation.""" + result = validate_deck({}, {"lightning": 20}, default_config, basic_pokemon_lookup) assert result.is_valid is False - assert any("must have exactly 40 cards" in e and "got 0" in e for e in result.errors) + assert any("got 0" in e for e in result.errors) # ============================================================================= @@ -270,59 +180,33 @@ class TestEnergyCountValidation: """Tests for energy deck card count validation (20 energy required).""" def test_valid_energy_count_passes(self, default_config): - """Test that exactly 20 energy cards passes validation. - - The energy deck must have exactly 20 cards per Mantimon house rules. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + """Test that exactly 20 energy cards passes validation.""" cards = {f"card-{i:03d}": 4 for i in range(10)} energy = {"lightning": 14, "colorless": 6} # 20 total - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, basic_pokemon_lookup) assert "Energy deck must have exactly 20" not in str(result.errors) def test_19_energy_fails(self, default_config): - """Test that 19 energy cards fails validation. - - One energy short of the required 20 should produce an error. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + """Test that 19 energy cards fails validation.""" cards = {f"card-{i:03d}": 4 for i in range(10)} energy = {"lightning": 19} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, basic_pokemon_lookup) assert result.is_valid is False - assert any("Energy deck must have exactly 20" in e and "got 19" in e for e in result.errors) + assert any("got 19" in e for e in result.errors) def test_21_energy_fails(self, default_config): - """Test that 21 energy cards fails validation. - - One energy over the required 20 should produce an error. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + """Test that 21 energy cards fails validation.""" cards = {f"card-{i:03d}": 4 for i in range(10)} energy = {"lightning": 21} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, basic_pokemon_lookup) assert result.is_valid is False - assert any("Energy deck must have exactly 20" in e and "got 21" in e for e in result.errors) - - def test_empty_energy_deck_fails(self, default_config): - """Test that an empty energy deck fails validation.""" - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) - cards = {f"card-{i:03d}": 4 for i in range(10)} - - result = validator.validate_deck(cards, {}) - - assert result.is_valid is False - assert any("Energy deck must have exactly 20" in e and "got 0" in e for e in result.errors) + assert any("got 21" in e for e in result.errors) # ============================================================================= @@ -334,67 +218,36 @@ class TestMaxCopiesValidation: """Tests for maximum copies per card validation (4 max).""" def test_4_copies_allowed(self, default_config): - """Test that 4 copies of a card is allowed. - - The maximum of 4 copies per card should pass validation. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) - cards = { - "test-001-pikachu": 4, # Max allowed - } - # Pad to 40 cards - for i in range(9): - cards[f"filler-{i:03d}"] = 4 + """Test that 4 copies of a card is allowed.""" + cards = {f"card-{i:03d}": 4 for i in range(10)} # 4 copies each energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, basic_pokemon_lookup) assert "max allowed is 4" not in str(result.errors) def test_5_copies_fails(self, default_config): - """Test that 5 copies of a card fails validation. - - One copy over the maximum should produce an error identifying the card. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) - cards = { - "test-001-pikachu": 5, # One over max - } - # Pad to 40 (5 + 35) + """Test that 5 copies of a card fails validation.""" + cards = {"over-limit": 5} + # Pad to 40 cards for i in range(7): - cards[f"filler-{i:03d}"] = 5 + cards[f"card-{i:03d}"] = 5 energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, basic_pokemon_lookup) assert result.is_valid is False - assert any( - "test-001-pikachu" in e and "5 copies" in e and "max allowed is 4" in e - for e in result.errors - ) + assert any("over-limit" in e and "5 copies" in e for e in result.errors) def test_multiple_cards_over_limit(self, default_config): - """Test that multiple cards over limit all get reported. - - Each card exceeding the limit should generate its own error. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) - cards = { - "card-a": 5, - "card-b": 6, - "card-c": 4, # OK - } - # Pad to 40 + """Test that multiple cards over limit all get reported.""" + cards = {"card-a": 5, "card-b": 6, "card-c": 4} # a and b over cards["filler"] = 25 energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, basic_pokemon_lookup) assert result.is_valid is False - # Both card-a and card-b should be reported error_str = str(result.errors) assert "card-a" in error_str assert "card-b" in error_str @@ -409,63 +262,30 @@ class TestBasicPokemonRequirement: """Tests for minimum Basic Pokemon requirement (at least 1).""" def test_deck_with_basic_pokemon_passes(self, default_config): - """Test that a deck with Basic Pokemon passes validation. - - Having at least 1 Basic Pokemon satisfies this requirement. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + """Test that a deck with Basic Pokemon passes validation.""" cards = {f"card-{i:03d}": 4 for i in range(10)} energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, basic_pokemon_lookup) assert "at least 1 Basic Pokemon" not in str(result.errors) def test_deck_without_basic_pokemon_fails(self, default_config): - """Test that a deck without Basic Pokemon fails validation. - - A deck composed entirely of Stage 1/2 Pokemon and Trainers - cannot start a game and should fail validation. - """ - # Create a mock that returns Stage 1 pokemon for all IDs - mock_service = MagicMock() - mock_service.get_card = lambda cid: CardDefinition( - id=cid, - name=f"Card {cid}", - card_type=CardType.POKEMON, - hp=60, - pokemon_type=EnergyType.LIGHTNING, - stage=PokemonStage.STAGE_1, - evolves_from="SomeBasic", - ) - validator = DeckValidator(default_config, mock_service) + """Test that a deck without Basic Pokemon fails validation.""" cards = {f"card-{i:03d}": 4 for i in range(10)} energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, stage1_pokemon_lookup) assert result.is_valid is False assert any("at least 1 Basic Pokemon" in e for e in result.errors) def test_deck_with_only_trainers_fails(self, default_config): - """Test that a deck with only Trainers fails Basic Pokemon check. - - Trainers don't count toward the Basic Pokemon requirement. - """ - # Create a mock that returns Trainers for all IDs - mock_service = MagicMock() - mock_service.get_card = lambda cid: CardDefinition( - id=cid, - name=f"Trainer {cid}", - card_type=CardType.TRAINER, - trainer_type="item", - ) - validator = DeckValidator(default_config, mock_service) + """Test that a deck with only Trainers fails Basic Pokemon check.""" cards = {f"trainer-{i:03d}": 4 for i in range(10)} energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, trainer_lookup) assert result.is_valid is False assert any("at least 1 Basic Pokemon" in e for e in result.errors) @@ -480,88 +300,44 @@ class TestCardIdValidation: """Tests for card ID existence validation.""" def test_valid_card_ids_pass(self, default_config): - """Test that valid card IDs pass validation. - - All card IDs in the deck should exist in the CardService. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + """Test that valid card IDs pass validation.""" cards = {f"card-{i:03d}": 4 for i in range(10)} energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, basic_pokemon_lookup) assert "Invalid card IDs" not in str(result.errors) def test_invalid_card_id_fails(self, default_config): - """Test that an invalid card ID fails validation. + """Test that an invalid card ID fails validation.""" - Card IDs not found in CardService should produce an error. - """ - # Create a mock that returns None for specific cards - mock_service = MagicMock() - - def mock_get_card(cid): - if cid == "nonexistent-card": + def partial_lookup(card_id: str) -> CardDefinition | None: + if card_id == "bad-card": return None - return CardDefinition( - id=cid, - name=f"Card {cid}", - card_type=CardType.POKEMON, - hp=60, - pokemon_type=EnergyType.LIGHTNING, - stage=PokemonStage.BASIC, - ) + return make_basic_pokemon(card_id) - mock_service.get_card = mock_get_card - - validator = DeckValidator(default_config, mock_service) - cards = { - "valid-card": 4, - "nonexistent-card": 4, - } - # Pad to 40 + cards = {"good-card": 4, "bad-card": 4} for i in range(8): cards[f"card-{i:03d}"] = 4 energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, partial_lookup) assert result.is_valid is False - assert any("Invalid card IDs" in e and "nonexistent-card" in e for e in result.errors) + assert any("Invalid card IDs" in e and "bad-card" in e for e in result.errors) def test_multiple_invalid_ids_reported(self, default_config): - """Test that multiple invalid IDs are reported together. + """Test that multiple invalid IDs are reported together.""" - The error message should list multiple invalid IDs (up to a limit). - """ - # Create a mock that returns None for "bad-*" cards - mock_service = MagicMock() - - def mock_get_card(cid): - if cid.startswith("bad"): + def partial_lookup(card_id: str) -> CardDefinition | None: + if card_id.startswith("bad"): return None - return CardDefinition( - id=cid, - name=f"Card {cid}", - card_type=CardType.POKEMON, - hp=60, - pokemon_type=EnergyType.LIGHTNING, - stage=PokemonStage.BASIC, - ) + return make_basic_pokemon(card_id) - mock_service.get_card = mock_get_card - - validator = DeckValidator(default_config, mock_service) - cards = { - "bad-1": 4, - "bad-2": 4, - "bad-3": 4, - "good": 28, - } + cards = {"bad-1": 4, "bad-2": 4, "bad-3": 4, "good": 28} energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, default_config, partial_lookup) assert result.is_valid is False error_str = str(result.errors) @@ -576,77 +352,55 @@ class TestCardIdValidation: class TestOwnershipValidation: - """Tests for card ownership validation (campaign mode).""" + """Tests for card ownership validation.""" def test_owned_cards_pass(self, default_config): - """Test that deck passes when user owns all cards. - - In campaign mode, user must own sufficient copies of each card. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + """Test that deck passes when user owns all cards.""" cards = {f"card-{i:03d}": 4 for i in range(10)} energy = {"lightning": 20} - # User owns 10 copies of each card owned = {f"card-{i:03d}": 10 for i in range(10)} - result = validator.validate_deck(cards, energy, owned_cards=owned) + result = validate_deck( + cards, energy, default_config, basic_pokemon_lookup, owned_cards=owned + ) assert "Insufficient cards" not in str(result.errors) def test_insufficient_ownership_fails(self, default_config): - """Test that deck fails when user doesn't own enough copies. - - Needing 4 copies but only owning 2 should produce an error. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + """Test that deck fails when user doesn't own enough copies.""" cards = {f"card-{i:03d}": 4 for i in range(10)} energy = {"lightning": 20} - # User owns only 2 copies of first card owned = {f"card-{i:03d}": 10 for i in range(10)} owned["card-000"] = 2 # Need 4, only have 2 - result = validator.validate_deck(cards, energy, owned_cards=owned) + result = validate_deck( + cards, energy, default_config, basic_pokemon_lookup, owned_cards=owned + ) assert result.is_valid is False - assert any( - "Insufficient cards" in e and "card-000" in e and "need 4" in e and "own 2" in e - for e in result.errors - ) + assert any("card-000" in e and "need 4" in e and "own 2" in e for e in result.errors) def test_unowned_card_fails(self, default_config): - """Test that deck fails when user doesn't own a card at all. - - A card with 0 owned copies should fail ownership validation. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + """Test that deck fails when user doesn't own a card at all.""" cards = {"owned-card": 20, "unowned-card": 20} energy = {"lightning": 20} - owned = {"owned-card": 20} # Missing unowned-card entirely + owned = {"owned-card": 20} # Missing unowned-card - result = validator.validate_deck(cards, energy, owned_cards=owned) - - assert result.is_valid is False - assert any( - "Insufficient cards" in e and "unowned-card" in e and "own 0" in e - for e in result.errors + result = validate_deck( + cards, energy, default_config, basic_pokemon_lookup, owned_cards=owned ) - def test_freeplay_skips_ownership(self, default_config): - """Test that passing None for owned_cards skips ownership validation. + assert result.is_valid is False + assert any("unowned-card" in e and "own 0" in e for e in result.errors) - In freeplay mode, users have access to all cards regardless of - their actual collection. - """ - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) + def test_none_owned_cards_skips_ownership_check(self, default_config): + """Test that passing None for owned_cards skips ownership validation.""" cards = {f"card-{i:03d}": 4 for i in range(10)} energy = {"lightning": 20} - # owned_cards=None means freeplay mode - result = validator.validate_deck(cards, energy, owned_cards=None) + result = validate_deck( + cards, energy, default_config, basic_pokemon_lookup, owned_cards=None + ) assert "Insufficient cards" not in str(result.errors) @@ -660,35 +414,15 @@ class TestMultipleErrors: """Tests for returning all errors at once.""" def test_multiple_errors_returned_together(self, default_config): - """Test that multiple validation errors are all returned. - - When a deck has multiple issues, all should be reported so the - user can fix everything at once rather than iteratively. - """ - # Create a mock that returns None for all cards - mock_service = MagicMock() - mock_service.get_card = lambda cid: None - - validator = DeckValidator(default_config, mock_service) - cards = { - "bad-card": 5, # Invalid ID + over copy limit - } - # Total is 5 (not 40) + """Test that multiple validation errors are all returned.""" + cards = {"bad-card": 5} # Invalid ID + over copy limit + wrong count energy = {"lightning": 10} # Only 10 (not 20) - owned = {} # Empty ownership + owned = {} - result = validator.validate_deck(cards, energy, owned_cards=owned) + result = validate_deck(cards, energy, default_config, null_lookup, owned_cards=owned) assert result.is_valid is False - # Should have multiple errors assert len(result.errors) >= 3 - error_str = str(result.errors) - # Card count error - assert "40 cards" in error_str - # Energy count error - assert "20" in error_str - # Invalid card ID or max copies - assert "bad-card" in error_str # ============================================================================= @@ -700,17 +434,12 @@ class TestCustomConfig: """Tests for using custom DeckConfig values.""" def test_custom_deck_size(self): - """Test that custom deck size is respected. - - Different game modes might use different deck sizes (e.g., 60-card). - """ + """Test that custom deck size is respected.""" custom_config = DeckConfig(min_size=60, max_size=60) - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(custom_config, mock_service) cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, custom_config, basic_pokemon_lookup) assert result.is_valid is False assert any("must have exactly 60 cards" in e for e in result.errors) @@ -718,12 +447,10 @@ class TestCustomConfig: def test_custom_energy_size(self): """Test that custom energy deck size is respected.""" custom_config = DeckConfig(energy_deck_size=30) - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(custom_config, mock_service) - cards = {f"card-{i:03d}": 4 for i in range(10)} # 40 cards - energy = {"lightning": 20} # 20, but need 30 + cards = {f"card-{i:03d}": 4 for i in range(10)} + energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, custom_config, basic_pokemon_lookup) assert result.is_valid is False assert any("must have exactly 30" in e for e in result.errors) @@ -731,111 +458,58 @@ class TestCustomConfig: def test_custom_max_copies(self): """Test that custom max copies per card is respected.""" custom_config = DeckConfig(max_copies_per_card=2) - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(custom_config, mock_service) cards = {f"card-{i:03d}": 4 for i in range(10)} # 4 copies each energy = {"lightning": 20} - result = validator.validate_deck(cards, energy) + result = validate_deck(cards, energy, custom_config, basic_pokemon_lookup) assert result.is_valid is False assert any("max allowed is 2" in e for e in result.errors) # ============================================================================= -# Utility Method Tests +# Utility Function Tests # ============================================================================= -class TestUtilityMethods: - """Tests for utility methods on DeckValidator.""" +class TestUtilityFunctions: + """Tests for utility functions.""" - def test_validate_cards_exist_all_valid(self, default_config): + def test_validate_cards_exist_all_valid(self): """Test validate_cards_exist returns empty list when all valid.""" - mock_service = create_basic_pokemon_mock() - validator = DeckValidator(default_config, mock_service) card_ids = ["card-1", "card-2", "card-3"] - invalid = validator.validate_cards_exist(card_ids) + invalid = validate_cards_exist(card_ids, basic_pokemon_lookup) assert invalid == [] - def test_validate_cards_exist_some_invalid(self, default_config): + def test_validate_cards_exist_some_invalid(self): """Test validate_cards_exist returns invalid IDs.""" - mock_service = MagicMock() - def mock_get(cid): - if cid.startswith("bad"): + def partial_lookup(card_id: str) -> CardDefinition | None: + if card_id.startswith("bad"): return None - return CardDefinition( - id=cid, - name=f"Card {cid}", - card_type=CardType.POKEMON, - hp=60, - pokemon_type=EnergyType.LIGHTNING, - stage=PokemonStage.BASIC, - ) + return make_basic_pokemon(card_id) - mock_service.get_card = mock_get - - validator = DeckValidator(default_config, mock_service) card_ids = ["good-1", "bad-1", "good-2", "bad-2"] - invalid = validator.validate_cards_exist(card_ids) + invalid = validate_cards_exist(card_ids, partial_lookup) assert set(invalid) == {"bad-1", "bad-2"} - def test_count_basic_pokemon(self, default_config): + def test_count_basic_pokemon(self): """Test count_basic_pokemon returns correct count.""" - mock_service = MagicMock() - def mock_get(cid): - if cid.startswith("basic"): - return CardDefinition( - id=cid, - name=f"Card {cid}", - card_type=CardType.POKEMON, - hp=60, - pokemon_type=EnergyType.LIGHTNING, - stage=PokemonStage.BASIC, - ) - elif cid.startswith("stage1"): - return CardDefinition( - id=cid, - name=f"Card {cid}", - card_type=CardType.POKEMON, - hp=90, - pokemon_type=EnergyType.LIGHTNING, - stage=PokemonStage.STAGE_1, - evolves_from="SomeBasic", - ) + def mixed_lookup(card_id: str) -> CardDefinition | None: + if card_id.startswith("basic"): + return make_basic_pokemon(card_id) + elif card_id.startswith("stage1"): + return make_stage1_pokemon(card_id) else: - return CardDefinition( - id=cid, - name=f"Trainer {cid}", - card_type=CardType.TRAINER, - trainer_type="item", - ) + return make_trainer(card_id) - mock_service.get_card = mock_get + cards = {"basic-1": 4, "basic-2": 3, "stage1-1": 4, "trainer-1": 4} - validator = DeckValidator(default_config, mock_service) - cards = { - "basic-1": 4, - "basic-2": 3, - "stage1-1": 4, - "trainer-1": 4, - } + count = count_basic_pokemon(cards, mixed_lookup) - count = validator.count_basic_pokemon(cards) - - # basic-1: 4 + basic-2: 3 = 7 - assert count == 7 - - def test_config_property(self, default_config, mock_card_service): - """Test that config property returns the injected DeckConfig.""" - validator = DeckValidator(default_config, mock_card_service) - - assert validator.config is default_config - assert validator.config.min_size == 40 - assert validator.config.energy_deck_size == 20 + assert count == 7 # basic-1: 4 + basic-2: 3