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) <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-30 16:39:04 -05:00
parent 6889499fff
commit b872a05397
5 changed files with 501 additions and 261 deletions

View File

@ -26,6 +26,7 @@ from services.trade_builder import (
clear_trade_builder, clear_trade_builder,
clear_trade_builder_by_team, clear_trade_builder_by_team,
) )
from services.league_service import league_service
from services.player_service import player_service from services.player_service import player_service
from services.team_service import team_service from services.team_service import team_service
from models.team import RosterType from models.team import RosterType
@ -130,6 +131,22 @@ class TradeCommands(commands.Cog):
) )
return 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 any existing trade and create new one
clear_trade_builder(interaction.user.id) clear_trade_builder(interaction.user.id)
trade_builder = get_trade_builder(interaction.user.id, user_team) trade_builder = get_trade_builder(interaction.user.id, user_team)

View File

@ -3,6 +3,7 @@ Current league state model
Represents the current state of the league including week, season, and settings. Represents the current state of the league including week, season, and settings.
""" """
from pydantic import Field, field_validator from pydantic import Field, field_validator
from models.base import SBABaseModel from models.base import SBABaseModel
@ -10,38 +11,45 @@ from models.base import SBABaseModel
class Current(SBABaseModel): class Current(SBABaseModel):
"""Model representing current league state and settings.""" """Model representing current league state and settings."""
week: int = Field(69, description="Current week number") week: int = Field(69, description="Current week number")
season: int = Field(69, description="Current season number") season: int = Field(69, description="Current season number")
freeze: bool = Field(True, description="Whether league is frozen") 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") trade_deadline: int = Field(1, description="Trade deadline week")
pick_trade_start: int = Field(69, description="Draft pick trading start 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") pick_trade_end: int = Field(420, description="Draft pick trading end week")
playoffs_begin: int = Field(420, description="Week when playoffs begin") playoffs_begin: int = Field(420, description="Week when playoffs begin")
@field_validator("bet_week", mode="before") @field_validator("bet_week", mode="before")
@classmethod @classmethod
def cast_bet_week_to_string(cls, v): def cast_bet_week_to_string(cls, v):
"""Ensure bet_week is always a string.""" """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 @property
def is_offseason(self) -> bool: def is_offseason(self) -> bool:
"""Check if league is currently in offseason.""" """Check if league is currently in offseason."""
return self.week > 18 return self.week > 18
@property @property
def is_playoffs(self) -> bool: def is_playoffs(self) -> bool:
"""Check if league is currently in playoffs.""" """Check if league is currently in playoffs."""
return self.week >= self.playoffs_begin return self.week >= self.playoffs_begin
@property @property
def can_trade_picks(self) -> bool: def can_trade_picks(self) -> bool:
"""Check if draft pick trading is currently allowed.""" """Check if draft pick trading is currently allowed."""
return self.pick_trade_start <= self.week <= self.pick_trade_end return self.pick_trade_start <= self.week <= self.pick_trade_end
@property @property
def ever_trade_picks(self) -> bool: def ever_trade_picks(self) -> bool:
"""Check if draft pick trading is allowed this season at all""" """Check if draft pick trading is allowed this season at all"""
return self.pick_trade_start <= self.playoffs_begin + 4 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

View File

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

View File

@ -3,6 +3,7 @@ Tests for SBA data models
Validates model creation, validation, and business logic. Validates model creation, validation, and business logic.
""" """
import pytest import pytest
from models import Team, Player, Current, DraftPick, DraftData, DraftList from models import Team, Player, Current, DraftPick, DraftData, DraftList
@ -10,94 +11,102 @@ from models import Team, Player, Current, DraftPick, DraftData, DraftList
class TestSBABaseModel: class TestSBABaseModel:
"""Test base model functionality.""" """Test base model functionality."""
def test_model_creation_with_api_data(self): def test_model_creation_with_api_data(self):
"""Test creating models from API data.""" """Test creating models from API data."""
team_data = { team_data = {
'id': 1, "id": 1,
'abbrev': 'NYY', "abbrev": "NYY",
'sname': 'Yankees', "sname": "Yankees",
'lname': 'New York Yankees', "lname": "New York Yankees",
'season': 12 "season": 12,
} }
team = Team.from_api_data(team_data) team = Team.from_api_data(team_data)
assert team.id == 1 assert team.id == 1
assert team.abbrev == 'NYY' assert team.abbrev == "NYY"
assert team.lname == 'New York Yankees' assert team.lname == "New York Yankees"
def test_to_dict_functionality(self): def test_to_dict_functionality(self):
"""Test model to dictionary conversion.""" """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() team_dict = team.to_dict()
assert 'abbrev' in team_dict assert "abbrev" in team_dict
assert team_dict['abbrev'] == 'LAA' assert team_dict["abbrev"] == "LAA"
assert team_dict['lname'] == 'Los Angeles Angels' assert team_dict["lname"] == "Los Angeles Angels"
def test_model_repr(self): def test_model_repr(self):
"""Test model string representation.""" """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) repr_str = repr(team)
assert 'Team(' in repr_str assert "Team(" in repr_str
assert 'abbrev=BOS' in repr_str assert "abbrev=BOS" in repr_str
class TestTeamModel: class TestTeamModel:
"""Test Team model functionality.""" """Test Team model functionality."""
def test_team_creation_minimal(self): def test_team_creation_minimal(self):
"""Test team creation with minimal required fields.""" """Test team creation with minimal required fields."""
team = Team( team = Team(
id=4, id=4, abbrev="HOU", sname="Astros", lname="Houston Astros", season=12
abbrev='HOU',
sname='Astros',
lname='Houston Astros',
season=12
) )
assert team.abbrev == 'HOU' assert team.abbrev == "HOU"
assert team.sname == 'Astros' assert team.sname == "Astros"
assert team.lname == 'Houston Astros' assert team.lname == "Houston Astros"
assert team.season == 12 assert team.season == 12
def test_team_creation_with_optional_fields(self): def test_team_creation_with_optional_fields(self):
"""Test team creation with optional fields.""" """Test team creation with optional fields."""
team = Team( team = Team(
id=5, id=5,
abbrev='SF', abbrev="SF",
sname='Giants', sname="Giants",
lname='San Francisco Giants', lname="San Francisco Giants",
season=12, season=12,
gmid=100, gmid=100,
division_id=1, division_id=1,
stadium='Oracle Park', stadium="Oracle Park",
color='FF8C00' color="FF8C00",
) )
assert team.gmid == 100 assert team.gmid == 100
assert team.division_id == 1 assert team.division_id == 1
assert team.stadium == 'Oracle Park' assert team.stadium == "Oracle Park"
assert team.color == 'FF8C00' assert team.color == "FF8C00"
def test_team_str_representation(self): def test_team_str_representation(self):
"""Test team string representation.""" """Test team string representation."""
team = Team(id=3, abbrev='SD', sname='Padres', lname='San Diego Padres', season=12) team = Team(
assert str(team) == 'SD - San Diego Padres' 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): def test_team_roster_type_major_league(self):
"""Test roster type detection for Major League teams.""" """Test roster type detection for Major League teams."""
from models.team import RosterType from models.team import RosterType
# 3 chars or less → Major League # 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 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 assert team.roster_type() == RosterType.MAJOR_LEAGUE
# Even "BHM" (ends in M) should be 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 assert team.roster_type() == RosterType.MAJOR_LEAGUE
def test_team_roster_type_minor_league(self): def test_team_roster_type_minor_league(self):
@ -105,14 +114,28 @@ class TestTeamModel:
from models.team import RosterType from models.team import RosterType
# Standard Minor League: [Team] + "MIL" # 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 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 assert team.roster_type() == RosterType.MINOR_LEAGUE
# Case insensitive # 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 assert team.roster_type() == RosterType.MINOR_LEAGUE
def test_team_roster_type_injured_list(self): def test_team_roster_type_injured_list(self):
@ -120,14 +143,32 @@ class TestTeamModel:
from models.team import RosterType from models.team import RosterType
# Standard Injured List: [Team] + "IL" # 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 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 assert team.roster_type() == RosterType.INJURED_LIST
# Case insensitive # 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 assert team.roster_type() == RosterType.INJURED_LIST
def test_team_roster_type_edge_case_bhmil(self): def test_team_roster_type_edge_case_bhmil(self):
@ -143,16 +184,30 @@ class TestTeamModel:
from models.team import RosterType from models.team import RosterType
# "BHMIL" = "BHM" + "IL" → sname contains "IL" → INJURED_LIST # "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 assert team.roster_type() == RosterType.INJURED_LIST
# Compare with a real Minor League team that has "Island" in name # Compare with a real Minor League team that has "Island" in name
# "NYYMIL" = "NYY" + "MIL", even though sname has "Island" → MINOR_LEAGUE # "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 assert team.roster_type() == RosterType.MINOR_LEAGUE
# Another IL edge case with sname containing "IL" at word boundary # 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 assert team.roster_type() == RosterType.INJURED_LIST
def test_team_roster_type_sname_disambiguation(self): def test_team_roster_type_sname_disambiguation(self):
@ -160,221 +215,231 @@ class TestTeamModel:
from models.team import RosterType from models.team import RosterType
# MiL team - sname does NOT have "IL" as a word # 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 assert team.roster_type() == RosterType.MINOR_LEAGUE
# IL team - sname has "IL" at word boundary # 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 assert team.roster_type() == RosterType.INJURED_LIST
# MiL team - sname has "IL" but only in "Island" (substring, not word boundary) # 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 assert team.roster_type() == RosterType.MINOR_LEAGUE
class TestPlayerModel: class TestPlayerModel:
"""Test Player model functionality.""" """Test Player model functionality."""
def test_player_creation(self): def test_player_creation(self):
"""Test player creation with required fields.""" """Test player creation with required fields."""
player = Player( player = Player(
id=101, id=101,
name='Mike Trout', name="Mike Trout",
wara=8.5, wara=8.5,
season=12, season=12,
team_id=1, team_id=1,
image='trout.jpg', image="trout.jpg",
pos_1='CF' pos_1="CF",
) )
assert player.name == 'Mike Trout' assert player.name == "Mike Trout"
assert player.wara == 8.5 assert player.wara == 8.5
assert player.team_id == 1 assert player.team_id == 1
assert player.pos_1 == 'CF' assert player.pos_1 == "CF"
def test_player_positions_property(self): def test_player_positions_property(self):
"""Test player positions property.""" """Test player positions property."""
player = Player( player = Player(
id=102, id=102,
name='Shohei Ohtani', name="Shohei Ohtani",
wara=9.0, wara=9.0,
season=12, season=12,
team_id=1, team_id=1,
image='ohtani.jpg', image="ohtani.jpg",
pos_1='SP', pos_1="SP",
pos_2='DH', pos_2="DH",
pos_3='RF' pos_3="RF",
) )
positions = player.positions positions = player.positions
assert len(positions) == 3 assert len(positions) == 3
assert 'SP' in positions assert "SP" in positions
assert 'DH' in positions assert "DH" in positions
assert 'RF' in positions assert "RF" in positions
def test_player_primary_position(self): def test_player_primary_position(self):
"""Test primary position property.""" """Test primary position property."""
player = Player( player = Player(
id=103, id=103,
name='Mookie Betts', name="Mookie Betts",
wara=7.2, wara=7.2,
season=12, season=12,
team_id=1, team_id=1,
image='betts.jpg', image="betts.jpg",
pos_1='RF', pos_1="RF",
pos_2='2B' pos_2="2B",
) )
assert player.primary_position == 'RF' assert player.primary_position == "RF"
def test_player_is_pitcher(self): def test_player_is_pitcher(self):
"""Test is_pitcher property.""" """Test is_pitcher property."""
pitcher = Player( pitcher = Player(
id=104, id=104,
name='Gerrit Cole', name="Gerrit Cole",
wara=6.8, wara=6.8,
season=12, season=12,
team_id=1, team_id=1,
image='cole.jpg', image="cole.jpg",
pos_1='SP' pos_1="SP",
) )
position_player = Player( position_player = Player(
id=105, id=105,
name='Aaron Judge', name="Aaron Judge",
wara=8.1, wara=8.1,
season=12, season=12,
team_id=1, team_id=1,
image='judge.jpg', image="judge.jpg",
pos_1='RF' pos_1="RF",
) )
assert pitcher.is_pitcher is True assert pitcher.is_pitcher is True
assert position_player.is_pitcher is False assert position_player.is_pitcher is False
def test_player_str_representation(self): def test_player_str_representation(self):
"""Test player string representation.""" """Test player string representation."""
player = Player( player = Player(
id=106, id=106,
name='Ronald Acuna Jr.', name="Ronald Acuna Jr.",
wara=8.8, wara=8.8,
season=12, season=12,
team_id=1, team_id=1,
image='acuna.jpg', image="acuna.jpg",
pos_1='OF' pos_1="OF",
) )
assert str(player) == 'Ronald Acuna Jr. (OF)' assert str(player) == "Ronald Acuna Jr. (OF)"
class TestCurrentModel: class TestCurrentModel:
"""Test Current league state model.""" """Test Current league state model."""
def test_current_default_values(self): def test_current_default_values(self):
"""Test current model with default values.""" """Test current model with default values."""
current = Current() current = Current()
assert current.week == 69 assert current.week == 69
assert current.season == 69 assert current.season == 69
assert current.freeze is True assert current.freeze is True
assert current.bet_week == 'sheets' assert current.bet_week == "sheets"
def test_current_with_custom_values(self): def test_current_with_custom_values(self):
"""Test current model with custom values.""" """Test current model with custom values."""
current = Current( current = Current(
week=15, week=15, season=12, freeze=False, trade_deadline=14, playoffs_begin=19
season=12,
freeze=False,
trade_deadline=14,
playoffs_begin=19
) )
assert current.week == 15 assert current.week == 15
assert current.season == 12 assert current.season == 12
assert current.freeze is False assert current.freeze is False
def test_current_properties(self): def test_current_properties(self):
"""Test current model properties.""" """Test current model properties."""
# Regular season # Regular season
current = Current(week=10, playoffs_begin=19) current = Current(week=10, playoffs_begin=19)
assert current.is_offseason is False assert current.is_offseason is False
assert current.is_playoffs is False assert current.is_playoffs is False
# Playoffs # Playoffs
current = Current(week=20, playoffs_begin=19) current = Current(week=20, playoffs_begin=19)
assert current.is_offseason is True assert current.is_offseason is True
assert current.is_playoffs is True assert current.is_playoffs is True
# Pick trading # Pick trading
current = Current(week=15, pick_trade_start=10, pick_trade_end=20) current = Current(week=15, pick_trade_start=10, pick_trade_end=20)
assert current.can_trade_picks is True 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: class TestDraftPickModel:
"""Test DraftPick model functionality.""" """Test DraftPick model functionality."""
def test_draft_pick_creation(self): def test_draft_pick_creation(self):
"""Test draft pick creation.""" """Test draft pick creation."""
pick = DraftPick( pick = DraftPick(season=12, overall=1, round=1, origowner_id=1, owner_id=1)
season=12,
overall=1,
round=1,
origowner_id=1,
owner_id=1
)
assert pick.season == 12 assert pick.season == 12
assert pick.overall == 1 assert pick.overall == 1
assert pick.origowner_id == 1 assert pick.origowner_id == 1
assert pick.owner_id == 1 assert pick.owner_id == 1
def test_draft_pick_properties(self): def test_draft_pick_properties(self):
"""Test draft pick properties.""" """Test draft pick properties."""
# Not traded, not selected # Not traded, not selected
pick = DraftPick( pick = DraftPick(season=12, overall=5, round=1, origowner_id=1, owner_id=1)
season=12,
overall=5,
round=1,
origowner_id=1,
owner_id=1
)
assert pick.is_traded is False assert pick.is_traded is False
assert pick.is_selected is False assert pick.is_selected is False
# Traded pick # Traded pick
traded_pick = DraftPick( traded_pick = DraftPick(
season=12, season=12, overall=10, round=1, origowner_id=1, owner_id=2
overall=10,
round=1,
origowner_id=1,
owner_id=2
) )
assert traded_pick.is_traded is True assert traded_pick.is_traded is True
# Selected pick # Selected pick
selected_pick = DraftPick( selected_pick = DraftPick(
season=12, season=12, overall=15, round=1, origowner_id=1, owner_id=1, player_id=100
overall=15,
round=1,
origowner_id=1,
owner_id=1,
player_id=100
) )
assert selected_pick.is_selected is True assert selected_pick.is_selected is True
class TestDraftDataModel: class TestDraftDataModel:
"""Test DraftData model functionality.""" """Test DraftData model functionality."""
def test_draft_data_creation(self): def test_draft_data_creation(self):
"""Test draft data creation.""" """Test draft data creation."""
draft_data = DraftData( draft_data = DraftData(
result_channel=123456789, result_channel=123456789, ping_channel=987654321, pick_minutes=10
ping_channel=987654321,
pick_minutes=10
) )
assert draft_data.result_channel == 123456789 assert draft_data.result_channel == 123456789
@ -384,20 +449,12 @@ class TestDraftDataModel:
def test_draft_data_properties(self): def test_draft_data_properties(self):
"""Test draft data properties.""" """Test draft data properties."""
# Inactive draft # Inactive draft
draft_data = DraftData( draft_data = DraftData(result_channel=123, ping_channel=456, timer=False)
result_channel=123,
ping_channel=456,
timer=False
)
assert draft_data.is_draft_active is False assert draft_data.is_draft_active is False
# Active draft # Active draft
active_draft = DraftData( active_draft = DraftData(result_channel=123, ping_channel=456, timer=True)
result_channel=123,
ping_channel=456,
timer=True
)
assert active_draft.is_draft_active is True assert active_draft.is_draft_active is True
@ -409,17 +466,13 @@ class TestDraftListModel:
not just IDs. The API returns these objects populated. 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.""" """Create a mock team for testing."""
return Team( return Team(
id=team_id, id=team_id, abbrev="TST", sname="Test", lname="Test Team", season=12
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.""" """Create a mock player for testing."""
return Player( return Player(
id=player_id, id=player_id,
@ -430,7 +483,7 @@ class TestDraftListModel:
team_id=1, team_id=1,
season=12, season=12,
wara=2.5, wara=2.5,
image="https://example.com/test.jpg" image="https://example.com/test.jpg",
) )
def test_draft_list_creation(self): def test_draft_list_creation(self):
@ -438,12 +491,7 @@ class TestDraftListModel:
mock_team = self._create_mock_team(team_id=1) mock_team = self._create_mock_team(team_id=1)
mock_player = self._create_mock_player(player_id=100) mock_player = self._create_mock_player(player_id=100)
draft_entry = DraftList( draft_entry = DraftList(season=12, team=mock_team, rank=1, player=mock_player)
season=12,
team=mock_team,
rank=1,
player=mock_player
)
assert draft_entry.season == 12 assert draft_entry.season == 12
assert draft_entry.team_id == 1 assert draft_entry.team_id == 1
@ -456,18 +504,10 @@ class TestDraftListModel:
mock_player_top = self._create_mock_player(player_id=100) mock_player_top = self._create_mock_player(player_id=100)
mock_player_lower = self._create_mock_player(player_id=200) mock_player_lower = self._create_mock_player(player_id=200)
top_pick = DraftList( top_pick = DraftList(season=12, team=mock_team, rank=1, player=mock_player_top)
season=12,
team=mock_team,
rank=1,
player=mock_player_top
)
lower_pick = DraftList( lower_pick = DraftList(
season=12, season=12, team=mock_team, rank=5, player=mock_player_lower
team=mock_team,
rank=5,
player=mock_player_lower
) )
assert top_pick.is_top_ranked is True assert top_pick.is_top_ranked is True
@ -486,32 +526,32 @@ class TestDraftListModel:
""" """
# Simulate API response format - nested objects, NOT flat IDs # Simulate API response format - nested objects, NOT flat IDs
api_response = { api_response = {
'id': 303, "id": 303,
'season': 13, "season": 13,
'rank': 1, "rank": 1,
'team': { "team": {
'id': 548, "id": 548,
'abbrev': 'WV', "abbrev": "WV",
'sname': 'Black Bears', "sname": "Black Bears",
'lname': 'West Virginia Black Bears', "lname": "West Virginia Black Bears",
'season': 13 "season": 13,
}, },
'player': { "player": {
'id': 12843, "id": 12843,
'name': 'George Springer', "name": "George Springer",
'wara': 0.31, "wara": 0.31,
'image': 'https://example.com/springer.png', "image": "https://example.com/springer.png",
'season': 13, "season": 13,
'pos_1': 'CF', "pos_1": "CF",
# Note: NO flat team_id here - it's nested in 'team' below # Note: NO flat team_id here - it's nested in 'team' below
'team': { "team": {
'id': 547, # Free Agent team "id": 547, # Free Agent team
'abbrev': 'FA', "abbrev": "FA",
'sname': 'Free Agents', "sname": "Free Agents",
'lname': 'Free Agents', "lname": "Free Agents",
'season': 13 "season": 13,
} },
} },
} }
# Create DraftList using from_api_data (what BaseService calls) # Create DraftList using from_api_data (what BaseService calls)
@ -522,87 +562,94 @@ class TestDraftListModel:
assert draft_entry.player is not None assert draft_entry.player is not None
# CRITICAL: player.team_id must be extracted from nested team object # 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}" f"player.team_id should be 547 (FA), got {draft_entry.player.team_id}"
)
# Verify the nested team object is also populated # Verify the nested team object is also populated
assert draft_entry.player.team is not None assert draft_entry.player.team is not None
assert draft_entry.player.team.id == 547 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 # Verify DraftList's own team data
assert draft_entry.team.id == 548 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 assert draft_entry.team_id == 548 # Property from nested team
class TestModelCoverageExtras: class TestModelCoverageExtras:
"""Additional model coverage tests.""" """Additional model coverage tests."""
def test_base_model_from_api_data_validation(self): def test_base_model_from_api_data_validation(self):
"""Test from_api_data with various edge cases.""" """Test from_api_data with various edge cases."""
from models.base import SBABaseModel from models.base import SBABaseModel
# Test with empty data raises ValueError # 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({}) SBABaseModel.from_api_data({})
# Test with None raises ValueError # 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) SBABaseModel.from_api_data(None)
def test_player_positions_comprehensive(self): def test_player_positions_comprehensive(self):
"""Test player positions property with all position variations.""" """Test player positions property with all position variations."""
player_data = { player_data = {
'id': 201, "id": 201,
'name': 'Multi-Position Player', "name": "Multi-Position Player",
'wara': 3.0, "wara": 3.0,
'season': 12, "season": 12,
'team_id': 5, "team_id": 5,
'image': 'https://example.com/player.jpg', "image": "https://example.com/player.jpg",
'pos_1': 'C', "pos_1": "C",
'pos_2': '1B', "pos_2": "1B",
'pos_3': '3B', "pos_3": "3B",
'pos_4': None, # Test None handling "pos_4": None, # Test None handling
'pos_5': 'DH', "pos_5": "DH",
'pos_6': 'OF', "pos_6": "OF",
'pos_7': None, # Another None "pos_7": None, # Another None
'pos_8': 'SS' "pos_8": "SS",
} }
player = Player.from_api_data(player_data) player = Player.from_api_data(player_data)
positions = player.positions positions = player.positions
assert 'C' in positions assert "C" in positions
assert '1B' in positions assert "1B" in positions
assert '3B' in positions assert "3B" in positions
assert 'DH' in positions assert "DH" in positions
assert 'OF' in positions assert "OF" in positions
assert 'SS' in positions assert "SS" in positions
assert len(positions) == 6 # Should exclude None values assert len(positions) == 6 # Should exclude None values
assert None not in positions assert None not in positions
def test_player_is_pitcher_variations(self): def test_player_is_pitcher_variations(self):
"""Test is_pitcher property with different positions.""" """Test is_pitcher property with different positions."""
test_cases = [ test_cases = [
('SP', True), # Starting pitcher ("SP", True), # Starting pitcher
('RP', True), # Relief pitcher ("RP", True), # Relief pitcher
('P', True), # Generic pitcher ("P", True), # Generic pitcher
('C', False), # Catcher ("C", False), # Catcher
('1B', False), # First base ("1B", False), # First base
('OF', False), # Outfield ("OF", False), # Outfield
('DH', False), # Designated hitter ("DH", False), # Designated hitter
] ]
for position, expected in test_cases: for position, expected in test_cases:
player_data = { player_data = {
'id': 300 + ord(position[0]), # Generate unique IDs based on position "id": 300 + ord(position[0]), # Generate unique IDs based on position
'name': f'Test {position}', "name": f"Test {position}",
'wara': 2.0, "wara": 2.0,
'season': 12, "season": 12,
'team_id': 5, "team_id": 5,
'image': 'https://example.com/player.jpg', "image": "https://example.com/player.jpg",
'pos_1': position, "pos_1": position,
} }
player = Player.from_api_data(player_data) player = Player.from_api_data(player_data)
assert player.is_pitcher == expected, f"Position {position} should return {expected}" assert player.is_pitcher == expected, (
assert player.primary_position == position f"Position {position} should return {expected}"
)
assert player.primary_position == position

View File

@ -124,6 +124,22 @@ class TradeEmbedView(discord.ui.View):
self, interaction: discord.Interaction, button: discord.ui.Button self, interaction: discord.Interaction, button: discord.ui.Button
): ):
"""Handle submit trade button click.""" """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: if self.builder.is_empty:
await interaction.response.send_message( await interaction.response.send_message(
"Cannot submit empty trade. Add some moves first!", ephemeral=True "Cannot submit empty trade. Add some moves first!", ephemeral=True
@ -433,7 +449,16 @@ class TradeAcceptanceView(discord.ui.View):
config = get_config() config = get_config()
current = await league_service.get_current_state() 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( fa_team = Team(
id=config.free_agent_team_id, id=config.free_agent_team_id,