strat-gameplay-webapp/backend/tests/integration/database/test_operations.py
Cal Corum 3c5055dbf6 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>
2025-10-22 22:45:44 -05:00

746 lines
23 KiB
Python

"""
Integration tests for DatabaseOperations.
Tests actual database operations using the test database.
These tests are slower than unit tests but verify real DB interactions.
Author: Claude
Date: 2025-10-22
"""
import pytest
from uuid import uuid4
from app.database.operations import DatabaseOperations
from app.database.session import init_db, engine
# Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration
@pytest.fixture(scope="function")
async def setup_database():
"""
Set up test database schema.
Runs once per test function (noop if tables exist).
"""
# Create all tables (will skip if they exist)
await init_db()
yield
# Teardown if needed (tables persist between test runs)
@pytest.fixture
async def db_ops():
"""Create DatabaseOperations instance for each test"""
return DatabaseOperations()
@pytest.fixture
def sample_game_id():
"""Generate a unique game ID for each test"""
return uuid4()
class TestDatabaseOperationsGame:
"""Tests for game CRUD operations"""
@pytest.mark.asyncio
async def test_create_game(self, setup_database, db_ops, sample_game_id):
"""Test creating a game in database"""
game = await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
assert game.id == sample_game_id
assert game.league_id == "sba"
assert game.status == "pending"
assert game.home_team_id == 1
assert game.away_team_id == 2
@pytest.mark.asyncio
async def test_create_game_with_ai(self, setup_database, db_ops, sample_game_id):
"""Test creating a game with AI opponent"""
game = await db_ops.create_game(
game_id=sample_game_id,
league_id="pd",
home_team_id=10,
away_team_id=20,
game_mode="practice",
visibility="private",
home_team_is_ai=False,
away_team_is_ai=True,
ai_difficulty="balanced"
)
assert game.away_team_is_ai is True
assert game.home_team_is_ai is False
assert game.ai_difficulty == "balanced"
@pytest.mark.asyncio
async def test_get_game(self, setup_database, db_ops, sample_game_id):
"""Test retrieving a game from database"""
# Create game
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
# Retrieve game
retrieved = await db_ops.get_game(sample_game_id)
assert retrieved is not None
assert retrieved.id == sample_game_id
assert retrieved.league_id == "sba"
@pytest.mark.asyncio
async def test_get_game_nonexistent(self, setup_database, db_ops):
"""Test retrieving nonexistent game returns None"""
fake_id = uuid4()
game = await db_ops.get_game(fake_id)
assert game is None
@pytest.mark.asyncio
async def test_update_game_state(self, setup_database, db_ops, sample_game_id):
"""Test updating game state"""
# Create game
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
# Update state
await db_ops.update_game_state(
game_id=sample_game_id,
inning=5,
half="bottom",
home_score=3,
away_score=2,
status="active"
)
# Verify update
game = await db_ops.get_game(sample_game_id)
assert game.current_inning == 5
assert game.current_half == "bottom"
assert game.home_score == 3
assert game.away_score == 2
assert game.status == "active"
@pytest.mark.asyncio
async def test_update_game_state_nonexistent_raises_error(self, setup_database, db_ops):
"""Test updating nonexistent game raises error"""
fake_id = uuid4()
with pytest.raises(ValueError, match="not found"):
await db_ops.update_game_state(
game_id=fake_id,
inning=1,
half="top",
home_score=0,
away_score=0
)
class TestDatabaseOperationsLineup:
"""Tests for lineup operations"""
@pytest.mark.asyncio
async def test_create_lineup_entry(self, setup_database, db_ops, sample_game_id):
"""Test creating a lineup entry"""
# Create game first
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
# Create lineup entry
lineup = await db_ops.create_lineup_entry(
game_id=sample_game_id,
team_id=1,
card_id=101,
position="CF",
batting_order=1,
is_starter=True
)
assert lineup.game_id == sample_game_id
assert lineup.team_id == 1
assert lineup.card_id == 101
assert lineup.position == "CF"
assert lineup.batting_order == 1
assert lineup.is_active is True
@pytest.mark.asyncio
async def test_create_pitcher_no_batting_order(self, setup_database, db_ops, sample_game_id):
"""Test creating pitcher without batting order"""
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
lineup = await db_ops.create_lineup_entry(
game_id=sample_game_id,
team_id=1,
card_id=200,
position="P",
batting_order=None, # Pitcher, no batting order (AL rules)
is_starter=True
)
assert lineup.position == "P"
assert lineup.batting_order is None
@pytest.mark.asyncio
async def test_get_active_lineup(self, setup_database, db_ops, sample_game_id):
"""Test retrieving active lineup for a team"""
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
# Create multiple lineup entries
await db_ops.create_lineup_entry(
game_id=sample_game_id,
team_id=1,
card_id=103,
position="1B",
batting_order=3
)
await db_ops.create_lineup_entry(
game_id=sample_game_id,
team_id=1,
card_id=101,
position="CF",
batting_order=1
)
await db_ops.create_lineup_entry(
game_id=sample_game_id,
team_id=1,
card_id=102,
position="SS",
batting_order=2
)
# Retrieve lineup
lineup = await db_ops.get_active_lineup(sample_game_id, team_id=1)
assert len(lineup) == 3
# Should be sorted by batting order
assert lineup[0].batting_order == 1
assert lineup[1].batting_order == 2
assert lineup[2].batting_order == 3
@pytest.mark.asyncio
async def test_get_active_lineup_empty(self, setup_database, db_ops, sample_game_id):
"""Test retrieving lineup for team with no entries"""
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
lineup = await db_ops.get_active_lineup(sample_game_id, team_id=1)
assert lineup == []
class TestDatabaseOperationsPlays:
"""Tests for play operations"""
@pytest.mark.asyncio
async def test_save_play(self, setup_database, db_ops, sample_game_id):
"""Test saving a play"""
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
play_data = {
"game_id": sample_game_id,
"play_number": 1,
"inning": 1,
"half": "top",
"outs_before": 0,
"batting_order": 1,
"result_description": "Single to left field",
"pa": 1,
"ab": 1,
"hit": 1
}
play = await db_ops.save_play(play_data)
assert play.game_id == sample_game_id
assert play.play_number == 1
assert play.result_description == "Single to left field"
@pytest.mark.asyncio
async def test_get_plays(self, setup_database, db_ops, sample_game_id):
"""Test retrieving plays for a game"""
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
# Save multiple plays
for i in range(3):
await db_ops.save_play({
"game_id": sample_game_id,
"play_number": i + 1,
"inning": 1,
"half": "top",
"outs_before": i,
"batting_order": i + 1,
"result_description": f"Play {i+1}",
"pa": 1
})
# Retrieve plays
plays = await db_ops.get_plays(sample_game_id)
assert len(plays) == 3
# Should be ordered by play_number
assert plays[0].play_number == 1
assert plays[1].play_number == 2
assert plays[2].play_number == 3
@pytest.mark.asyncio
async def test_get_plays_empty(self, setup_database, db_ops, sample_game_id):
"""Test retrieving plays for game with no plays"""
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
plays = await db_ops.get_plays(sample_game_id)
assert plays == []
class TestDatabaseOperationsRecovery:
"""Tests for game state recovery"""
@pytest.mark.asyncio
async def test_load_game_state_complete(self, setup_database, db_ops, sample_game_id):
"""Test loading complete game state"""
# Create game
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
# Add lineups
await db_ops.create_lineup_entry(
game_id=sample_game_id,
team_id=1,
card_id=101,
position="CF",
batting_order=1
)
# Add play
await db_ops.save_play({
"game_id": sample_game_id,
"play_number": 1,
"inning": 1,
"half": "top",
"outs_before": 0,
"batting_order": 1,
"result_description": "Single",
"pa": 1
})
# Update game state
await db_ops.update_game_state(
game_id=sample_game_id,
inning=2,
half="bottom",
home_score=1,
away_score=0
)
# Load complete state
state = await db_ops.load_game_state(sample_game_id)
assert state is not None
assert state["game"]["id"] == sample_game_id
assert state["game"]["current_inning"] == 2
assert state["game"]["current_half"] == "bottom"
assert len(state["lineups"]) == 1
assert len(state["plays"]) == 1
@pytest.mark.asyncio
async def test_load_game_state_nonexistent(self, setup_database, db_ops):
"""Test loading nonexistent game returns None"""
fake_id = uuid4()
state = await db_ops.load_game_state(fake_id)
assert state is None
class TestDatabaseOperationsGameSession:
"""Tests for game session operations"""
@pytest.mark.asyncio
async def test_create_game_session(self, setup_database, db_ops, sample_game_id):
"""Test creating a game session"""
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
session = await db_ops.create_game_session(sample_game_id)
assert session.game_id == sample_game_id
assert session.state_snapshot is None # Initially null
@pytest.mark.asyncio
async def test_update_session_snapshot(self, setup_database, db_ops, sample_game_id):
"""Test updating session snapshot"""
await db_ops.create_game(
game_id=sample_game_id,
league_id="sba",
home_team_id=1,
away_team_id=2,
game_mode="friendly",
visibility="public"
)
await db_ops.create_game_session(sample_game_id)
snapshot = {
"inning": 3,
"outs": 2,
"runners": [1, 3]
}
await db_ops.update_session_snapshot(sample_game_id, snapshot)
# Note: Would need to query session to verify, but this tests no errors
@pytest.mark.asyncio
async def test_update_session_snapshot_nonexistent_raises_error(self, setup_database, db_ops):
"""Test updating nonexistent session raises error"""
fake_id = uuid4()
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