strat-gameplay-webapp/backend/tests/unit/websocket/test_connection_manager.py
Cal Corum 4253b71db9 CLAUDE: Enhance WebSocket handlers with comprehensive test coverage
WebSocket Infrastructure:
- Connection manager: Improved connection/disconnection handling
- Handlers: Enhanced event handlers for game operations

Test Coverage (148 new tests):
- test_connection_handlers.py: Connection lifecycle tests
- test_connection_manager.py: Manager operations tests
- test_handler_locking.py: Concurrency/locking tests
- test_query_handlers.py: Game query handler tests
- test_rate_limiting.py: Rate limit enforcement tests
- test_substitution_handlers.py: Player substitution tests
- test_manual_outcome_handlers.py: Manual outcome workflow tests
- conftest.py: Shared WebSocket test fixtures

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

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

818 lines
26 KiB
Python

"""
Tests for ConnectionManager class.
Verifies session management, room membership, event broadcasting,
and session expiration functionality that forms the foundation of
real-time WebSocket communication.
Author: Claude
Date: 2025-01-27
Updated: 2025-01-27 - Added session expiration tests
"""
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import pendulum
from app.websocket.connection_manager import ConnectionManager, SessionInfo
# ============================================================================
# CONNECTION TESTS
# ============================================================================
class TestConnect:
"""Tests for connection registration."""
@pytest.mark.asyncio
async def test_connect_registers_session(self):
"""
Verify connect() stores user_id in user_sessions dict.
The session ID (sid) should map to the user_id for later lookups.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
assert "sid_123" in manager.user_sessions
assert manager.user_sessions["sid_123"] == "user_456"
@pytest.mark.asyncio
async def test_connect_duplicate_sid_replaces(self):
"""
Verify connecting with same SID replaces existing user_id.
This handles reconnection scenarios where the same socket reconnects
with a different user (edge case).
"""
sio = MagicMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_A")
await manager.connect("sid_123", "user_B")
assert manager.user_sessions["sid_123"] == "user_B"
@pytest.mark.asyncio
async def test_connect_multiple_users(self):
"""Verify multiple users can connect simultaneously."""
sio = MagicMock()
manager = ConnectionManager(sio)
await manager.connect("sid_1", "user_1")
await manager.connect("sid_2", "user_2")
await manager.connect("sid_3", "user_3")
assert len(manager.user_sessions) == 3
assert manager.user_sessions["sid_1"] == "user_1"
assert manager.user_sessions["sid_2"] == "user_2"
assert manager.user_sessions["sid_3"] == "user_3"
# ============================================================================
# DISCONNECT TESTS
# ============================================================================
class TestDisconnect:
"""Tests for disconnection handling."""
@pytest.mark.asyncio
async def test_disconnect_removes_session(self):
"""
Verify disconnect() removes user from user_sessions dict.
After disconnection, the SID should no longer be tracked.
"""
sio = MagicMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
await manager.disconnect("sid_123")
assert "sid_123" not in manager.user_sessions
@pytest.mark.asyncio
async def test_disconnect_removes_from_all_rooms(self):
"""
Verify disconnect() removes user from all game rooms they joined.
User should be cleaned up from every game room to prevent stale
participants.
"""
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
# Connect and join multiple games
await manager.connect("sid_123", "user_456")
await manager.join_game("sid_123", "game_A", "home")
await manager.join_game("sid_123", "game_B", "away")
# Verify in both rooms
assert "sid_123" in manager.game_rooms["game_A"]
assert "sid_123" in manager.game_rooms["game_B"]
# Disconnect
await manager.disconnect("sid_123")
# Should be removed from all rooms
assert "sid_123" not in manager.game_rooms["game_A"]
assert "sid_123" not in manager.game_rooms["game_B"]
@pytest.mark.asyncio
async def test_disconnect_nonexistent_noop(self):
"""
Verify disconnecting unknown SID is a safe no-op.
Should not raise exception when SID was never connected.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
# Should not raise
await manager.disconnect("unknown_sid")
assert "unknown_sid" not in manager.user_sessions
@pytest.mark.asyncio
async def test_disconnect_broadcasts_to_rooms(self):
"""
Verify disconnect() broadcasts user_disconnected to game rooms.
Other participants in the game should be notified when a user leaves.
"""
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
await manager.join_game("sid_123", "game_A", "home")
# Clear previous emit calls
sio.emit.reset_mock()
await manager.disconnect("sid_123")
# Should broadcast user_disconnected
sio.emit.assert_called()
call_args = sio.emit.call_args_list[-1]
assert call_args[0][0] == "user_disconnected"
assert call_args[0][1]["user_id"] == "user_456"
# ============================================================================
# ROOM MANAGEMENT TESTS
# ============================================================================
class TestRoomManagement:
"""Tests for game room join/leave functionality."""
@pytest.mark.asyncio
async def test_join_game_creates_room(self):
"""
Verify join_game() creates room entry if game doesn't exist.
First user joining a game should create the room tracking.
"""
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
await manager.join_game("sid_123", "game_A", "home")
assert "game_A" in manager.game_rooms
assert "sid_123" in manager.game_rooms["game_A"]
@pytest.mark.asyncio
async def test_join_game_adds_to_existing(self):
"""
Verify join_game() adds to existing room.
Multiple users can join the same game room.
"""
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_1", "user_1")
await manager.connect("sid_2", "user_2")
await manager.join_game("sid_1", "game_A", "home")
await manager.join_game("sid_2", "game_A", "away")
assert len(manager.game_rooms["game_A"]) == 2
assert "sid_1" in manager.game_rooms["game_A"]
assert "sid_2" in manager.game_rooms["game_A"]
@pytest.mark.asyncio
async def test_join_game_enters_socketio_room(self):
"""
Verify join_game() calls Socket.io enter_room.
Must register with Socket.io's room system for broadcasting to work.
"""
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
await manager.join_game("sid_123", "game_A", "home")
sio.enter_room.assert_called_once_with("sid_123", "game_A")
@pytest.mark.asyncio
async def test_join_game_broadcasts_user_connected(self):
"""
Verify join_game() broadcasts user_connected to the room.
Other participants should be notified when a new user joins.
"""
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
await manager.join_game("sid_123", "game_A", "home")
# Should broadcast user_connected
sio.emit.assert_called()
call_args = sio.emit.call_args
assert call_args[0][0] == "user_connected"
assert call_args[0][1]["user_id"] == "user_456"
assert call_args[0][1]["role"] == "home"
@pytest.mark.asyncio
async def test_leave_game_removes_from_room(self):
"""
Verify leave_game() removes user from specific game room.
User should only be removed from the specified game.
"""
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.leave_room = AsyncMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
await manager.join_game("sid_123", "game_A", "home")
await manager.join_game("sid_123", "game_B", "away")
await manager.leave_game("sid_123", "game_A")
assert "sid_123" not in manager.game_rooms["game_A"]
assert "sid_123" in manager.game_rooms["game_B"]
@pytest.mark.asyncio
async def test_leave_game_leaves_socketio_room(self):
"""
Verify leave_game() calls Socket.io leave_room.
"""
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.leave_room = AsyncMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
await manager.join_game("sid_123", "game_A", "home")
await manager.leave_game("sid_123", "game_A")
sio.leave_room.assert_called_once_with("sid_123", "game_A")
@pytest.mark.asyncio
async def test_get_game_participants(self):
"""
Verify get_game_participants() returns all SIDs in room.
"""
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_1", "user_1")
await manager.connect("sid_2", "user_2")
await manager.join_game("sid_1", "game_A", "home")
await manager.join_game("sid_2", "game_A", "away")
participants = manager.get_game_participants("game_A")
assert len(participants) == 2
assert "sid_1" in participants
assert "sid_2" in participants
@pytest.mark.asyncio
async def test_get_game_participants_empty_room(self):
"""
Verify get_game_participants() returns empty set for unknown game.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
participants = manager.get_game_participants("nonexistent_game")
assert participants == set()
# ============================================================================
# BROADCASTING TESTS
# ============================================================================
class TestBroadcasting:
"""Tests for event emission functionality."""
@pytest.mark.asyncio
async def test_broadcast_to_game_emits_to_room(self):
"""
Verify broadcast_to_game() emits to the correct Socket.io room.
"""
sio = MagicMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.broadcast_to_game("game_A", "test_event", {"key": "value"})
sio.emit.assert_called_once_with(
"test_event", {"key": "value"}, room="game_A"
)
@pytest.mark.asyncio
async def test_broadcast_to_empty_room_noop(self):
"""
Verify broadcasting to empty room doesn't raise exception.
Socket.io handles empty rooms gracefully - we just emit.
"""
sio = MagicMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
# Should not raise
await manager.broadcast_to_game("empty_game", "event", {})
sio.emit.assert_called_once()
@pytest.mark.asyncio
async def test_emit_to_user_targets_sid(self):
"""
Verify emit_to_user() sends to specific session ID.
"""
sio = MagicMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.emit_to_user("sid_123", "private_event", {"secret": "data"})
sio.emit.assert_called_once_with(
"private_event", {"secret": "data"}, room="sid_123"
)
@pytest.mark.asyncio
async def test_emit_to_user_vs_broadcast_isolation(self):
"""
Verify emit_to_user and broadcast_to_game are independent.
Private message should not go to game room, and vice versa.
"""
sio = MagicMock()
sio.emit = AsyncMock()
sio.enter_room = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
await manager.join_game("sid_123", "game_A", "home")
# Clear previous emits
sio.emit.reset_mock()
# Send private message
await manager.emit_to_user("sid_123", "private", {"data": 1})
# Verify it went to sid, not game room
call_args = sio.emit.call_args
assert call_args[1]["room"] == "sid_123" # Not "game_A"
# ============================================================================
# SESSION INFO TESTS
# ============================================================================
class TestSessionInfo:
"""Tests for SessionInfo dataclass."""
def test_session_info_creates_with_defaults(self):
"""
Verify SessionInfo creates with required fields and defaults.
games should default to empty set, ip_address to None.
"""
now = pendulum.now("UTC")
info = SessionInfo(
user_id="user_123",
connected_at=now,
last_activity=now,
)
assert info.user_id == "user_123"
assert info.connected_at == now
assert info.last_activity == now
assert info.games == set()
assert info.ip_address is None
def test_session_info_with_ip_address(self):
"""Verify SessionInfo stores IP address when provided."""
now = pendulum.now("UTC")
info = SessionInfo(
user_id="user_123",
connected_at=now,
last_activity=now,
ip_address="192.168.1.100",
)
assert info.ip_address == "192.168.1.100"
def test_inactive_seconds_fresh_session(self):
"""
Verify inactive_seconds returns near-zero for fresh session.
A session just created should have very low inactivity.
"""
now = pendulum.now("UTC")
info = SessionInfo(
user_id="user_123",
connected_at=now,
last_activity=now,
)
# Should be very close to 0 (within 1 second)
assert info.inactive_seconds() < 1.0
def test_inactive_seconds_old_session(self):
"""
Verify inactive_seconds returns correct duration for old session.
A session inactive for 5 minutes should report ~300 seconds.
"""
now = pendulum.now("UTC")
five_min_ago = now.subtract(minutes=5)
info = SessionInfo(
user_id="user_123",
connected_at=five_min_ago,
last_activity=five_min_ago,
)
# Should be approximately 300 seconds (allow some tolerance)
inactive = info.inactive_seconds()
assert 299.0 < inactive < 302.0
# ============================================================================
# SESSION ACTIVITY TESTS
# ============================================================================
class TestSessionActivity:
"""Tests for session activity tracking."""
@pytest.mark.asyncio
async def test_update_activity_refreshes_timestamp(self):
"""
Verify update_activity() refreshes last_activity timestamp.
After calling update_activity, the session should show recent activity.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
# Get original timestamp
session = manager.get_session("sid_123")
original_activity = session.last_activity
# Small delay to ensure time difference
await asyncio.sleep(0.01)
# Update activity
await manager.update_activity("sid_123")
# Timestamp should be updated
updated_session = manager.get_session("sid_123")
assert updated_session.last_activity > original_activity
@pytest.mark.asyncio
async def test_update_activity_unknown_sid_noop(self):
"""
Verify update_activity() for unknown SID is safe no-op.
Should not raise exception for non-existent session.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
# Should not raise
await manager.update_activity("unknown_sid")
@pytest.mark.asyncio
async def test_get_session_returns_info(self):
"""
Verify get_session() returns SessionInfo for valid SID.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456", ip_address="10.0.0.1")
session = manager.get_session("sid_123")
assert session is not None
assert session.user_id == "user_456"
assert session.ip_address == "10.0.0.1"
@pytest.mark.asyncio
async def test_get_session_unknown_returns_none(self):
"""
Verify get_session() returns None for unknown SID.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
session = manager.get_session("unknown_sid")
assert session is None
@pytest.mark.asyncio
async def test_get_user_id_returns_user(self):
"""
Verify get_user_id() returns user_id for valid SID.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
user_id = manager.get_user_id("sid_123")
assert user_id == "user_456"
@pytest.mark.asyncio
async def test_get_user_id_unknown_returns_none(self):
"""
Verify get_user_id() returns None for unknown SID.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
user_id = manager.get_user_id("unknown_sid")
assert user_id is None
# ============================================================================
# SESSION EXPIRATION TESTS
# ============================================================================
class TestSessionExpiration:
"""Tests for session expiration functionality."""
@pytest.mark.asyncio
async def test_expire_inactive_sessions_removes_old(self):
"""
Verify expire_inactive_sessions() removes sessions beyond timeout.
Sessions inactive longer than timeout should be disconnected.
"""
sio = MagicMock()
sio.emit = AsyncMock()
sio.disconnect = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
# Manually set session to be old
manager._sessions["sid_123"].last_activity = pendulum.now("UTC").subtract(minutes=10)
# Expire with 5 minute timeout
expired = await manager.expire_inactive_sessions(timeout_seconds=300)
assert "sid_123" in expired
assert "sid_123" not in manager._sessions
@pytest.mark.asyncio
async def test_expire_inactive_sessions_keeps_active(self):
"""
Verify expire_inactive_sessions() keeps recently active sessions.
Sessions with recent activity should not be expired.
"""
sio = MagicMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
await manager.update_activity("sid_123") # Fresh activity
# Expire with 5 minute timeout
expired = await manager.expire_inactive_sessions(timeout_seconds=300)
assert "sid_123" not in expired
assert "sid_123" in manager._sessions
@pytest.mark.asyncio
async def test_expire_inactive_calls_socketio_disconnect(self):
"""
Verify expire_inactive_sessions() calls Socket.io disconnect.
Expired sessions should be forcefully disconnected from Socket.io.
"""
sio = MagicMock()
sio.emit = AsyncMock()
sio.disconnect = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
manager._sessions["sid_123"].last_activity = pendulum.now("UTC").subtract(minutes=10)
await manager.expire_inactive_sessions(timeout_seconds=300)
sio.disconnect.assert_called_once_with("sid_123")
@pytest.mark.asyncio
async def test_expire_inactive_handles_disconnect_error(self):
"""
Verify expire_inactive_sessions() handles Socket.io disconnect errors.
Should not raise if Socket.io disconnect fails (connection may already be gone).
"""
sio = MagicMock()
sio.emit = AsyncMock()
sio.disconnect = AsyncMock(side_effect=Exception("Already disconnected"))
manager = ConnectionManager(sio)
await manager.connect("sid_123", "user_456")
manager._sessions["sid_123"].last_activity = pendulum.now("UTC").subtract(minutes=10)
# Should not raise
expired = await manager.expire_inactive_sessions(timeout_seconds=300)
assert "sid_123" in expired
@pytest.mark.asyncio
async def test_expire_returns_list_of_expired_sids(self):
"""
Verify expire_inactive_sessions() returns list of expired session IDs.
"""
sio = MagicMock()
sio.emit = AsyncMock()
sio.disconnect = AsyncMock()
manager = ConnectionManager(sio)
# Connect multiple sessions
await manager.connect("sid_1", "user_1")
await manager.connect("sid_2", "user_2")
await manager.connect("sid_3", "user_3")
# Make only some sessions old
manager._sessions["sid_1"].last_activity = pendulum.now("UTC").subtract(minutes=10)
manager._sessions["sid_3"].last_activity = pendulum.now("UTC").subtract(minutes=10)
# sid_2 stays fresh
expired = await manager.expire_inactive_sessions(timeout_seconds=300)
assert len(expired) == 2
assert "sid_1" in expired
assert "sid_3" in expired
assert "sid_2" not in expired
# ============================================================================
# CONNECTION STATISTICS TESTS
# ============================================================================
class TestConnectionStatistics:
"""Tests for connection statistics functionality."""
@pytest.mark.asyncio
async def test_get_stats_empty(self):
"""
Verify get_stats() returns zero counts when no connections.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
stats = manager.get_stats()
assert stats["total_sessions"] == 0
assert stats["unique_users"] == 0
assert stats["active_game_rooms"] == 0
@pytest.mark.asyncio
async def test_get_stats_with_sessions(self):
"""
Verify get_stats() counts active sessions correctly.
"""
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_1", "user_1")
await manager.connect("sid_2", "user_2")
await manager.connect("sid_3", "user_1") # Same user, different session
stats = manager.get_stats()
assert stats["total_sessions"] == 3
assert stats["unique_users"] == 2 # Only 2 unique users
@pytest.mark.asyncio
async def test_get_stats_with_games(self):
"""
Verify get_stats() counts active game rooms.
"""
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.emit = AsyncMock()
manager = ConnectionManager(sio)
await manager.connect("sid_1", "user_1")
await manager.connect("sid_2", "user_2")
await manager.join_game("sid_1", "game_A", "home")
await manager.join_game("sid_2", "game_A", "away")
await manager.join_game("sid_1", "game_B", "home")
stats = manager.get_stats()
assert stats["active_game_rooms"] == 2
assert "game_A" in stats["sessions_per_game"]
assert stats["sessions_per_game"]["game_A"] == 2
assert stats["sessions_per_game"]["game_B"] == 1
@pytest.mark.asyncio
async def test_get_stats_inactivity_distribution(self):
"""
Verify get_stats() categorizes sessions by inactivity.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
await manager.connect("sid_1", "user_1")
await manager.connect("sid_2", "user_2")
await manager.connect("sid_3", "user_3")
# Make sessions have different inactivity levels
now = pendulum.now("UTC")
manager._sessions["sid_1"].last_activity = now # Active (<1m)
manager._sessions["sid_2"].last_activity = now.subtract(minutes=3) # 1-5m
manager._sessions["sid_3"].last_activity = now.subtract(minutes=20) # >15m
stats = manager.get_stats()
assert stats["inactivity_distribution"]["<1m"] == 1
assert stats["inactivity_distribution"]["1-5m"] == 1
assert stats["inactivity_distribution"][">15m"] == 1
@pytest.mark.asyncio
async def test_get_stats_session_ages(self):
"""
Verify get_stats() calculates session age statistics.
"""
sio = MagicMock()
manager = ConnectionManager(sio)
now = pendulum.now("UTC")
await manager.connect("sid_1", "user_1")
# Override connected_at to simulate older connection
manager._sessions["sid_1"].connected_at = now.subtract(minutes=30)
await manager.connect("sid_2", "user_2")
# Fresh connection
stats = manager.get_stats()
# Oldest should be around 30 minutes = 1800 seconds
assert stats["oldest_session_seconds"] > 1700
# Average should be around 15 minutes = 900 seconds
assert 800 < stats["avg_session_seconds"] < 1000