Add is_past_trade_deadline property to Current model and guard /trade initiate, submit, and finalize flows. All checks fail-closed (block if API unreachable). 981 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
656 lines
21 KiB
Python
656 lines
21 KiB
Python
"""
|
|
Tests for SBA data models
|
|
|
|
Validates model creation, validation, and business logic.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from models import Team, Player, Current, DraftPick, DraftData, DraftList
|
|
|
|
|
|
class TestSBABaseModel:
|
|
"""Test base model functionality."""
|
|
|
|
def test_model_creation_with_api_data(self):
|
|
"""Test creating models from API data."""
|
|
team_data = {
|
|
"id": 1,
|
|
"abbrev": "NYY",
|
|
"sname": "Yankees",
|
|
"lname": "New York Yankees",
|
|
"season": 12,
|
|
}
|
|
|
|
team = Team.from_api_data(team_data)
|
|
assert team.id == 1
|
|
assert team.abbrev == "NYY"
|
|
assert team.lname == "New York Yankees"
|
|
|
|
def test_to_dict_functionality(self):
|
|
"""Test model to dictionary conversion."""
|
|
team = Team(
|
|
id=1, abbrev="LAA", sname="Angels", lname="Los Angeles Angels", season=12
|
|
)
|
|
|
|
team_dict = team.to_dict()
|
|
assert "abbrev" in team_dict
|
|
assert team_dict["abbrev"] == "LAA"
|
|
assert team_dict["lname"] == "Los Angeles Angels"
|
|
|
|
def test_model_repr(self):
|
|
"""Test model string representation."""
|
|
team = Team(
|
|
id=2, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12
|
|
)
|
|
repr_str = repr(team)
|
|
assert "Team(" in repr_str
|
|
assert "abbrev=BOS" in repr_str
|
|
|
|
|
|
class TestTeamModel:
|
|
"""Test Team model functionality."""
|
|
|
|
def test_team_creation_minimal(self):
|
|
"""Test team creation with minimal required fields."""
|
|
team = Team(
|
|
id=4, abbrev="HOU", sname="Astros", lname="Houston Astros", season=12
|
|
)
|
|
|
|
assert team.abbrev == "HOU"
|
|
assert team.sname == "Astros"
|
|
assert team.lname == "Houston Astros"
|
|
assert team.season == 12
|
|
|
|
def test_team_creation_with_optional_fields(self):
|
|
"""Test team creation with optional fields."""
|
|
team = Team(
|
|
id=5,
|
|
abbrev="SF",
|
|
sname="Giants",
|
|
lname="San Francisco Giants",
|
|
season=12,
|
|
gmid=100,
|
|
division_id=1,
|
|
stadium="Oracle Park",
|
|
color="FF8C00",
|
|
)
|
|
|
|
assert team.gmid == 100
|
|
assert team.division_id == 1
|
|
assert team.stadium == "Oracle Park"
|
|
assert team.color == "FF8C00"
|
|
|
|
def test_team_str_representation(self):
|
|
"""Test team string representation."""
|
|
team = Team(
|
|
id=3, abbrev="SD", sname="Padres", lname="San Diego Padres", season=12
|
|
)
|
|
assert str(team) == "SD - San Diego Padres"
|
|
|
|
def test_team_roster_type_major_league(self):
|
|
"""Test roster type detection for Major League teams."""
|
|
from models.team import RosterType
|
|
|
|
# 3 chars or less → Major League
|
|
team = Team(
|
|
id=1, abbrev="NYY", sname="Yankees", lname="New York Yankees", season=12
|
|
)
|
|
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
|
|
|
team = Team(
|
|
id=2, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12
|
|
)
|
|
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
|
|
|
# Even "BHM" (ends in M) should be Major League
|
|
team = Team(
|
|
id=3, abbrev="BHM", sname="Iron", lname="Birmingham Iron", season=12
|
|
)
|
|
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
|
|
|
def test_team_roster_type_minor_league(self):
|
|
"""Test roster type detection for Minor League teams."""
|
|
from models.team import RosterType
|
|
|
|
# Standard Minor League: [Team] + "MIL"
|
|
team = Team(
|
|
id=4,
|
|
abbrev="NYYMIL",
|
|
sname="RailRiders",
|
|
lname="Staten Island RailRiders",
|
|
season=12,
|
|
)
|
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
|
|
|
team = Team(
|
|
id=5,
|
|
abbrev="PORMIL",
|
|
sname="Portland MiL",
|
|
lname="Portland Minor League",
|
|
season=12,
|
|
)
|
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
|
|
|
# Case insensitive
|
|
team = Team(
|
|
id=6, abbrev="LAAmil", sname="Bees", lname="Salt Lake Bees", season=12
|
|
)
|
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
|
|
|
def test_team_roster_type_injured_list(self):
|
|
"""Test roster type detection for Injured List teams."""
|
|
from models.team import RosterType
|
|
|
|
# Standard Injured List: [Team] + "IL"
|
|
team = Team(
|
|
id=7,
|
|
abbrev="NYYIL",
|
|
sname="Yankees IL",
|
|
lname="New York Yankees IL",
|
|
season=12,
|
|
)
|
|
assert team.roster_type() == RosterType.INJURED_LIST
|
|
|
|
team = Team(
|
|
id=8,
|
|
abbrev="PORIL",
|
|
sname="Loggers IL",
|
|
lname="Portland Loggers IL",
|
|
season=12,
|
|
)
|
|
assert team.roster_type() == RosterType.INJURED_LIST
|
|
|
|
# Case insensitive
|
|
team = Team(
|
|
id=9,
|
|
abbrev="LAAil",
|
|
sname="Angels IL",
|
|
lname="Los Angeles Angels IL",
|
|
season=12,
|
|
)
|
|
assert team.roster_type() == RosterType.INJURED_LIST
|
|
|
|
def test_team_roster_type_edge_case_bhmil(self):
|
|
"""
|
|
Test critical edge case: "BHMIL" should be Injured List, not Minor League.
|
|
|
|
This is BHM (Birmingham, ends in M) + IL (Injured List).
|
|
NOT BH + MIL (Minor League).
|
|
|
|
Bug history: Originally failed because "BHMIL" ends with "MIL", so it was
|
|
incorrectly classified as Minor League.
|
|
"""
|
|
from models.team import RosterType
|
|
|
|
# "BHMIL" = "BHM" + "IL" → sname contains "IL" → INJURED_LIST
|
|
team = Team(
|
|
id=10,
|
|
abbrev="BHMIL",
|
|
sname="Iron IL",
|
|
lname="Birmingham Iron IL",
|
|
season=12,
|
|
)
|
|
assert team.roster_type() == RosterType.INJURED_LIST
|
|
|
|
# Compare with a real Minor League team that has "Island" in name
|
|
# "NYYMIL" = "NYY" + "MIL", even though sname has "Island" → MINOR_LEAGUE
|
|
team = Team(
|
|
id=11,
|
|
abbrev="NYYMIL",
|
|
sname="Staten Island RailRiders",
|
|
lname="Staten Island RailRiders",
|
|
season=12,
|
|
)
|
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
|
|
|
# Another IL edge case with sname containing "IL" at word boundary
|
|
team = Team(
|
|
id=12, abbrev="WVMIL", sname="WV IL", lname="West Virginia IL", season=12
|
|
)
|
|
assert team.roster_type() == RosterType.INJURED_LIST
|
|
|
|
def test_team_roster_type_sname_disambiguation(self):
|
|
"""Test that sname is used correctly to disambiguate MIL vs IL."""
|
|
from models.team import RosterType
|
|
|
|
# MiL team - sname does NOT have "IL" as a word
|
|
team = Team(
|
|
id=13,
|
|
abbrev="WVMIL",
|
|
sname="Miners",
|
|
lname="West Virginia Miners",
|
|
season=12,
|
|
)
|
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
|
|
|
# IL team - sname has "IL" at word boundary
|
|
team = Team(
|
|
id=14,
|
|
abbrev="WVMIL",
|
|
sname="Miners IL",
|
|
lname="West Virginia Miners IL",
|
|
season=12,
|
|
)
|
|
assert team.roster_type() == RosterType.INJURED_LIST
|
|
|
|
# MiL team - sname has "IL" but only in "Island" (substring, not word boundary)
|
|
team = Team(
|
|
id=15,
|
|
abbrev="CHIMIL",
|
|
sname="Island Hoppers",
|
|
lname="Chicago Island Hoppers",
|
|
season=12,
|
|
)
|
|
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
|
|
|
|
|
class TestPlayerModel:
|
|
"""Test Player model functionality."""
|
|
|
|
def test_player_creation(self):
|
|
"""Test player creation with required fields."""
|
|
player = Player(
|
|
id=101,
|
|
name="Mike Trout",
|
|
wara=8.5,
|
|
season=12,
|
|
team_id=1,
|
|
image="trout.jpg",
|
|
pos_1="CF",
|
|
)
|
|
|
|
assert player.name == "Mike Trout"
|
|
assert player.wara == 8.5
|
|
assert player.team_id == 1
|
|
assert player.pos_1 == "CF"
|
|
|
|
def test_player_positions_property(self):
|
|
"""Test player positions property."""
|
|
player = Player(
|
|
id=102,
|
|
name="Shohei Ohtani",
|
|
wara=9.0,
|
|
season=12,
|
|
team_id=1,
|
|
image="ohtani.jpg",
|
|
pos_1="SP",
|
|
pos_2="DH",
|
|
pos_3="RF",
|
|
)
|
|
|
|
positions = player.positions
|
|
assert len(positions) == 3
|
|
assert "SP" in positions
|
|
assert "DH" in positions
|
|
assert "RF" in positions
|
|
|
|
def test_player_primary_position(self):
|
|
"""Test primary position property."""
|
|
player = Player(
|
|
id=103,
|
|
name="Mookie Betts",
|
|
wara=7.2,
|
|
season=12,
|
|
team_id=1,
|
|
image="betts.jpg",
|
|
pos_1="RF",
|
|
pos_2="2B",
|
|
)
|
|
|
|
assert player.primary_position == "RF"
|
|
|
|
def test_player_is_pitcher(self):
|
|
"""Test is_pitcher property."""
|
|
pitcher = Player(
|
|
id=104,
|
|
name="Gerrit Cole",
|
|
wara=6.8,
|
|
season=12,
|
|
team_id=1,
|
|
image="cole.jpg",
|
|
pos_1="SP",
|
|
)
|
|
|
|
position_player = Player(
|
|
id=105,
|
|
name="Aaron Judge",
|
|
wara=8.1,
|
|
season=12,
|
|
team_id=1,
|
|
image="judge.jpg",
|
|
pos_1="RF",
|
|
)
|
|
|
|
assert pitcher.is_pitcher is True
|
|
assert position_player.is_pitcher is False
|
|
|
|
def test_player_str_representation(self):
|
|
"""Test player string representation."""
|
|
player = Player(
|
|
id=106,
|
|
name="Ronald Acuna Jr.",
|
|
wara=8.8,
|
|
season=12,
|
|
team_id=1,
|
|
image="acuna.jpg",
|
|
pos_1="OF",
|
|
)
|
|
|
|
assert str(player) == "Ronald Acuna Jr. (OF)"
|
|
|
|
|
|
class TestCurrentModel:
|
|
"""Test Current league state model."""
|
|
|
|
def test_current_default_values(self):
|
|
"""Test current model with default values."""
|
|
current = Current()
|
|
|
|
assert current.week == 69
|
|
assert current.season == 69
|
|
assert current.freeze is True
|
|
assert current.bet_week == "sheets"
|
|
|
|
def test_current_with_custom_values(self):
|
|
"""Test current model with custom values."""
|
|
current = Current(
|
|
week=15, season=12, freeze=False, trade_deadline=14, playoffs_begin=19
|
|
)
|
|
|
|
assert current.week == 15
|
|
assert current.season == 12
|
|
assert current.freeze is False
|
|
|
|
def test_current_properties(self):
|
|
"""Test current model properties."""
|
|
# Regular season
|
|
current = Current(week=10, playoffs_begin=19)
|
|
assert current.is_offseason is False
|
|
assert current.is_playoffs is False
|
|
|
|
# Playoffs
|
|
current = Current(week=20, playoffs_begin=19)
|
|
assert current.is_offseason is True
|
|
assert current.is_playoffs is True
|
|
|
|
# Pick trading
|
|
current = Current(week=15, pick_trade_start=10, pick_trade_end=20)
|
|
assert current.can_trade_picks is True
|
|
|
|
def test_is_past_trade_deadline(self):
|
|
"""Test trade deadline property — trades allowed during deadline week, blocked after."""
|
|
# Before deadline
|
|
current = Current(week=10, trade_deadline=14)
|
|
assert current.is_past_trade_deadline is False
|
|
|
|
# At deadline week (still allowed)
|
|
current = Current(week=14, trade_deadline=14)
|
|
assert current.is_past_trade_deadline is False
|
|
|
|
# One week past deadline
|
|
current = Current(week=15, trade_deadline=14)
|
|
assert current.is_past_trade_deadline is True
|
|
|
|
# Offseason bypasses deadline (week > 18)
|
|
current = Current(week=20, trade_deadline=14)
|
|
assert current.is_offseason is True
|
|
assert current.is_past_trade_deadline is False
|
|
|
|
|
|
class TestDraftPickModel:
|
|
"""Test DraftPick model functionality."""
|
|
|
|
def test_draft_pick_creation(self):
|
|
"""Test draft pick creation."""
|
|
pick = DraftPick(season=12, overall=1, round=1, origowner_id=1, owner_id=1)
|
|
|
|
assert pick.season == 12
|
|
assert pick.overall == 1
|
|
assert pick.origowner_id == 1
|
|
assert pick.owner_id == 1
|
|
|
|
def test_draft_pick_properties(self):
|
|
"""Test draft pick properties."""
|
|
# Not traded, not selected
|
|
pick = DraftPick(season=12, overall=5, round=1, origowner_id=1, owner_id=1)
|
|
|
|
assert pick.is_traded is False
|
|
assert pick.is_selected is False
|
|
|
|
# Traded pick
|
|
traded_pick = DraftPick(
|
|
season=12, overall=10, round=1, origowner_id=1, owner_id=2
|
|
)
|
|
|
|
assert traded_pick.is_traded is True
|
|
|
|
# Selected pick
|
|
selected_pick = DraftPick(
|
|
season=12, overall=15, round=1, origowner_id=1, owner_id=1, player_id=100
|
|
)
|
|
|
|
assert selected_pick.is_selected is True
|
|
|
|
|
|
class TestDraftDataModel:
|
|
"""Test DraftData model functionality."""
|
|
|
|
def test_draft_data_creation(self):
|
|
"""Test draft data creation."""
|
|
draft_data = DraftData(
|
|
result_channel=123456789, ping_channel=987654321, pick_minutes=10
|
|
)
|
|
|
|
assert draft_data.result_channel == 123456789
|
|
assert draft_data.ping_channel == 987654321
|
|
assert draft_data.pick_minutes == 10
|
|
|
|
def test_draft_data_properties(self):
|
|
"""Test draft data properties."""
|
|
# Inactive draft
|
|
draft_data = DraftData(result_channel=123, ping_channel=456, timer=False)
|
|
|
|
assert draft_data.is_draft_active is False
|
|
|
|
# Active draft
|
|
active_draft = DraftData(result_channel=123, ping_channel=456, timer=True)
|
|
|
|
assert active_draft.is_draft_active is True
|
|
|
|
|
|
class TestDraftListModel:
|
|
"""Test DraftList model functionality.
|
|
|
|
Note: DraftList model requires nested Team and Player objects,
|
|
not just IDs. The API returns these objects populated.
|
|
"""
|
|
|
|
def _create_mock_team(self, team_id: int = 1) -> "Team":
|
|
"""Create a mock team for testing."""
|
|
return Team(
|
|
id=team_id, abbrev="TST", sname="Test", lname="Test Team", season=12
|
|
)
|
|
|
|
def _create_mock_player(self, player_id: int = 100) -> "Player":
|
|
"""Create a mock player for testing."""
|
|
return Player(
|
|
id=player_id,
|
|
name="Test Player",
|
|
fname="Test",
|
|
lname="Player",
|
|
pos_1="1B",
|
|
team_id=1,
|
|
season=12,
|
|
wara=2.5,
|
|
image="https://example.com/test.jpg",
|
|
)
|
|
|
|
def test_draft_list_creation(self):
|
|
"""Test draft list creation with nested objects."""
|
|
mock_team = self._create_mock_team(team_id=1)
|
|
mock_player = self._create_mock_player(player_id=100)
|
|
|
|
draft_entry = DraftList(season=12, team=mock_team, rank=1, player=mock_player)
|
|
|
|
assert draft_entry.season == 12
|
|
assert draft_entry.team_id == 1
|
|
assert draft_entry.rank == 1
|
|
assert draft_entry.player_id == 100
|
|
|
|
def test_draft_list_top_ranked_property(self):
|
|
"""Test top ranked property."""
|
|
mock_team = self._create_mock_team(team_id=1)
|
|
mock_player_top = self._create_mock_player(player_id=100)
|
|
mock_player_lower = self._create_mock_player(player_id=200)
|
|
|
|
top_pick = DraftList(season=12, team=mock_team, rank=1, player=mock_player_top)
|
|
|
|
lower_pick = DraftList(
|
|
season=12, team=mock_team, rank=5, player=mock_player_lower
|
|
)
|
|
|
|
assert top_pick.is_top_ranked is True
|
|
assert lower_pick.is_top_ranked is False
|
|
|
|
def test_draft_list_from_api_data_extracts_player_team_id(self):
|
|
"""
|
|
Test that DraftList.from_api_data() properly extracts player.team_id from nested team object.
|
|
|
|
This is critical for auto-draft functionality. The API returns player data with a nested
|
|
team object (not a flat team_id). Without the custom from_api_data(), Pydantic's default
|
|
construction doesn't call Player.from_api_data(), leaving player.team_id as None.
|
|
|
|
Bug fixed: Auto-draft was failing because player.team_id was None, causing all players
|
|
to be incorrectly marked as "not available" (None != 547 always True).
|
|
"""
|
|
# Simulate API response format - nested objects, NOT flat IDs
|
|
api_response = {
|
|
"id": 303,
|
|
"season": 13,
|
|
"rank": 1,
|
|
"team": {
|
|
"id": 548,
|
|
"abbrev": "WV",
|
|
"sname": "Black Bears",
|
|
"lname": "West Virginia Black Bears",
|
|
"season": 13,
|
|
},
|
|
"player": {
|
|
"id": 12843,
|
|
"name": "George Springer",
|
|
"wara": 0.31,
|
|
"image": "https://example.com/springer.png",
|
|
"season": 13,
|
|
"pos_1": "CF",
|
|
# Note: NO flat team_id here - it's nested in 'team' below
|
|
"team": {
|
|
"id": 547, # Free Agent team
|
|
"abbrev": "FA",
|
|
"sname": "Free Agents",
|
|
"lname": "Free Agents",
|
|
"season": 13,
|
|
},
|
|
},
|
|
}
|
|
|
|
# Create DraftList using from_api_data (what BaseService calls)
|
|
draft_entry = DraftList.from_api_data(api_response)
|
|
|
|
# Verify nested objects are created
|
|
assert draft_entry.team is not None
|
|
assert draft_entry.player is not None
|
|
|
|
# CRITICAL: player.team_id must be extracted from nested team object
|
|
assert draft_entry.player.team_id == 547, (
|
|
f"player.team_id should be 547 (FA), got {draft_entry.player.team_id}"
|
|
)
|
|
|
|
# Verify the nested team object is also populated
|
|
assert draft_entry.player.team is not None
|
|
assert draft_entry.player.team.id == 547
|
|
assert draft_entry.player.team.abbrev == "FA"
|
|
|
|
# Verify DraftList's own team data
|
|
assert draft_entry.team.id == 548
|
|
assert draft_entry.team.abbrev == "WV"
|
|
assert draft_entry.team_id == 548 # Property from nested team
|
|
|
|
|
|
class TestModelCoverageExtras:
|
|
"""Additional model coverage tests."""
|
|
|
|
def test_base_model_from_api_data_validation(self):
|
|
"""Test from_api_data with various edge cases."""
|
|
from models.base import SBABaseModel
|
|
|
|
# Test with empty data raises ValueError
|
|
with pytest.raises(
|
|
ValueError, match="Cannot create SBABaseModel from empty data"
|
|
):
|
|
SBABaseModel.from_api_data({})
|
|
|
|
# Test with None raises ValueError
|
|
with pytest.raises(
|
|
ValueError, match="Cannot create SBABaseModel from empty data"
|
|
):
|
|
SBABaseModel.from_api_data(None)
|
|
|
|
def test_player_positions_comprehensive(self):
|
|
"""Test player positions property with all position variations."""
|
|
player_data = {
|
|
"id": 201,
|
|
"name": "Multi-Position Player",
|
|
"wara": 3.0,
|
|
"season": 12,
|
|
"team_id": 5,
|
|
"image": "https://example.com/player.jpg",
|
|
"pos_1": "C",
|
|
"pos_2": "1B",
|
|
"pos_3": "3B",
|
|
"pos_4": None, # Test None handling
|
|
"pos_5": "DH",
|
|
"pos_6": "OF",
|
|
"pos_7": None, # Another None
|
|
"pos_8": "SS",
|
|
}
|
|
player = Player.from_api_data(player_data)
|
|
|
|
positions = player.positions
|
|
assert "C" in positions
|
|
assert "1B" in positions
|
|
assert "3B" in positions
|
|
assert "DH" in positions
|
|
assert "OF" in positions
|
|
assert "SS" in positions
|
|
assert len(positions) == 6 # Should exclude None values
|
|
assert None not in positions
|
|
|
|
def test_player_is_pitcher_variations(self):
|
|
"""Test is_pitcher property with different positions."""
|
|
test_cases = [
|
|
("SP", True), # Starting pitcher
|
|
("RP", True), # Relief pitcher
|
|
("P", True), # Generic pitcher
|
|
("C", False), # Catcher
|
|
("1B", False), # First base
|
|
("OF", False), # Outfield
|
|
("DH", False), # Designated hitter
|
|
]
|
|
|
|
for position, expected in test_cases:
|
|
player_data = {
|
|
"id": 300 + ord(position[0]), # Generate unique IDs based on position
|
|
"name": f"Test {position}",
|
|
"wara": 2.0,
|
|
"season": 12,
|
|
"team_id": 5,
|
|
"image": "https://example.com/player.jpg",
|
|
"pos_1": position,
|
|
}
|
|
player = Player.from_api_data(player_data)
|
|
assert player.is_pitcher == expected, (
|
|
f"Position {position} should return {expected}"
|
|
)
|
|
assert player.primary_position == position
|