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