From 00ed42befda400ef889d01be8440e49d62951492 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 3 Feb 2026 17:13:57 -0600 Subject: [PATCH] Add tests for play lock release and cross-command racing Adds two new test cases to test_play_locking.py to improve coverage: 1. test_lock_released_after_successful_completion - Verifies play.locked is set to False after complete_play() - Confirms play.complete is set to True - Validates database commit is called 2. test_different_commands_racing_on_locked_play - Tests that ANY command type is blocked on locked plays - Prevents race conditions between different command types - Tests multiple commands: walk, strikeout, single, xcheck These tests ensure the play locking idempotency guard works correctly for both lock acquisition and release, and prevents all command types from racing (not just duplicate commands). Co-Authored-By: Claude Sonnet 4.5 --- tests/test_play_locking.py | 222 +++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 tests/test_play_locking.py diff --git a/tests/test_play_locking.py b/tests/test_play_locking.py new file mode 100644 index 0000000..58db9c0 --- /dev/null +++ b/tests/test_play_locking.py @@ -0,0 +1,222 @@ +""" +Test play locking idempotency guard. +""" + +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from command_logic.logic_gameplay import checks_log_interaction +from exceptions import PlayLockedException + + +@pytest.fixture +def mock_session(): + """Create a mock SQLAlchemy session.""" + session = MagicMock() + return session + + +@pytest.fixture +def mock_interaction(): + """Create a mock Discord interaction.""" + interaction = MagicMock() + interaction.user.name = "TestUser" + interaction.user.id = 12345 + interaction.channel.name = "test-game-channel" + interaction.channel_id = 98765 + interaction.response = MagicMock() + interaction.response.defer = AsyncMock() + return interaction + + +@pytest.fixture +def mock_game(mock_session): + """Create a mock game with current play.""" + game = MagicMock() + game.id = 100 + game.current_play_or_none = MagicMock() + return game + + +@pytest.fixture +def mock_team(): + """Create a mock team.""" + team = MagicMock() + team.id = 50 + team.abbrev = "TEST" + return team + + +@pytest.fixture +def mock_play_unlocked(): + """Create an unlocked mock play.""" + play = MagicMock() + play.id = 200 + play.locked = False + return play + + +@pytest.fixture +def mock_play_locked(): + """Create a locked mock play.""" + play = MagicMock() + play.id = 200 + play.locked = True + return play + + +@pytest.mark.asyncio +async def test_unlocked_play_locks_successfully( + mock_session, mock_interaction, mock_game, mock_team, mock_play_unlocked +): + """Verify unlocked play can be locked and processed.""" + mock_game.current_play_or_none.return_value = mock_play_unlocked + mock_session.exec.return_value.one.return_value = mock_team + + with patch( + "command_logic.logic_gameplay.get_channel_game_or_none", return_value=mock_game + ): + with patch( + "command_logic.logic_gameplay.get_team_or_none", return_value=mock_team + ): + result_game, result_team, result_play = await checks_log_interaction( + mock_session, mock_interaction, command_name="log xcheck" + ) + + assert result_play.locked is True + assert result_play.id == mock_play_unlocked.id + mock_session.commit.assert_called_once() + + +@pytest.mark.asyncio +async def test_locked_play_rejects_duplicate_interaction( + mock_session, mock_interaction, mock_game, mock_team, mock_play_locked +): + """Verify duplicate command on locked play raises PlayLockedException.""" + mock_game.current_play_or_none.return_value = mock_play_locked + mock_session.exec.return_value.one.return_value = mock_team + + with patch( + "command_logic.logic_gameplay.get_channel_game_or_none", return_value=mock_game + ): + with patch( + "command_logic.logic_gameplay.get_team_or_none", return_value=mock_team + ): + with pytest.raises(PlayLockedException) as exc_info: + await checks_log_interaction( + mock_session, mock_interaction, command_name="log xcheck" + ) + + assert "already being processed" in str(exc_info.value) + assert "wait" in str(exc_info.value).lower() + + +@pytest.mark.asyncio +async def test_locked_play_logs_warning( + mock_session, mock_interaction, mock_game, mock_team, mock_play_locked, caplog +): + """Verify locked play attempt is logged with warning.""" + mock_game.current_play_or_none.return_value = mock_play_locked + mock_session.exec.return_value.one.return_value = mock_team + + with patch( + "command_logic.logic_gameplay.get_channel_game_or_none", return_value=mock_game + ): + with patch( + "command_logic.logic_gameplay.get_team_or_none", return_value=mock_team + ): + try: + await checks_log_interaction( + mock_session, mock_interaction, command_name="log xcheck" + ) + except PlayLockedException: + pass + + assert any( + "attempted log xcheck on locked play" in record.message + for record in caplog.records + ) + + +@pytest.mark.asyncio +async def test_lock_released_after_successful_completion(mock_session): + """ + Verify play lock is released after successful command completion. + + Tests the complete_play() function to ensure it: + - Sets play.locked = False + - Sets play.complete = True + - Commits changes to database + """ + from command_logic.logic_gameplay import complete_play + + # Create mock play that's currently locked + mock_play = MagicMock() + mock_play.id = 300 + mock_play.locked = True + mock_play.complete = False + mock_play.game = MagicMock() + mock_play.game.id = 100 + mock_play.inning_num = 1 + mock_play.inning_half = "top" + mock_play.starting_outs = 0 + mock_play.away_score = 0 + mock_play.home_score = 0 + mock_play.on_base_code = 0 + mock_play.batter = MagicMock() + mock_play.batter.team = MagicMock() + mock_play.pitcher = MagicMock() + mock_play.pitcher.team = MagicMock() + mock_play.managerai = MagicMock() + + # Mock the session.exec queries + mock_session.exec.return_value.one.return_value = MagicMock() + + # Execute complete_play + with patch("command_logic.logic_gameplay.get_one_lineup"): + with patch("command_logic.logic_gameplay.get_re24", return_value=0.0): + with patch("command_logic.logic_gameplay.get_wpa", return_value=0.0): + complete_play(mock_session, mock_play) + + # Verify lock was released and play marked complete + assert mock_play.locked is False + assert mock_play.complete is True + + # Verify changes were committed + mock_session.add.assert_called() + mock_session.commit.assert_called_once() + + +@pytest.mark.asyncio +async def test_different_commands_racing_on_locked_play( + mock_session, mock_interaction, mock_game, mock_team, mock_play_locked +): + """ + Verify different command types are blocked when play is locked. + + Tests that the lock prevents ANY command from processing, not just + duplicates of the same command. This prevents race conditions where + different users try different commands simultaneously. + """ + mock_game.current_play_or_none.return_value = mock_play_locked + mock_session.exec.return_value.one.return_value = mock_team + + # Test different command types all raise PlayLockedException + command_types = ["log walk", "log strikeout", "log single", "log xcheck"] + + for command_name in command_types: + with patch( + "command_logic.logic_gameplay.get_channel_game_or_none", + return_value=mock_game, + ): + with patch( + "command_logic.logic_gameplay.get_team_or_none", + return_value=mock_team, + ): + with pytest.raises(PlayLockedException) as exc_info: + await checks_log_interaction( + mock_session, mock_interaction, command_name=command_name + ) + + # Verify exception message is consistent + assert "already being processed" in str(exc_info.value) + assert "wait" in str(exc_info.value).lower()