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:
Cal Corum 2025-10-22 22:45:44 -05:00
parent c7e8804183
commit 3c5055dbf6
7 changed files with 1064 additions and 12 deletions

View File

@ -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)
---

View File

@ -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

View File

@ -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",
]

View File

@ -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"""

View 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
)

View File

@ -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

View 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"