mantimon-tcg/backend/tests/factories.py
Cal Corum 50684a1b11 Add database infrastructure with SQLAlchemy models and test suite
Phase 1 Database Implementation (DB-001 through DB-012):

Models:
- User: OAuth support (Google/Discord), premium subscriptions
- Collection: Card ownership with CardSource enum
- Deck: JSONB cards/energy_cards, validation state
- CampaignProgress: One-to-one with User, medals/NPCs as JSONB
- ActiveGame: In-progress games with GameType enum
- GameHistory: Completed games with EndReason enum, replay data

Infrastructure:
- Alembic migrations with sync psycopg2 (avoids async issues)
- Docker Compose for Postgres (5433) and Redis (6380)
- App config with Pydantic settings
- Redis client helper

Test Infrastructure:
- 68 database tests (47 model + 21 relationship)
- Async factory pattern for test data creation
- Sync TRUNCATE cleanup (solves pytest-asyncio event loop mismatch)
- Uses dev containers instead of testcontainers for reliability

Key technical decisions:
- passive_deletes=True for ON DELETE SET NULL relationships
- NullPool for test sessions (no connection reuse)
- expire_on_commit=False with manual expire() for relationship tests
2026-01-27 10:17:30 -06:00

688 lines
19 KiB
Python

"""Async model factories for Mantimon TCG database tests.
This module provides factory classes for creating test data with sensible defaults.
Unlike factory_boy, these factories are designed for async SQLAlchemy sessions.
Usage:
@pytest.mark.asyncio
async def test_something(db_session):
# Create with defaults
user = await UserFactory.create(db_session)
# Create with overrides
premium_user = await UserFactory.create(
db_session,
is_premium=True,
display_name="VIP Player"
)
# Create batch
users = await UserFactory.create_batch(db_session, count=5)
# Create with relationships
deck = await DeckFactory.create_with_user(db_session, card_count=40)
Design Principles:
- Each factory has sensible defaults for all required fields
- Unique fields use counters or UUIDs to avoid conflicts
- Factories return persisted objects (flushed, with IDs)
- Relationship helpers create associated objects automatically
"""
from datetime import UTC, datetime, timedelta
from typing import Generic, TypeVar
from uuid import uuid4
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import (
ActiveGame,
CampaignProgress,
CardSource,
Collection,
Deck,
EndReason,
GameHistory,
GameType,
User,
)
T = TypeVar("T")
class AsyncFactory(Generic[T]): # noqa: UP046 - Using Generic for Python 3.11 compat
"""Base class for async model factories.
Provides common methods for creating and persisting model instances.
Subclasses must define `model` and implement `get_defaults()`.
"""
model: type[T]
_counter: int = 0
@classmethod
def _next_counter(cls) -> int:
"""Get the next unique counter value for this factory."""
cls._counter += 1
return cls._counter
@classmethod
async def create(
cls,
session: AsyncSession,
**overrides,
) -> T:
"""Create and persist a model instance.
Args:
session: Async SQLAlchemy session.
**overrides: Field values to override defaults.
Returns:
Persisted model instance with generated ID.
"""
defaults = cls.get_defaults()
defaults.update(overrides)
instance = cls.model(**defaults)
session.add(instance)
await session.flush()
await session.refresh(instance)
return instance
@classmethod
async def create_batch(
cls,
session: AsyncSession,
count: int,
**overrides,
) -> list[T]:
"""Create multiple model instances.
Args:
session: Async SQLAlchemy session.
count: Number of instances to create.
**overrides: Field values to override defaults (same for all).
Returns:
List of persisted model instances.
"""
return [await cls.create(session, **overrides) for _ in range(count)]
@classmethod
def get_defaults(cls) -> dict:
"""Get default field values for the model.
Override in subclasses to provide model-specific defaults.
Returns:
Dictionary of field name -> default value.
"""
raise NotImplementedError("Subclasses must implement get_defaults()")
class UserFactory(AsyncFactory[User]):
"""Factory for creating test User instances.
Defaults:
- Unique email based on counter
- Google OAuth provider
- Non-premium account
- No last login
Example:
user = await UserFactory.create(db_session)
premium = await UserFactory.create(db_session, is_premium=True)
"""
model = User
@classmethod
def get_defaults(cls) -> dict:
counter = cls._next_counter()
return {
"email": f"testuser{counter}@example.com",
"display_name": f"Test User {counter}",
"avatar_url": f"https://example.com/avatars/{counter}.png",
"oauth_provider": "google",
"oauth_id": f"google_{uuid4().hex}",
"is_premium": False,
"premium_until": None,
"last_login": None,
}
@classmethod
async def create_premium(
cls,
session: AsyncSession,
days_remaining: int = 30,
**overrides,
) -> User:
"""Create a premium user with active subscription.
Args:
session: Async SQLAlchemy session.
days_remaining: Days until premium expires.
**overrides: Additional field overrides.
Returns:
Premium user instance.
"""
premium_until = datetime.now(UTC) + timedelta(days=days_remaining)
return await cls.create(
session,
is_premium=True,
premium_until=premium_until,
**overrides,
)
class CollectionFactory(AsyncFactory[Collection]):
"""Factory for creating test Collection instances.
Defaults:
- Card from booster pack
- Quantity of 1
- Current timestamp for obtained_at
Note: Requires a user_id to be provided or use create_for_user().
Example:
user = await UserFactory.create(db_session)
card = await CollectionFactory.create(db_session, user_id=user.id)
"""
model = Collection
@classmethod
def get_defaults(cls) -> dict:
counter = cls._next_counter()
return {
"user_id": None, # Must be provided
"card_definition_id": f"test_card_{counter:04d}",
"quantity": 1,
"source": CardSource.BOOSTER,
"obtained_at": datetime.now(UTC),
}
@classmethod
async def create_for_user(
cls,
session: AsyncSession,
user: User,
card_count: int = 1,
source: CardSource = CardSource.BOOSTER,
**overrides,
) -> list[Collection]:
"""Create collection entries for a user.
Args:
session: Async SQLAlchemy session.
user: User to own the cards.
card_count: Number of different cards to add.
source: How cards were obtained.
**overrides: Additional field overrides.
Returns:
List of Collection entries.
"""
entries = []
for _ in range(card_count):
entry = await cls.create(
session,
user_id=user.id,
card_definition_id=f"card_{uuid4().hex[:8]}",
source=source,
**overrides,
)
entries.append(entry)
return entries
class DeckFactory(AsyncFactory[Deck]):
"""Factory for creating test Deck instances.
Defaults:
- Named "Test Deck N"
- Empty cards and energy_cards JSONB
- Invalid deck (is_valid=False)
- Not a starter deck
Note: Requires a user_id to be provided or use create_for_user().
Example:
user = await UserFactory.create(db_session)
deck = await DeckFactory.create(db_session, user_id=user.id)
"""
model = Deck
@classmethod
def get_defaults(cls) -> dict:
counter = cls._next_counter()
return {
"user_id": None, # Must be provided
"name": f"Test Deck {counter}",
"cards": {}, # JSONB - empty by default
"energy_cards": {}, # JSONB - empty by default
"is_valid": False,
"validation_errors": None,
"is_starter": False,
"starter_type": None,
"description": None,
}
@classmethod
async def create_for_user(
cls,
session: AsyncSession,
user: User,
**overrides,
) -> Deck:
"""Create a deck owned by a user.
Args:
session: Async SQLAlchemy session.
user: User to own the deck.
**overrides: Additional field overrides.
Returns:
Deck instance.
"""
return await cls.create(session, user_id=user.id, **overrides)
@classmethod
async def create_valid_deck(
cls,
session: AsyncSession,
user: User,
**overrides,
) -> Deck:
"""Create a valid deck with sample cards.
Creates a deck that passes basic validation with:
- 20 Pokemon cards
- 10 Trainer cards
- 10 Energy cards
Args:
session: Async SQLAlchemy session.
user: User to own the deck.
**overrides: Additional field overrides.
Returns:
Valid deck instance.
"""
cards = {
"pikachu_base_001": 4,
"raichu_base_001": 2,
"charmander_base_001": 4,
"charmeleon_base_001": 2,
"charizard_base_001": 2,
"bulbasaur_base_001": 4,
"ivysaur_base_001": 2,
"potion_001": 4,
"professor_oak_001": 4,
"pokeball_001": 2,
}
energy_cards = {
"lightning": 4,
"fire": 4,
"grass": 2,
}
return await cls.create(
session,
user_id=user.id,
cards=cards,
energy_cards=energy_cards,
is_valid=True,
validation_errors=None,
**overrides,
)
@classmethod
async def create_starter_deck(
cls,
session: AsyncSession,
user: User,
starter_type: str = "fire",
**overrides,
) -> Deck:
"""Create a starter deck.
Args:
session: Async SQLAlchemy session.
user: User to own the deck.
starter_type: Type of starter (fire, water, grass, etc.).
**overrides: Additional field overrides.
Returns:
Starter deck instance.
"""
return await cls.create(
session,
user_id=user.id,
name=f"{starter_type.title()} Starter Deck",
is_starter=True,
starter_type=starter_type,
is_valid=True,
**overrides,
)
class CampaignProgressFactory(AsyncFactory[CampaignProgress]):
"""Factory for creating test CampaignProgress instances.
Defaults:
- At grass_club
- No medals or defeated NPCs
- Zero wins/losses
- Zero booster packs and mantibucks
Note: Requires a user_id to be provided or use create_for_user().
Each user can only have one CampaignProgress (one-to-one).
Example:
user = await UserFactory.create(db_session)
progress = await CampaignProgressFactory.create(db_session, user_id=user.id)
"""
model = CampaignProgress
@classmethod
def get_defaults(cls) -> dict:
return {
"user_id": None, # Must be provided (one-to-one)
"current_club": "grass_club",
"medals": [], # JSONB
"defeated_npcs": [], # JSONB
"total_wins": 0,
"total_losses": 0,
"booster_packs": 0,
"mantibucks": 0,
}
@classmethod
async def create_for_user(
cls,
session: AsyncSession,
user: User,
**overrides,
) -> CampaignProgress:
"""Create campaign progress for a user.
Args:
session: Async SQLAlchemy session.
user: User to track progress for.
**overrides: Additional field overrides.
Returns:
CampaignProgress instance.
"""
return await cls.create(session, user_id=user.id, **overrides)
@classmethod
async def create_advanced(
cls,
session: AsyncSession,
user: User,
medals_count: int = 4,
**overrides,
) -> CampaignProgress:
"""Create campaign progress partway through the game.
Args:
session: Async SQLAlchemy session.
user: User to track progress for.
medals_count: Number of medals earned (0-8).
**overrides: Additional field overrides.
Returns:
CampaignProgress instance with progress.
"""
medal_types = [
"grass_medal",
"fire_medal",
"water_medal",
"lightning_medal",
"psychic_medal",
"fighting_medal",
"science_medal",
"rock_medal",
]
medals = medal_types[:medals_count]
npcs = [f"trainer_{i}" for i in range(medals_count * 3)]
return await cls.create(
session,
user_id=user.id,
medals=medals,
defeated_npcs=npcs,
total_wins=medals_count * 5,
total_losses=medals_count * 2,
booster_packs=medals_count,
mantibucks=medals_count * 100,
**overrides,
)
class ActiveGameFactory(AsyncFactory[ActiveGame]):
"""Factory for creating test ActiveGame instances.
Defaults:
- Campaign game type
- Turn 1
- Empty game state and rules config
Note: Requires player1_id to be provided.
Example:
user = await UserFactory.create(db_session)
game = await ActiveGameFactory.create(db_session, player1_id=user.id)
"""
model = ActiveGame
@classmethod
def get_defaults(cls) -> dict:
return {
"game_type": GameType.CAMPAIGN,
"player1_id": None, # Must be provided
"player2_id": None, # Optional for PvP
"npc_id": "grass_trainer_1", # Default NPC opponent
"rules_config": {"prize_count": 4, "deck_size": 40}, # JSONB
"game_state": {"turn": 1, "phase": "main"}, # JSONB placeholder
"turn_number": 1,
"started_at": datetime.now(UTC),
"last_action_at": datetime.now(UTC),
"turn_deadline": None,
}
@classmethod
async def create_campaign_game(
cls,
session: AsyncSession,
player: User,
npc_id: str = "grass_trainer_1",
**overrides,
) -> ActiveGame:
"""Create a campaign game against an NPC.
Args:
session: Async SQLAlchemy session.
player: Player in the campaign.
npc_id: ID of the NPC opponent.
**overrides: Additional field overrides.
Returns:
ActiveGame instance.
"""
return await cls.create(
session,
game_type=GameType.CAMPAIGN,
player1_id=player.id,
player2_id=None,
npc_id=npc_id,
**overrides,
)
@classmethod
async def create_pvp_game(
cls,
session: AsyncSession,
player1: User,
player2: User,
game_type: GameType = GameType.FREEPLAY,
**overrides,
) -> ActiveGame:
"""Create a PvP game between two players.
Args:
session: Async SQLAlchemy session.
player1: First player.
player2: Second player.
game_type: FREEPLAY or RANKED.
**overrides: Additional field overrides.
Returns:
ActiveGame instance.
"""
return await cls.create(
session,
game_type=game_type,
player1_id=player1.id,
player2_id=player2.id,
npc_id=None,
**overrides,
)
class GameHistoryFactory(AsyncFactory[GameHistory]):
"""Factory for creating test GameHistory instances.
Defaults:
- Campaign game that player won
- Ended by taking all prizes
- 10 turns, 300 seconds duration
Note: Requires player1_id to be provided.
Example:
user = await UserFactory.create(db_session)
history = await GameHistoryFactory.create(db_session, player1_id=user.id)
"""
model = GameHistory
@classmethod
def get_defaults(cls) -> dict:
return {
"game_type": GameType.CAMPAIGN,
"player1_id": None, # Must be provided
"player2_id": None,
"npc_id": "grass_trainer_1",
"winner_id": None, # Set to player1_id for player win
"winner_is_npc": False,
"end_reason": EndReason.PRIZES_TAKEN,
"turn_count": 10,
"duration_seconds": 300,
"replay_data": None, # JSONB
"played_at": datetime.now(UTC),
}
@classmethod
async def create_player_win(
cls,
session: AsyncSession,
player: User,
npc_id: str = "grass_trainer_1",
**overrides,
) -> GameHistory:
"""Create a game history where the player won.
Args:
session: Async SQLAlchemy session.
player: Winning player.
npc_id: NPC opponent ID.
**overrides: Additional field overrides.
Returns:
GameHistory instance.
"""
return await cls.create(
session,
game_type=GameType.CAMPAIGN,
player1_id=player.id,
winner_id=player.id,
winner_is_npc=False,
npc_id=npc_id,
**overrides,
)
@classmethod
async def create_player_loss(
cls,
session: AsyncSession,
player: User,
npc_id: str = "grass_trainer_1",
end_reason: EndReason = EndReason.NO_POKEMON,
**overrides,
) -> GameHistory:
"""Create a game history where the player lost to NPC.
Args:
session: Async SQLAlchemy session.
player: Losing player.
npc_id: NPC opponent ID.
end_reason: How the game ended.
**overrides: Additional field overrides.
Returns:
GameHistory instance.
"""
return await cls.create(
session,
game_type=GameType.CAMPAIGN,
player1_id=player.id,
winner_id=None,
winner_is_npc=True,
npc_id=npc_id,
end_reason=end_reason,
**overrides,
)
@classmethod
async def create_pvp_game(
cls,
session: AsyncSession,
player1: User,
player2: User,
winner: User | None = None,
game_type: GameType = GameType.FREEPLAY,
**overrides,
) -> GameHistory:
"""Create a PvP game history.
Args:
session: Async SQLAlchemy session.
player1: First player.
player2: Second player.
winner: Winning player (None for draw).
game_type: FREEPLAY or RANKED.
**overrides: Additional field overrides.
Returns:
GameHistory instance.
"""
end_reason = EndReason.DRAW if winner is None else EndReason.PRIZES_TAKEN
return await cls.create(
session,
game_type=game_type,
player1_id=player1.id,
player2_id=player2.id,
npc_id=None,
winner_id=winner.id if winner else None,
winner_is_npc=False,
end_reason=end_reason,
**overrides,
)