From b872a0539704f4018df874909cf2b5a93fed04d9 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 30 Mar 2026 16:39:04 -0500 Subject: [PATCH] feat: enforce trade deadline in /trade commands 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) --- commands/transactions/trade.py | 17 + models/current.py | 26 +- tests/test_commands_trade_deadline.py | 143 +++++++ tests/test_models.py | 549 ++++++++++++++------------ views/trade_embed.py | 27 +- 5 files changed, 501 insertions(+), 261 deletions(-) create mode 100644 tests/test_commands_trade_deadline.py diff --git a/commands/transactions/trade.py b/commands/transactions/trade.py index 64bb23f..1fe0875 100644 --- a/commands/transactions/trade.py +++ b/commands/transactions/trade.py @@ -26,6 +26,7 @@ from services.trade_builder import ( clear_trade_builder, clear_trade_builder_by_team, ) +from services.league_service import league_service from services.player_service import player_service from services.team_service import team_service from models.team import RosterType @@ -130,6 +131,22 @@ class TradeCommands(commands.Cog): ) return + # Check trade deadline + current = await league_service.get_current_state() + if not current: + await interaction.followup.send( + "❌ Could not retrieve league state. Please try again later.", + ephemeral=True, + ) + return + if current.is_past_trade_deadline: + await interaction.followup.send( + f"❌ **The trade deadline has passed.** The deadline was Week {current.trade_deadline} " + f"and we are currently in Week {current.week}. No new trades can be initiated.", + ephemeral=True, + ) + return + # Clear any existing trade and create new one clear_trade_builder(interaction.user.id) trade_builder = get_trade_builder(interaction.user.id, user_team) diff --git a/models/current.py b/models/current.py index 7143ce7..fcdcf30 100644 --- a/models/current.py +++ b/models/current.py @@ -3,6 +3,7 @@ Current league state model Represents the current state of the league including week, season, and settings. """ + from pydantic import Field, field_validator from models.base import SBABaseModel @@ -10,38 +11,45 @@ from models.base import SBABaseModel class Current(SBABaseModel): """Model representing current league state and settings.""" - + week: int = Field(69, description="Current week number") season: int = Field(69, description="Current season number") freeze: bool = Field(True, description="Whether league is frozen") - bet_week: str = Field('sheets', description="Betting week identifier") + bet_week: str = Field("sheets", description="Betting week identifier") trade_deadline: int = Field(1, description="Trade deadline week") pick_trade_start: int = Field(69, description="Draft pick trading start week") pick_trade_end: int = Field(420, description="Draft pick trading end week") playoffs_begin: int = Field(420, description="Week when playoffs begin") - + @field_validator("bet_week", mode="before") @classmethod def cast_bet_week_to_string(cls, v): """Ensure bet_week is always a string.""" - return str(v) if v is not None else 'sheets' - + return str(v) if v is not None else "sheets" + @property def is_offseason(self) -> bool: """Check if league is currently in offseason.""" return self.week > 18 - + @property def is_playoffs(self) -> bool: """Check if league is currently in playoffs.""" return self.week >= self.playoffs_begin - + @property def can_trade_picks(self) -> bool: """Check if draft pick trading is currently allowed.""" return self.pick_trade_start <= self.week <= self.pick_trade_end - + @property def ever_trade_picks(self) -> bool: """Check if draft pick trading is allowed this season at all""" - return self.pick_trade_start <= self.playoffs_begin + 4 \ No newline at end of file + return self.pick_trade_start <= self.playoffs_begin + 4 + + @property + def is_past_trade_deadline(self) -> bool: + """Check if the trade deadline has passed.""" + if self.is_offseason: + return False + return self.week > self.trade_deadline diff --git a/tests/test_commands_trade_deadline.py b/tests/test_commands_trade_deadline.py new file mode 100644 index 0000000..711df38 --- /dev/null +++ b/tests/test_commands_trade_deadline.py @@ -0,0 +1,143 @@ +""" +Tests for trade deadline enforcement in /trade commands. + +Validates that trades are blocked after the trade deadline and allowed during/before it. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from tests.factories import CurrentFactory, TeamFactory + + +class TestTradeInitiateDeadlineGuard: + """Test trade deadline enforcement in /trade initiate command.""" + + @pytest.fixture + def mock_interaction(self): + """Create mock Discord interaction with deferred response.""" + interaction = AsyncMock() + interaction.user = MagicMock() + interaction.user.id = 258104532423147520 + interaction.response = AsyncMock() + interaction.followup = AsyncMock() + interaction.guild = MagicMock() + interaction.guild.id = 669356687294988350 + return interaction + + @pytest.mark.asyncio + async def test_trade_initiate_blocked_past_deadline(self, mock_interaction): + """After the trade deadline, /trade initiate should return a friendly error.""" + user_team = TeamFactory.west_virginia() + other_team = TeamFactory.new_york() + past_deadline = CurrentFactory.create(week=15, trade_deadline=14) + + with ( + patch( + "commands.transactions.trade.validate_user_has_team", + new_callable=AsyncMock, + return_value=user_team, + ), + patch( + "commands.transactions.trade.get_team_by_abbrev_with_validation", + new_callable=AsyncMock, + return_value=other_team, + ), + patch("commands.transactions.trade.league_service") as mock_league, + ): + mock_league.get_current_state = AsyncMock(return_value=past_deadline) + + from commands.transactions.trade import TradeCommands + + bot = MagicMock() + cog = TradeCommands(bot) + await cog.trade_initiate.callback(cog, mock_interaction, "NY") + + mock_interaction.followup.send.assert_called_once() + call_kwargs = mock_interaction.followup.send.call_args + msg = ( + call_kwargs[0][0] + if call_kwargs[0] + else call_kwargs[1].get("content", "") + ) + assert "trade deadline has passed" in msg.lower() + + @pytest.mark.asyncio + async def test_trade_initiate_allowed_at_deadline_week(self, mock_interaction): + """During the deadline week itself, /trade initiate should proceed.""" + user_team = TeamFactory.west_virginia() + other_team = TeamFactory.new_york() + at_deadline = CurrentFactory.create(week=14, trade_deadline=14) + + with ( + patch( + "commands.transactions.trade.validate_user_has_team", + new_callable=AsyncMock, + return_value=user_team, + ), + patch( + "commands.transactions.trade.get_team_by_abbrev_with_validation", + new_callable=AsyncMock, + return_value=other_team, + ), + patch("commands.transactions.trade.league_service") as mock_league, + patch("commands.transactions.trade.clear_trade_builder") as mock_clear, + patch("commands.transactions.trade.get_trade_builder") as mock_get_builder, + patch( + "commands.transactions.trade.create_trade_embed", + new_callable=AsyncMock, + return_value=MagicMock(), + ), + ): + mock_league.get_current_state = AsyncMock(return_value=at_deadline) + mock_builder = MagicMock() + mock_builder.add_team = AsyncMock(return_value=(True, None)) + mock_builder.trade_id = "test-123" + mock_get_builder.return_value = mock_builder + + from commands.transactions.trade import TradeCommands + + bot = MagicMock() + cog = TradeCommands(bot) + cog.channel_manager = MagicMock() + cog.channel_manager.create_trade_channel = AsyncMock(return_value=None) + await cog.trade_initiate.callback(cog, mock_interaction, "NY") + + # Should have proceeded past deadline check to clear/create trade + mock_clear.assert_called_once() + + @pytest.mark.asyncio + async def test_trade_initiate_blocked_when_current_none(self, mock_interaction): + """When league state can't be fetched, /trade initiate should fail closed.""" + user_team = TeamFactory.west_virginia() + other_team = TeamFactory.new_york() + + with ( + patch( + "commands.transactions.trade.validate_user_has_team", + new_callable=AsyncMock, + return_value=user_team, + ), + patch( + "commands.transactions.trade.get_team_by_abbrev_with_validation", + new_callable=AsyncMock, + return_value=other_team, + ), + patch("commands.transactions.trade.league_service") as mock_league, + ): + mock_league.get_current_state = AsyncMock(return_value=None) + + from commands.transactions.trade import TradeCommands + + bot = MagicMock() + cog = TradeCommands(bot) + await cog.trade_initiate.callback(cog, mock_interaction, "NY") + + mock_interaction.followup.send.assert_called_once() + call_kwargs = mock_interaction.followup.send.call_args + msg = ( + call_kwargs[0][0] + if call_kwargs[0] + else call_kwargs[1].get("content", "") + ) + assert "could not retrieve league state" in msg.lower() diff --git a/tests/test_models.py b/tests/test_models.py index bf85351..b561572 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,6 +3,7 @@ Tests for SBA data models Validates model creation, validation, and business logic. """ + import pytest from models import Team, Player, Current, DraftPick, DraftData, DraftList @@ -10,94 +11,102 @@ 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 + "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' - + 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 = 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' - + 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) + 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 + 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 + 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.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', + abbrev="SF", + sname="Giants", + lname="San Francisco Giants", season=12, gmid=100, division_id=1, - stadium='Oracle Park', - color='FF8C00' + stadium="Oracle Park", + color="FF8C00", ) - + assert team.gmid == 100 assert team.division_id == 1 - assert team.stadium == 'Oracle Park' - assert team.color == 'FF8C00' - + 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' + 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) + 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) + 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) + 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): @@ -105,14 +114,28 @@ class TestTeamModel: from models.team import RosterType # Standard Minor League: [Team] + "MIL" - team = Team(id=4, abbrev='NYYMIL', sname='RailRiders', lname='Staten Island RailRiders', season=12) + 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) + 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) + 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): @@ -120,14 +143,32 @@ class TestTeamModel: 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) + 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) + 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) + 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): @@ -143,16 +184,30 @@ class TestTeamModel: 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) + 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) + 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) + 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): @@ -160,221 +215,231 @@ class TestTeamModel: 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) + 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) + 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) + 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', + name="Mike Trout", wara=8.5, season=12, team_id=1, - image='trout.jpg', - pos_1='CF' + image="trout.jpg", + pos_1="CF", ) - - assert player.name == 'Mike Trout' + + assert player.name == "Mike Trout" assert player.wara == 8.5 assert player.team_id == 1 - assert player.pos_1 == 'CF' - + assert player.pos_1 == "CF" + def test_player_positions_property(self): """Test player positions property.""" player = Player( id=102, - name='Shohei Ohtani', + name="Shohei Ohtani", wara=9.0, season=12, team_id=1, - image='ohtani.jpg', - pos_1='SP', - pos_2='DH', - pos_3='RF' + 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 - + 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', + name="Mookie Betts", wara=7.2, season=12, team_id=1, - image='betts.jpg', - pos_1='RF', - pos_2='2B' + image="betts.jpg", + pos_1="RF", + pos_2="2B", ) - - assert player.primary_position == 'RF' - + + assert player.primary_position == "RF" + def test_player_is_pitcher(self): """Test is_pitcher property.""" pitcher = Player( id=104, - name='Gerrit Cole', + name="Gerrit Cole", wara=6.8, season=12, team_id=1, - image='cole.jpg', - pos_1='SP' + image="cole.jpg", + pos_1="SP", ) - + position_player = Player( id=105, - name='Aaron Judge', + name="Aaron Judge", wara=8.1, season=12, team_id=1, - image='judge.jpg', - pos_1='RF' + 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.', + name="Ronald Acuna Jr.", wara=8.8, season=12, team_id=1, - image='acuna.jpg', - pos_1='OF' + image="acuna.jpg", + pos_1="OF", ) - - assert str(player) == 'Ronald Acuna Jr. (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' - + 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 + 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 - ) - + 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 - ) - + 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 + 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 + 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 + result_channel=123456789, ping_channel=987654321, pick_minutes=10 ) assert draft_data.result_channel == 123456789 @@ -384,20 +449,12 @@ class TestDraftDataModel: def test_draft_data_properties(self): """Test draft data properties.""" # Inactive draft - draft_data = DraftData( - result_channel=123, - ping_channel=456, - timer=False - ) + 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 - ) + active_draft = DraftData(result_channel=123, ping_channel=456, timer=True) assert active_draft.is_draft_active is True @@ -409,17 +466,13 @@ class TestDraftListModel: not just IDs. The API returns these objects populated. """ - def _create_mock_team(self, team_id: int = 1) -> 'Team': + 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 + id=team_id, abbrev="TST", sname="Test", lname="Test Team", season=12 ) - def _create_mock_player(self, player_id: int = 100) -> 'Player': + def _create_mock_player(self, player_id: int = 100) -> "Player": """Create a mock player for testing.""" return Player( id=player_id, @@ -430,7 +483,7 @@ class TestDraftListModel: team_id=1, season=12, wara=2.5, - image="https://example.com/test.jpg" + image="https://example.com/test.jpg", ) def test_draft_list_creation(self): @@ -438,12 +491,7 @@ class TestDraftListModel: 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 - ) + draft_entry = DraftList(season=12, team=mock_team, rank=1, player=mock_player) assert draft_entry.season == 12 assert draft_entry.team_id == 1 @@ -456,18 +504,10 @@ class TestDraftListModel: 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 - ) + 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 + season=12, team=mock_team, rank=5, player=mock_player_lower ) assert top_pick.is_top_ranked is True @@ -486,32 +526,32 @@ class TestDraftListModel: """ # 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 + "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', + "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 - } - } + "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) @@ -522,87 +562,94 @@ class TestDraftListModel: 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, \ + 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' + 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.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"): + 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"): + 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' + "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 "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 + ("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, + "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 \ No newline at end of file + assert player.is_pitcher == expected, ( + f"Position {position} should return {expected}" + ) + assert player.primary_position == position diff --git a/views/trade_embed.py b/views/trade_embed.py index d3f7329..2ec5383 100644 --- a/views/trade_embed.py +++ b/views/trade_embed.py @@ -124,6 +124,22 @@ class TradeEmbedView(discord.ui.View): self, interaction: discord.Interaction, button: discord.ui.Button ): """Handle submit trade button click.""" + # Check trade deadline + current = await league_service.get_current_state() + if not current: + await interaction.response.send_message( + "❌ Could not retrieve league state. Please try again later.", + ephemeral=True, + ) + return + if current.is_past_trade_deadline: + await interaction.response.send_message( + f"❌ **The trade deadline has passed** (Week {current.trade_deadline}). " + f"This trade can no longer be submitted.", + ephemeral=True, + ) + return + if self.builder.is_empty: await interaction.response.send_message( "Cannot submit empty trade. Add some moves first!", ephemeral=True @@ -433,7 +449,16 @@ class TradeAcceptanceView(discord.ui.View): config = get_config() current = await league_service.get_current_state() - next_week = current.week + 1 if current else 1 + if not current or current.is_past_trade_deadline: + deadline_msg = ( + f"❌ **The trade deadline has passed** (Week {current.trade_deadline}). " + f"This trade cannot be finalized." + if current + else "❌ Could not retrieve league state. Please try again later." + ) + await interaction.followup.send(deadline_msg, ephemeral=True) + return + next_week = current.week + 1 fa_team = Team( id=config.free_agent_team_id,