- TurnTimeoutService with percentage-based warnings (35 tests) - ConnectionManager enhancements for spectators and reconnection - GameService with timer integration, spectator support, handle_timeout - GameNamespace with spectate/leave_spectate handlers, reconnection - WebSocket message schemas for spectator events - WinConditionsConfig additions for turn timer thresholds - 83 GameService tests, 37 ConnectionManager tests, 37 GameNamespace tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1296 lines
42 KiB
Python
1296 lines
42 KiB
Python
"""Tests for GameNamespaceHandler.
|
|
|
|
This module tests the WebSocket event handlers for the /game namespace.
|
|
All tests use dependency injection to mock GameService and ConnectionManager,
|
|
following the project's no-monkey-patching testing pattern.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from app.core.enums import GameEndReason, TurnPhase
|
|
from app.core.models.game_state import GameState, PlayerState
|
|
from app.core.visibility import VisibleGameState
|
|
from app.schemas.ws_messages import ConnectionStatus, WSErrorCode
|
|
from app.services.connection_manager import ConnectionInfo
|
|
from app.services.game_service import (
|
|
ForcedActionRequiredError,
|
|
GameActionResult,
|
|
GameAlreadyEndedError,
|
|
GameEndResult,
|
|
GameJoinResult,
|
|
GameNotFoundError,
|
|
InvalidActionError,
|
|
NotPlayerTurnError,
|
|
PendingForcedAction,
|
|
)
|
|
from app.socketio.game_namespace import GameNamespaceHandler
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_game_service() -> AsyncMock:
|
|
"""Create a mock GameService.
|
|
|
|
The GameService orchestrates game operations - join, execute action,
|
|
resign, end game, and state retrieval.
|
|
"""
|
|
service = AsyncMock()
|
|
service.join_game = AsyncMock()
|
|
service.execute_action = AsyncMock()
|
|
service.resign_game = AsyncMock()
|
|
service.end_game = AsyncMock()
|
|
service.get_game_state = AsyncMock()
|
|
return service
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_connection_manager() -> AsyncMock:
|
|
"""Create a mock ConnectionManager.
|
|
|
|
The ConnectionManager tracks WebSocket connections and their
|
|
association with games.
|
|
"""
|
|
cm = AsyncMock()
|
|
cm.join_game = AsyncMock(return_value=True)
|
|
cm.leave_game = AsyncMock()
|
|
cm.get_connection = AsyncMock(return_value=None)
|
|
cm.get_game_user_sids = AsyncMock(return_value={})
|
|
cm.get_opponent_sid = AsyncMock(return_value=None)
|
|
cm.get_user_active_game = AsyncMock(return_value=None)
|
|
return cm
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_state_manager() -> AsyncMock:
|
|
"""Create a mock GameStateManager.
|
|
|
|
The GameStateManager handles persistence to Redis/Postgres
|
|
and provides methods to look up active games.
|
|
"""
|
|
sm = AsyncMock()
|
|
sm.get_player_active_games = AsyncMock(return_value=[])
|
|
sm.load_state = AsyncMock(return_value=None)
|
|
sm.save_to_cache = AsyncMock()
|
|
sm.persist_to_db = AsyncMock()
|
|
return sm
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_sio() -> AsyncMock:
|
|
"""Create a mock Socket.IO AsyncServer.
|
|
|
|
The server is used to emit events and manage rooms.
|
|
"""
|
|
sio = AsyncMock()
|
|
sio.emit = AsyncMock()
|
|
sio.enter_room = AsyncMock()
|
|
sio.leave_room = AsyncMock()
|
|
return sio
|
|
|
|
|
|
@pytest.fixture
|
|
def handler(
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_state_manager: AsyncMock,
|
|
) -> GameNamespaceHandler:
|
|
"""Create a GameNamespaceHandler with injected mock dependencies."""
|
|
return GameNamespaceHandler(
|
|
game_svc=mock_game_service,
|
|
conn_manager=mock_connection_manager,
|
|
state_manager=mock_state_manager,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_visible_state() -> VisibleGameState:
|
|
"""Create a sample visible game state for testing.
|
|
|
|
This represents what a player would see after joining a game.
|
|
"""
|
|
from app.core.visibility import VisiblePlayerState, VisibleZone
|
|
|
|
return VisibleGameState(
|
|
game_id="game-123",
|
|
viewer_id="player-1",
|
|
players={
|
|
"player-1": VisiblePlayerState(
|
|
player_id="player-1",
|
|
is_current_player=True,
|
|
deck_count=40,
|
|
hand=VisibleZone(count=7, cards=[], zone_type="hand"),
|
|
),
|
|
"player-2": VisiblePlayerState(
|
|
player_id="player-2",
|
|
is_current_player=False,
|
|
deck_count=40,
|
|
hand=VisibleZone(count=7, cards=[], zone_type="hand"),
|
|
),
|
|
},
|
|
current_player_id="player-1",
|
|
turn_number=1,
|
|
phase=TurnPhase.MAIN,
|
|
is_my_turn=True,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_game_state() -> GameState:
|
|
"""Create a sample game state for testing broadcasts."""
|
|
player1 = PlayerState(player_id="player-1")
|
|
player2 = PlayerState(player_id="player-2")
|
|
|
|
return GameState(
|
|
game_id="game-123",
|
|
players={"player-1": player1, "player-2": player2},
|
|
current_player_id="player-1",
|
|
turn_number=1,
|
|
phase=TurnPhase.MAIN,
|
|
)
|
|
|
|
|
|
class TestHandleJoin:
|
|
"""Tests for the handle_join event handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_success(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
sample_visible_state: VisibleGameState,
|
|
) -> None:
|
|
"""Test successful game join returns game state.
|
|
|
|
When a player joins a game they're a participant in, they should
|
|
receive the filtered game state and be added to the game room.
|
|
"""
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=True,
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
visible_state=sample_visible_state,
|
|
is_your_turn=True,
|
|
game_over=False,
|
|
)
|
|
|
|
result = await handler.handle_join(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123", "message_id": "msg-1"},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["game_id"] == "game-123"
|
|
assert result["is_your_turn"] is True
|
|
assert "state" in result
|
|
|
|
# Should have joined the connection manager
|
|
mock_connection_manager.join_game.assert_called_once_with("sid-123", "game-123")
|
|
|
|
# Should have entered the Socket.IO room
|
|
mock_sio.enter_room.assert_called_once_with("sid-123", "game:game-123", namespace="/game")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_missing_game_id(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test that missing game_id returns error.
|
|
|
|
The game_id is required to join a game.
|
|
"""
|
|
result = await handler.handle_join(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"message_id": "msg-1"}, # No game_id
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value
|
|
assert "game_id" in result["error"]["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_not_found(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test joining non-existent game returns error.
|
|
|
|
When GameService indicates the game wasn't found, we should
|
|
return a game_not_found error.
|
|
"""
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=False,
|
|
game_id="nonexistent",
|
|
player_id="player-1",
|
|
message="Game not found",
|
|
)
|
|
|
|
result = await handler.handle_join(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "nonexistent"},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_not_participant(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test joining as non-participant returns error.
|
|
|
|
Only players in the game can join it.
|
|
"""
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=False,
|
|
game_id="game-123",
|
|
player_id="stranger",
|
|
message="You are not a participant in this game",
|
|
)
|
|
|
|
result = await handler.handle_join(
|
|
mock_sio,
|
|
"sid-123",
|
|
"stranger",
|
|
{"game_id": "game-123"},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.NOT_IN_GAME.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_includes_pending_forced_action(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
sample_visible_state: VisibleGameState,
|
|
) -> None:
|
|
"""Test that pending forced action is included in join result.
|
|
|
|
When rejoining a game with a pending forced action, the client
|
|
needs to know what action is required.
|
|
"""
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=True,
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
visible_state=sample_visible_state,
|
|
is_your_turn=True,
|
|
pending_forced_action=PendingForcedAction(
|
|
player_id="player-1",
|
|
action_type="select_active",
|
|
reason="Your active Pokemon was knocked out.",
|
|
params={"available_bench_ids": ["bench-1"]},
|
|
),
|
|
)
|
|
|
|
result = await handler.handle_join(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123"},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert "pending_forced_action" in result
|
|
assert result["pending_forced_action"]["action_type"] == "select_active"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_notifies_opponent(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
sample_visible_state: VisibleGameState,
|
|
) -> None:
|
|
"""Test that joining notifies the opponent.
|
|
|
|
When a player joins/rejoins, the opponent should be notified
|
|
of their connection status.
|
|
"""
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=True,
|
|
game_id="game-123",
|
|
player_id="player-1",
|
|
visible_state=sample_visible_state,
|
|
is_your_turn=True,
|
|
)
|
|
mock_connection_manager.get_opponent_sid.return_value = "opponent-sid"
|
|
|
|
await handler.handle_join(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123"},
|
|
)
|
|
|
|
# Should have tried to notify opponent
|
|
mock_connection_manager.get_opponent_sid.assert_called()
|
|
|
|
# Should have emitted opponent status to opponent
|
|
emit_calls = [
|
|
call for call in mock_sio.emit.call_args_list if call[0][0] == "game:opponent_status"
|
|
]
|
|
assert len(emit_calls) == 1
|
|
|
|
|
|
class TestHandleAction:
|
|
"""Tests for the handle_action event handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_success(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test successful action execution.
|
|
|
|
A valid action should be executed and the state should be
|
|
broadcast to all participants.
|
|
"""
|
|
mock_game_service.execute_action.return_value = GameActionResult(
|
|
success=True,
|
|
game_id="game-123",
|
|
action_type="pass",
|
|
message="Turn ended",
|
|
turn_changed=True,
|
|
current_player_id="player-2",
|
|
)
|
|
mock_game_service.get_game_state.return_value = sample_game_state
|
|
mock_connection_manager.get_game_user_sids.return_value = {
|
|
"player-1": "sid-1",
|
|
"player-2": "sid-2",
|
|
}
|
|
|
|
result = await handler.handle_action(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{
|
|
"game_id": "game-123",
|
|
"action": {"type": "pass"},
|
|
"message_id": "msg-1",
|
|
},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["action_type"] == "pass"
|
|
assert result["turn_changed"] is True
|
|
|
|
# Should have broadcast state to participants
|
|
assert mock_sio.emit.called
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_missing_game_id(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test that missing game_id returns error."""
|
|
result = await handler.handle_action(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"action": {"type": "pass"}},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_missing_action(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test that missing action returns error."""
|
|
result = await handler.handle_action(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123"},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_invalid_action_format(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test that invalid action format returns error.
|
|
|
|
The action data must match a valid Action type.
|
|
"""
|
|
result = await handler.handle_action(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123", "action": {"type": "invalid_action_type"}},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.INVALID_ACTION.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_game_not_found(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test action on non-existent game returns error."""
|
|
mock_game_service.execute_action.side_effect = GameNotFoundError("game-123")
|
|
|
|
result = await handler.handle_action(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123", "action": {"type": "pass"}},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_not_your_turn(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test action when not player's turn returns error."""
|
|
mock_game_service.execute_action.side_effect = NotPlayerTurnError(
|
|
"game-123", "player-2", "player-1"
|
|
)
|
|
|
|
result = await handler.handle_action(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-2",
|
|
{"game_id": "game-123", "action": {"type": "pass"}},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.NOT_YOUR_TURN.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_forced_action_required(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test action when forced action required returns error."""
|
|
mock_game_service.execute_action.side_effect = ForcedActionRequiredError(
|
|
"game-123", "player-1", "select_active", "pass"
|
|
)
|
|
|
|
result = await handler.handle_action(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123", "action": {"type": "pass"}},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.ACTION_NOT_ALLOWED.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_invalid_action(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test invalid action (rejected by engine) returns error."""
|
|
mock_game_service.execute_action.side_effect = InvalidActionError(
|
|
"game-123", "player-1", "Not enough energy"
|
|
)
|
|
|
|
result = await handler.handle_action(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123", "action": {"type": "attack", "attack_index": 0}},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.INVALID_ACTION.value
|
|
assert "Not enough energy" in result["error"]["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_game_over(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test action that ends game broadcasts game over.
|
|
|
|
When an action results in game over, the result should include
|
|
game_over=True and the game should be ended.
|
|
"""
|
|
mock_game_service.execute_action.return_value = GameActionResult(
|
|
success=True,
|
|
game_id="game-123",
|
|
action_type="attack",
|
|
game_over=True,
|
|
winner_id="player-1",
|
|
end_reason=GameEndReason.PRIZES_TAKEN,
|
|
)
|
|
mock_game_service.get_game_state.return_value = sample_game_state
|
|
mock_game_service.end_game.return_value = GameEndResult(
|
|
success=True,
|
|
game_id="game-123",
|
|
winner_id="player-1",
|
|
)
|
|
mock_connection_manager.get_game_user_sids.return_value = {}
|
|
|
|
result = await handler.handle_action(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123", "action": {"type": "attack", "attack_index": 0}},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["game_over"] is True
|
|
assert result["winner_id"] == "player-1"
|
|
|
|
# Should have called end_game
|
|
mock_game_service.end_game.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_action_includes_pending_forced_action(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that action result includes pending forced action.
|
|
|
|
When an action creates a forced action (e.g., knockout triggers
|
|
select_active), the result should include it.
|
|
"""
|
|
mock_game_service.execute_action.return_value = GameActionResult(
|
|
success=True,
|
|
game_id="game-123",
|
|
action_type="attack",
|
|
pending_forced_action=PendingForcedAction(
|
|
player_id="player-2",
|
|
action_type="select_active",
|
|
reason="Your active was knocked out.",
|
|
),
|
|
)
|
|
mock_game_service.get_game_state.return_value = sample_game_state
|
|
mock_connection_manager.get_game_user_sids.return_value = {}
|
|
|
|
result = await handler.handle_action(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123", "action": {"type": "attack", "attack_index": 0}},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert "pending_forced_action" in result
|
|
assert result["pending_forced_action"]["action_type"] == "select_active"
|
|
|
|
|
|
class TestHandleResign:
|
|
"""Tests for the handle_resign event handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resign_success(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test successful resignation.
|
|
|
|
Resignation should end the game with the opponent as winner.
|
|
"""
|
|
mock_game_service.resign_game.return_value = GameActionResult(
|
|
success=True,
|
|
game_id="game-123",
|
|
action_type="resign",
|
|
game_over=True,
|
|
winner_id="player-2",
|
|
end_reason=GameEndReason.RESIGNATION,
|
|
)
|
|
mock_game_service.get_game_state.return_value = sample_game_state
|
|
mock_game_service.end_game.return_value = GameEndResult(
|
|
success=True,
|
|
game_id="game-123",
|
|
winner_id="player-2",
|
|
)
|
|
mock_connection_manager.get_game_user_sids.return_value = {}
|
|
|
|
result = await handler.handle_resign(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123"},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["game_over"] is True
|
|
assert result["winner_id"] == "player-2"
|
|
|
|
# Should have called end_game
|
|
mock_game_service.end_game.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resign_missing_game_id(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test resignation with missing game_id returns error."""
|
|
result = await handler.handle_resign(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.INVALID_MESSAGE.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resign_game_not_found(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test resignation from non-existent game returns error."""
|
|
mock_game_service.resign_game.side_effect = GameNotFoundError("game-123")
|
|
|
|
result = await handler.handle_resign(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123"},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resign_game_already_ended(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test resignation from ended game returns error."""
|
|
mock_game_service.resign_game.side_effect = GameAlreadyEndedError("game-123")
|
|
|
|
result = await handler.handle_resign(
|
|
mock_sio,
|
|
"sid-123",
|
|
"player-1",
|
|
{"game_id": "game-123"},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.GAME_ENDED.value
|
|
|
|
|
|
class TestHandleDisconnect:
|
|
"""Tests for the handle_disconnect event handler."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_notifies_opponent(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test that disconnect notifies opponent.
|
|
|
|
When a player disconnects from a game, the opponent should
|
|
be notified of the disconnection.
|
|
"""
|
|
from datetime import UTC, datetime
|
|
|
|
mock_connection_manager.get_connection.return_value = ConnectionInfo(
|
|
sid="sid-123",
|
|
user_id="player-1",
|
|
game_id="game-123",
|
|
connected_at=datetime.now(UTC),
|
|
last_seen=datetime.now(UTC),
|
|
)
|
|
mock_connection_manager.get_opponent_sid.return_value = "opponent-sid"
|
|
|
|
await handler.handle_disconnect(mock_sio, "sid-123", "player-1")
|
|
|
|
# Should have looked up connection to find game
|
|
mock_connection_manager.get_connection.assert_called_once_with("sid-123")
|
|
|
|
# Should have emitted opponent status to opponent
|
|
emit_calls = [
|
|
call for call in mock_sio.emit.call_args_list if call[0][0] == "game:opponent_status"
|
|
]
|
|
assert len(emit_calls) == 1
|
|
assert emit_calls[0][1]["to"] == "opponent-sid"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_no_game(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test disconnect when not in a game does nothing.
|
|
|
|
If the player wasn't in a game, there's no opponent to notify.
|
|
"""
|
|
mock_connection_manager.get_connection.return_value = None
|
|
|
|
await handler.handle_disconnect(mock_sio, "sid-123", "player-1")
|
|
|
|
# Should not emit anything
|
|
mock_sio.emit.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_no_game_id_in_connection(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test disconnect when connection exists but not in game.
|
|
|
|
The connection may exist but game_id may be None if they
|
|
haven't joined a game yet.
|
|
"""
|
|
from datetime import UTC, datetime
|
|
|
|
mock_connection_manager.get_connection.return_value = ConnectionInfo(
|
|
sid="sid-123",
|
|
user_id="player-1",
|
|
game_id=None, # Not in a game
|
|
connected_at=datetime.now(UTC),
|
|
last_seen=datetime.now(UTC),
|
|
)
|
|
|
|
await handler.handle_disconnect(mock_sio, "sid-123", "player-1")
|
|
|
|
# Should not emit anything
|
|
mock_sio.emit.assert_not_called()
|
|
|
|
|
|
class TestBroadcastGameState:
|
|
"""Tests for the _broadcast_game_state helper method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_sends_to_each_player(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
sample_game_state: GameState,
|
|
) -> None:
|
|
"""Test that broadcast sends filtered state to each player.
|
|
|
|
Each player should receive their own visibility-filtered
|
|
view of the game state.
|
|
"""
|
|
mock_game_service.get_game_state.return_value = sample_game_state
|
|
mock_connection_manager.get_game_user_sids.return_value = {
|
|
"player-1": "sid-1",
|
|
"player-2": "sid-2",
|
|
}
|
|
|
|
await handler._broadcast_game_state(mock_sio, "game-123")
|
|
|
|
# Should have emitted to both players
|
|
assert mock_sio.emit.call_count == 2
|
|
|
|
# Verify each emission was to a different player
|
|
emit_calls = mock_sio.emit.call_args_list
|
|
to_sids = {call[1]["to"] for call in emit_calls}
|
|
assert to_sids == {"sid-1", "sid-2"}
|
|
|
|
# All emissions should be game:state events
|
|
for call in emit_calls:
|
|
assert call[0][0] == "game:state"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_handles_game_not_found(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test that broadcast handles missing game gracefully.
|
|
|
|
If the game can't be found (e.g., already archived), the
|
|
broadcast should log a warning but not raise an error.
|
|
"""
|
|
mock_game_service.get_game_state.side_effect = GameNotFoundError("game-123")
|
|
|
|
# Should not raise
|
|
await handler._broadcast_game_state(mock_sio, "game-123")
|
|
|
|
# Should not have emitted anything
|
|
mock_sio.emit.assert_not_called()
|
|
|
|
|
|
class TestNotifyOpponentStatus:
|
|
"""Tests for the _notify_opponent_status helper method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_sends_to_opponent(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test that notify sends status to opponent.
|
|
|
|
The opponent's connection should receive a status update
|
|
about the player's connection state.
|
|
"""
|
|
mock_connection_manager.get_opponent_sid.return_value = "opponent-sid"
|
|
|
|
await handler._notify_opponent_status(
|
|
mock_sio,
|
|
"sender-sid",
|
|
"game-123",
|
|
"player-1",
|
|
ConnectionStatus.CONNECTED,
|
|
)
|
|
|
|
mock_sio.emit.assert_called_once()
|
|
call = mock_sio.emit.call_args
|
|
assert call[0][0] == "game:opponent_status"
|
|
assert call[1]["to"] == "opponent-sid"
|
|
message = call[0][1]
|
|
assert message["opponent_id"] == "player-1"
|
|
assert message["status"] == ConnectionStatus.CONNECTED.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_no_opponent_connected(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test that notify does nothing when opponent not connected.
|
|
|
|
If the opponent isn't connected, we can't notify them.
|
|
"""
|
|
mock_connection_manager.get_opponent_sid.return_value = None
|
|
|
|
await handler._notify_opponent_status(
|
|
mock_sio,
|
|
"sender-sid",
|
|
"game-123",
|
|
"player-1",
|
|
ConnectionStatus.CONNECTED,
|
|
)
|
|
|
|
mock_sio.emit.assert_not_called()
|
|
|
|
|
|
class TestErrorResponse:
|
|
"""Tests for the _error_response helper method."""
|
|
|
|
def test_error_response_format(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
) -> None:
|
|
"""Test that error response has correct format.
|
|
|
|
Error responses should include success=False, error code,
|
|
message, and the original request's message_id.
|
|
"""
|
|
result = handler._error_response(
|
|
WSErrorCode.GAME_NOT_FOUND,
|
|
"Game not found",
|
|
"msg-123",
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["error"]["code"] == WSErrorCode.GAME_NOT_FOUND.value
|
|
assert result["error"]["message"] == "Game not found"
|
|
assert result["request_message_id"] == "msg-123"
|
|
|
|
|
|
class TestHandleReconnect:
|
|
"""Tests for the handle_reconnect event handler.
|
|
|
|
The reconnection handler is called after successful authentication
|
|
to check if the user has an active game and auto-rejoin them.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def mock_active_game(self) -> AsyncMock:
|
|
"""Create a mock ActiveGame record.
|
|
|
|
ActiveGame represents a game in progress stored in Postgres.
|
|
"""
|
|
from datetime import UTC, datetime
|
|
from uuid import UUID
|
|
|
|
game = AsyncMock()
|
|
game.id = UUID("12345678-1234-5678-1234-567812345678")
|
|
game.started_at = datetime.now(UTC)
|
|
game.last_action_at = datetime.now(UTC)
|
|
return game
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_no_active_games(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_state_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test that reconnect returns None when user has no active games.
|
|
|
|
If the user isn't in any games, there's nothing to reconnect to.
|
|
"""
|
|
mock_state_manager.get_player_active_games.return_value = []
|
|
|
|
result = await handler.handle_reconnect(
|
|
mock_sio,
|
|
"sid-123",
|
|
"12345678-1234-5678-1234-567812345678",
|
|
)
|
|
|
|
assert result is None
|
|
mock_state_manager.get_player_active_games.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_invalid_uuid_returns_none(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_state_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
) -> None:
|
|
"""Test that invalid user_id (non-UUID) returns None.
|
|
|
|
NPC IDs are not valid UUIDs and shouldn't have active games.
|
|
"""
|
|
result = await handler.handle_reconnect(
|
|
mock_sio,
|
|
"sid-123",
|
|
"npc-grass-trainer-1", # Not a valid UUID
|
|
)
|
|
|
|
assert result is None
|
|
# Should not have queried for active games
|
|
mock_state_manager.get_player_active_games.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_success(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_state_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
mock_active_game: AsyncMock,
|
|
sample_visible_state: VisibleGameState,
|
|
) -> None:
|
|
"""Test successful reconnection to an active game.
|
|
|
|
When a user connects with an active game, they should be
|
|
automatically rejoined to that game.
|
|
"""
|
|
mock_state_manager.get_player_active_games.return_value = [mock_active_game]
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=True,
|
|
game_id=str(mock_active_game.id),
|
|
player_id="12345678-1234-5678-1234-567812345678",
|
|
visible_state=sample_visible_state,
|
|
is_your_turn=True,
|
|
game_over=False,
|
|
)
|
|
|
|
result = await handler.handle_reconnect(
|
|
mock_sio,
|
|
"sid-123",
|
|
"12345678-1234-5678-1234-567812345678",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result["game_id"] == str(mock_active_game.id)
|
|
assert result["is_your_turn"] is True
|
|
assert "state" in result
|
|
|
|
# Should have joined connection manager
|
|
mock_connection_manager.join_game.assert_called_once_with(
|
|
"sid-123", str(mock_active_game.id)
|
|
)
|
|
|
|
# Should have entered the room
|
|
mock_sio.enter_room.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_notifies_opponent(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_state_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
mock_active_game: AsyncMock,
|
|
sample_visible_state: VisibleGameState,
|
|
) -> None:
|
|
"""Test that reconnection notifies the opponent.
|
|
|
|
When a player reconnects, their opponent should be notified
|
|
that they're back online.
|
|
"""
|
|
mock_state_manager.get_player_active_games.return_value = [mock_active_game]
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=True,
|
|
game_id=str(mock_active_game.id),
|
|
player_id="12345678-1234-5678-1234-567812345678",
|
|
visible_state=sample_visible_state,
|
|
is_your_turn=True,
|
|
)
|
|
mock_connection_manager.get_opponent_sid.return_value = "opponent-sid"
|
|
|
|
await handler.handle_reconnect(
|
|
mock_sio,
|
|
"sid-123",
|
|
"12345678-1234-5678-1234-567812345678",
|
|
)
|
|
|
|
# Should have tried to notify opponent
|
|
mock_connection_manager.get_opponent_sid.assert_called()
|
|
|
|
# Should have emitted opponent status
|
|
emit_calls = [
|
|
call for call in mock_sio.emit.call_args_list if call[0][0] == "game:opponent_status"
|
|
]
|
|
assert len(emit_calls) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_includes_pending_forced_action(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_state_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
mock_active_game: AsyncMock,
|
|
sample_visible_state: VisibleGameState,
|
|
) -> None:
|
|
"""Test that reconnect includes pending forced action.
|
|
|
|
If the game has a pending forced action when the player
|
|
reconnects, it should be included in the response.
|
|
"""
|
|
mock_state_manager.get_player_active_games.return_value = [mock_active_game]
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=True,
|
|
game_id=str(mock_active_game.id),
|
|
player_id="12345678-1234-5678-1234-567812345678",
|
|
visible_state=sample_visible_state,
|
|
is_your_turn=True,
|
|
pending_forced_action=PendingForcedAction(
|
|
player_id="12345678-1234-5678-1234-567812345678",
|
|
action_type="select_active",
|
|
reason="Your active Pokemon was knocked out.",
|
|
params={"available_bench_ids": ["bench-1"]},
|
|
),
|
|
)
|
|
|
|
result = await handler.handle_reconnect(
|
|
mock_sio,
|
|
"sid-123",
|
|
"12345678-1234-5678-1234-567812345678",
|
|
)
|
|
|
|
assert result is not None
|
|
assert "pending_forced_action" in result
|
|
assert result["pending_forced_action"]["action_type"] == "select_active"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_includes_turn_timer(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_state_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
mock_active_game: AsyncMock,
|
|
sample_visible_state: VisibleGameState,
|
|
) -> None:
|
|
"""Test that reconnect includes turn timer information.
|
|
|
|
If turn timers are enabled, the remaining time should be
|
|
included in the reconnection response.
|
|
"""
|
|
mock_state_manager.get_player_active_games.return_value = [mock_active_game]
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=True,
|
|
game_id=str(mock_active_game.id),
|
|
player_id="12345678-1234-5678-1234-567812345678",
|
|
visible_state=sample_visible_state,
|
|
is_your_turn=True,
|
|
turn_timeout_seconds=120,
|
|
turn_deadline=1700000000.0,
|
|
)
|
|
|
|
result = await handler.handle_reconnect(
|
|
mock_sio,
|
|
"sid-123",
|
|
"12345678-1234-5678-1234-567812345678",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result["turn_timeout_seconds"] == 120
|
|
assert result["turn_deadline"] == 1700000000.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_join_game_fails(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_state_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
mock_active_game: AsyncMock,
|
|
) -> None:
|
|
"""Test that reconnect returns None when join_game fails.
|
|
|
|
If the GameService fails to join the game (e.g., game was archived
|
|
between lookup and join), we should return None gracefully.
|
|
"""
|
|
mock_state_manager.get_player_active_games.return_value = [mock_active_game]
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=False,
|
|
game_id=str(mock_active_game.id),
|
|
player_id="12345678-1234-5678-1234-567812345678",
|
|
message="Game not found",
|
|
)
|
|
|
|
result = await handler.handle_reconnect(
|
|
mock_sio,
|
|
"sid-123",
|
|
"12345678-1234-5678-1234-567812345678",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_multiple_games_uses_most_recent(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_state_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
sample_visible_state: VisibleGameState,
|
|
) -> None:
|
|
"""Test that with multiple active games, the most recent is used.
|
|
|
|
If a player has multiple active games (edge case), we should
|
|
reconnect to the one with the most recent activity.
|
|
"""
|
|
from datetime import UTC, datetime, timedelta
|
|
from uuid import UUID
|
|
|
|
older_game = AsyncMock()
|
|
older_game.id = UUID("11111111-1111-1111-1111-111111111111")
|
|
older_game.started_at = datetime.now(UTC) - timedelta(hours=2)
|
|
older_game.last_action_at = datetime.now(UTC) - timedelta(hours=1)
|
|
|
|
newer_game = AsyncMock()
|
|
newer_game.id = UUID("22222222-2222-2222-2222-222222222222")
|
|
newer_game.started_at = datetime.now(UTC) - timedelta(hours=1)
|
|
newer_game.last_action_at = datetime.now(UTC) - timedelta(minutes=5)
|
|
|
|
# Return older game first to verify sorting works
|
|
mock_state_manager.get_player_active_games.return_value = [older_game, newer_game]
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=True,
|
|
game_id=str(newer_game.id),
|
|
player_id="12345678-1234-5678-1234-567812345678",
|
|
visible_state=sample_visible_state,
|
|
is_your_turn=True,
|
|
)
|
|
|
|
result = await handler.handle_reconnect(
|
|
mock_sio,
|
|
"sid-123",
|
|
"12345678-1234-5678-1234-567812345678",
|
|
)
|
|
|
|
assert result is not None
|
|
# Should have joined the newer game
|
|
assert result["game_id"] == str(newer_game.id)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_to_ended_game(
|
|
self,
|
|
handler: GameNamespaceHandler,
|
|
mock_game_service: AsyncMock,
|
|
mock_connection_manager: AsyncMock,
|
|
mock_state_manager: AsyncMock,
|
|
mock_sio: AsyncMock,
|
|
mock_active_game: AsyncMock,
|
|
sample_visible_state: VisibleGameState,
|
|
) -> None:
|
|
"""Test reconnection to a game that has ended.
|
|
|
|
If the game ended while the player was disconnected, they
|
|
should still see the final state with game_over=True.
|
|
"""
|
|
mock_state_manager.get_player_active_games.return_value = [mock_active_game]
|
|
mock_game_service.join_game.return_value = GameJoinResult(
|
|
success=True,
|
|
game_id=str(mock_active_game.id),
|
|
player_id="12345678-1234-5678-1234-567812345678",
|
|
visible_state=sample_visible_state,
|
|
is_your_turn=False,
|
|
game_over=True,
|
|
message="Game has ended",
|
|
)
|
|
|
|
result = await handler.handle_reconnect(
|
|
mock_sio,
|
|
"sid-123",
|
|
"12345678-1234-5678-1234-567812345678",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result["game_over"] is True
|