- 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>
987 lines
31 KiB
Python
987 lines
31 KiB
Python
"""Tests for ConnectionManager service.
|
|
|
|
This module tests WebSocket connection tracking with Redis. Since these are
|
|
unit tests, we inject a mock Redis factory to test the ConnectionManager logic
|
|
without requiring a real Redis instance.
|
|
|
|
Uses dependency injection pattern - no monkey patching required.
|
|
"""
|
|
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from datetime import UTC, datetime, timedelta
|
|
from unittest.mock import AsyncMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from app.services.connection_manager import (
|
|
CONN_PREFIX,
|
|
GAME_CONNS_PREFIX,
|
|
HEARTBEAT_INTERVAL_SECONDS,
|
|
USER_CONN_PREFIX,
|
|
ConnectionInfo,
|
|
ConnectionManager,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_redis() -> AsyncMock:
|
|
"""Create a mock Redis client.
|
|
|
|
This mock provides all the Redis operations used by ConnectionManager
|
|
with sensible defaults that can be overridden per-test.
|
|
"""
|
|
redis = AsyncMock()
|
|
redis.hset = AsyncMock()
|
|
redis.hget = AsyncMock()
|
|
redis.hgetall = AsyncMock(return_value={})
|
|
redis.set = AsyncMock()
|
|
redis.get = AsyncMock(return_value=None)
|
|
redis.delete = AsyncMock()
|
|
redis.exists = AsyncMock(return_value=False)
|
|
redis.expire = AsyncMock()
|
|
redis.sadd = AsyncMock()
|
|
redis.srem = AsyncMock()
|
|
redis.smembers = AsyncMock(return_value=set())
|
|
redis.scard = AsyncMock(return_value=0)
|
|
return redis
|
|
|
|
|
|
@pytest.fixture
|
|
def manager(mock_redis: AsyncMock) -> ConnectionManager:
|
|
"""Create a ConnectionManager with injected mock Redis.
|
|
|
|
The mock Redis is injected via the redis_factory parameter,
|
|
eliminating the need for monkey patching in tests.
|
|
"""
|
|
|
|
@asynccontextmanager
|
|
async def mock_redis_factory() -> AsyncIterator[AsyncMock]:
|
|
yield mock_redis
|
|
|
|
return ConnectionManager(conn_ttl_seconds=3600, redis_factory=mock_redis_factory)
|
|
|
|
|
|
class TestConnectionInfoDataclass:
|
|
"""Tests for the ConnectionInfo dataclass."""
|
|
|
|
def test_is_stale_returns_false_for_recent_connection(self) -> None:
|
|
"""Test that recent connections are not marked as stale.
|
|
|
|
Connections that have been seen within the threshold should be
|
|
considered active, not stale.
|
|
"""
|
|
now = datetime.now(UTC)
|
|
info = ConnectionInfo(
|
|
sid="test-sid",
|
|
user_id="user-123",
|
|
game_id=None,
|
|
connected_at=now,
|
|
last_seen=now,
|
|
)
|
|
|
|
assert info.is_stale() is False
|
|
|
|
def test_is_stale_returns_true_for_old_connection(self) -> None:
|
|
"""Test that old connections are marked as stale.
|
|
|
|
Connections that haven't been seen for longer than the threshold
|
|
should be considered stale and eligible for cleanup.
|
|
"""
|
|
now = datetime.now(UTC)
|
|
old_time = now - timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS * 4)
|
|
info = ConnectionInfo(
|
|
sid="test-sid",
|
|
user_id="user-123",
|
|
game_id=None,
|
|
connected_at=old_time,
|
|
last_seen=old_time,
|
|
)
|
|
|
|
assert info.is_stale() is True
|
|
|
|
def test_is_stale_with_custom_threshold(self) -> None:
|
|
"""Test is_stale with a custom threshold.
|
|
|
|
The threshold parameter allows callers to customize what counts
|
|
as stale for different use cases.
|
|
"""
|
|
now = datetime.now(UTC)
|
|
last_seen = now - timedelta(seconds=60)
|
|
info = ConnectionInfo(
|
|
sid="test-sid",
|
|
user_id="user-123",
|
|
game_id=None,
|
|
connected_at=last_seen,
|
|
last_seen=last_seen,
|
|
)
|
|
|
|
# 60 seconds old, with 30 second threshold = stale
|
|
assert info.is_stale(threshold_seconds=30) is True
|
|
|
|
# 60 seconds old, with 120 second threshold = not stale
|
|
assert info.is_stale(threshold_seconds=120) is False
|
|
|
|
|
|
class TestRegisterConnection:
|
|
"""Tests for connection registration."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_connection_creates_records(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that registering a connection creates all necessary Redis records.
|
|
|
|
Registration should create:
|
|
1. Connection hash with user_id, game_id, timestamps
|
|
2. User-to-connection mapping
|
|
"""
|
|
sid = "test-sid-123"
|
|
user_id = str(uuid4())
|
|
|
|
await manager.register_connection(sid, user_id)
|
|
|
|
# Verify connection hash was created
|
|
conn_key = f"{CONN_PREFIX}{sid}"
|
|
mock_redis.hset.assert_called()
|
|
hset_call = mock_redis.hset.call_args
|
|
assert hset_call.args[0] == conn_key
|
|
mapping = hset_call.kwargs["mapping"]
|
|
assert mapping["user_id"] == user_id
|
|
assert mapping["game_id"] == ""
|
|
|
|
# Verify user-to-connection mapping was created
|
|
user_conn_key = f"{USER_CONN_PREFIX}{user_id}"
|
|
mock_redis.set.assert_called_with(user_conn_key, sid)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_connection_replaces_old_connection(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that registering a new connection replaces the old one.
|
|
|
|
When a user reconnects, their old connection should be cleaned up
|
|
to prevent stale connection data from lingering.
|
|
"""
|
|
old_sid = "old-sid"
|
|
new_sid = "new-sid"
|
|
user_id = str(uuid4())
|
|
|
|
# Mock: user has existing connection
|
|
mock_redis.get.return_value = old_sid
|
|
mock_redis.hgetall.return_value = {
|
|
"user_id": user_id,
|
|
"game_id": "",
|
|
"connected_at": datetime.now(UTC).isoformat(),
|
|
"last_seen": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
await manager.register_connection(new_sid, user_id)
|
|
|
|
# Verify old connection was cleaned up (delete called for old conn key)
|
|
old_conn_key = f"{CONN_PREFIX}{old_sid}"
|
|
delete_calls = [call.args[0] for call in mock_redis.delete.call_args_list]
|
|
assert old_conn_key in delete_calls
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_connection_accepts_uuid(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that register_connection accepts UUID objects.
|
|
|
|
The user_id can be passed as either a string or UUID object
|
|
for convenience.
|
|
"""
|
|
sid = "test-sid"
|
|
user_uuid = uuid4()
|
|
|
|
await manager.register_connection(sid, user_uuid)
|
|
|
|
# Verify user_id was converted to string
|
|
hset_call = mock_redis.hset.call_args
|
|
mapping = hset_call.kwargs["mapping"]
|
|
assert mapping["user_id"] == str(user_uuid)
|
|
|
|
|
|
class TestUnregisterConnection:
|
|
"""Tests for connection unregistration."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unregister_returns_none_for_unknown_sid(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that unregistering unknown connection returns None.
|
|
|
|
Unregistering a non-existent connection should be a no-op
|
|
and return None to indicate nothing was cleaned up.
|
|
"""
|
|
mock_redis.hgetall.return_value = {}
|
|
|
|
result = await manager.unregister_connection("unknown-sid")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unregister_cleans_up_all_data(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that unregistering cleans up all Redis data.
|
|
|
|
Unregistration should remove:
|
|
1. Connection hash
|
|
2. User-to-connection mapping
|
|
3. Remove from game connection set (if in a game)
|
|
"""
|
|
sid = "test-sid"
|
|
user_id = "user-123"
|
|
game_id = "game-456"
|
|
now = datetime.now(UTC)
|
|
|
|
mock_redis.hgetall.return_value = {
|
|
"user_id": user_id,
|
|
"game_id": game_id,
|
|
"connected_at": now.isoformat(),
|
|
"last_seen": now.isoformat(),
|
|
}
|
|
mock_redis.get.return_value = sid # User's current connection
|
|
|
|
result = await manager.unregister_connection(sid)
|
|
|
|
assert result is not None
|
|
assert result.user_id == user_id
|
|
assert result.game_id == game_id
|
|
|
|
# Verify cleanup calls
|
|
mock_redis.delete.assert_called()
|
|
mock_redis.srem.assert_called_with(f"{GAME_CONNS_PREFIX}{game_id}", sid)
|
|
|
|
|
|
class TestGameAssociation:
|
|
"""Tests for game association methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_adds_to_game_set(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that joining a game adds connection to game's set.
|
|
|
|
The connection should be tracked in the game's connection set
|
|
for broadcasting updates to all participants.
|
|
"""
|
|
sid = "test-sid"
|
|
game_id = "game-123"
|
|
|
|
mock_redis.exists.return_value = True
|
|
mock_redis.hget.return_value = "" # Not in a game yet
|
|
|
|
result = await manager.join_game(sid, game_id)
|
|
|
|
assert result is True
|
|
mock_redis.sadd.assert_called_with(f"{GAME_CONNS_PREFIX}{game_id}", sid)
|
|
mock_redis.hset.assert_called_with(f"{CONN_PREFIX}{sid}", "game_id", game_id)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_returns_false_for_unknown_connection(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that joining a game fails for unknown connections.
|
|
|
|
Non-existent connections should not be able to join games.
|
|
"""
|
|
mock_redis.exists.return_value = False
|
|
|
|
result = await manager.join_game("unknown-sid", "game-123")
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_game_leaves_previous_game(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that joining a new game leaves the previous game.
|
|
|
|
A connection can only be in one game at a time, so joining
|
|
a new game should automatically leave any previous game.
|
|
"""
|
|
sid = "test-sid"
|
|
old_game = "old-game"
|
|
new_game = "new-game"
|
|
|
|
mock_redis.exists.return_value = True
|
|
mock_redis.hget.return_value = old_game
|
|
|
|
await manager.join_game(sid, new_game)
|
|
|
|
# Should remove from old game
|
|
mock_redis.srem.assert_called_with(f"{GAME_CONNS_PREFIX}{old_game}", sid)
|
|
# Should add to new game
|
|
mock_redis.sadd.assert_called_with(f"{GAME_CONNS_PREFIX}{new_game}", sid)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_leave_game_removes_from_set(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that leaving a game removes connection from set.
|
|
|
|
The connection should be removed from the game's set and
|
|
its game_id should be cleared.
|
|
"""
|
|
sid = "test-sid"
|
|
game_id = "game-123"
|
|
|
|
mock_redis.hget.return_value = game_id
|
|
|
|
result = await manager.leave_game(sid)
|
|
|
|
assert result == game_id
|
|
mock_redis.srem.assert_called_with(f"{GAME_CONNS_PREFIX}{game_id}", sid)
|
|
mock_redis.hset.assert_called_with(f"{CONN_PREFIX}{sid}", "game_id", "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_leave_game_returns_none_when_not_in_game(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that leaving when not in a game returns None.
|
|
|
|
If the connection isn't in a game, leave_game should return
|
|
None to indicate no game was left.
|
|
"""
|
|
mock_redis.hget.return_value = ""
|
|
|
|
result = await manager.leave_game("test-sid")
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestHeartbeat:
|
|
"""Tests for heartbeat/activity tracking."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_heartbeat_refreshes_last_seen(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that heartbeat updates the last_seen timestamp.
|
|
|
|
Heartbeats keep connections alive by updating timestamps
|
|
and refreshing TTLs on Redis keys.
|
|
"""
|
|
sid = "test-sid"
|
|
mock_redis.exists.return_value = True
|
|
mock_redis.hget.return_value = "user-123"
|
|
|
|
result = await manager.update_heartbeat(sid)
|
|
|
|
assert result is True
|
|
mock_redis.hset.assert_called()
|
|
mock_redis.expire.assert_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_heartbeat_returns_false_for_unknown(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that heartbeat fails for unknown connections.
|
|
|
|
Unknown connections should not be able to send heartbeats,
|
|
returning False to indicate failure.
|
|
"""
|
|
mock_redis.exists.return_value = False
|
|
|
|
result = await manager.update_heartbeat("unknown-sid")
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestQueryMethods:
|
|
"""Tests for connection query methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_connection_returns_info(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that get_connection returns ConnectionInfo.
|
|
|
|
The returned info should contain all the stored connection data.
|
|
"""
|
|
sid = "test-sid"
|
|
now = datetime.now(UTC)
|
|
|
|
mock_redis.hgetall.return_value = {
|
|
"user_id": "user-123",
|
|
"game_id": "game-456",
|
|
"connected_at": now.isoformat(),
|
|
"last_seen": now.isoformat(),
|
|
}
|
|
|
|
result = await manager.get_connection(sid)
|
|
|
|
assert result is not None
|
|
assert result.sid == sid
|
|
assert result.user_id == "user-123"
|
|
assert result.game_id == "game-456"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_connection_returns_none_for_unknown(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that get_connection returns None for unknown sid.
|
|
|
|
Non-existent connections should return None rather than raising.
|
|
"""
|
|
mock_redis.hgetall.return_value = {}
|
|
|
|
result = await manager.get_connection("unknown-sid")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_connection_returns_none_for_corrupted_data(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that get_connection returns None for corrupted records.
|
|
|
|
If a connection record is missing required fields (user_id,
|
|
connected_at, last_seen), it indicates data corruption. The
|
|
method should return None and log a warning rather than
|
|
returning a ConnectionInfo with invalid/empty data.
|
|
"""
|
|
# Record missing user_id
|
|
mock_redis.hgetall.return_value = {
|
|
"game_id": "game-123",
|
|
"connected_at": "2024-01-01T00:00:00+00:00",
|
|
"last_seen": "2024-01-01T00:00:00+00:00",
|
|
}
|
|
|
|
result = await manager.get_connection("corrupted-sid")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_user_online_returns_true_for_connected_user(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that is_user_online returns True for connected users.
|
|
|
|
Users with active, non-stale connections should be considered online.
|
|
"""
|
|
user_id = "user-123"
|
|
now = datetime.now(UTC)
|
|
|
|
mock_redis.get.return_value = "test-sid"
|
|
mock_redis.hgetall.return_value = {
|
|
"user_id": user_id,
|
|
"game_id": "",
|
|
"connected_at": now.isoformat(),
|
|
"last_seen": now.isoformat(),
|
|
}
|
|
|
|
result = await manager.is_user_online(user_id)
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_user_online_returns_false_for_stale_connection(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that is_user_online returns False for stale connections.
|
|
|
|
Users with stale connections (no recent heartbeat) should be
|
|
considered offline even if their connection record exists.
|
|
"""
|
|
user_id = "user-123"
|
|
old_time = datetime.now(UTC) - timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS * 4)
|
|
|
|
mock_redis.get.return_value = "test-sid"
|
|
mock_redis.hgetall.return_value = {
|
|
"user_id": user_id,
|
|
"game_id": "",
|
|
"connected_at": old_time.isoformat(),
|
|
"last_seen": old_time.isoformat(),
|
|
}
|
|
|
|
result = await manager.is_user_online(user_id)
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_game_connections_returns_all_participants(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that get_game_connections returns all game participants.
|
|
|
|
Should return ConnectionInfo for each sid in the game's connection set.
|
|
"""
|
|
game_id = "game-123"
|
|
now = datetime.now(UTC)
|
|
|
|
mock_redis.smembers.return_value = {"sid-1", "sid-2"}
|
|
|
|
# Use a function to return the right data based on the key queried
|
|
def hgetall_by_key(key: str) -> dict[str, str]:
|
|
if key == f"{CONN_PREFIX}sid-1":
|
|
return {
|
|
"user_id": "user-1",
|
|
"game_id": game_id,
|
|
"connected_at": now.isoformat(),
|
|
"last_seen": now.isoformat(),
|
|
}
|
|
elif key == f"{CONN_PREFIX}sid-2":
|
|
return {
|
|
"user_id": "user-2",
|
|
"game_id": game_id,
|
|
"connected_at": now.isoformat(),
|
|
"last_seen": now.isoformat(),
|
|
}
|
|
return {}
|
|
|
|
mock_redis.hgetall.side_effect = hgetall_by_key
|
|
|
|
result = await manager.get_game_connections(game_id)
|
|
|
|
assert len(result) == 2
|
|
user_ids = {conn.user_id for conn in result}
|
|
assert user_ids == {"user-1", "user-2"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_opponent_sid_returns_other_player(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that get_opponent_sid returns the other player's sid.
|
|
|
|
In a 2-player game, this should return the sid of the player
|
|
who is not the current user.
|
|
"""
|
|
game_id = "game-123"
|
|
current_user = "user-1"
|
|
opponent_user = "user-2"
|
|
opponent_sid = "sid-2"
|
|
now = datetime.now(UTC)
|
|
|
|
mock_redis.smembers.return_value = {"sid-1", "sid-2"}
|
|
|
|
# Use a function to return the right data based on the key queried
|
|
# This avoids dependency on set iteration order
|
|
def hgetall_by_key(key: str) -> dict[str, str]:
|
|
if key == f"{CONN_PREFIX}sid-1":
|
|
return {
|
|
"user_id": current_user,
|
|
"game_id": game_id,
|
|
"connected_at": now.isoformat(),
|
|
"last_seen": now.isoformat(),
|
|
}
|
|
elif key == f"{CONN_PREFIX}sid-2":
|
|
return {
|
|
"user_id": opponent_user,
|
|
"game_id": game_id,
|
|
"connected_at": now.isoformat(),
|
|
"last_seen": now.isoformat(),
|
|
}
|
|
return {}
|
|
|
|
mock_redis.hgetall.side_effect = hgetall_by_key
|
|
|
|
result = await manager.get_opponent_sid(game_id, current_user)
|
|
|
|
assert result == opponent_sid
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_active_game_returns_game_id(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that get_user_active_game returns the game_id from connection.
|
|
|
|
This method looks up the user's connection and returns their
|
|
current game_id if they are in a game.
|
|
"""
|
|
user_id = "user-123"
|
|
game_id = "game-456"
|
|
now = datetime.now(UTC)
|
|
|
|
# Mock: user has a connection with a game
|
|
mock_redis.get.return_value = "test-sid"
|
|
mock_redis.hgetall.return_value = {
|
|
"user_id": user_id,
|
|
"game_id": game_id,
|
|
"connected_at": now.isoformat(),
|
|
"last_seen": now.isoformat(),
|
|
}
|
|
|
|
result = await manager.get_user_active_game(user_id)
|
|
|
|
assert result == game_id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_active_game_returns_none_when_not_in_game(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that get_user_active_game returns None when user has no game.
|
|
|
|
If the user is connected but not in a game, their game_id will
|
|
be None (or empty string in Redis), so we return None.
|
|
"""
|
|
user_id = "user-123"
|
|
now = datetime.now(UTC)
|
|
|
|
mock_redis.get.return_value = "test-sid"
|
|
mock_redis.hgetall.return_value = {
|
|
"user_id": user_id,
|
|
"game_id": "", # Empty string means not in a game
|
|
"connected_at": now.isoformat(),
|
|
"last_seen": now.isoformat(),
|
|
}
|
|
|
|
result = await manager.get_user_active_game(user_id)
|
|
|
|
# Empty string game_id becomes None in ConnectionInfo
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_active_game_returns_none_when_not_connected(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that get_user_active_game returns None when user is offline.
|
|
|
|
If the user has no active connection, there's no game to return.
|
|
"""
|
|
mock_redis.get.return_value = None # No connection for user
|
|
|
|
result = await manager.get_user_active_game("user-123")
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_active_game_accepts_uuid(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that get_user_active_game accepts UUID objects.
|
|
|
|
The method should work with both string and UUID user IDs
|
|
for convenience.
|
|
"""
|
|
user_uuid = uuid4()
|
|
game_id = "game-789"
|
|
now = datetime.now(UTC)
|
|
|
|
mock_redis.get.return_value = "test-sid"
|
|
mock_redis.hgetall.return_value = {
|
|
"user_id": str(user_uuid),
|
|
"game_id": game_id,
|
|
"connected_at": now.isoformat(),
|
|
"last_seen": now.isoformat(),
|
|
}
|
|
|
|
result = await manager.get_user_active_game(user_uuid)
|
|
|
|
assert result == game_id
|
|
|
|
|
|
class TestKeyGeneration:
|
|
"""Tests for Redis key generation methods."""
|
|
|
|
def test_conn_key_format(self) -> None:
|
|
"""Test that connection keys have correct format.
|
|
|
|
Keys should follow the pattern conn:{sid} for easy identification
|
|
and pattern matching.
|
|
"""
|
|
manager = ConnectionManager()
|
|
key = manager._conn_key("test-sid-123")
|
|
assert key == "conn:test-sid-123"
|
|
|
|
def test_user_conn_key_format(self) -> None:
|
|
"""Test that user connection keys have correct format.
|
|
|
|
Keys should follow the pattern user_conn:{user_id}.
|
|
"""
|
|
manager = ConnectionManager()
|
|
key = manager._user_conn_key("user-456")
|
|
assert key == "user_conn:user-456"
|
|
|
|
def test_game_conns_key_format(self) -> None:
|
|
"""Test that game connections keys have correct format.
|
|
|
|
Keys should follow the pattern game_conns:{game_id}.
|
|
"""
|
|
manager = ConnectionManager()
|
|
key = manager._game_conns_key("game-789")
|
|
assert key == "game_conns:game-789"
|
|
|
|
def test_spectators_key_format(self) -> None:
|
|
"""Test that spectators keys have correct format.
|
|
|
|
Keys should follow the pattern spectators:{game_id}.
|
|
"""
|
|
manager = ConnectionManager()
|
|
key = manager._spectators_key("game-789")
|
|
assert key == "spectators:game-789"
|
|
|
|
|
|
class TestSpectatorManagement:
|
|
"""Tests for spectator-related methods.
|
|
|
|
Spectators are tracked separately from game participants using
|
|
a dedicated Redis set per game.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_spectator_success(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test registering a spectator adds them to the spectators set.
|
|
|
|
When a user starts spectating a game, they should be added to
|
|
the spectators:{game_id} set and their connection's game_id should
|
|
be updated to indicate spectating.
|
|
"""
|
|
sid = "spectator-sid"
|
|
user_id = "user-123"
|
|
game_id = "game-456"
|
|
|
|
mock_redis.exists.return_value = True
|
|
|
|
result = await manager.register_spectator(sid, user_id, game_id)
|
|
|
|
assert result is True
|
|
# Should update connection's game_id to indicate spectating
|
|
mock_redis.hset.assert_called_with(
|
|
f"{CONN_PREFIX}{sid}",
|
|
"game_id",
|
|
f"spectating:{game_id}",
|
|
)
|
|
# Should add to spectators set
|
|
mock_redis.sadd.assert_called_with(f"spectators:{game_id}", sid)
|
|
# Should set TTL on spectators set
|
|
mock_redis.expire.assert_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_spectator_connection_not_found(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test registering spectator fails if connection doesn't exist.
|
|
|
|
A user must have an active connection before they can spectate.
|
|
"""
|
|
mock_redis.exists.return_value = False
|
|
|
|
result = await manager.register_spectator("unknown-sid", "user-123", "game-456")
|
|
|
|
assert result is False
|
|
mock_redis.sadd.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unregister_spectator_success(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test unregistering a spectator removes them from the set.
|
|
|
|
When a spectator leaves, they should be removed from the
|
|
spectators set and their connection's game_id should be cleared.
|
|
"""
|
|
sid = "spectator-sid"
|
|
game_id = "game-456"
|
|
|
|
mock_redis.srem.return_value = 1 # 1 member removed
|
|
mock_redis.hget.return_value = f"spectating:{game_id}"
|
|
|
|
result = await manager.unregister_spectator(sid, game_id)
|
|
|
|
assert result is True
|
|
mock_redis.srem.assert_called_with(f"spectators:{game_id}", sid)
|
|
mock_redis.hset.assert_called_with(f"{CONN_PREFIX}{sid}", "game_id", "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unregister_spectator_not_in_set(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test unregistering non-spectator returns False.
|
|
|
|
If the sid is not in the spectators set, unregister should
|
|
return False to indicate nothing was removed.
|
|
"""
|
|
mock_redis.srem.return_value = 0 # No members removed
|
|
mock_redis.hget.return_value = ""
|
|
|
|
result = await manager.unregister_spectator("unknown-sid", "game-456")
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_spectator_count(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test get_spectator_count returns the set cardinality.
|
|
|
|
Should use Redis SCARD to get the number of spectators efficiently.
|
|
"""
|
|
mock_redis.scard.return_value = 5
|
|
|
|
result = await manager.get_spectator_count("game-456")
|
|
|
|
assert result == 5
|
|
mock_redis.scard.assert_called_with("spectators:game-456")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_game_spectators(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test get_game_spectators returns all spectator sids.
|
|
|
|
Should return all sids from the spectators set for the game.
|
|
"""
|
|
expected_sids = {"sid-1", "sid-2", "sid-3"}
|
|
mock_redis.smembers.return_value = expected_sids
|
|
|
|
result = await manager.get_game_spectators("game-456")
|
|
|
|
assert set(result) == expected_sids
|
|
mock_redis.smembers.assert_called_with("spectators:game-456")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_spectating_returns_true(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test is_spectating returns True when sid is spectating.
|
|
|
|
Should use Redis SISMEMBER for efficient membership check.
|
|
"""
|
|
mock_redis.sismember.return_value = True
|
|
|
|
result = await manager.is_spectating("spectator-sid", "game-456")
|
|
|
|
assert result is True
|
|
mock_redis.sismember.assert_called_with("spectators:game-456", "spectator-sid")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_spectating_returns_false(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test is_spectating returns False when not spectating.
|
|
|
|
Should return False for sids not in the spectators set.
|
|
"""
|
|
mock_redis.sismember.return_value = False
|
|
|
|
result = await manager.is_spectating("player-sid", "game-456")
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestCleanupWithSpectators:
|
|
"""Tests for connection cleanup including spectator state."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_removes_spectator_from_set(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that cleanup removes spectator from spectators set.
|
|
|
|
When a spectating connection is cleaned up (disconnect), the
|
|
sid should be removed from the spectators set.
|
|
"""
|
|
sid = "spectator-sid"
|
|
game_id = "game-456"
|
|
|
|
mock_redis.hgetall.return_value = {
|
|
"user_id": "user-123",
|
|
"game_id": f"spectating:{game_id}",
|
|
"connected_at": "2024-01-01T00:00:00+00:00",
|
|
"last_seen": "2024-01-01T00:00:00+00:00",
|
|
}
|
|
mock_redis.get.return_value = sid
|
|
|
|
await manager._cleanup_connection(sid)
|
|
|
|
# Should remove from spectators set
|
|
mock_redis.srem.assert_called_with(f"spectators:{game_id}", sid)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_player_removes_from_game_conns(
|
|
self,
|
|
manager: ConnectionManager,
|
|
mock_redis: AsyncMock,
|
|
) -> None:
|
|
"""Test that cleanup removes player from game_conns set.
|
|
|
|
When a playing (not spectating) connection is cleaned up,
|
|
the sid should be removed from game_conns, not spectators.
|
|
"""
|
|
sid = "player-sid"
|
|
game_id = "game-456"
|
|
|
|
mock_redis.hgetall.return_value = {
|
|
"user_id": "user-123",
|
|
"game_id": game_id, # Regular game_id, not spectating:
|
|
"connected_at": "2024-01-01T00:00:00+00:00",
|
|
"last_seen": "2024-01-01T00:00:00+00:00",
|
|
}
|
|
mock_redis.get.return_value = sid
|
|
|
|
await manager._cleanup_connection(sid)
|
|
|
|
# Should remove from game_conns, not spectators
|
|
mock_redis.srem.assert_called_with(f"{GAME_CONNS_PREFIX}{game_id}", sid)
|