feat: enforce FA lock deadline — block signing FA players after week 14

The fa_lock_week config existed but was never enforced. Now /dropadd blocks
adding players FROM Free Agency when current_week >= fa_lock_week (14).
Dropping players TO FA remains allowed after the deadline.

Also consolidates two league_service.get_current_state() calls into one
shared fetch at the top of add_move() to eliminate a redundant API round-trip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-31 16:07:28 -05:00
parent f95c857363
commit 6016afb999
2 changed files with 93 additions and 14 deletions

View File

@ -277,6 +277,35 @@ class TransactionBuilder:
Returns: Returns:
Tuple of (success: bool, error_message: str). If success is True, error_message is empty. Tuple of (success: bool, error_message: str). If success is True, error_message is empty.
""" """
# Fetch current state once if needed by FA lock or pending-transaction check
is_fa_pickup = (
move.from_roster == RosterType.FREE_AGENCY
and move.to_roster != RosterType.FREE_AGENCY
)
needs_current_state = is_fa_pickup or (
check_pending_transactions and next_week is None
)
current_week: Optional[int] = None
if needs_current_state:
try:
current_state = await league_service.get_current_state()
current_week = current_state.week if current_state else 1
except Exception as e:
logger.warning(f"Could not get current week: {e}")
current_week = 1
# Block adding players FROM Free Agency after the FA lock deadline
if is_fa_pickup and current_week is not None:
config = get_config()
if current_week >= config.fa_lock_week:
error_msg = (
f"Free agency is closed (week {current_week}, deadline was week {config.fa_lock_week}). "
f"Cannot add {move.player.name} from FA."
)
logger.warning(error_msg)
return False, error_msg
# Check if player is already in a move in this transaction builder # Check if player is already in a move in this transaction builder
existing_move = self.get_move_for_player(move.player.id) existing_move = self.get_move_for_player(move.player.id)
if existing_move: if existing_move:
@ -299,23 +328,15 @@ class TransactionBuilder:
return False, error_msg return False, error_msg
# Check if player is already in another team's pending transaction for next week # Check if player is already in another team's pending transaction for next week
# This prevents duplicate claims that would need to be resolved at freeze time
# Only applies to /dropadd (scheduled moves), not /ilmove (immediate moves)
if check_pending_transactions: if check_pending_transactions:
if next_week is None: if next_week is None:
try: next_week = (current_week + 1) if current_week else 1
current_state = await league_service.get_current_state()
next_week = (current_state.week + 1) if current_state else 1
except Exception as e:
logger.warning(
f"Could not get current week for pending transaction check: {e}"
)
next_week = 1
is_pending, claiming_team = ( (
await transaction_service.is_player_in_pending_transaction( is_pending,
player_id=move.player.id, week=next_week, season=self.season claiming_team,
) ) = await transaction_service.is_player_in_pending_transaction(
player_id=move.player.id, week=next_week, season=self.season
) )
if is_pending: if is_pending:

View File

@ -115,6 +115,13 @@ class TestTransactionBuilder:
svc.get_current_roster.return_value = mock_roster svc.get_current_roster.return_value = mock_roster
return svc return svc
@pytest.fixture(autouse=True)
def mock_league_service(self):
"""Patch league_service for all tests so FA lock check uses week 10 (before deadline)."""
with patch("services.transaction_builder.league_service") as mock_ls:
mock_ls.get_current_state = AsyncMock(return_value=MagicMock(week=10))
yield mock_ls
@pytest.fixture @pytest.fixture
def builder(self, mock_team, mock_roster_service): def builder(self, mock_team, mock_roster_service):
"""Create a TransactionBuilder for testing with injected roster service.""" """Create a TransactionBuilder for testing with injected roster service."""
@ -152,6 +159,50 @@ class TestTransactionBuilder:
assert builder.is_empty is False assert builder.is_empty is False
assert move in builder.moves assert move in builder.moves
@pytest.mark.asyncio
async def test_add_move_from_fa_blocked_after_deadline(self, builder, mock_player):
"""Test that adding a player FROM Free Agency is blocked after fa_lock_week."""
move = TransactionMove(
player=mock_player,
from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE,
to_team=builder.team,
)
with patch(
"services.transaction_builder.league_service"
) as mock_league_service:
mock_league_service.get_current_state = AsyncMock(
return_value=MagicMock(week=15)
)
success, error_message = await builder.add_move(
move, check_pending_transactions=False
)
assert success is False
assert "Free agency is closed" in error_message
assert builder.move_count == 0
@pytest.mark.asyncio
async def test_drop_to_fa_allowed_after_deadline(self, builder, mock_player):
"""Test that dropping a player TO Free Agency is still allowed after fa_lock_week."""
move = TransactionMove(
player=mock_player,
from_roster=RosterType.MAJOR_LEAGUE,
to_roster=RosterType.FREE_AGENCY,
from_team=builder.team,
)
# Drop to FA doesn't trigger the FA lock check (autouse fixture provides week 10)
success, error_message = await builder.add_move(
move, check_pending_transactions=False
)
assert success is True
assert error_message == ""
assert builder.move_count == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_duplicate_move_fails(self, builder, mock_player): async def test_add_duplicate_move_fails(self, builder, mock_player):
"""Test that adding duplicate moves for same player fails.""" """Test that adding duplicate moves for same player fails."""
@ -809,6 +860,13 @@ class TestPendingTransactionValidation:
"""Create a mock player for testing.""" """Create a mock player for testing."""
return Player(id=12472, name="Test Player", wara=2.5, season=12, pos_1="OF") return Player(id=12472, name="Test Player", wara=2.5, season=12, pos_1="OF")
@pytest.fixture(autouse=True)
def mock_league_service(self):
"""Patch league_service so FA lock check and week resolution use week 10."""
with patch("services.transaction_builder.league_service") as mock_ls:
mock_ls.get_current_state = AsyncMock(return_value=MagicMock(week=10))
yield mock_ls
@pytest.fixture @pytest.fixture
def builder(self, mock_team): def builder(self, mock_team):
"""Create a TransactionBuilder for testing.""" """Create a TransactionBuilder for testing."""