WebSocket Infrastructure: - Connection manager: Improved connection/disconnection handling - Handlers: Enhanced event handlers for game operations Test Coverage (148 new tests): - test_connection_handlers.py: Connection lifecycle tests - test_connection_manager.py: Manager operations tests - test_handler_locking.py: Concurrency/locking tests - test_query_handlers.py: Game query handler tests - test_rate_limiting.py: Rate limit enforcement tests - test_substitution_handlers.py: Player substitution tests - test_manual_outcome_handlers.py: Manual outcome workflow tests - conftest.py: Shared WebSocket test fixtures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
999 lines
34 KiB
Python
999 lines
34 KiB
Python
"""
|
|
Tests for WebSocket substitution handlers.
|
|
|
|
Covers all three substitution types:
|
|
- Pinch hitter (batting substitution)
|
|
- Defensive replacement (field substitution)
|
|
- Pitching change (pitcher substitution)
|
|
|
|
Each handler follows the same pattern: validate input, acquire lock,
|
|
call SubstitutionManager, broadcast results.
|
|
|
|
Author: Claude
|
|
Date: 2025-01-27
|
|
"""
|
|
|
|
import asyncio
|
|
import pytest
|
|
from contextlib import asynccontextmanager
|
|
from uuid import uuid4
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from app.core.substitution_manager import SubstitutionResult
|
|
|
|
|
|
# ============================================================================
|
|
# TEST FIXTURES
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_sub_result_success():
|
|
"""Create a successful substitution result."""
|
|
return SubstitutionResult(
|
|
success=True,
|
|
player_out_lineup_id=1,
|
|
player_in_card_id=999,
|
|
new_lineup_id=100,
|
|
new_position="CF",
|
|
new_batting_order=1,
|
|
error_message=None,
|
|
error_code=None,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_sub_result_failure():
|
|
"""Create a failed substitution result."""
|
|
return SubstitutionResult(
|
|
success=False,
|
|
player_out_lineup_id=1,
|
|
player_in_card_id=999,
|
|
new_lineup_id=None,
|
|
new_position=None,
|
|
new_batting_order=None,
|
|
error_message="Player not found in roster",
|
|
error_code="PLAYER_NOT_IN_ROSTER",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def base_sub_data():
|
|
"""Base data for substitution requests."""
|
|
return {
|
|
"game_id": str(uuid4()),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# PINCH HITTER TESTS
|
|
# ============================================================================
|
|
|
|
|
|
class TestPinchHitter:
|
|
"""Tests for request_pinch_hitter handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pinch_hitter_success(
|
|
self, mock_manager, mock_game_state, mock_sub_result_success
|
|
):
|
|
"""
|
|
Verify successful pinch hitter substitution.
|
|
|
|
Should call SubstitutionManager.pinch_hit(), broadcast player_substituted,
|
|
and confirm to requester.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
@asynccontextmanager
|
|
async def mock_lock(game_id, timeout=30.0):
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = mock_lock
|
|
|
|
mock_sub_instance = MagicMock()
|
|
mock_sub_instance.pinch_hit = AsyncMock(return_value=mock_sub_result_success)
|
|
MockSubMgr.return_value = mock_sub_instance
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pinch_hitter"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
# Verify SubstitutionManager called
|
|
mock_sub_instance.pinch_hit.assert_called_once()
|
|
|
|
# Verify broadcast
|
|
mock_manager.broadcast_to_game.assert_called()
|
|
broadcast_call = mock_manager.broadcast_to_game.call_args
|
|
assert broadcast_call[0][1] == "player_substituted"
|
|
assert broadcast_call[0][2]["type"] == "pinch_hitter"
|
|
|
|
# Verify confirmation to requester
|
|
mock_manager.emit_to_user.assert_called()
|
|
confirm_call = mock_manager.emit_to_user.call_args
|
|
assert confirm_call[0][1] == "substitution_confirmed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pinch_hitter_missing_game_id(self, mock_manager):
|
|
"""Verify error when game_id is missing."""
|
|
from socketio import AsyncServer
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
sio = AsyncServer()
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pinch_hitter"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{"player_out_lineup_id": 1, "player_in_card_id": 999, "team_id": 1},
|
|
)
|
|
|
|
mock_manager.emit_to_user.assert_called_once()
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
assert "MISSING_FIELD" in call_args[0][2]["code"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pinch_hitter_invalid_game_id(self, mock_manager):
|
|
"""Verify error when game_id is invalid UUID."""
|
|
from socketio import AsyncServer
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
sio = AsyncServer()
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pinch_hitter"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": "not-a-uuid",
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
assert "INVALID_FORMAT" in call_args[0][2]["code"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pinch_hitter_game_not_found(self, mock_manager):
|
|
"""Verify error when game doesn't exist."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = None
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pinch_hitter"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(uuid4()),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert "not found" in call_args[0][2]["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pinch_hitter_missing_player_out(self, mock_manager, mock_game_state):
|
|
"""Verify error when player_out_lineup_id is missing."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pinch_hitter"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
assert "player_out" in call_args[0][2]["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pinch_hitter_missing_player_in(self, mock_manager, mock_game_state):
|
|
"""Verify error when player_in_card_id is missing."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pinch_hitter"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
assert "player_in" in call_args[0][2]["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pinch_hitter_missing_team_id(self, mock_manager, mock_game_state):
|
|
"""Verify error when team_id is missing."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pinch_hitter"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
assert "team_id" in call_args[0][2]["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pinch_hitter_validation_failure(
|
|
self, mock_manager, mock_game_state, mock_sub_result_failure
|
|
):
|
|
"""Verify SubstitutionManager failure is propagated to user."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
@asynccontextmanager
|
|
async def mock_lock(game_id, timeout=30.0):
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = mock_lock
|
|
|
|
mock_sub_instance = MagicMock()
|
|
mock_sub_instance.pinch_hit = AsyncMock(return_value=mock_sub_result_failure)
|
|
MockSubMgr.return_value = mock_sub_instance
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pinch_hitter"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
assert call_args[0][2]["code"] == "PLAYER_NOT_IN_ROSTER"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pinch_hitter_lock_timeout(self, mock_manager, mock_game_state):
|
|
"""Verify lock timeout returns server busy message."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
@asynccontextmanager
|
|
async def timeout_lock(game_id, timeout=30.0):
|
|
raise asyncio.TimeoutError("Lock timeout")
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = timeout_lock
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pinch_hitter"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "error"
|
|
assert "busy" in call_args[0][2]["message"].lower()
|
|
|
|
|
|
# ============================================================================
|
|
# DEFENSIVE REPLACEMENT TESTS
|
|
# ============================================================================
|
|
|
|
|
|
class TestDefensiveReplacement:
|
|
"""Tests for request_defensive_replacement handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defensive_replacement_success(
|
|
self, mock_manager, mock_game_state, mock_sub_result_success
|
|
):
|
|
"""Verify successful defensive replacement."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
@asynccontextmanager
|
|
async def mock_lock(game_id, timeout=30.0):
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = mock_lock
|
|
|
|
mock_sub_instance = MagicMock()
|
|
mock_sub_instance.defensive_replace = AsyncMock(
|
|
return_value=mock_sub_result_success
|
|
)
|
|
MockSubMgr.return_value = mock_sub_instance
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_defensive_replacement"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"new_position": "CF",
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
# Verify SubstitutionManager called with position
|
|
mock_sub_instance.defensive_replace.assert_called_once()
|
|
call_kwargs = mock_sub_instance.defensive_replace.call_args[1]
|
|
assert call_kwargs["new_position"] == "CF"
|
|
|
|
# Verify broadcast type
|
|
broadcast_call = mock_manager.broadcast_to_game.call_args
|
|
assert broadcast_call[0][2]["type"] == "defensive_replacement"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defensive_replacement_missing_game_id(self, mock_manager):
|
|
"""Verify error when game_id is missing."""
|
|
from socketio import AsyncServer
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
sio = AsyncServer()
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_defensive_replacement"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"new_position": "CF",
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
assert "MISSING_FIELD" in call_args[0][2]["code"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defensive_replacement_invalid_game_id(self, mock_manager):
|
|
"""Verify error when game_id is invalid."""
|
|
from socketio import AsyncServer
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
sio = AsyncServer()
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_defensive_replacement"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": "invalid",
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"new_position": "CF",
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert "INVALID_FORMAT" in call_args[0][2]["code"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defensive_replacement_game_not_found(self, mock_manager):
|
|
"""Verify error when game doesn't exist."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = None
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_defensive_replacement"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(uuid4()),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"new_position": "CF",
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
assert "not found" in mock_manager.emit_to_user.call_args[0][2]["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defensive_replacement_missing_position(
|
|
self, mock_manager, mock_game_state
|
|
):
|
|
"""Verify error when new_position is missing."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_defensive_replacement"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
assert "position" in call_args[0][2]["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defensive_replacement_validation_failure(
|
|
self, mock_manager, mock_game_state, mock_sub_result_failure
|
|
):
|
|
"""Verify SubstitutionManager failure is propagated."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
@asynccontextmanager
|
|
async def mock_lock(game_id, timeout=30.0):
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = mock_lock
|
|
|
|
mock_sub_instance = MagicMock()
|
|
mock_sub_instance.defensive_replace = AsyncMock(
|
|
return_value=mock_sub_result_failure
|
|
)
|
|
MockSubMgr.return_value = mock_sub_instance
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_defensive_replacement"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"new_position": "CF",
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defensive_replacement_lock_timeout(
|
|
self, mock_manager, mock_game_state
|
|
):
|
|
"""Verify lock timeout returns server busy message."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
@asynccontextmanager
|
|
async def timeout_lock(game_id, timeout=30.0):
|
|
raise asyncio.TimeoutError("Lock timeout")
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = timeout_lock
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_defensive_replacement"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"new_position": "CF",
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert "busy" in call_args[0][2]["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defensive_replacement_missing_player_out(
|
|
self, mock_manager, mock_game_state
|
|
):
|
|
"""
|
|
Verify error when player_out_lineup_id is missing.
|
|
|
|
Defensive replacement requires specifying which player is being replaced.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_defensive_replacement"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_in_card_id": 999,
|
|
"new_position": "CF",
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
assert "player_out" in call_args[0][2]["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defensive_replacement_missing_player_in(
|
|
self, mock_manager, mock_game_state
|
|
):
|
|
"""
|
|
Verify error when player_in_card_id is missing.
|
|
|
|
Defensive replacement requires specifying which player is entering.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_defensive_replacement"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"new_position": "CF",
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
assert "player_in" in call_args[0][2]["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defensive_replacement_missing_team_id(
|
|
self, mock_manager, mock_game_state
|
|
):
|
|
"""
|
|
Verify error when team_id is missing.
|
|
|
|
Defensive replacement must specify which team is making the substitution.
|
|
"""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_defensive_replacement"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"new_position": "CF",
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
assert "team_id" in call_args[0][2]["message"].lower()
|
|
|
|
|
|
# ============================================================================
|
|
# PITCHING CHANGE TESTS
|
|
# ============================================================================
|
|
|
|
|
|
class TestPitchingChange:
|
|
"""Tests for request_pitching_change handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pitching_change_success(
|
|
self, mock_manager, mock_game_state, mock_sub_result_success
|
|
):
|
|
"""Verify successful pitching change."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
mock_sub_result_success.new_position = "P" # Override for pitcher
|
|
|
|
@asynccontextmanager
|
|
async def mock_lock(game_id, timeout=30.0):
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = mock_lock
|
|
|
|
mock_sub_instance = MagicMock()
|
|
mock_sub_instance.change_pitcher = AsyncMock(
|
|
return_value=mock_sub_result_success
|
|
)
|
|
MockSubMgr.return_value = mock_sub_instance
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pitching_change"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
mock_sub_instance.change_pitcher.assert_called_once()
|
|
|
|
broadcast_call = mock_manager.broadcast_to_game.call_args
|
|
assert broadcast_call[0][2]["type"] == "pitching_change"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pitching_change_missing_game_id(self, mock_manager):
|
|
"""Verify error when game_id is missing."""
|
|
from socketio import AsyncServer
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
sio = AsyncServer()
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pitching_change"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{"player_out_lineup_id": 1, "player_in_card_id": 999, "team_id": 1},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert "MISSING_FIELD" in call_args[0][2]["code"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pitching_change_invalid_game_id(self, mock_manager):
|
|
"""Verify error when game_id is invalid."""
|
|
from socketio import AsyncServer
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
sio = AsyncServer()
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pitching_change"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": "bad-uuid",
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert "INVALID_FORMAT" in call_args[0][2]["code"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pitching_change_game_not_found(self, mock_manager):
|
|
"""Verify error when game doesn't exist."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = None
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pitching_change"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(uuid4()),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
assert "not found" in mock_manager.emit_to_user.call_args[0][2]["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pitching_change_missing_player_out(
|
|
self, mock_manager, mock_game_state
|
|
):
|
|
"""Verify error when player_out_lineup_id is missing."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pitching_change"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pitching_change_missing_player_in(
|
|
self, mock_manager, mock_game_state
|
|
):
|
|
"""Verify error when player_in_card_id is missing."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pitching_change"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pitching_change_missing_team_id(self, mock_manager, mock_game_state):
|
|
"""Verify error when team_id is missing."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pitching_change"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pitching_change_validation_failure(
|
|
self, mock_manager, mock_game_state, mock_sub_result_failure
|
|
):
|
|
"""Verify SubstitutionManager failure is propagated."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
@asynccontextmanager
|
|
async def mock_lock(game_id, timeout=30.0):
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \
|
|
patch("app.websocket.handlers.SubstitutionManager") as MockSubMgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = mock_lock
|
|
|
|
mock_sub_instance = MagicMock()
|
|
mock_sub_instance.change_pitcher = AsyncMock(
|
|
return_value=mock_sub_result_failure
|
|
)
|
|
MockSubMgr.return_value = mock_sub_instance
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pitching_change"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert call_args[0][1] == "substitution_error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pitching_change_lock_timeout(self, mock_manager, mock_game_state):
|
|
"""Verify lock timeout returns server busy message."""
|
|
from socketio import AsyncServer
|
|
|
|
sio = AsyncServer()
|
|
|
|
@asynccontextmanager
|
|
async def timeout_lock(game_id, timeout=30.0):
|
|
raise asyncio.TimeoutError("Lock timeout")
|
|
yield
|
|
|
|
with patch("app.websocket.handlers.state_manager") as mock_state_mgr:
|
|
mock_state_mgr.get_state.return_value = mock_game_state
|
|
mock_state_mgr.game_lock = timeout_lock
|
|
|
|
from app.websocket.handlers import register_handlers
|
|
|
|
register_handlers(sio, mock_manager)
|
|
handler = sio.handlers["/"]["request_pitching_change"]
|
|
|
|
await handler(
|
|
"test_sid",
|
|
{
|
|
"game_id": str(mock_game_state.game_id),
|
|
"player_out_lineup_id": 1,
|
|
"player_in_card_id": 999,
|
|
"team_id": 1,
|
|
},
|
|
)
|
|
|
|
call_args = mock_manager.emit_to_user.call_args
|
|
assert "busy" in call_args[0][2]["message"].lower()
|