""" Tests for WebSocket connection and room management handlers. Verifies authentication (connect), disconnection (disconnect), room joining (join_game, leave_game), heartbeat, and game state request handlers. Author: Claude Date: 2025-01-27 """ import pytest from uuid import uuid4 from unittest.mock import AsyncMock, MagicMock, patch from tests.unit.websocket.conftest import get_handler # ============================================================================ # CONNECT HANDLER TESTS # ============================================================================ class TestConnectHandler: """Tests for the connect event handler.""" @pytest.mark.asyncio async def test_connect_with_valid_cookie(self, mock_manager): """ Verify connect() accepts connection with valid cookie token. Cookie-based auth is the primary auth method when using HttpOnly cookies set by the OAuth flow. Handler should extract token from HTTP_COOKIE. """ from socketio import AsyncServer sio = AsyncServer() # Mock verify_token to return valid user data with patch("app.websocket.handlers.verify_token") as mock_verify: mock_verify.return_value = {"user_id": "user_123", "username": "testuser"} from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["connect"] # Simulate environ with cookie environ = {"HTTP_COOKIE": "pd_access_token=valid_token_here; other=value"} auth = None result = await handler("test_sid", environ, auth) assert result is True mock_manager.connect.assert_called_once_with("test_sid", "user_123", ip_address=None) @pytest.mark.asyncio async def test_connect_with_auth_object(self, mock_manager): """ Verify connect() accepts connection with auth object token. Auth object is used by direct JS clients that can't use cookies. Handler should fall back to auth.token when cookie not present. """ from socketio import AsyncServer sio = AsyncServer() with patch("app.websocket.handlers.verify_token") as mock_verify: mock_verify.return_value = {"user_id": "user_456", "username": "jsuser"} from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["connect"] # Simulate environ without cookie, but with auth object environ = {"HTTP_COOKIE": ""} auth = {"token": "auth_object_token"} result = await handler("test_sid", environ, auth) assert result is True mock_verify.assert_called_once_with("auth_object_token") mock_manager.connect.assert_called_once_with("test_sid", "user_456", ip_address=None) @pytest.mark.asyncio async def test_connect_missing_credentials_rejected(self, mock_manager): """ Verify connect() rejects connection when no token provided. Handler should return False to reject the Socket.io connection when neither cookie nor auth object contains a token. """ from socketio import AsyncServer sio = AsyncServer() from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["connect"] # No cookie, no auth environ = {"HTTP_COOKIE": ""} auth = None result = await handler("test_sid", environ, auth) assert result is False mock_manager.connect.assert_not_called() @pytest.mark.asyncio async def test_connect_invalid_token_rejected(self, mock_manager): """ Verify connect() rejects connection when token is invalid. When verify_token returns no user_id, the connection should be rejected. """ from socketio import AsyncServer sio = AsyncServer() with patch("app.websocket.handlers.verify_token") as mock_verify: mock_verify.return_value = {} # No user_id in response from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["connect"] environ = {"HTTP_COOKIE": "pd_access_token=invalid_token"} auth = None result = await handler("test_sid", environ, auth) assert result is False mock_manager.connect.assert_not_called() @pytest.mark.asyncio async def test_connect_exception_rejected(self, mock_manager): """ Verify connect() rejects connection when exception occurs. Any exception during auth processing should result in connection rejection, not a server crash. """ from socketio import AsyncServer sio = AsyncServer() with patch("app.websocket.handlers.verify_token") as mock_verify: mock_verify.side_effect = Exception("Token verification failed") from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["connect"] environ = {"HTTP_COOKIE": "pd_access_token=error_token"} auth = None result = await handler("test_sid", environ, auth) assert result is False @pytest.mark.asyncio async def test_connect_extracts_ip_address(self, mock_manager): """ Verify connect() extracts IP address from environ. IP address is used for logging and rate limiting. Handler should extract REMOTE_ADDR from the WSGI environ dict. """ from socketio import AsyncServer sio = AsyncServer() with patch("app.websocket.handlers.verify_token") as mock_verify: mock_verify.return_value = {"user_id": "user_123"} from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["connect"] environ = { "HTTP_COOKIE": "pd_access_token=valid_token", "REMOTE_ADDR": "192.168.1.100", } result = await handler("test_sid", environ, None) assert result is True mock_manager.connect.assert_called_once_with( "test_sid", "user_123", ip_address="192.168.1.100" ) @pytest.mark.asyncio async def test_connect_emits_connected_event(self, mock_manager): """ Verify connect() emits connected event with user_id. After successful authentication, the handler should emit a 'connected' event to the client with their user_id for client-side tracking. """ from socketio import AsyncServer sio = AsyncServer() sio.emit = AsyncMock() with patch("app.websocket.handlers.verify_token") as mock_verify: mock_verify.return_value = {"user_id": "user_789"} from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["connect"] environ = {"HTTP_COOKIE": "pd_access_token=valid_token"} await handler("test_sid", environ, None) # Verify connected event emitted sio.emit.assert_called_once() call_args = sio.emit.call_args assert call_args[0][0] == "connected" assert call_args[0][1]["user_id"] == "user_789" @pytest.mark.asyncio async def test_connect_value_error_rejected(self, mock_manager): """ Verify connect() handles ValueError during token parsing. If verify_token raises ValueError (malformed token), the connection should be rejected without crashing. """ from socketio import AsyncServer sio = AsyncServer() with patch("app.websocket.handlers.verify_token") as mock_verify: mock_verify.side_effect = ValueError("Invalid token format") from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["connect"] environ = {"HTTP_COOKIE": "pd_access_token=malformed"} result = await handler("test_sid", environ, None) assert result is False mock_manager.connect.assert_not_called() @pytest.mark.asyncio async def test_connect_key_error_rejected(self, mock_manager): """ Verify connect() handles KeyError during token parsing. If verify_token raises KeyError (missing required field), the connection should be rejected without crashing. """ from socketio import AsyncServer sio = AsyncServer() with patch("app.websocket.handlers.verify_token") as mock_verify: mock_verify.side_effect = KeyError("missing_field") from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["connect"] environ = {"HTTP_COOKIE": "pd_access_token=incomplete"} result = await handler("test_sid", environ, None) assert result is False @pytest.mark.asyncio async def test_connect_network_error_rejected(self, mock_manager): """ Verify connect() handles network errors gracefully. If a ConnectionError or OSError occurs during connection setup, the connection should be rejected. """ from socketio import AsyncServer sio = AsyncServer() with patch("app.websocket.handlers.verify_token") as mock_verify: mock_verify.return_value = {"user_id": "user_123"} mock_manager.connect = AsyncMock(side_effect=ConnectionError("Network failed")) from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["connect"] environ = {"HTTP_COOKIE": "pd_access_token=valid"} result = await handler("test_sid", environ, None) assert result is False # ============================================================================ # DISCONNECT HANDLER TESTS # ============================================================================ class TestDisconnectHandler: """Tests for the disconnect event handler.""" @pytest.mark.asyncio async def test_disconnect_calls_manager(self, mock_manager): """ Verify disconnect() delegates to ConnectionManager. Handler should call manager.disconnect() with the session ID to clean up all session state and room memberships. """ from socketio import AsyncServer sio = AsyncServer() from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["disconnect"] await handler("test_sid") mock_manager.disconnect.assert_called_once_with("test_sid") @pytest.mark.asyncio async def test_disconnect_cleans_rate_limiter(self, mock_manager): """ Verify disconnect() removes rate limiter buckets. Handler should call rate_limiter.remove_connection() to clean up rate limiting state and prevent memory leaks. """ from socketio import AsyncServer sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter") as mock_limiter: mock_limiter.remove_connection = MagicMock() from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["disconnect"] await handler("test_sid") mock_limiter.remove_connection.assert_called_once_with("test_sid") @pytest.mark.asyncio async def test_disconnect_handles_manager_error(self, mock_manager): """ Verify disconnect() handles manager errors gracefully. Even if manager.disconnect() raises an error, the handler should not crash - disconnection cleanup should be best-effort. """ from socketio import AsyncServer sio = AsyncServer() mock_manager.disconnect = AsyncMock(side_effect=Exception("Cleanup failed")) with patch("app.websocket.handlers.rate_limiter"): from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["disconnect"] # Should not raise even if manager fails # Note: The handler may or may not catch this - depends on impl try: await handler("test_sid") except Exception: pass # Some implementations may let errors propagate @pytest.mark.asyncio async def test_disconnect_multiple_calls_idempotent(self, mock_manager): """ Verify disconnect() can be called multiple times safely. Socket.io may call disconnect multiple times in edge cases. Handler should be idempotent and not crash on repeated calls. """ from socketio import AsyncServer sio = AsyncServer() with patch("app.websocket.handlers.rate_limiter"): from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["disconnect"] # First disconnect await handler("test_sid") # Second disconnect should also succeed await handler("test_sid") # Manager should be called twice assert mock_manager.disconnect.call_count == 2 # ============================================================================ # JOIN GAME HANDLER TESTS # ============================================================================ class TestJoinGameHandler: """Tests for the join_game event handler.""" @pytest.mark.asyncio async def test_join_game_success(self, mock_manager): """ Verify join_game() successfully joins user to game room. Handler should call manager.join_game and emit game_joined confirmation. """ from socketio import AsyncServer sio = AsyncServer() from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["join_game"] game_id = str(uuid4()) await handler("test_sid", {"game_id": game_id}) mock_manager.join_game.assert_called_once_with("test_sid", game_id, "player") mock_manager.emit_to_user.assert_called_once() # Verify confirmation event call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "game_joined" assert call_args[0][2]["game_id"] == game_id @pytest.mark.asyncio async def test_join_game_missing_game_id(self, mock_manager): """ Verify join_game() returns error when game_id missing. Handler should emit error event without attempting to join. """ from socketio import AsyncServer sio = AsyncServer() from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["join_game"] await handler("test_sid", {}) mock_manager.join_game.assert_not_called() mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "game_id" in call_args[0][2]["message"].lower() @pytest.mark.asyncio async def test_join_game_with_role(self, mock_manager): """ Verify join_game() passes role to manager. Role can be 'home', 'away', 'spectator', or 'player'. Handler should pass the specified role to manager.join_game(). """ from socketio import AsyncServer sio = AsyncServer() from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["join_game"] game_id = str(uuid4()) await handler("test_sid", {"game_id": game_id, "role": "spectator"}) mock_manager.join_game.assert_called_once_with("test_sid", game_id, "spectator") # ============================================================================ # LEAVE GAME HANDLER TESTS # ============================================================================ class TestLeaveGameHandler: """Tests for the leave_game event handler.""" @pytest.mark.asyncio async def test_leave_game_success(self, mock_manager): """ Verify leave_game() removes user from game room. Handler should call manager.leave_game with game_id. """ from socketio import AsyncServer sio = AsyncServer() from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["leave_game"] game_id = str(uuid4()) await handler("test_sid", {"game_id": game_id}) mock_manager.leave_game.assert_called_once_with("test_sid", game_id) @pytest.mark.asyncio async def test_leave_game_missing_game_id_noop(self, mock_manager): """ Verify leave_game() is a no-op when game_id missing. Handler should not call leave_game if no game_id provided. """ from socketio import AsyncServer sio = AsyncServer() from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["leave_game"] await handler("test_sid", {}) mock_manager.leave_game.assert_not_called() # ============================================================================ # HEARTBEAT HANDLER TESTS # ============================================================================ class TestHeartbeatHandler: """Tests for the heartbeat event handler.""" @pytest.mark.asyncio async def test_heartbeat_acknowledges(self, mock_manager): """ Verify heartbeat() emits acknowledgment. Handler should emit heartbeat_ack directly to the client socket. This keeps the connection alive and verifies client is responsive. """ from socketio import AsyncServer sio = AsyncServer() sio.emit = AsyncMock() from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["heartbeat"] await handler("test_sid") sio.emit.assert_called_once_with("heartbeat_ack", {}, room="test_sid") # ============================================================================ # REQUEST GAME STATE HANDLER TESTS # ============================================================================ class TestRequestGameStateHandler: """Tests for the request_game_state event handler.""" @pytest.mark.asyncio async def test_request_game_state_from_memory(self, mock_manager, mock_game_state): """ Verify request_game_state() returns state from memory with play history. When game state is in StateManager memory, handler should return it along with play history via game_state_sync event. """ from socketio import AsyncServer sio = AsyncServer() with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \ patch("app.websocket.handlers.DatabaseOperations") as mock_db_ops_class: mock_state_mgr.get_state.return_value = mock_game_state # Mock DatabaseOperations.get_plays() to return empty list mock_db_ops = AsyncMock() mock_db_ops.get_plays = AsyncMock(return_value=[]) mock_db_ops_class.return_value = mock_db_ops from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_game_state"] await handler("test_sid", {"game_id": str(mock_game_state.game_id)}) mock_state_mgr.get_state.assert_called_once() mock_state_mgr.recover_game.assert_not_called() mock_db_ops.get_plays.assert_called_once() mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "game_state_sync" # Verify payload structure payload = call_args[0][2] assert "state" in payload assert "recent_plays" in payload assert "timestamp" in payload @pytest.mark.asyncio async def test_request_game_state_from_db(self, mock_manager, mock_game_state): """ Verify request_game_state() recovers from database when not in memory. When game state is not in memory, handler should call recover_game() to load and replay from database (recovery path), then return state with play history via game_state_sync event. """ from socketio import AsyncServer sio = AsyncServer() with patch("app.websocket.handlers.state_manager") as mock_state_mgr, \ patch("app.websocket.handlers.DatabaseOperations") as mock_db_ops_class: mock_state_mgr.get_state.return_value = None # Not in memory mock_state_mgr.recover_game = AsyncMock(return_value=mock_game_state) # Mock DatabaseOperations.get_plays() to return empty list mock_db_ops = AsyncMock() mock_db_ops.get_plays = AsyncMock(return_value=[]) mock_db_ops_class.return_value = mock_db_ops from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_game_state"] await handler("test_sid", {"game_id": str(mock_game_state.game_id)}) mock_state_mgr.get_state.assert_called_once() mock_state_mgr.recover_game.assert_called_once() mock_db_ops.get_plays.assert_called_once() mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "game_state_sync" # Verify payload structure payload = call_args[0][2] assert "state" in payload assert "recent_plays" in payload assert "timestamp" in payload @pytest.mark.asyncio async def test_request_game_state_missing_game_id(self, mock_manager): """ Verify request_game_state() returns error when game_id missing. """ from socketio import AsyncServer sio = AsyncServer() from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_game_state"] await handler("test_sid", {}) mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "game_id" in call_args[0][2]["message"].lower() @pytest.mark.asyncio async def test_request_game_state_invalid_format(self, mock_manager): """ Verify request_game_state() returns error for invalid UUID format. Handler should validate game_id is a valid UUID before attempting to fetch state. """ from socketio import AsyncServer sio = AsyncServer() from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_game_state"] await handler("test_sid", {"game_id": "not-a-valid-uuid"}) mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "invalid" in call_args[0][2]["message"].lower() @pytest.mark.asyncio async def test_request_game_state_not_found(self, mock_manager): """ Verify request_game_state() returns error when game not found anywhere. When game is neither in memory nor database, handler should emit an error event indicating the game was not found. """ 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 mock_state_mgr.recover_game = AsyncMock(return_value=None) from app.websocket.handlers import register_handlers register_handlers(sio, mock_manager) handler = sio.handlers["/"]["request_game_state"] game_id = str(uuid4()) await handler("test_sid", {"game_id": game_id}) mock_manager.emit_to_user.assert_called_once() call_args = mock_manager.emit_to_user.call_args assert call_args[0][1] == "error" assert "not found" in call_args[0][2]["message"].lower()