From 6016afb999e7cda9617b5da8eba7feaca16535c2 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 31 Mar 2026 16:07:28 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20enforce=20FA=20lock=20deadline=20?= =?UTF-8?q?=E2=80=94=20block=20signing=20FA=20players=20after=20week=2014?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- services/transaction_builder.py | 49 ++++++++++++------ tests/test_services_transaction_builder.py | 58 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/services/transaction_builder.py b/services/transaction_builder.py index c33485b..95e1725 100644 --- a/services/transaction_builder.py +++ b/services/transaction_builder.py @@ -277,6 +277,35 @@ class TransactionBuilder: Returns: 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 existing_move = self.get_move_for_player(move.player.id) if existing_move: @@ -299,23 +328,15 @@ class TransactionBuilder: return False, error_msg # 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 next_week is None: - try: - 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 + next_week = (current_week + 1) if current_week else 1 - is_pending, claiming_team = ( - await transaction_service.is_player_in_pending_transaction( - player_id=move.player.id, week=next_week, season=self.season - ) + ( + is_pending, + claiming_team, + ) = await transaction_service.is_player_in_pending_transaction( + player_id=move.player.id, week=next_week, season=self.season ) if is_pending: diff --git a/tests/test_services_transaction_builder.py b/tests/test_services_transaction_builder.py index 0fc1e72..bd87160 100644 --- a/tests/test_services_transaction_builder.py +++ b/tests/test_services_transaction_builder.py @@ -115,6 +115,13 @@ class TestTransactionBuilder: svc.get_current_roster.return_value = mock_roster 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 def builder(self, mock_team, mock_roster_service): """Create a TransactionBuilder for testing with injected roster service.""" @@ -152,6 +159,50 @@ class TestTransactionBuilder: assert builder.is_empty is False 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 async def test_add_duplicate_move_fails(self, builder, mock_player): """Test that adding duplicate moves for same player fails.""" @@ -809,6 +860,13 @@ class TestPendingTransactionValidation: """Create a mock player for testing.""" 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 def builder(self, mock_team): """Create a TransactionBuilder for testing."""