paper-dynasty-discord/tests/test_play_locking.py
Cal Corum 1a9efa8f7e fix: add locked_play context manager to prevent stuck play locks
Early returns in log_chaos, log_sac_bunt, and log_stealing left play
locks permanently stuck because the lock was acquired but never released.
The new locked_play async context manager wraps checks_log_interaction()
and guarantees lock release on exception, early return, or normal exit.

Migrated all 18 locking commands in gameplay.py and removed redundant
double-locking in end_game_command.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 21:54:44 -06:00

480 lines
16 KiB
Python

"""
Test play locking idempotency guard.
Tests the play locking system including:
- Lock acquisition and rejection of duplicate submissions
- Automatic lock release on exception via global error handler
- Manual lock release via release_play_lock()
- Context manager safe_play_lock() behavior
"""
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from command_logic.logic_gameplay import checks_log_interaction, release_play_lock, safe_play_lock
from command_logic.play_context import locked_play
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()
def test_release_play_lock_unlocks_locked_play(mock_session):
"""
Verify release_play_lock() releases a locked play.
Tests that the release_play_lock helper function correctly:
- Sets play.locked = False
- Commits the change to the database
"""
mock_play = MagicMock()
mock_play.id = 400
mock_play.locked = True
mock_play.complete = False
release_play_lock(mock_session, mock_play)
assert mock_play.locked is False
mock_session.add.assert_called_once_with(mock_play)
mock_session.commit.assert_called_once()
def test_release_play_lock_handles_unlocked_play(mock_session):
"""
Verify release_play_lock() safely handles already unlocked play.
Tests that calling release_play_lock on an already unlocked play
is a no-op and doesn't cause errors.
"""
mock_play = MagicMock()
mock_play.id = 400
mock_play.locked = False
mock_play.complete = True
# Should not raise exception even if play is already unlocked
release_play_lock(mock_session, mock_play)
# Should still be unlocked
assert mock_play.locked is False
def test_safe_play_lock_releases_on_exception(mock_session):
"""
Verify safe_play_lock context manager releases lock on exception.
Tests that when an exception occurs within the context manager,
the play lock is automatically released before re-raising.
"""
mock_play = MagicMock()
mock_play.id = 500
mock_play.locked = True
mock_play.complete = False
with pytest.raises(ValueError):
with safe_play_lock(mock_session, mock_play):
# Simulate exception during command processing
raise ValueError("Test exception")
# Lock should be released
assert mock_play.locked is False
mock_session.add.assert_called()
mock_session.commit.assert_called()
def test_safe_play_lock_allows_normal_completion(mock_session):
"""
Verify safe_play_lock doesn't interfere with normal completion.
Tests that when no exception occurs, the context manager doesn't
prematurely release the lock (complete_play should handle that).
"""
mock_play = MagicMock()
mock_play.id = 600
mock_play.locked = True
mock_play.complete = False
with safe_play_lock(mock_session, mock_play):
# Normal processing
pass
# Lock should remain (will be released by complete_play)
# Note: The finally block will force unlock, but this simulates
# the case where complete_play() is called within the context
mock_play.complete = True
with safe_play_lock(mock_session, mock_play):
pass
# Completed play should not be unlocked by context manager
# --- locked_play context manager tests ---
@pytest.mark.asyncio
async def test_locked_play_releases_on_exception(mock_session, mock_interaction, mock_game, mock_team):
"""
Verify locked_play releases the lock when an exception is raised inside the block.
This ensures that if command processing throws (e.g. a dice roll function errors),
the play lock is released so the game isn't permanently stuck.
"""
mock_play = MagicMock()
mock_play.id = 700
mock_play.locked = True
mock_play.complete = False
def fake_release(session, play):
play.locked = False
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
return_value=(mock_game, mock_team, mock_play),
):
with patch("command_logic.play_context.release_play_lock", side_effect=fake_release) as mock_release:
with pytest.raises(ValueError):
async with locked_play(mock_session, mock_interaction, "log test") as (g, t, p):
raise ValueError("processing error")
# Called once in except block; finally sees locked=False and skips
mock_release.assert_called_once_with(mock_session, mock_play)
@pytest.mark.asyncio
async def test_locked_play_releases_on_early_return(mock_session, mock_interaction, mock_game, mock_team):
"""
Verify locked_play releases the lock when the block exits normally without
calling complete_play (i.e. an early return from validation).
This is the primary bug fix — commands like log_chaos, log_sac_bunt, and
log_stealing had early returns after lock acquisition that left the lock stuck.
"""
mock_play = MagicMock()
mock_play.id = 701
mock_play.locked = True
mock_play.complete = False
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
return_value=(mock_game, mock_team, mock_play),
):
with patch("command_logic.play_context.release_play_lock") as mock_release:
async with locked_play(mock_session, mock_interaction, "log bunt") as (g, t, p):
# Simulate early return: block exits without calling complete_play
pass
# Lock should be released because play.locked=True and play.complete=False
mock_release.assert_called_once_with(mock_session, mock_play)
@pytest.mark.asyncio
async def test_locked_play_skips_release_when_complete(mock_session, mock_interaction, mock_game, mock_team):
"""
Verify locked_play does NOT attempt to release the lock when complete_play()
has already run (play.complete=True, play.locked=False).
This is the normal success path — complete_play handles the lock release,
so the context manager should be a no-op.
"""
mock_play = MagicMock()
mock_play.id = 702
mock_play.locked = True
mock_play.complete = False
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
return_value=(mock_game, mock_team, mock_play),
):
with patch("command_logic.play_context.release_play_lock") as mock_release:
async with locked_play(mock_session, mock_interaction, "log single") as (g, t, p):
# Simulate complete_play() having been called
mock_play.locked = False
mock_play.complete = True
# Should NOT attempt release since play is already complete
mock_release.assert_not_called()
@pytest.mark.asyncio
async def test_locked_play_propagates_checks_exception(mock_session, mock_interaction):
"""
Verify that when checks_log_interaction raises PlayLockedException (because
the play is already locked by another command), the exception propagates
without any cleanup attempt — the lock was never ours.
"""
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
side_effect=PlayLockedException("Play is already being processed. Please wait."),
):
with patch("command_logic.play_context.release_play_lock") as mock_release:
with pytest.raises(PlayLockedException):
async with locked_play(mock_session, mock_interaction, "log walk") as (g, t, p):
pass # Should never reach here
# No release attempted — lock was never acquired
mock_release.assert_not_called()
@pytest.mark.asyncio
async def test_locked_play_reraises_original_exception(mock_session, mock_interaction, mock_game, mock_team):
"""
Verify that the original exception is preserved and re-raised after
the lock is released. The caller (global error handler) should see
the real error, not a lock-release error.
"""
mock_play = MagicMock()
mock_play.id = 704
mock_play.locked = True
mock_play.complete = False
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
return_value=(mock_game, mock_team, mock_play),
):
with patch("command_logic.play_context.release_play_lock"):
with pytest.raises(RuntimeError, match="dice roll failed"):
async with locked_play(mock_session, mock_interaction, "log xcheck") as (g, t, p):
raise RuntimeError("dice roll failed")
@pytest.mark.asyncio
async def test_locked_play_handles_release_failure(mock_session, mock_interaction, mock_game, mock_team):
"""
Verify that if release_play_lock itself fails (e.g. DB connection lost),
the original exception is still propagated rather than being masked by
the release failure.
"""
mock_play = MagicMock()
mock_play.id = 705
mock_play.locked = True
mock_play.complete = False
with patch(
"command_logic.play_context.checks_log_interaction",
new_callable=AsyncMock,
return_value=(mock_game, mock_team, mock_play),
):
with patch(
"command_logic.play_context.release_play_lock",
side_effect=Exception("DB connection lost"),
):
# The original ValueError should propagate, not the release failure
with pytest.raises(ValueError, match="original error"):
async with locked_play(mock_session, mock_interaction, "log chaos") as (g, t, p):
raise ValueError("original error")