strat-gameplay-webapp/backend/tests/unit/websocket/test_connection_handlers.py
Cal Corum 19b35f148b CLAUDE: Load play history on mid-game join via game_state_sync
Backend changes:
- Modified request_game_state handler to fetch plays from database
- Convert Play DB models to frontend-compatible PlayResult dicts
- Emit game_state_sync event with state + recent_plays array

Frontend changes:
- Added deduplication by play_number in addPlayToHistory()
- Prevents duplicate plays when game_state_sync is received

Field mapping from Play model:
- hit_type -> outcome
- result_description -> description
- batter_id -> batter_lineup_id
- batter_final -> batter_result

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 12:38:56 -06:00

742 lines
25 KiB
Python

"""
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()