mantimon-tcg/backend/tests/unit/services/test_game_service.py
Cal Corum 0c810e5b30 Add Phase 4 WebSocket infrastructure (WS-001 through GS-001)
WebSocket Message Schemas (WS-002):
- Add Pydantic models for all client/server WebSocket messages
- Implement discriminated unions for message type parsing
- Include JoinGame, Action, Resign, Heartbeat client messages
- Include GameState, ActionResult, Error, TurnStart server messages

Connection Manager (WS-003):
- Add Redis-backed WebSocket connection tracking
- Implement user-to-sid mapping with TTL management
- Support game room association and opponent lookup
- Add heartbeat tracking for connection health

Socket.IO Authentication (WS-004):
- Add JWT-based authentication middleware
- Support token extraction from multiple formats
- Implement session setup with ConnectionManager integration
- Add require_auth helper for event handlers

Socket.IO Server Setup (WS-001):
- Configure AsyncServer with ASGI mode
- Register /game namespace with event handlers
- Integrate with FastAPI via ASGIApp wrapper
- Configure CORS from application settings

Game Service (GS-001):
- Add stateless GameService for game lifecycle orchestration
- Create engine per-operation using rules from GameState
- Implement action-based RNG seeding for deterministic replay
- Add rng_seed field to GameState for replay support

Architecture verified:
- Core module independence (no forbidden imports)
- Config from request pattern (rules in GameState)
- Dependency injection (constructor deps, method config)
- All 1090 tests passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:21:20 -06:00

810 lines
27 KiB
Python

