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