mantimon-tcg/backend/tests/services/test_deck_service.py
Cal Corum 7d397a2e22 Fix medium priority issues from code review
UNSET sentinel pattern:
- Add UNSET sentinel in protocols.py for nullable field updates
- Fix inability to clear deck description (UNSET=keep, None=clear)
- Fix repository inability to clear validation_errors

Starter deck improvements:
- Remove unused has_starter_deck from CollectionService
- Add deprecation notes to old starter deck methods

Validation improvements:
- Add energy type validation in deck_validator.py
- Add energy type validation in deck schemas
- Add VALID_ENERGY_TYPES constant

Game loading fix:
- Fix get_deck_for_game silently skipping invalid cards
- Now raises ValueError with clear error message

Tests:
- Add TestEnergyTypeValidation test class
- Add TestGetDeckForGame test class
- Add tests for validate_energy_types utility function

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:32:08 -06:00

884 lines
28 KiB
Python

"""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
# =============================================================================
# Get Deck For Game Tests
# =============================================================================
class TestGetDeckForGame:
"""Tests for expanding decks to CardDefinition lists for gameplay."""
@pytest.mark.asyncio
async def test_get_deck_for_game_returns_card_definitions(
self,
db_session: AsyncSession,
deck_service: DeckService,
):
"""
Test that get_deck_for_game returns CardDefinition objects.
Expands the deck dict into a list of CardDefinition with duplicates
based on quantity.
"""
user = await UserFactory.create(db_session)
# Create a deck with valid cards (a1-001-bulbasaur exists in test data)
deck = await DeckFactory.create_for_user(
db_session,
user,
cards={"a1-001-bulbasaur": 4},
energy_cards={"grass": 20},
)
cards = await deck_service.get_deck_for_game(user.id, deck.id)
assert len(cards) == 4
assert all(card.id == "a1-001-bulbasaur" for card in cards)
@pytest.mark.asyncio
async def test_get_deck_for_game_deck_not_found(
self,
db_session: AsyncSession,
deck_service: DeckService,
):
"""
Test that get_deck_for_game raises DeckNotFoundError.
Should fail for non-existent deck.
"""
user = await UserFactory.create(db_session)
from uuid import uuid4
with pytest.raises(DeckNotFoundError):
await deck_service.get_deck_for_game(user.id, uuid4())
@pytest.mark.asyncio
async def test_get_deck_for_game_invalid_card_raises(
self,
db_session: AsyncSession,
deck_service: DeckService,
):
"""
Test that get_deck_for_game raises ValueError for invalid cards.
If the deck contains card IDs that don't exist in the card database,
the method should raise ValueError rather than silently skipping.
This prevents games from starting with incomplete decks.
"""
user = await UserFactory.create(db_session)
# Create a deck with invalid card ID
deck = await DeckFactory.create_for_user(
db_session,
user,
cards={"nonexistent-card-id": 4, "also-invalid": 2},
energy_cards={"grass": 20},
)
with pytest.raises(ValueError) as exc_info:
await deck_service.get_deck_for_game(user.id, deck.id)
assert "invalid card" in str(exc_info.value).lower()
assert "nonexistent-card-id" in str(exc_info.value)