From 3c5055dbf6c048ae36704be034a739f4e8580406 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 22 Oct 2025 22:45:44 -0500 Subject: [PATCH] CLAUDE: Implement polymorphic RosterLink for both PD and SBA leagues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/CLAUDE.md | 45 ++- backend/app/database/operations.py | 204 +++++++++- backend/app/models/__init__.py | 46 +++ backend/app/models/db_models.py | 31 +- backend/app/models/roster_models.py | 121 ++++++ .../integration/database/test_operations.py | 273 +++++++++++++- .../tests/unit/models/test_roster_models.py | 356 ++++++++++++++++++ 7 files changed, 1064 insertions(+), 12 deletions(-) create mode 100644 backend/app/models/roster_models.py create mode 100644 backend/tests/unit/models/test_roster_models.py diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 186a8a3..71164bf 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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) --- diff --git a/backend/app/database/operations.py b/backend/app/database/operations.py index 0d3e143..fa3e1ee 100644 --- a/backend/app/database/operations.py +++ b/backend/app/database/operations.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e69de29..7210d3d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/db_models.py b/backend/app/models/db_models.py index 3977dd5..5200ff9 100644 --- a/backend/app/models/db_models.py +++ b/backend/app/models/db_models.py @@ -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""" diff --git a/backend/app/models/roster_models.py b/backend/app/models/roster_models.py new file mode 100644 index 0000000..0050153 --- /dev/null +++ b/backend/app/models/roster_models.py @@ -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 + ) diff --git a/backend/tests/integration/database/test_operations.py b/backend/tests/integration/database/test_operations.py index 8267d21..da3065f 100644 --- a/backend/tests/integration/database/test_operations.py +++ b/backend/tests/integration/database/test_operations.py @@ -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 diff --git a/backend/tests/unit/models/test_roster_models.py b/backend/tests/unit/models/test_roster_models.py new file mode 100644 index 0000000..997dcf5 --- /dev/null +++ b/backend/tests/unit/models/test_roster_models.py @@ -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"