mantimon-tcg/backend/tests/services/test_collection_service.py
Cal Corum 3ec670753b Fix security and validation issues from code review
Critical fixes:
- Add admin API key authentication for admin endpoints
- Add race condition protection via unique partial index for starter decks
- Make starter deck selection atomic with combined method

Moderate fixes:
- Fix DI pattern violation in validate_deck_endpoint
- Add card ID format validation (regex pattern)
- Add card quantity validation (1-99 range)
- Fix exception chaining with from None (B904)

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

466 lines
16 KiB
Python

"""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"