Huge v2 uplift, now deployable with real user management and tooling!

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-27 11:32:15 -05:00
parent c912a56c2d
commit bea85e6b28
61 changed files with 25153 additions and 362 deletions

1
server/tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests package for Golf game."""

View File

@@ -0,0 +1,431 @@
"""
Tests for event sourcing and state replay.
These tests verify that:
1. Events are emitted correctly from game actions
2. State can be rebuilt from events
3. Rebuilt state matches original game state
4. Events are applied in correct sequence order
"""
import pytest
from typing import Optional
from game import Game, GamePhase, GameOptions, Player
from models.events import GameEvent, EventType
from models.game_state import RebuiltGameState, rebuild_state
class EventCollector:
"""Helper class to collect events from a game."""
def __init__(self):
self.events: list[GameEvent] = []
def collect(self, event: GameEvent) -> None:
"""Callback to collect an event."""
self.events.append(event)
def clear(self) -> None:
"""Clear collected events."""
self.events = []
def create_test_game(
num_players: int = 2,
options: Optional[GameOptions] = None,
) -> tuple[Game, EventCollector]:
"""
Create a game with event collection enabled.
Returns:
Tuple of (Game, EventCollector).
"""
game = Game()
collector = EventCollector()
game.set_event_emitter(collector.collect)
# Emit game created
game.emit_game_created("TEST", "p1")
# Add players
for i in range(num_players):
player = Player(id=f"p{i+1}", name=f"Player {i+1}")
game.add_player(player)
return game, collector
class TestEventEmission:
"""Test that events are emitted correctly."""
def test_game_created_event(self):
"""Game created event should be first event."""
game, collector = create_test_game(num_players=0)
assert len(collector.events) == 1
event = collector.events[0]
assert event.event_type == EventType.GAME_CREATED
assert event.sequence_num == 1
assert event.data["room_code"] == "TEST"
def test_player_joined_events(self):
"""Player joined events should be emitted for each player."""
game, collector = create_test_game(num_players=3)
# game_created + 3 player_joined
assert len(collector.events) == 4
joined_events = [e for e in collector.events if e.event_type == EventType.PLAYER_JOINED]
assert len(joined_events) == 3
for i, event in enumerate(joined_events):
assert event.player_id == f"p{i+1}"
assert event.data["player_name"] == f"Player {i+1}"
def test_game_started_and_round_started_events(self):
"""Starting game should emit game_started and round_started."""
game, collector = create_test_game(num_players=2)
initial_count = len(collector.events)
game.start_game(num_decks=1, num_rounds=3, options=GameOptions())
new_events = collector.events[initial_count:]
# Should have game_started and round_started
event_types = [e.event_type for e in new_events]
assert EventType.GAME_STARTED in event_types
assert EventType.ROUND_STARTED in event_types
# Verify round_started has deck_seed
round_started = next(e for e in new_events if e.event_type == EventType.ROUND_STARTED)
assert "deck_seed" in round_started.data
assert "dealt_cards" in round_started.data
assert "first_discard" in round_started.data
def test_initial_flip_event(self):
"""Initial flip should emit event with card positions."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2))
initial_count = len(collector.events)
game.flip_initial_cards("p1", [0, 1])
new_events = collector.events[initial_count:]
flip_events = [e for e in new_events if e.event_type == EventType.INITIAL_FLIP]
assert len(flip_events) == 1
event = flip_events[0]
assert event.player_id == "p1"
assert event.data["positions"] == [0, 1]
assert len(event.data["cards"]) == 2
def test_draw_card_event(self):
"""Drawing a card should emit card_drawn event."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
initial_count = len(collector.events)
card = game.draw_card("p1", "deck")
assert card is not None
new_events = collector.events[initial_count:]
draw_events = [e for e in new_events if e.event_type == EventType.CARD_DRAWN]
assert len(draw_events) == 1
event = draw_events[0]
assert event.player_id == "p1"
assert event.data["source"] == "deck"
assert event.data["card"]["rank"] == card.rank.value
def test_swap_card_event(self):
"""Swapping a card should emit card_swapped event."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
game.draw_card("p1", "deck")
initial_count = len(collector.events)
old_card = game.swap_card("p1", 0)
assert old_card is not None
new_events = collector.events[initial_count:]
swap_events = [e for e in new_events if e.event_type == EventType.CARD_SWAPPED]
assert len(swap_events) == 1
event = swap_events[0]
assert event.player_id == "p1"
assert event.data["position"] == 0
def test_discard_card_event(self):
"""Discarding drawn card should emit card_discarded event."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
drawn = game.draw_card("p1", "deck")
initial_count = len(collector.events)
game.discard_drawn("p1")
new_events = collector.events[initial_count:]
discard_events = [e for e in new_events if e.event_type == EventType.CARD_DISCARDED]
assert len(discard_events) == 1
event = discard_events[0]
assert event.player_id == "p1"
assert event.data["card"]["rank"] == drawn.rank.value
class TestDeckSeeding:
"""Test deterministic deck shuffling."""
def test_same_seed_same_order(self):
"""Same seed should produce same card order."""
from game import Deck
deck1 = Deck(num_decks=1, seed=12345)
deck2 = Deck(num_decks=1, seed=12345)
cards1 = [deck1.draw() for _ in range(10)]
cards2 = [deck2.draw() for _ in range(10)]
for c1, c2 in zip(cards1, cards2):
assert c1.rank == c2.rank
assert c1.suit == c2.suit
def test_different_seed_different_order(self):
"""Different seeds should produce different order."""
from game import Deck
deck1 = Deck(num_decks=1, seed=12345)
deck2 = Deck(num_decks=1, seed=54321)
cards1 = [deck1.draw() for _ in range(52)]
cards2 = [deck2.draw() for _ in range(52)]
# At least some cards should be different
differences = sum(
1 for c1, c2 in zip(cards1, cards2)
if c1.rank != c2.rank or c1.suit != c2.suit
)
assert differences > 10 # Very unlikely to have <10 differences
class TestEventSequencing:
"""Test event sequence ordering."""
def test_sequence_numbers_increment(self):
"""Event sequence numbers should increment monotonically."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
# Play a few turns
game.draw_card("p1", "deck")
game.discard_drawn("p1")
game.draw_card("p2", "deck")
game.swap_card("p2", 0)
sequences = [e.sequence_num for e in collector.events]
for i in range(1, len(sequences)):
assert sequences[i] == sequences[i-1] + 1, \
f"Sequence gap: {sequences[i-1]} -> {sequences[i]}"
def test_all_events_have_game_id(self):
"""All events should have the same game_id."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
game_id = game.game_id
for event in collector.events:
assert event.game_id == game_id
class TestStateRebuilder:
"""Test rebuilding state from events."""
def test_rebuild_empty_events_raises(self):
"""Cannot rebuild from empty event list."""
with pytest.raises(ValueError):
rebuild_state([])
def test_rebuild_basic_game(self):
"""Can rebuild state from basic game events."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2))
# Do initial flips
game.flip_initial_cards("p1", [0, 1])
game.flip_initial_cards("p2", [0, 1])
# Rebuild state
state = rebuild_state(collector.events)
assert state.game_id == game.game_id
assert state.room_code == "TEST"
assert len(state.players) == 2
# Compare enum values since they're from different modules
assert state.phase.value == "playing"
assert state.current_round == 1
def test_rebuild_matches_player_cards(self):
"""Rebuilt player cards should match original."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2))
game.flip_initial_cards("p1", [0, 1])
game.flip_initial_cards("p2", [0, 1])
# Rebuild and compare
state = rebuild_state(collector.events)
for player in game.players:
rebuilt_player = state.get_player(player.id)
assert rebuilt_player is not None
assert len(rebuilt_player.cards) == 6
for i, (orig, rebuilt) in enumerate(zip(player.cards, rebuilt_player.cards)):
assert rebuilt.rank == orig.rank.value, f"Rank mismatch at position {i}"
assert rebuilt.suit == orig.suit.value, f"Suit mismatch at position {i}"
assert rebuilt.face_up == orig.face_up, f"Face up mismatch at position {i}"
def test_rebuild_after_turns(self):
"""Rebuilt state should match after several turns."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
# Play several turns
for _ in range(5):
current = game.current_player()
if not current:
break
game.draw_card(current.id, "deck")
game.discard_drawn(current.id)
if game.phase == GamePhase.ROUND_OVER:
break
# Rebuild and verify
state = rebuild_state(collector.events)
assert state.current_player_idx == game.current_player_index
assert len(state.discard_pile) > 0
def test_rebuild_sequence_validation(self):
"""Applying events out of order should fail."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
# Skip first event
events = collector.events[1:]
with pytest.raises(ValueError, match="Expected sequence"):
rebuild_state(events)
class TestFullGameReplay:
"""Test complete game replay scenarios."""
def test_play_and_replay_single_round(self):
"""Play a full round and verify replay matches."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=2))
# Initial flips
game.flip_initial_cards("p1", [0, 1])
game.flip_initial_cards("p2", [0, 1])
# Play until round ends
turn_count = 0
max_turns = 100
while game.phase not in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER) and turn_count < max_turns:
current = game.current_player()
if not current:
break
game.draw_card(current.id, "deck")
game.discard_drawn(current.id)
turn_count += 1
# Rebuild and verify final state
state = rebuild_state(collector.events)
# Phase should match
assert state.phase.value == game.phase.value
# Scores should match (if round is over)
if game.phase == GamePhase.ROUND_OVER:
for player in game.players:
rebuilt_player = state.get_player(player.id)
assert rebuilt_player is not None
assert rebuilt_player.score == player.score
def test_partial_replay(self):
"""Can replay to any point in the game."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
# Play several turns
for _ in range(10):
current = game.current_player()
if not current or game.phase == GamePhase.ROUND_OVER:
break
game.draw_card(current.id, "deck")
game.discard_drawn(current.id)
# Replay to different points
for n in range(1, len(collector.events) + 1):
partial_events = collector.events[:n]
state = rebuild_state(partial_events)
assert state.sequence_num == n
def test_swap_action_replay(self):
"""Verify swap actions are correctly replayed."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
# Do a swap
drawn = game.draw_card("p1", "deck")
old_card = game.get_player("p1").cards[0]
game.swap_card("p1", 0)
# Rebuild and verify
state = rebuild_state(collector.events)
rebuilt_player = state.get_player("p1")
# The swapped card should be in the hand
assert rebuilt_player.cards[0].rank == drawn.rank.value
assert rebuilt_player.cards[0].face_up is True
# The old card should be on discard pile
assert state.discard_pile[-1].rank == old_card.rank.value
class TestEventSerialization:
"""Test event serialization/deserialization."""
def test_event_to_dict_roundtrip(self):
"""Events can be serialized and deserialized."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
for event in collector.events:
event_dict = event.to_dict()
restored = GameEvent.from_dict(event_dict)
assert restored.event_type == event.event_type
assert restored.game_id == event.game_id
assert restored.sequence_num == event.sequence_num
assert restored.player_id == event.player_id
assert restored.data == event.data
def test_event_to_json_roundtrip(self):
"""Events can be JSON serialized and deserialized."""
game, collector = create_test_game(num_players=2)
game.start_game(num_decks=1, num_rounds=1, options=GameOptions(initial_flips=0))
for event in collector.events:
json_str = event.to_json()
restored = GameEvent.from_json(json_str)
assert restored.event_type == event.event_type
assert restored.game_id == event.game_id
assert restored.sequence_num == event.sequence_num

View File

@@ -0,0 +1,564 @@
"""
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"])

