CLAUDE: Implement polymorphic RosterLink for both PD and SBA leagues
Added league-agnostic roster tracking with single-table design: Database Changes: - Modified RosterLink model with surrogate primary key (id) - Added nullable card_id (PD) and player_id (SBA) columns - Added CHECK constraint ensuring exactly one ID populated (XOR logic) - Added unique constraints for (game_id, card_id) and (game_id, player_id) - Imported CheckConstraint and UniqueConstraint from SQLAlchemy New Files: - app/models/roster_models.py: Pydantic models for type safety - BaseRosterLinkData: Abstract base class - PdRosterLinkData: PD league card-based rosters - SbaRosterLinkData: SBA league player-based rosters - RosterLinkCreate: Request validation model - tests/unit/models/test_roster_models.py: 24 unit tests (all passing) - Tests for PD/SBA roster link creation and validation - Tests for RosterLinkCreate XOR validation - Tests for polymorphic behavior Database Operations: - add_pd_roster_card(): Add PD card to game roster - add_sba_roster_player(): Add SBA player to game roster - get_pd_roster(): Get PD cards with optional team filter - get_sba_roster(): Get SBA players with optional team filter - remove_roster_entry(): Remove roster entry by ID Tests: - Added 12 integration tests for roster operations - Fixed setup_database fixture scope (module → function) Documentation: - Updated backend/CLAUDE.md with RosterLink documentation - Added usage examples and design rationale - Updated Game model relationship description Design Pattern: Single table with application-layer type safety rather than SQLAlchemy polymorphic inheritance. Simpler queries, database-enforced integrity, and Pydantic type safety at application layer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c7e8804183
commit
3c5055dbf6
@ -237,7 +237,7 @@ Primary game container with state tracking.
|
||||
- `plays`: All plays in the game (cascade delete)
|
||||
- `lineups`: All lineup entries (cascade delete)
|
||||
- `cardset_links`: PD only - approved cardsets (cascade delete)
|
||||
- `roster_links`: PD only - cards in use (cascade delete)
|
||||
- `roster_links`: Roster tracking - cards (PD) or players (SBA) (cascade delete)
|
||||
- `session`: Real-time WebSocket session (cascade delete)
|
||||
|
||||
---
|
||||
@ -340,11 +340,48 @@ PD league only - defines legal cardsets for a game.
|
||||
---
|
||||
|
||||
#### **RosterLink** (`roster_links`)
|
||||
PD league only - tracks which cards each team is using.
|
||||
Tracks eligible cards (PD) or players (SBA) for a game.
|
||||
|
||||
**Polymorphic Design**: Single table supporting both leagues with application-layer type safety.
|
||||
|
||||
**Key Fields:**
|
||||
- `game_id`, `card_id`: Composite primary key
|
||||
- `team_id`: Which team owns this card in this game
|
||||
- `id` (Integer): Surrogate primary key (auto-increment)
|
||||
- `game_id` (UUID): Foreign key to games table
|
||||
- `card_id` (Integer, nullable): PD league - card identifier
|
||||
- `player_id` (Integer, nullable): SBA league - player identifier
|
||||
- `team_id` (Integer): Which team owns this entity in this game
|
||||
|
||||
**Constraints:**
|
||||
- `roster_link_one_id_required`: CHECK constraint ensures exactly one of `card_id` or `player_id` is populated (XOR logic)
|
||||
- `uq_game_card`: UNIQUE constraint on (game_id, card_id) for PD
|
||||
- `uq_game_player`: UNIQUE constraint on (game_id, player_id) for SBA
|
||||
|
||||
**Usage Pattern:**
|
||||
```python
|
||||
# PD league - add card to roster
|
||||
roster_data = await db_ops.add_pd_roster_card(
|
||||
game_id=game_id,
|
||||
card_id=123,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
# SBA league - add player to roster
|
||||
roster_data = await db_ops.add_sba_roster_player(
|
||||
game_id=game_id,
|
||||
player_id=456,
|
||||
team_id=2
|
||||
)
|
||||
|
||||
# Get roster (league-specific)
|
||||
pd_roster = await db_ops.get_pd_roster(game_id, team_id=1)
|
||||
sba_roster = await db_ops.get_sba_roster(game_id, team_id=2)
|
||||
```
|
||||
|
||||
**Design Rationale:**
|
||||
- Single table avoids complex joins and simplifies queries
|
||||
- Nullable columns with CHECK constraint ensures data integrity at database level
|
||||
- Pydantic models (`PdRosterLinkData`, `SbaRosterLinkData`) provide type safety at application layer
|
||||
- Surrogate key allows nullable columns (can't use nullable columns in composite PK)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -15,7 +15,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.database.session import AsyncSessionLocal
|
||||
from app.models.db_models import Game, Play, Lineup, GameSession
|
||||
from app.models.db_models import Game, Play, Lineup, GameSession, RosterLink
|
||||
from app.models.roster_models import PdRosterLinkData, SbaRosterLinkData
|
||||
|
||||
logger = logging.getLogger(f'{__name__}.DatabaseOperations')
|
||||
|
||||
@ -406,3 +407,204 @@ class DatabaseOperations:
|
||||
await session.rollback()
|
||||
logger.error(f"Failed to update session snapshot: {e}")
|
||||
raise
|
||||
|
||||
async def add_pd_roster_card(
|
||||
self,
|
||||
game_id: UUID,
|
||||
card_id: int,
|
||||
team_id: int
|
||||
) -> PdRosterLinkData:
|
||||
"""
|
||||
Add a PD card to game roster.
|
||||
|
||||
Args:
|
||||
game_id: Game identifier
|
||||
card_id: Card identifier
|
||||
team_id: Team identifier
|
||||
|
||||
Returns:
|
||||
PdRosterLinkData with populated id
|
||||
|
||||
Raises:
|
||||
ValueError: If card already rostered or constraint violation
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
roster_link = RosterLink(
|
||||
game_id=game_id,
|
||||
card_id=card_id,
|
||||
team_id=team_id
|
||||
)
|
||||
session.add(roster_link)
|
||||
await session.commit()
|
||||
await session.refresh(roster_link)
|
||||
logger.info(f"Added PD card {card_id} to roster for game {game_id}")
|
||||
|
||||
return PdRosterLinkData(
|
||||
id=roster_link.id,
|
||||
game_id=roster_link.game_id,
|
||||
card_id=roster_link.card_id,
|
||||
team_id=roster_link.team_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Failed to add PD roster card: {e}")
|
||||
raise ValueError(f"Could not add card to roster: {e}")
|
||||
|
||||
async def add_sba_roster_player(
|
||||
self,
|
||||
game_id: UUID,
|
||||
player_id: int,
|
||||
team_id: int
|
||||
) -> SbaRosterLinkData:
|
||||
"""
|
||||
Add an SBA player to game roster.
|
||||
|
||||
Args:
|
||||
game_id: Game identifier
|
||||
player_id: Player identifier
|
||||
team_id: Team identifier
|
||||
|
||||
Returns:
|
||||
SbaRosterLinkData with populated id
|
||||
|
||||
Raises:
|
||||
ValueError: If player already rostered or constraint violation
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
roster_link = RosterLink(
|
||||
game_id=game_id,
|
||||
player_id=player_id,
|
||||
team_id=team_id
|
||||
)
|
||||
session.add(roster_link)
|
||||
await session.commit()
|
||||
await session.refresh(roster_link)
|
||||
logger.info(f"Added SBA player {player_id} to roster for game {game_id}")
|
||||
|
||||
return SbaRosterLinkData(
|
||||
id=roster_link.id,
|
||||
game_id=roster_link.game_id,
|
||||
player_id=roster_link.player_id,
|
||||
team_id=roster_link.team_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Failed to add SBA roster player: {e}")
|
||||
raise ValueError(f"Could not add player to roster: {e}")
|
||||
|
||||
async def get_pd_roster(
|
||||
self,
|
||||
game_id: UUID,
|
||||
team_id: Optional[int] = None
|
||||
) -> List[PdRosterLinkData]:
|
||||
"""
|
||||
Get PD cards for a game, optionally filtered by team.
|
||||
|
||||
Args:
|
||||
game_id: Game identifier
|
||||
team_id: Optional team filter
|
||||
|
||||
Returns:
|
||||
List of PdRosterLinkData
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
query = select(RosterLink).where(
|
||||
RosterLink.game_id == game_id,
|
||||
RosterLink.card_id.is_not(None)
|
||||
)
|
||||
|
||||
if team_id is not None:
|
||||
query = query.where(RosterLink.team_id == team_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
roster_links = result.scalars().all()
|
||||
|
||||
return [
|
||||
PdRosterLinkData(
|
||||
id=link.id,
|
||||
game_id=link.game_id,
|
||||
card_id=link.card_id,
|
||||
team_id=link.team_id
|
||||
)
|
||||
for link in roster_links
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get PD roster: {e}")
|
||||
raise
|
||||
|
||||
async def get_sba_roster(
|
||||
self,
|
||||
game_id: UUID,
|
||||
team_id: Optional[int] = None
|
||||
) -> List[SbaRosterLinkData]:
|
||||
"""
|
||||
Get SBA players for a game, optionally filtered by team.
|
||||
|
||||
Args:
|
||||
game_id: Game identifier
|
||||
team_id: Optional team filter
|
||||
|
||||
Returns:
|
||||
List of SbaRosterLinkData
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
query = select(RosterLink).where(
|
||||
RosterLink.game_id == game_id,
|
||||
RosterLink.player_id.is_not(None)
|
||||
)
|
||||
|
||||
if team_id is not None:
|
||||
query = query.where(RosterLink.team_id == team_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
roster_links = result.scalars().all()
|
||||
|
||||
return [
|
||||
SbaRosterLinkData(
|
||||
id=link.id,
|
||||
game_id=link.game_id,
|
||||
player_id=link.player_id,
|
||||
team_id=link.team_id
|
||||
)
|
||||
for link in roster_links
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get SBA roster: {e}")
|
||||
raise
|
||||
|
||||
async def remove_roster_entry(self, roster_id: int) -> None:
|
||||
"""
|
||||
Remove a roster entry by ID.
|
||||
|
||||
Args:
|
||||
roster_id: RosterLink ID
|
||||
|
||||
Raises:
|
||||
ValueError: If roster entry not found
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(RosterLink).where(RosterLink.id == roster_id)
|
||||
)
|
||||
roster_link = result.scalar_one_or_none()
|
||||
|
||||
if not roster_link:
|
||||
raise ValueError(f"Roster entry {roster_id} not found")
|
||||
|
||||
await session.delete(roster_link)
|
||||
await session.commit()
|
||||
logger.info(f"Removed roster entry {roster_id}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Failed to remove roster entry: {e}")
|
||||
raise
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
"""Models package - exports for easy importing"""
|
||||
|
||||
from app.models.db_models import (
|
||||
Game,
|
||||
Play,
|
||||
Lineup,
|
||||
GameSession,
|
||||
RosterLink,
|
||||
GameCardsetLink,
|
||||
)
|
||||
from app.models.game_models import (
|
||||
GameState,
|
||||
RunnerState,
|
||||
LineupPlayerState,
|
||||
TeamLineupState,
|
||||
DefensiveDecision,
|
||||
OffensiveDecision,
|
||||
)
|
||||
from app.models.roster_models import (
|
||||
BaseRosterLinkData,
|
||||
PdRosterLinkData,
|
||||
SbaRosterLinkData,
|
||||
RosterLinkCreate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Database models
|
||||
"Game",
|
||||
"Play",
|
||||
"Lineup",
|
||||
"GameSession",
|
||||
"RosterLink",
|
||||
"GameCardsetLink",
|
||||
# Game state models
|
||||
"GameState",
|
||||
"RunnerState",
|
||||
"LineupPlayerState",
|
||||
"TeamLineupState",
|
||||
"DefensiveDecision",
|
||||
"OffensiveDecision",
|
||||
# Roster models
|
||||
"BaseRosterLinkData",
|
||||
"PdRosterLinkData",
|
||||
"SbaRosterLinkData",
|
||||
"RosterLinkCreate",
|
||||
]
|
||||
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON, Text, ForeignKey, Float
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON, Text, ForeignKey, Float, CheckConstraint, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
@ -20,16 +20,39 @@ class GameCardsetLink(Base):
|
||||
|
||||
|
||||
class RosterLink(Base):
|
||||
"""Tracks which cards each team is using in a game - PD only"""
|
||||
"""Tracks eligible cards (PD) or players (SBA) for a game
|
||||
|
||||
PD League: Uses card_id to track which cards are rostered
|
||||
SBA League: Uses player_id to track which players are rostered
|
||||
|
||||
Exactly one of card_id or player_id must be populated per row.
|
||||
"""
|
||||
__tablename__ = "roster_links"
|
||||
|
||||
game_id = Column(UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), primary_key=True)
|
||||
card_id = Column(Integer, primary_key=True)
|
||||
# Surrogate primary key (allows nullable card_id/player_id)
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
game_id = Column(UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
card_id = Column(Integer, nullable=True) # PD only
|
||||
player_id = Column(Integer, nullable=True) # SBA only
|
||||
team_id = Column(Integer, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
game = relationship("Game", back_populates="roster_links")
|
||||
|
||||
# Table-level constraints
|
||||
__table_args__ = (
|
||||
# Ensure exactly one ID is populated (XOR logic)
|
||||
CheckConstraint(
|
||||
'(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1',
|
||||
name='roster_link_one_id_required'
|
||||
),
|
||||
# Unique constraint for PD: one card per game
|
||||
UniqueConstraint('game_id', 'card_id', name='uq_game_card'),
|
||||
# Unique constraint for SBA: one player per game
|
||||
UniqueConstraint('game_id', 'player_id', name='uq_game_player'),
|
||||
)
|
||||
|
||||
|
||||
class Game(Base):
|
||||
"""Game model"""
|
||||
|
||||
121
backend/app/models/roster_models.py
Normal file
121
backend/app/models/roster_models.py
Normal file
@ -0,0 +1,121 @@
|
||||
"""Pydantic models for roster link type safety
|
||||
|
||||
Provides league-specific type-safe models for roster operations:
|
||||
- PdRosterLinkData: PD league card-based rosters
|
||||
- SbaRosterLinkData: SBA league player-based rosters
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
|
||||
class BaseRosterLinkData(BaseModel, ABC):
|
||||
"""Abstract base for roster link data
|
||||
|
||||
Common fields shared across all leagues
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True)
|
||||
|
||||
id: Optional[int] = None # Database ID (populated after save)
|
||||
game_id: UUID
|
||||
team_id: int
|
||||
|
||||
@abstractmethod
|
||||
def get_entity_id(self) -> int:
|
||||
"""Get the entity ID (card_id or player_id)"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_entity_type(self) -> str:
|
||||
"""Get entity type identifier ('card' or 'player')"""
|
||||
pass
|
||||
|
||||
|
||||
class PdRosterLinkData(BaseRosterLinkData):
|
||||
"""PD league roster link - tracks cards
|
||||
|
||||
Used for Paper Dynasty league games where rosters are composed of cards.
|
||||
Each card represents a player with detailed scouting data.
|
||||
"""
|
||||
|
||||
card_id: int
|
||||
|
||||
@field_validator("card_id")
|
||||
@classmethod
|
||||
def validate_card_id(cls, v: int) -> int:
|
||||
if v <= 0:
|
||||
raise ValueError("card_id must be positive")
|
||||
return v
|
||||
|
||||
def get_entity_id(self) -> int:
|
||||
return self.card_id
|
||||
|
||||
def get_entity_type(self) -> str:
|
||||
return "card"
|
||||
|
||||
|
||||
class SbaRosterLinkData(BaseRosterLinkData):
|
||||
"""SBA league roster link - tracks players
|
||||
|
||||
Used for SBA league games where rosters are composed of players.
|
||||
Players are identified directly by player_id without a card system.
|
||||
"""
|
||||
|
||||
player_id: int
|
||||
|
||||
@field_validator("player_id")
|
||||
@classmethod
|
||||
def validate_player_id(cls, v: int) -> int:
|
||||
if v <= 0:
|
||||
raise ValueError("player_id must be positive")
|
||||
return v
|
||||
|
||||
def get_entity_id(self) -> int:
|
||||
return self.player_id
|
||||
|
||||
def get_entity_type(self) -> str:
|
||||
return "player"
|
||||
|
||||
|
||||
class RosterLinkCreate(BaseModel):
|
||||
"""Request model for creating a roster link"""
|
||||
|
||||
game_id: UUID
|
||||
team_id: int
|
||||
card_id: Optional[int] = None
|
||||
player_id: Optional[int] = None
|
||||
|
||||
@field_validator("team_id")
|
||||
@classmethod
|
||||
def validate_team_id(cls, v: int) -> int:
|
||||
if v <= 0:
|
||||
raise ValueError("team_id must be positive")
|
||||
return v
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
"""Validate that exactly one ID is populated"""
|
||||
has_card = self.card_id is not None
|
||||
has_player = self.player_id is not None
|
||||
|
||||
if has_card == has_player: # XOR check (both True or both False = invalid)
|
||||
raise ValueError("Exactly one of card_id or player_id must be provided")
|
||||
|
||||
def to_pd_data(self) -> PdRosterLinkData:
|
||||
"""Convert to PD roster data (validates card_id is present)"""
|
||||
if self.card_id is None:
|
||||
raise ValueError("card_id required for PD roster")
|
||||
return PdRosterLinkData(
|
||||
game_id=self.game_id, team_id=self.team_id, card_id=self.card_id
|
||||
)
|
||||
|
||||
def to_sba_data(self) -> SbaRosterLinkData:
|
||||
"""Convert to SBA roster data (validates player_id is present)"""
|
||||
if self.player_id is None:
|
||||
raise ValueError("player_id required for SBA roster")
|
||||
return SbaRosterLinkData(
|
||||
game_id=self.game_id, team_id=self.team_id, player_id=self.player_id
|
||||
)
|
||||
@ -19,14 +19,14 @@ from app.database.session import init_db, engine
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture(scope="function")
|
||||
async def setup_database():
|
||||
"""
|
||||
Set up test database schema.
|
||||
|
||||
Runs once per test module.
|
||||
Runs once per test function (noop if tables exist).
|
||||
"""
|
||||
# Create all tables
|
||||
# Create all tables (will skip if they exist)
|
||||
await init_db()
|
||||
yield
|
||||
# Teardown if needed (tables persist between test runs)
|
||||
@ -476,3 +476,270 @@ class TestDatabaseOperationsGameSession:
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await db_ops.update_session_snapshot(fake_id, {})
|
||||
|
||||
|
||||
class TestDatabaseOperationsRoster:
|
||||
"""Tests for roster link operations"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_pd_roster_card(self, setup_database, db_ops, sample_game_id):
|
||||
"""Test adding a PD card to roster"""
|
||||
# Create game first
|
||||
await db_ops.create_game(
|
||||
game_id=sample_game_id,
|
||||
league_id="pd",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
game_mode="friendly",
|
||||
visibility="public"
|
||||
)
|
||||
|
||||
# Add roster card
|
||||
roster_data = await db_ops.add_pd_roster_card(
|
||||
game_id=sample_game_id,
|
||||
card_id=123,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
assert roster_data.id is not None
|
||||
assert roster_data.game_id == sample_game_id
|
||||
assert roster_data.card_id == 123
|
||||
assert roster_data.team_id == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_sba_roster_player(self, setup_database, db_ops, sample_game_id):
|
||||
"""Test adding an SBA player to roster"""
|
||||
# Create game first
|
||||
await db_ops.create_game(
|
||||
game_id=sample_game_id,
|
||||
league_id="sba",
|
||||
home_team_id=10,
|
||||
away_team_id=20,
|
||||
game_mode="friendly",
|
||||
visibility="public"
|
||||
)
|
||||
|
||||
# Add roster player
|
||||
roster_data = await db_ops.add_sba_roster_player(
|
||||
game_id=sample_game_id,
|
||||
player_id=456,
|
||||
team_id=10
|
||||
)
|
||||
|
||||
assert roster_data.id is not None
|
||||
assert roster_data.game_id == sample_game_id
|
||||
assert roster_data.player_id == 456
|
||||
assert roster_data.team_id == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_duplicate_pd_card_raises_error(self, setup_database, db_ops, sample_game_id):
|
||||
"""Test adding duplicate PD card to roster fails"""
|
||||
# Create game and add card
|
||||
await db_ops.create_game(
|
||||
game_id=sample_game_id,
|
||||
league_id="pd",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
game_mode="friendly",
|
||||
visibility="public"
|
||||
)
|
||||
await db_ops.add_pd_roster_card(
|
||||
game_id=sample_game_id,
|
||||
card_id=123,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
# Try to add same card again - should fail
|
||||
with pytest.raises(ValueError, match="Could not add card to roster"):
|
||||
await db_ops.add_pd_roster_card(
|
||||
game_id=sample_game_id,
|
||||
card_id=123,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_duplicate_sba_player_raises_error(self, setup_database, db_ops, sample_game_id):
|
||||
"""Test adding duplicate SBA player to roster fails"""
|
||||
# Create game and add player
|
||||
await db_ops.create_game(
|
||||
game_id=sample_game_id,
|
||||
league_id="sba",
|
||||
home_team_id=10,
|
||||
away_team_id=20,
|
||||
game_mode="friendly",
|
||||
visibility="public"
|
||||
)
|
||||
await db_ops.add_sba_roster_player(
|
||||
game_id=sample_game_id,
|
||||
player_id=456,
|
||||
team_id=10
|
||||
)
|
||||
|
||||
# Try to add same player again - should fail
|
||||
with pytest.raises(ValueError, match="Could not add player to roster"):
|
||||
await db_ops.add_sba_roster_player(
|
||||
game_id=sample_game_id,
|
||||
player_id=456,
|
||||
team_id=10
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pd_roster_all_teams(self, setup_database, db_ops, sample_game_id):
|
||||
"""Test getting all PD cards for a game"""
|
||||
# Create game and add cards for both teams
|
||||
await db_ops.create_game(
|
||||
game_id=sample_game_id,
|
||||
league_id="pd",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
game_mode="friendly",
|
||||
visibility="public"
|
||||
)
|
||||
|
||||
await db_ops.add_pd_roster_card(sample_game_id, 101, 1)
|
||||
await db_ops.add_pd_roster_card(sample_game_id, 102, 1)
|
||||
await db_ops.add_pd_roster_card(sample_game_id, 201, 2)
|
||||
|
||||
# Get all roster entries
|
||||
roster = await db_ops.get_pd_roster(sample_game_id)
|
||||
|
||||
assert len(roster) == 3
|
||||
card_ids = {r.card_id for r in roster}
|
||||
assert card_ids == {101, 102, 201}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pd_roster_filtered_by_team(self, setup_database, db_ops, sample_game_id):
|
||||
"""Test getting PD cards filtered by team"""
|
||||
# Create game and add cards for both teams
|
||||
await db_ops.create_game(
|
||||
game_id=sample_game_id,
|
||||
league_id="pd",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
game_mode="friendly",
|
||||
visibility="public"
|
||||
)
|
||||
|
||||
await db_ops.add_pd_roster_card(sample_game_id, 101, 1)
|
||||
await db_ops.add_pd_roster_card(sample_game_id, 102, 1)
|
||||
await db_ops.add_pd_roster_card(sample_game_id, 201, 2)
|
||||
|
||||
# Get team 1 roster
|
||||
team1_roster = await db_ops.get_pd_roster(sample_game_id, team_id=1)
|
||||
|
||||
assert len(team1_roster) == 2
|
||||
card_ids = {r.card_id for r in team1_roster}
|
||||
assert card_ids == {101, 102}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sba_roster_all_teams(self, setup_database, db_ops, sample_game_id):
|
||||
"""Test getting all SBA players for a game"""
|
||||
# Create game and add players for both teams
|
||||
await db_ops.create_game(
|
||||
game_id=sample_game_id,
|
||||
league_id="sba",
|
||||
home_team_id=10,
|
||||
away_team_id=20,
|
||||
game_mode="friendly",
|
||||
visibility="public"
|
||||
)
|
||||
|
||||
await db_ops.add_sba_roster_player(sample_game_id, 401, 10)
|
||||
await db_ops.add_sba_roster_player(sample_game_id, 402, 10)
|
||||
await db_ops.add_sba_roster_player(sample_game_id, 501, 20)
|
||||
|
||||
# Get all roster entries
|
||||
roster = await db_ops.get_sba_roster(sample_game_id)
|
||||
|
||||
assert len(roster) == 3
|
||||
player_ids = {r.player_id for r in roster}
|
||||
assert player_ids == {401, 402, 501}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sba_roster_filtered_by_team(self, setup_database, db_ops, sample_game_id):
|
||||
"""Test getting SBA players filtered by team"""
|
||||
# Create game and add players for both teams
|
||||
await db_ops.create_game(
|
||||
game_id=sample_game_id,
|
||||
league_id="sba",
|
||||
home_team_id=10,
|
||||
away_team_id=20,
|
||||
game_mode="friendly",
|
||||
visibility="public"
|
||||
)
|
||||
|
||||
await db_ops.add_sba_roster_player(sample_game_id, 401, 10)
|
||||
await db_ops.add_sba_roster_player(sample_game_id, 402, 10)
|
||||
await db_ops.add_sba_roster_player(sample_game_id, 501, 20)
|
||||
|
||||
# Get team 10 roster
|
||||
team10_roster = await db_ops.get_sba_roster(sample_game_id, team_id=10)
|
||||
|
||||
assert len(team10_roster) == 2
|
||||
player_ids = {r.player_id for r in team10_roster}
|
||||
assert player_ids == {401, 402}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_roster_entry(self, setup_database, db_ops, sample_game_id):
|
||||
"""Test removing a roster entry"""
|
||||
# Create game and add card
|
||||
await db_ops.create_game(
|
||||
game_id=sample_game_id,
|
||||
league_id="pd",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
game_mode="friendly",
|
||||
visibility="public"
|
||||
)
|
||||
roster_data = await db_ops.add_pd_roster_card(
|
||||
game_id=sample_game_id,
|
||||
card_id=123,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
# Remove it
|
||||
await db_ops.remove_roster_entry(roster_data.id)
|
||||
|
||||
# Verify it's gone
|
||||
roster = await db_ops.get_pd_roster(sample_game_id)
|
||||
assert len(roster) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_nonexistent_roster_entry_raises_error(self, setup_database, db_ops):
|
||||
"""Test removing nonexistent roster entry fails"""
|
||||
fake_id = 999999
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await db_ops.remove_roster_entry(fake_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_empty_pd_roster(self, setup_database, db_ops, sample_game_id):
|
||||
"""Test getting PD roster for game with no cards"""
|
||||
# Create game but don't add any cards
|
||||
await db_ops.create_game(
|
||||
game_id=sample_game_id,
|
||||
league_id="pd",
|
||||
home_team_id=1,
|
||||
away_team_id=2,
|
||||
game_mode="friendly",
|
||||
visibility="public"
|
||||
)
|
||||
|
||||
roster = await db_ops.get_pd_roster(sample_game_id)
|
||||
assert len(roster) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_empty_sba_roster(self, setup_database, db_ops, sample_game_id):
|
||||
"""Test getting SBA roster for game with no players"""
|
||||
# Create game but don't add any players
|
||||
await db_ops.create_game(
|
||||
game_id=sample_game_id,
|
||||
league_id="sba",
|
||||
home_team_id=10,
|
||||
away_team_id=20,
|
||||
game_mode="friendly",
|
||||
visibility="public"
|
||||
)
|
||||
|
||||
roster = await db_ops.get_sba_roster(sample_game_id)
|
||||
assert len(roster) == 0
|
||||
|
||||
356
backend/tests/unit/models/test_roster_models.py
Normal file
356
backend/tests/unit/models/test_roster_models.py
Normal file
@ -0,0 +1,356 @@
|
||||
"""
|
||||
Unit tests for roster models (Pydantic).
|
||||
|
||||
Tests polymorphic roster link models for type safety across leagues.
|
||||
|
||||
Author: Claude
|
||||
Date: 2025-10-22
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.models.roster_models import (
|
||||
PdRosterLinkData,
|
||||
SbaRosterLinkData,
|
||||
RosterLinkCreate,
|
||||
)
|
||||
|
||||
|
||||
class TestPdRosterLinkData:
|
||||
"""Test PD league roster link data model"""
|
||||
|
||||
def test_create_valid_pd_roster_link(self):
|
||||
"""Test creating valid PD roster link"""
|
||||
game_id = uuid4()
|
||||
data = PdRosterLinkData(
|
||||
game_id=game_id,
|
||||
card_id=123,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
assert data.game_id == game_id
|
||||
assert data.card_id == 123
|
||||
assert data.team_id == 1
|
||||
assert data.id is None # Not yet saved to DB
|
||||
|
||||
def test_pd_roster_link_with_id(self):
|
||||
"""Test PD roster link with database ID"""
|
||||
game_id = uuid4()
|
||||
data = PdRosterLinkData(
|
||||
id=42,
|
||||
game_id=game_id,
|
||||
card_id=123,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
assert data.id == 42
|
||||
|
||||
def test_pd_roster_link_invalid_card_id(self):
|
||||
"""Test PD roster link rejects negative card_id"""
|
||||
game_id = uuid4()
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
PdRosterLinkData(
|
||||
game_id=game_id,
|
||||
card_id=-1,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
assert "card_id must be positive" in str(exc_info.value)
|
||||
|
||||
def test_pd_roster_link_zero_card_id(self):
|
||||
"""Test PD roster link rejects zero card_id"""
|
||||
game_id = uuid4()
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
PdRosterLinkData(
|
||||
game_id=game_id,
|
||||
card_id=0,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
assert "card_id must be positive" in str(exc_info.value)
|
||||
|
||||
def test_pd_get_entity_id(self):
|
||||
"""Test get_entity_id returns card_id"""
|
||||
data = PdRosterLinkData(
|
||||
game_id=uuid4(),
|
||||
card_id=123,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
assert data.get_entity_id() == 123
|
||||
|
||||
def test_pd_get_entity_type(self):
|
||||
"""Test get_entity_type returns 'card'"""
|
||||
data = PdRosterLinkData(
|
||||
game_id=uuid4(),
|
||||
card_id=123,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
assert data.get_entity_type() == "card"
|
||||
|
||||
|
||||
class TestSbaRosterLinkData:
|
||||
"""Test SBA league roster link data model"""
|
||||
|
||||
def test_create_valid_sba_roster_link(self):
|
||||
"""Test creating valid SBA roster link"""
|
||||
game_id = uuid4()
|
||||
data = SbaRosterLinkData(
|
||||
game_id=game_id,
|
||||
player_id=456,
|
||||
team_id=2
|
||||
)
|
||||
|
||||
assert data.game_id == game_id
|
||||
assert data.player_id == 456
|
||||
assert data.team_id == 2
|
||||
assert data.id is None
|
||||
|
||||
def test_sba_roster_link_with_id(self):
|
||||
"""Test SBA roster link with database ID"""
|
||||
game_id = uuid4()
|
||||
data = SbaRosterLinkData(
|
||||
id=99,
|
||||
game_id=game_id,
|
||||
player_id=456,
|
||||
team_id=2
|
||||
)
|
||||
|
||||
assert data.id == 99
|
||||
|
||||
def test_sba_roster_link_invalid_player_id(self):
|
||||
"""Test SBA roster link rejects negative player_id"""
|
||||
game_id = uuid4()
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SbaRosterLinkData(
|
||||
game_id=game_id,
|
||||
player_id=-5,
|
||||
team_id=2
|
||||
)
|
||||
|
||||
assert "player_id must be positive" in str(exc_info.value)
|
||||
|
||||
def test_sba_roster_link_zero_player_id(self):
|
||||
"""Test SBA roster link rejects zero player_id"""
|
||||
game_id = uuid4()
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
SbaRosterLinkData(
|
||||
game_id=game_id,
|
||||
player_id=0,
|
||||
team_id=2
|
||||
)
|
||||
|
||||
assert "player_id must be positive" in str(exc_info.value)
|
||||
|
||||
def test_sba_get_entity_id(self):
|
||||
"""Test get_entity_id returns player_id"""
|
||||
data = SbaRosterLinkData(
|
||||
game_id=uuid4(),
|
||||
player_id=456,
|
||||
team_id=2
|
||||
)
|
||||
|
||||
assert data.get_entity_id() == 456
|
||||
|
||||
def test_sba_get_entity_type(self):
|
||||
"""Test get_entity_type returns 'player'"""
|
||||
data = SbaRosterLinkData(
|
||||
game_id=uuid4(),
|
||||
player_id=456,
|
||||
team_id=2
|
||||
)
|
||||
|
||||
assert data.get_entity_type() == "player"
|
||||
|
||||
|
||||
class TestRosterLinkCreate:
|
||||
"""Test RosterLinkCreate request model"""
|
||||
|
||||
def test_create_with_card_id(self):
|
||||
"""Test creating request with card_id only"""
|
||||
game_id = uuid4()
|
||||
data = RosterLinkCreate(
|
||||
game_id=game_id,
|
||||
team_id=1,
|
||||
card_id=123
|
||||
)
|
||||
|
||||
assert data.game_id == game_id
|
||||
assert data.team_id == 1
|
||||
assert data.card_id == 123
|
||||
assert data.player_id is None
|
||||
|
||||
def test_create_with_player_id(self):
|
||||
"""Test creating request with player_id only"""
|
||||
game_id = uuid4()
|
||||
data = RosterLinkCreate(
|
||||
game_id=game_id,
|
||||
team_id=2,
|
||||
player_id=456
|
||||
)
|
||||
|
||||
assert data.game_id == game_id
|
||||
assert data.team_id == 2
|
||||
assert data.player_id == 456
|
||||
assert data.card_id is None
|
||||
|
||||
def test_create_with_both_ids_fails(self):
|
||||
"""Test that providing both IDs raises error"""
|
||||
game_id = uuid4()
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
RosterLinkCreate(
|
||||
game_id=game_id,
|
||||
team_id=1,
|
||||
card_id=123,
|
||||
player_id=456
|
||||
)
|
||||
|
||||
assert "Exactly one of card_id or player_id must be provided" in str(exc_info.value)
|
||||
|
||||
def test_create_with_neither_id_fails(self):
|
||||
"""Test that providing neither ID raises error"""
|
||||
game_id = uuid4()
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
RosterLinkCreate(
|
||||
game_id=game_id,
|
||||
team_id=1
|
||||
)
|
||||
|
||||
assert "Exactly one of card_id or player_id must be provided" in str(exc_info.value)
|
||||
|
||||
def test_invalid_team_id(self):
|
||||
"""Test that negative team_id raises error"""
|
||||
game_id = uuid4()
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
RosterLinkCreate(
|
||||
game_id=game_id,
|
||||
team_id=-1,
|
||||
card_id=123
|
||||
)
|
||||
|
||||
assert "team_id must be positive" in str(exc_info.value)
|
||||
|
||||
def test_zero_team_id(self):
|
||||
"""Test that zero team_id raises error"""
|
||||
game_id = uuid4()
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
RosterLinkCreate(
|
||||
game_id=game_id,
|
||||
team_id=0,
|
||||
player_id=456
|
||||
)
|
||||
|
||||
assert "team_id must be positive" in str(exc_info.value)
|
||||
|
||||
def test_to_pd_data_success(self):
|
||||
"""Test converting to PdRosterLinkData"""
|
||||
game_id = uuid4()
|
||||
create_data = RosterLinkCreate(
|
||||
game_id=game_id,
|
||||
team_id=1,
|
||||
card_id=123
|
||||
)
|
||||
|
||||
pd_data = create_data.to_pd_data()
|
||||
|
||||
assert isinstance(pd_data, PdRosterLinkData)
|
||||
assert pd_data.game_id == game_id
|
||||
assert pd_data.team_id == 1
|
||||
assert pd_data.card_id == 123
|
||||
|
||||
def test_to_pd_data_fails_without_card_id(self):
|
||||
"""Test converting to PdRosterLinkData fails without card_id"""
|
||||
game_id = uuid4()
|
||||
create_data = RosterLinkCreate(
|
||||
game_id=game_id,
|
||||
team_id=1,
|
||||
player_id=456
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
create_data.to_pd_data()
|
||||
|
||||
assert "card_id required for PD roster" in str(exc_info.value)
|
||||
|
||||
def test_to_sba_data_success(self):
|
||||
"""Test converting to SbaRosterLinkData"""
|
||||
game_id = uuid4()
|
||||
create_data = RosterLinkCreate(
|
||||
game_id=game_id,
|
||||
team_id=2,
|
||||
player_id=456
|
||||
)
|
||||
|
||||
sba_data = create_data.to_sba_data()
|
||||
|
||||
assert isinstance(sba_data, SbaRosterLinkData)
|
||||
assert sba_data.game_id == game_id
|
||||
assert sba_data.team_id == 2
|
||||
assert sba_data.player_id == 456
|
||||
|
||||
def test_to_sba_data_fails_without_player_id(self):
|
||||
"""Test converting to SbaRosterLinkData fails without player_id"""
|
||||
game_id = uuid4()
|
||||
create_data = RosterLinkCreate(
|
||||
game_id=game_id,
|
||||
team_id=1,
|
||||
card_id=123
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
create_data.to_sba_data()
|
||||
|
||||
assert "player_id required for SBA roster" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestPolymorphicBehavior:
|
||||
"""Test polymorphic patterns across roster types"""
|
||||
|
||||
def test_both_types_have_get_entity_id(self):
|
||||
"""Test both types implement get_entity_id"""
|
||||
pd_data = PdRosterLinkData(
|
||||
game_id=uuid4(),
|
||||
card_id=123,
|
||||
team_id=1
|
||||
)
|
||||
sba_data = SbaRosterLinkData(
|
||||
game_id=uuid4(),
|
||||
player_id=456,
|
||||
team_id=2
|
||||
)
|
||||
|
||||
# Both should have the method
|
||||
assert callable(pd_data.get_entity_id)
|
||||
assert callable(sba_data.get_entity_id)
|
||||
|
||||
# Different return values
|
||||
assert pd_data.get_entity_id() == 123
|
||||
assert sba_data.get_entity_id() == 456
|
||||
|
||||
def test_both_types_have_get_entity_type(self):
|
||||
"""Test both types implement get_entity_type"""
|
||||
pd_data = PdRosterLinkData(
|
||||
game_id=uuid4(),
|
||||
card_id=123,
|
||||
team_id=1
|
||||
)
|
||||
sba_data = SbaRosterLinkData(
|
||||
game_id=uuid4(),
|
||||
player_id=456,
|
||||
team_id=2
|
||||
)
|
||||
|
||||
# Different return values
|
||||
assert pd_data.get_entity_type() == "card"
|
||||
assert sba_data.get_entity_type() == "player"
|
||||
Loading…
Reference in New Issue
Block a user