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
688 lines
19 KiB
Python
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,
|
|
)
|