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>
466 lines
16 KiB
Python
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"
|