"""Tests for GameService.
This module tests the game service layer that orchestrates between
WebSocket communication and the core GameEngine.
The GameService is STATELESS regarding game rules:
- No GameEngine is stored in the service
- Engine is created per-operation using rules from GameState
- Rules come from frontend at game creation, stored in GameState
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from app.core.engine import ActionResult
from app.core.enums import GameEndReason, TurnPhase
from app.core.models.actions import AttackAction, PassAction, ResignAction
from app.core.models.game_state import GameState, PlayerState
from app.core.win_conditions import WinResult
from app.services.game_service import (
GameAlreadyEndedError,
GameNotFoundError,
GameService,
InvalidActionError,
NotPlayerTurnError,
PlayerNotInGameError,
)
@pytest.fixture
def mock_state_manager() -> AsyncMock:
"""Create a mock GameStateManager.
The state manager handles persistence to Redis (cache) and
Postgres (durable storage).
"""
manager = AsyncMock()
manager.load_state = AsyncMock(return_value=None)
manager.save_to_cache = AsyncMock()
manager.persist_to_db = AsyncMock()
manager.cache_exists = AsyncMock(return_value=False)
return manager
@pytest.fixture
def mock_card_service() -> MagicMock:
"""Create a mock CardService.
CardService provides card definitions for game creation.
"""
return MagicMock()
@pytest.fixture
def game_service(
mock_state_manager: AsyncMock,
mock_card_service: MagicMock,
) -> GameService:
"""Create a GameService with mocked dependencies.
Note: No engine is passed - GameService creates engines per-operation
using rules stored in each game's GameState.
"""
return GameService(
state_manager=mock_state_manager,
card_service=mock_card_service,
)
@pytest.fixture
def sample_game_state() -> GameState:
"""Create a sample game state for testing.
The game state includes two players and basic turn tracking.
The rules are stored in the state itself (default RulesConfig).
"""
player1 = PlayerState(player_id="player-1")
player2 = PlayerState(player_id="player-2")
return GameState(
game_id="game-123",
players={"player-1": player1, "player-2": player2},
current_player_id="player-1",
turn_number=1,
phase=TurnPhase.MAIN,
)
class TestGameStateAccess:
"""Tests for game state access methods."""
@pytest.mark.asyncio
async def test_get_game_state_returns_state(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that get_game_state returns the game state when found.
The state manager should be called to load the state, and the
result should be returned to the caller.
"""
mock_state_manager.load_state.return_value = sample_game_state
result = await game_service.get_game_state("game-123")
assert result == sample_game_state
mock_state_manager.load_state.assert_called_once_with("game-123")
@pytest.mark.asyncio
async def test_get_game_state_raises_not_found(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
) -> None:
"""Test that get_game_state raises GameNotFoundError when not found.
When the state manager returns None, we should raise a specific
exception rather than returning None.
"""
mock_state_manager.load_state.return_value = None
with pytest.raises(GameNotFoundError) as exc_info:
await game_service.get_game_state("nonexistent")
assert exc_info.value.game_id == "nonexistent"
@pytest.mark.asyncio
async def test_get_player_view_returns_visible_state(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that get_player_view returns visibility-filtered state.
The returned state should be filtered for the requesting player,
hiding the opponent's private information.
"""
mock_state_manager.load_state.return_value = sample_game_state
result = await game_service.get_player_view("game-123", "player-1")
assert result.game_id == "game-123"
assert result.viewer_id == "player-1"
assert result.is_my_turn is True
@pytest.mark.asyncio
async def test_get_player_view_raises_not_in_game(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that get_player_view raises error for non-participants.
Only players in the game should be able to view its state.
"""
mock_state_manager.load_state.return_value = sample_game_state
with pytest.raises(PlayerNotInGameError) as exc_info:
await game_service.get_player_view("game-123", "stranger")
assert exc_info.value.player_id == "stranger"
@pytest.mark.asyncio
async def test_is_player_turn_returns_true_for_current_player(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that is_player_turn returns True for the current player.
The current player is determined by the game state's current_player_id.
"""
mock_state_manager.load_state.return_value = sample_game_state
result = await game_service.is_player_turn("game-123", "player-1")
assert result is True
@pytest.mark.asyncio
async def test_is_player_turn_returns_false_for_other_player(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that is_player_turn returns False for non-current player.
Players who are not the current player should get False.
"""
mock_state_manager.load_state.return_value = sample_game_state
result = await game_service.is_player_turn("game-123", "player-2")
assert result is False
@pytest.mark.asyncio
async def test_game_exists_returns_true_when_cached(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
) -> None:
"""Test that game_exists returns True when game is in cache.
This is a quick check that doesn't need to load the full state.
"""
mock_state_manager.cache_exists.return_value = True
result = await game_service.game_exists("game-123")
assert result is True
mock_state_manager.cache_exists.assert_called_once_with("game-123")
class TestJoinGame:
"""Tests for the join_game method."""
@pytest.mark.asyncio
async def test_join_game_success(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test successful game join returns visible state.
When a player joins their game, they should receive the
visibility-filtered state and know if it's their turn.
"""
mock_state_manager.load_state.return_value = sample_game_state
result = await game_service.join_game("game-123", "player-1")
assert result.success is True
assert result.game_id == "game-123"
assert result.player_id == "player-1"
assert result.visible_state is not None
assert result.is_your_turn is True
@pytest.mark.asyncio
async def test_join_game_not_your_turn(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that is_your_turn is False when it's opponent's turn.
The second player should see is_your_turn=False when the first
player is the current player.
"""
mock_state_manager.load_state.return_value = sample_game_state
result = await game_service.join_game("game-123", "player-2")
assert result.success is True
assert result.is_your_turn is False
@pytest.mark.asyncio
async def test_join_game_not_found(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
) -> None:
"""Test join_game returns failure for non-existent game.
Rather than raising an exception, join_game returns a failed
result for better WebSocket error handling.
"""
mock_state_manager.load_state.return_value = None
result = await game_service.join_game("nonexistent", "player-1")
assert result.success is False
assert "not found" in result.message.lower()
@pytest.mark.asyncio
async def test_join_game_not_participant(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test join_game returns failure for non-participants.
Players who are not in the game should not be able to join.
"""
mock_state_manager.load_state.return_value = sample_game_state
result = await game_service.join_game("game-123", "stranger")
assert result.success is False
assert "not a participant" in result.message.lower()
@pytest.mark.asyncio
async def test_join_ended_game(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test joining an ended game still succeeds but indicates game over.
Players should be able to rejoin ended games to see the final
state, but is_your_turn should be False.
"""
sample_game_state.winner_id = "player-1"
sample_game_state.end_reason = GameEndReason.PRIZES_TAKEN
mock_state_manager.load_state.return_value = sample_game_state
result = await game_service.join_game("game-123", "player-2")
assert result.success is True
assert result.is_your_turn is False
assert "ended" in result.message.lower()
class TestExecuteAction:
"""Tests for the execute_action method.
These tests verify action execution through GameService. Since GameService
creates engines per-operation, we patch _create_engine_for_game to return
a mock engine with controlled behavior.
"""
@pytest.mark.asyncio
async def test_execute_action_success(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test successful action execution.
A valid action by the current player should be executed and
the state should be saved to cache.
"""
mock_state_manager.load_state.return_value = sample_game_state
mock_engine = MagicMock()
mock_engine.execute_action = AsyncMock(
return_value=ActionResult(
success=True,
message="Attack executed",
state_changes=[{"type": "damage", "amount": 30}],
)
)
with patch.object(game_service, "_create_engine_for_game", return_value=mock_engine):
action = AttackAction(attack_index=0)
result = await game_service.execute_action("game-123", "player-1", action)
assert result.success is True
assert result.action_type == "attack"
mock_state_manager.save_to_cache.assert_called_once()
@pytest.mark.asyncio
async def test_execute_action_game_not_found(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
) -> None:
"""Test execute_action raises error when game not found.
Missing games should raise GameNotFoundError for proper
error handling in the WebSocket layer.
"""
mock_state_manager.load_state.return_value = None
with pytest.raises(GameNotFoundError):
await game_service.execute_action("nonexistent", "player-1", PassAction())
@pytest.mark.asyncio
async def test_execute_action_not_in_game(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test execute_action raises error for non-participants.
Only players in the game can execute actions.
"""
mock_state_manager.load_state.return_value = sample_game_state
with pytest.raises(PlayerNotInGameError):
await game_service.execute_action("game-123", "stranger", PassAction())
@pytest.mark.asyncio
async def test_execute_action_game_ended(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test execute_action raises error on ended games.
No actions should be allowed once a game has ended.
"""
sample_game_state.winner_id = "player-1"
sample_game_state.end_reason = GameEndReason.RESIGNATION
mock_state_manager.load_state.return_value = sample_game_state
with pytest.raises(GameAlreadyEndedError):
await game_service.execute_action("game-123", "player-1", PassAction())
@pytest.mark.asyncio
async def test_execute_action_not_your_turn(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test execute_action raises error when not player's turn.
Only the current player can execute actions.
"""
mock_state_manager.load_state.return_value = sample_game_state
with pytest.raises(NotPlayerTurnError) as exc_info:
await game_service.execute_action("game-123", "player-2", PassAction())
assert exc_info.value.player_id == "player-2"
assert exc_info.value.current_player_id == "player-1"
@pytest.mark.asyncio
async def test_execute_action_resign_allowed_out_of_turn(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that resignation is allowed even when not your turn.
Resignation is a special action that can be executed anytime
by either player.
"""
mock_state_manager.load_state.return_value = sample_game_state
mock_engine = MagicMock()
mock_engine.execute_action = AsyncMock(
return_value=ActionResult(
success=True,
message="Player resigned",
win_result=WinResult(
winner_id="player-1",
loser_id="player-2",
end_reason=GameEndReason.RESIGNATION,
reason="Player resigned",
),
)
)
with patch.object(game_service, "_create_engine_for_game", return_value=mock_engine):
# player-2 resigns even though it's player-1's turn
result = await game_service.execute_action("game-123", "player-2", ResignAction())
assert result.success is True
assert result.game_over is True
assert result.winner_id == "player-1"
@pytest.mark.asyncio
async def test_execute_action_invalid_action(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test execute_action raises error for invalid actions.
When the GameEngine rejects an action, we should raise
InvalidActionError with the reason.
"""
mock_state_manager.load_state.return_value = sample_game_state
mock_engine = MagicMock()
mock_engine.execute_action = AsyncMock(
return_value=ActionResult(
success=False,
message="Not enough energy to attack",
)
)
with (
patch.object(game_service, "_create_engine_for_game", return_value=mock_engine),
pytest.raises(InvalidActionError) as exc_info,
):
await game_service.execute_action("game-123", "player-1", AttackAction(attack_index=0))
assert "Not enough energy" in exc_info.value.reason
@pytest.mark.asyncio
async def test_execute_action_game_over(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test execute_action detects game over and persists to DB.
When an action results in a win, the state should be persisted
to the database for durability.
"""
mock_state_manager.load_state.return_value = sample_game_state
mock_engine = MagicMock()
mock_engine.execute_action = AsyncMock(
return_value=ActionResult(
success=True,
message="Final prize taken!",
win_result=WinResult(
winner_id="player-1",
loser_id="player-2",
end_reason=GameEndReason.PRIZES_TAKEN,
reason="All prizes taken",
),
)
)
with patch.object(game_service, "_create_engine_for_game", return_value=mock_engine):
result = await game_service.execute_action(
"game-123", "player-1", AttackAction(attack_index=0)
)
assert result.game_over is True
assert result.winner_id == "player-1"
assert result.end_reason == GameEndReason.PRIZES_TAKEN
mock_state_manager.persist_to_db.assert_called_once()
@pytest.mark.asyncio
async def test_execute_action_uses_game_rules(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that execute_action creates engine with game's rules.
The engine should be created using the rules stored in the game
state, not any service-level defaults.
"""
mock_state_manager.load_state.return_value = sample_game_state
mock_engine = MagicMock()
mock_engine.execute_action = AsyncMock(
return_value=ActionResult(success=True, message="OK")
)
with patch.object(
game_service, "_create_engine_for_game", return_value=mock_engine
) as mock_create:
await game_service.execute_action("game-123", "player-1", PassAction())
# Verify engine was created with the game state
mock_create.assert_called_once_with(sample_game_state)
class TestResignGame:
"""Tests for the resign_game convenience method."""
@pytest.mark.asyncio
async def test_resign_game_executes_resign_action(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that resign_game is a convenience wrapper for execute_action.
The resign_game method should internally create a ResignAction
and call execute_action.
"""
mock_state_manager.load_state.return_value = sample_game_state
mock_engine = MagicMock()
mock_engine.execute_action = AsyncMock(
return_value=ActionResult(
success=True,
message="Player resigned",
win_result=WinResult(
winner_id="player-2",
loser_id="player-1",
end_reason=GameEndReason.RESIGNATION,
reason="Player resigned",
),
)
)
with patch.object(game_service, "_create_engine_for_game", return_value=mock_engine):
result = await game_service.resign_game("game-123", "player-1")
assert result.success is True
assert result.action_type == "resign"
assert result.game_over is True
class TestEndGame:
"""Tests for the end_game method (forced ending)."""
@pytest.mark.asyncio
async def test_end_game_sets_winner_and_reason(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test that end_game sets winner and end reason.
Used for timeout or disconnection scenarios where the game
needs to be forcibly ended.
"""
mock_state_manager.load_state.return_value = sample_game_state
await game_service.end_game(
"game-123",
winner_id="player-1",
end_reason=GameEndReason.TIMEOUT,
)
# Verify state was modified
assert sample_game_state.winner_id == "player-1"
assert sample_game_state.end_reason == GameEndReason.TIMEOUT
# Verify persistence
mock_state_manager.save_to_cache.assert_called_once()
mock_state_manager.persist_to_db.assert_called_once()
@pytest.mark.asyncio
async def test_end_game_draw(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
sample_game_state: GameState,
) -> None:
"""Test ending a game as a draw (no winner).
Some scenarios (timeout with equal scores) can result in a draw.
"""
mock_state_manager.load_state.return_value = sample_game_state
await game_service.end_game(
"game-123",
winner_id=None,
end_reason=GameEndReason.DRAW,
)
assert sample_game_state.winner_id is None
assert sample_game_state.end_reason == GameEndReason.DRAW
@pytest.mark.asyncio
async def test_end_game_not_found(
self,
game_service: GameService,
mock_state_manager: AsyncMock,
) -> None:
"""Test end_game raises error when game not found."""
mock_state_manager.load_state.return_value = None
with pytest.raises(GameNotFoundError):
await game_service.end_game(
"nonexistent",
winner_id="player-1",
end_reason=GameEndReason.TIMEOUT,
)
class TestCreateGame:
"""Tests for the create_game method (skeleton)."""
@pytest.mark.asyncio
async def test_create_game_raises_not_implemented(
self,
game_service: GameService,
) -> None:
"""Test that create_game raises NotImplementedError.
The full implementation will be done in GS-002. For now,
it should raise NotImplementedError with a clear message.
"""
with pytest.raises(NotImplementedError) as exc_info:
await game_service.create_game(
player1_id=str(uuid4()),
player2_id=str(uuid4()),
)
assert "GS-002" in str(exc_info.value)
class TestCreateEngineForGame:
"""Tests for the _create_engine_for_game method.
This method is responsible for creating a GameEngine configured
with the rules from a specific game's state.
"""
def test_create_engine_uses_game_rules(
self,
game_service: GameService,
sample_game_state: GameState,
) -> None:
"""Test that engine is created with the game's rules.
The engine should use the RulesConfig stored in the game state,
not any default configuration.
"""
engine = game_service._create_engine_for_game(sample_game_state)
# Engine should have the game's rules
assert engine.rules == sample_game_state.rules
def test_create_engine_with_rng_seed(
self,
game_service: GameService,
sample_game_state: GameState,
) -> None:
"""Test that engine uses seeded RNG when game has rng_seed.
When a game has an rng_seed set, the engine should use a
deterministic RNG for replay support.
"""
sample_game_state.rng_seed = 12345
engine = game_service._create_engine_for_game(sample_game_state)
# Engine should have been created (we can't easily verify seed,
# but we can verify it doesn't error)
assert engine is not None
def test_create_engine_without_rng_seed(
self,
game_service: GameService,
sample_game_state: GameState,
) -> None:
"""Test that engine uses secure RNG when no seed is set.
Without an rng_seed, the engine should use cryptographically
secure random number generation.
"""
sample_game_state.rng_seed = None
engine = game_service._create_engine_for_game(sample_game_state)
assert engine is not None
def test_create_engine_derives_unique_seed_per_action(
self,
game_service: GameService,
sample_game_state: GameState,
) -> None:
"""Test that different action counts produce different RNG sequences.
For deterministic replay, each action needs a unique but
reproducible RNG seed based on game seed + action count.
"""
sample_game_state.rng_seed = 12345
# Simulate first action (action_log is empty)
sample_game_state.action_log = []
engine1 = game_service._create_engine_for_game(sample_game_state)
# Simulate second action (one action in log)
sample_game_state.action_log = [{"type": "pass"}]
engine2 = game_service._create_engine_for_game(sample_game_state)
# Both engines should be created successfully
# (They will have different seeds due to action count)
assert engine1 is not None
assert engine2 is not None
class TestExceptionMessages:
"""Tests for exception message formatting."""
def test_game_not_found_error_message(self) -> None:
"""Test GameNotFoundError has descriptive message."""
error = GameNotFoundError("game-123")
assert "game-123" in str(error)
assert error.game_id == "game-123"
def test_not_player_turn_error_message(self) -> None:
"""Test NotPlayerTurnError has descriptive message."""
error = NotPlayerTurnError("game-123", "player-2", "player-1")
assert "player-2" in str(error)
assert "player-1" in str(error)
assert error.game_id == "game-123"
def test_invalid_action_error_message(self) -> None:
"""Test InvalidActionError has descriptive message."""
error = InvalidActionError("game-123", "player-1", "Not enough energy")
assert "Not enough energy" in str(error)
assert error.reason == "Not enough energy"
def test_player_not_in_game_error_message(self) -> None:
"""Test PlayerNotInGameError has descriptive message."""
error = PlayerNotInGameError("game-123", "stranger")
assert "stranger" in str(error)
assert "game-123" in str(error)
def test_game_already_ended_error_message(self) -> None:
"""Test GameAlreadyEndedError has descriptive message."""
error = GameAlreadyEndedError("game-123")
assert "game-123" in str(error)
assert "ended" in str(error).lower()