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 <noreply@anthropic.com>
This commit is contained in:
parent
38a411fd3e
commit
00ed42befd
222
tests/test_play_locking.py
Normal file
222
tests/test_play_locking.py
Normal file
@ -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()
|
||||||
Loading…
Reference in New Issue
Block a user