565 lines
19 KiB
Python
565 lines
19 KiB
Python
"""
|
|
Tests for V2 Persistence & Recovery components.
|
|
|
|
These tests cover:
|
|
- StateCache: Redis-backed game state caching
|
|
- GamePubSub: Cross-server event broadcasting
|
|
- RecoveryService: Game recovery from event store
|
|
|
|
Tests use fakeredis for isolated Redis testing.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from datetime import datetime, timezone
|
|
|
|
# Import the modules under test
|
|
from stores.state_cache import StateCache
|
|
from stores.pubsub import GamePubSub, PubSubMessage, MessageType
|
|
from services.recovery_service import RecoveryService, RecoveryResult
|
|
from models.events import (
|
|
GameEvent, EventType,
|
|
game_created, player_joined, game_started, round_started,
|
|
)
|
|
from models.game_state import RebuiltGameState, GamePhase
|
|
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
@pytest.fixture
|
|
def mock_redis():
|
|
"""Create a mock Redis client for testing."""
|
|
mock = AsyncMock()
|
|
|
|
# Track stored data
|
|
data = {}
|
|
sets = {}
|
|
hashes = {}
|
|
|
|
async def mock_set(key, value, ex=None):
|
|
data[key] = value
|
|
|
|
async def mock_get(key):
|
|
return data.get(key)
|
|
|
|
async def mock_delete(*keys):
|
|
for key in keys:
|
|
data.pop(key, None)
|
|
sets.pop(key, None)
|
|
hashes.pop(key, None)
|
|
|
|
async def mock_exists(key):
|
|
return 1 if key in data or key in hashes else 0
|
|
|
|
async def mock_sadd(key, *values):
|
|
if key not in sets:
|
|
sets[key] = set()
|
|
sets[key].update(values)
|
|
return len(values)
|
|
|
|
async def mock_srem(key, *values):
|
|
if key in sets:
|
|
for v in values:
|
|
sets[key].discard(v)
|
|
|
|
async def mock_smembers(key):
|
|
return sets.get(key, set())
|
|
|
|
async def mock_hset(key, field=None, value=None, mapping=None, **kwargs):
|
|
"""Mock hset supporting both hset(key, field, value) and hset(key, mapping={})"""
|
|
if key not in hashes:
|
|
hashes[key] = {}
|
|
if mapping:
|
|
for k, v in mapping.items():
|
|
hashes[key][k.encode() if isinstance(k, str) else k] = v.encode() if isinstance(v, str) else v
|
|
elif field is not None and value is not None:
|
|
hashes[key][field.encode() if isinstance(field, str) else field] = value.encode() if isinstance(value, str) else value
|
|
|
|
async def mock_hgetall(key):
|
|
return hashes.get(key, {})
|
|
|
|
async def mock_expire(key, seconds):
|
|
pass # No-op for testing
|
|
|
|
def mock_pipeline():
|
|
pipe = AsyncMock()
|
|
|
|
async def pipe_hset(key, field=None, value=None, mapping=None, **kwargs):
|
|
await mock_hset(key, field, value, mapping, **kwargs)
|
|
|
|
async def pipe_sadd(key, *values):
|
|
await mock_sadd(key, *values)
|
|
|
|
async def pipe_set(key, value, ex=None):
|
|
await mock_set(key, value, ex)
|
|
|
|
pipe.hset = pipe_hset
|
|
pipe.expire = AsyncMock()
|
|
pipe.sadd = pipe_sadd
|
|
pipe.set = pipe_set
|
|
pipe.srem = AsyncMock()
|
|
pipe.delete = AsyncMock()
|
|
|
|
async def execute():
|
|
return []
|
|
|
|
pipe.execute = execute
|
|
return pipe
|
|
|
|
mock.set = mock_set
|
|
mock.get = mock_get
|
|
mock.delete = mock_delete
|
|
mock.exists = mock_exists
|
|
mock.sadd = mock_sadd
|
|
mock.srem = mock_srem
|
|
mock.smembers = mock_smembers
|
|
mock.hset = mock_hset
|
|
mock.hgetall = mock_hgetall
|
|
mock.expire = mock_expire
|
|
mock.pipeline = mock_pipeline
|
|
mock.ping = AsyncMock(return_value=True)
|
|
mock.close = AsyncMock()
|
|
|
|
# Store references for assertions
|
|
mock._data = data
|
|
mock._sets = sets
|
|
mock._hashes = hashes
|
|
|
|
return mock
|
|
|
|
|
|
@pytest.fixture
|
|
def state_cache(mock_redis):
|
|
"""Create a StateCache with mock Redis."""
|
|
return StateCache(mock_redis)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_event_store():
|
|
"""Create a mock EventStore."""
|
|
mock = AsyncMock()
|
|
mock.get_events = AsyncMock(return_value=[])
|
|
mock.get_active_games = AsyncMock(return_value=[])
|
|
return mock
|
|
|
|
|
|
# =============================================================================
|
|
# StateCache Tests
|
|
# =============================================================================
|
|
|
|
class TestStateCache:
|
|
"""Tests for StateCache class."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_room(self, state_cache, mock_redis):
|
|
"""Test creating a new room."""
|
|
await state_cache.create_room(
|
|
room_code="ABCD",
|
|
game_id="game-123",
|
|
host_id="player-1",
|
|
server_id="server-1",
|
|
)
|
|
|
|
# Verify room was created via pipeline
|
|
# (Pipeline operations are mocked, just verify no errors)
|
|
assert True # Room creation succeeded
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_exists_true(self, state_cache, mock_redis):
|
|
"""Test room_exists returns True when room exists."""
|
|
mock_redis._hashes["golf:room:ABCD"] = {b"game_id": b"123"}
|
|
|
|
result = await state_cache.room_exists("ABCD")
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_exists_false(self, state_cache, mock_redis):
|
|
"""Test room_exists returns False when room doesn't exist."""
|
|
result = await state_cache.room_exists("XXXX")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_active_rooms(self, state_cache, mock_redis):
|
|
"""Test getting active rooms."""
|
|
mock_redis._sets["golf:rooms:active"] = {"ABCD", "EFGH"}
|
|
|
|
rooms = await state_cache.get_active_rooms()
|
|
assert rooms == {"ABCD", "EFGH"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_and_get_game_state(self, state_cache, mock_redis):
|
|
"""Test saving and retrieving game state."""
|
|
state = {
|
|
"game_id": "game-123",
|
|
"phase": "playing",
|
|
"players": {"p1": {"name": "Alice"}},
|
|
}
|
|
|
|
await state_cache.save_game_state("game-123", state)
|
|
|
|
# Verify it was stored
|
|
key = "golf:game:game-123"
|
|
assert key in mock_redis._data
|
|
|
|
# Retrieve it
|
|
retrieved = await state_cache.get_game_state("game-123")
|
|
assert retrieved == state
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_nonexistent_game_state(self, state_cache, mock_redis):
|
|
"""Test getting state for non-existent game returns None."""
|
|
result = await state_cache.get_game_state("nonexistent")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_player_to_room(self, state_cache, mock_redis):
|
|
"""Test adding a player to a room."""
|
|
await state_cache.add_player_to_room("ABCD", "player-2")
|
|
|
|
# Pipeline was used successfully (no exception thrown)
|
|
# The actual data verification would require integration tests
|
|
assert True # add_player_to_room completed without error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_room_players(self, state_cache, mock_redis):
|
|
"""Test getting players in a room."""
|
|
mock_redis._sets["golf:room:ABCD:players"] = {"player-1", "player-2"}
|
|
|
|
players = await state_cache.get_room_players("ABCD")
|
|
assert players == {"player-1", "player-2"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_player_room(self, state_cache, mock_redis):
|
|
"""Test getting the room a player is in."""
|
|
mock_redis._data["golf:player:player-1:room"] = b"ABCD"
|
|
|
|
room = await state_cache.get_player_room("player-1")
|
|
assert room == "ABCD"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_player_room_not_in_room(self, state_cache, mock_redis):
|
|
"""Test getting room for player not in any room."""
|
|
room = await state_cache.get_player_room("unknown-player")
|
|
assert room is None
|
|
|
|
|
|
# =============================================================================
|
|
# GamePubSub Tests
|
|
# =============================================================================
|
|
|
|
class TestGamePubSub:
|
|
"""Tests for GamePubSub class."""
|
|
|
|
@pytest.fixture
|
|
def mock_pubsub_redis(self):
|
|
"""Create mock Redis with pubsub support."""
|
|
mock = AsyncMock()
|
|
mock_pubsub = AsyncMock()
|
|
mock_pubsub.subscribe = AsyncMock()
|
|
mock_pubsub.unsubscribe = AsyncMock()
|
|
mock_pubsub.get_message = AsyncMock(return_value=None)
|
|
mock_pubsub.close = AsyncMock()
|
|
mock.pubsub = MagicMock(return_value=mock_pubsub)
|
|
mock.publish = AsyncMock(return_value=1)
|
|
return mock, mock_pubsub
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subscribe_to_room(self, mock_pubsub_redis):
|
|
"""Test subscribing to room events."""
|
|
redis_client, mock_ps = mock_pubsub_redis
|
|
pubsub = GamePubSub(redis_client, server_id="test-server")
|
|
|
|
handler = AsyncMock()
|
|
await pubsub.subscribe("ABCD", handler)
|
|
|
|
mock_ps.subscribe.assert_called_once_with("golf:room:ABCD")
|
|
assert "golf:room:ABCD" in pubsub._handlers
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unsubscribe_from_room(self, mock_pubsub_redis):
|
|
"""Test unsubscribing from room events."""
|
|
redis_client, mock_ps = mock_pubsub_redis
|
|
pubsub = GamePubSub(redis_client, server_id="test-server")
|
|
|
|
handler = AsyncMock()
|
|
await pubsub.subscribe("ABCD", handler)
|
|
await pubsub.unsubscribe("ABCD")
|
|
|
|
mock_ps.unsubscribe.assert_called_once_with("golf:room:ABCD")
|
|
assert "golf:room:ABCD" not in pubsub._handlers
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_publish_message(self, mock_pubsub_redis):
|
|
"""Test publishing a message."""
|
|
redis_client, _ = mock_pubsub_redis
|
|
pubsub = GamePubSub(redis_client, server_id="test-server")
|
|
|
|
message = PubSubMessage(
|
|
type=MessageType.GAME_STATE_UPDATE,
|
|
room_code="ABCD",
|
|
data={"phase": "playing"},
|
|
)
|
|
count = await pubsub.publish(message)
|
|
|
|
assert count == 1
|
|
redis_client.publish.assert_called_once()
|
|
call_args = redis_client.publish.call_args
|
|
assert call_args[0][0] == "golf:room:ABCD"
|
|
|
|
def test_pubsub_message_serialization(self):
|
|
"""Test PubSubMessage JSON serialization."""
|
|
message = PubSubMessage(
|
|
type=MessageType.PLAYER_JOINED,
|
|
room_code="ABCD",
|
|
data={"player_name": "Alice"},
|
|
sender_id="server-1",
|
|
)
|
|
|
|
json_str = message.to_json()
|
|
parsed = PubSubMessage.from_json(json_str)
|
|
|
|
assert parsed.type == MessageType.PLAYER_JOINED
|
|
assert parsed.room_code == "ABCD"
|
|
assert parsed.data == {"player_name": "Alice"}
|
|
assert parsed.sender_id == "server-1"
|
|
|
|
|
|
# =============================================================================
|
|
# RecoveryService Tests
|
|
# =============================================================================
|
|
|
|
class TestRecoveryService:
|
|
"""Tests for RecoveryService class."""
|
|
|
|
@pytest.fixture
|
|
def mock_dependencies(self, mock_event_store, state_cache):
|
|
"""Create mocked dependencies for RecoveryService."""
|
|
return mock_event_store, state_cache
|
|
|
|
def create_test_events(self, game_id: str = "game-123") -> list[GameEvent]:
|
|
"""Create a sequence of test events for recovery."""
|
|
return [
|
|
game_created(
|
|
game_id=game_id,
|
|
sequence_num=1,
|
|
room_code="ABCD",
|
|
host_id="player-1",
|
|
options={"rounds": 9},
|
|
),
|
|
player_joined(
|
|
game_id=game_id,
|
|
sequence_num=2,
|
|
player_id="player-1",
|
|
player_name="Alice",
|
|
),
|
|
player_joined(
|
|
game_id=game_id,
|
|
sequence_num=3,
|
|
player_id="player-2",
|
|
player_name="Bob",
|
|
),
|
|
game_started(
|
|
game_id=game_id,
|
|
sequence_num=4,
|
|
player_order=["player-1", "player-2"],
|
|
num_decks=1,
|
|
num_rounds=9,
|
|
options={"rounds": 9},
|
|
),
|
|
round_started(
|
|
game_id=game_id,
|
|
sequence_num=5,
|
|
round_num=1,
|
|
deck_seed=12345,
|
|
dealt_cards={
|
|
"player-1": [
|
|
{"rank": "K", "suit": "hearts"},
|
|
{"rank": "5", "suit": "diamonds"},
|
|
{"rank": "A", "suit": "clubs"},
|
|
{"rank": "7", "suit": "spades"},
|
|
{"rank": "Q", "suit": "hearts"},
|
|
{"rank": "3", "suit": "clubs"},
|
|
],
|
|
"player-2": [
|
|
{"rank": "10", "suit": "spades"},
|
|
{"rank": "2", "suit": "hearts"},
|
|
{"rank": "J", "suit": "diamonds"},
|
|
{"rank": "9", "suit": "clubs"},
|
|
{"rank": "4", "suit": "hearts"},
|
|
{"rank": "8", "suit": "spades"},
|
|
],
|
|
},
|
|
first_discard={"rank": "6", "suit": "diamonds"},
|
|
),
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recover_game_success(self, mock_dependencies):
|
|
"""Test successful game recovery."""
|
|
event_store, state_cache = mock_dependencies
|
|
events = self.create_test_events()
|
|
event_store.get_events.return_value = events
|
|
|
|
recovery = RecoveryService(event_store, state_cache)
|
|
result = await recovery.recover_game("game-123", "ABCD")
|
|
|
|
assert result.success is True
|
|
assert result.game_id == "game-123"
|
|
assert result.room_code == "ABCD"
|
|
assert result.phase == "initial_flip"
|
|
assert result.sequence_num == 5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recover_game_no_events(self, mock_dependencies):
|
|
"""Test recovery with no events returns failure."""
|
|
event_store, state_cache = mock_dependencies
|
|
event_store.get_events.return_value = []
|
|
|
|
recovery = RecoveryService(event_store, state_cache)
|
|
result = await recovery.recover_game("game-123")
|
|
|
|
assert result.success is False
|
|
assert result.error == "no_events"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recover_game_already_ended(self, mock_dependencies):
|
|
"""Test recovery skips ended games."""
|
|
event_store, state_cache = mock_dependencies
|
|
|
|
# Create events ending with GAME_ENDED
|
|
events = self.create_test_events()
|
|
events.append(GameEvent(
|
|
event_type=EventType.GAME_ENDED,
|
|
game_id="game-123",
|
|
sequence_num=6,
|
|
data={"final_scores": {}, "rounds_won": {}},
|
|
))
|
|
event_store.get_events.return_value = events
|
|
|
|
recovery = RecoveryService(event_store, state_cache)
|
|
result = await recovery.recover_game("game-123")
|
|
|
|
assert result.success is False
|
|
assert result.error == "game_ended"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recover_all_games(self, mock_dependencies):
|
|
"""Test recovering multiple games."""
|
|
event_store, state_cache = mock_dependencies
|
|
|
|
# Set up two active games
|
|
event_store.get_active_games.return_value = [
|
|
{"id": "game-1", "room_code": "AAAA"},
|
|
{"id": "game-2", "room_code": "BBBB"},
|
|
]
|
|
|
|
# Each game has events
|
|
event_store.get_events.side_effect = [
|
|
self.create_test_events("game-1"),
|
|
self.create_test_events("game-2"),
|
|
]
|
|
|
|
recovery = RecoveryService(event_store, state_cache)
|
|
results = await recovery.recover_all_games()
|
|
|
|
assert results["recovered"] == 2
|
|
assert results["failed"] == 0
|
|
assert results["skipped"] == 0
|
|
assert len(results["games"]) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_to_dict_conversion(self, mock_dependencies):
|
|
"""Test state to dict conversion for caching."""
|
|
event_store, state_cache = mock_dependencies
|
|
events = self.create_test_events()
|
|
event_store.get_events.return_value = events
|
|
|
|
recovery = RecoveryService(event_store, state_cache)
|
|
result = await recovery.recover_game("game-123")
|
|
|
|
# Verify recovery succeeded
|
|
assert result.success is True
|
|
|
|
# Verify state was cached (game_id key should be set)
|
|
game_key = "golf:game:game-123"
|
|
assert game_key in state_cache.redis._data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dict_to_state_conversion(self, mock_dependencies):
|
|
"""Test dict to state conversion for recovery."""
|
|
event_store, state_cache = mock_dependencies
|
|
recovery = RecoveryService(event_store, state_cache)
|
|
|
|
state_dict = {
|
|
"game_id": "game-123",
|
|
"room_code": "ABCD",
|
|
"phase": "playing",
|
|
"current_round": 1,
|
|
"total_rounds": 9,
|
|
"current_player_idx": 0,
|
|
"player_order": ["player-1", "player-2"],
|
|
"deck_remaining": 40,
|
|
"options": {},
|
|
"sequence_num": 5,
|
|
"finisher_id": None,
|
|
"host_id": "player-1",
|
|
"initial_flips_done": ["player-1"],
|
|
"players_with_final_turn": [],
|
|
"drawn_from_discard": False,
|
|
"players": {
|
|
"player-1": {
|
|
"id": "player-1",
|
|
"name": "Alice",
|
|
"cards": [
|
|
{"rank": "K", "suit": "hearts", "face_up": True},
|
|
],
|
|
"score": 0,
|
|
"total_score": 0,
|
|
"rounds_won": 0,
|
|
"is_cpu": False,
|
|
"cpu_profile": None,
|
|
},
|
|
},
|
|
"discard_pile": [{"rank": "6", "suit": "diamonds", "face_up": True}],
|
|
"drawn_card": None,
|
|
}
|
|
|
|
state = recovery._dict_to_state(state_dict)
|
|
|
|
assert state.game_id == "game-123"
|
|
assert state.room_code == "ABCD"
|
|
assert state.phase == GamePhase.PLAYING
|
|
assert state.current_round == 1
|
|
assert "player-1" in state.players
|
|
assert state.players["player-1"].name == "Alice"
|
|
assert len(state.discard_pile) == 1
|
|
|
|
|
|
# =============================================================================
|
|
# Integration Tests (require actual Redis - skip if not available)
|
|
# =============================================================================
|
|
|
|
@pytest.mark.skip(reason="Requires actual Redis - run manually with docker-compose")
|
|
class TestIntegration:
|
|
"""Integration tests requiring actual Redis."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_recovery_cycle(self):
|
|
"""Test complete recovery cycle with real Redis."""
|
|
# This would test the actual flow:
|
|
# 1. Create game events
|
|
# 2. Store in PostgreSQL
|
|
# 3. Cache state in Redis
|
|
# 4. "Restart" - clear local state
|
|
# 5. Recover from PostgreSQL
|
|
# 6. Verify state matches
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|