mantimon-tcg/backend/tests/unit/socketio/test_game_namespace.py
Cal Corum f452e69999 Complete Phase 4 implementation files
- 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>
2026-01-30 08:03:43 -06:00

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