302
server/tests/test_replay.py Normal file
View File

@@ -0,0 +1,302 @@
"""
Tests for the replay service.
Verifies:
- Replay building from events
- Share link creation and retrieval
- Export/import roundtrip
- Access control
"""
import pytest
import json
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from models.events import GameEvent, EventType
from models.game_state import RebuiltGameState, rebuild_state
class TestReplayBuilding:
"""Test replay construction from events."""
def test_rebuild_state_from_events(self):
"""Verify state can be rebuilt from a sequence of events."""
events = [
GameEvent(
event_type=EventType.GAME_CREATED,
game_id="test-game-1",
sequence_num=1,
player_id=None,
data={
"room_code": "ABCD",
"host_id": "player-1",
"options": {},
},
timestamp=datetime.now(timezone.utc),
),
GameEvent(
event_type=EventType.PLAYER_JOINED,
game_id="test-game-1",
sequence_num=2,
player_id="player-1",
data={
"player_name": "Alice",
"is_cpu": False,
},
timestamp=datetime.now(timezone.utc),
),
GameEvent(
event_type=EventType.PLAYER_JOINED,
game_id="test-game-1",
sequence_num=3,
player_id="player-2",
data={
"player_name": "Bob",
"is_cpu": False,
},
timestamp=datetime.now(timezone.utc),
),
]
state = rebuild_state(events)
assert state.game_id == "test-game-1"
assert state.room_code == "ABCD"
assert len(state.players) == 2
assert "player-1" in state.players
assert "player-2" in state.players
assert state.players["player-1"].name == "Alice"
assert state.players["player-2"].name == "Bob"
assert state.sequence_num == 3
def test_rebuild_state_partial(self):
"""Can rebuild state to any point in event history."""
events = [
GameEvent(
event_type=EventType.GAME_CREATED,
game_id="test-game-1",
sequence_num=1,
player_id=None,
data={
"room_code": "ABCD",
"host_id": "player-1",
"options": {},
},
timestamp=datetime.now(timezone.utc),
),
GameEvent(
event_type=EventType.PLAYER_JOINED,
game_id="test-game-1",
sequence_num=2,
player_id="player-1",
data={
"player_name": "Alice",
"is_cpu": False,
},
timestamp=datetime.now(timezone.utc),
),
GameEvent(
event_type=EventType.PLAYER_JOINED,
game_id="test-game-1",
sequence_num=3,
player_id="player-2",
data={
"player_name": "Bob",
"is_cpu": False,
},
timestamp=datetime.now(timezone.utc),
),
]
# Rebuild only first 2 events
state = rebuild_state(events[:2])
assert len(state.players) == 1
assert state.sequence_num == 2
# Rebuild all events
state = rebuild_state(events)
assert len(state.players) == 2
assert state.sequence_num == 3
class TestExportImport:
"""Test game export and import."""
def test_export_format(self):
"""Verify exported format matches expected structure."""
export_data = {
"version": "1.0",
"exported_at": "2024-01-15T12:00:00Z",
"game": {
"id": "test-game-1",
"room_code": "ABCD",
"players": ["Alice", "Bob"],
"winner": "Alice",
"final_scores": {"Alice": 15, "Bob": 23},
"duration_seconds": 300.5,
"total_rounds": 1,
"options": {},
},
"events": [
{
"type": "game_created",
"sequence": 1,
"player_id": None,
"data": {"room_code": "ABCD", "host_id": "p1", "options": {}},
"timestamp": 0.0,
},
],
}
assert export_data["version"] == "1.0"
assert "exported_at" in export_data
assert "game" in export_data
assert "events" in export_data
assert export_data["game"]["players"] == ["Alice", "Bob"]
def test_import_validates_version(self):
"""Import should reject unsupported versions."""
invalid_export = {
"version": "2.0", # Unsupported version
"events": [],
}
# This would be tested with the actual service
assert invalid_export["version"] != "1.0"
class TestShareLinks:
"""Test share link functionality."""
def test_share_code_format(self):
"""Share codes should be 12 characters."""
import secrets
share_code = secrets.token_urlsafe(9)[:12]
assert len(share_code) == 12
# URL-safe characters only
assert all(c.isalnum() or c in '-_' for c in share_code)
def test_expiry_calculation(self):
"""Verify expiry date calculation."""
now = datetime.now(timezone.utc)
expires_days = 7
expires_at = now + timedelta(days=expires_days)
assert expires_at > now
assert (expires_at - now).days == 7
class TestSpectatorManager:
"""Test spectator management."""
@pytest.mark.asyncio
async def test_add_remove_spectator(self):
"""Test adding and removing spectators."""
from services.spectator import SpectatorManager
manager = SpectatorManager()
ws = AsyncMock()
# Add spectator
result = await manager.add_spectator("game-1", ws, user_id="user-1")
assert result is True
assert manager.get_spectator_count("game-1") == 1
# Remove spectator
await manager.remove_spectator("game-1", ws)
assert manager.get_spectator_count("game-1") == 0
@pytest.mark.asyncio
async def test_spectator_limit(self):
"""Test spectator limit enforcement."""
from services.spectator import SpectatorManager, MAX_SPECTATORS_PER_GAME
manager = SpectatorManager()
# Add max spectators
for i in range(MAX_SPECTATORS_PER_GAME):
ws = AsyncMock()
result = await manager.add_spectator("game-1", ws)
assert result is True
# Try to add one more
ws = AsyncMock()
result = await manager.add_spectator("game-1", ws)
assert result is False
@pytest.mark.asyncio
async def test_broadcast_to_spectators(self):
"""Test broadcasting messages to spectators."""
from services.spectator import SpectatorManager
manager = SpectatorManager()
ws1 = AsyncMock()
ws2 = AsyncMock()
await manager.add_spectator("game-1", ws1)
await manager.add_spectator("game-1", ws2)
message = {"type": "game_update", "data": "test"}
await manager.broadcast_to_spectators("game-1", message)
ws1.send_json.assert_called_once_with(message)
ws2.send_json.assert_called_once_with(message)
@pytest.mark.asyncio
async def test_dead_connection_cleanup(self):
"""Test cleanup of dead WebSocket connections."""
from services.spectator import SpectatorManager
manager = SpectatorManager()
# Add a spectator that will fail on send
ws = AsyncMock()
ws.send_json.side_effect = Exception("Connection closed")
await manager.add_spectator("game-1", ws)
assert manager.get_spectator_count("game-1") == 1
# Broadcast should clean up dead connection
await manager.broadcast_to_spectators("game-1", {"type": "test"})
assert manager.get_spectator_count("game-1") == 0
class TestReplayFrames:
"""Test replay frame construction."""
def test_frame_timestamps(self):
"""Verify frame timestamps are relative to game start."""
start_time = datetime.now(timezone.utc)
events = [
GameEvent(
event_type=EventType.GAME_CREATED,
game_id="test-game-1",
sequence_num=1,
player_id=None,
data={"room_code": "ABCD", "host_id": "p1", "options": {}},
timestamp=start_time,
),
GameEvent(
event_type=EventType.PLAYER_JOINED,
game_id="test-game-1",
sequence_num=2,
player_id="player-1",
data={"player_name": "Alice", "is_cpu": False},
timestamp=start_time + timedelta(seconds=5),
),
]
# First event should have timestamp 0
elapsed_0 = (events[0].timestamp - start_time).total_seconds()
assert elapsed_0 == 0.0
# Second event should have timestamp 5
elapsed_1 = (events[1].timestamp - start_time).total_seconds()
assert elapsed_1 == 5.0
if __name__ == "__main__":
pytest.main([__file__, "-v"])