mantimon-tcg/backend/tests/api/test_games_api.py
Cal Corum cc0254d5ab Implement REST endpoints for game management (API-001)
Add REST API endpoints for game lifecycle operations:
- POST /games - Create new game between two players
- GET /games/{game_id} - Get game info for reconnection
- GET /games/me/active - List user's active games
- POST /games/{game_id}/resign - Resign from game via HTTP

Includes proper reverse proxy support for WebSocket URL generation
(X-Forwarded-* headers -> settings.base_url -> Host header fallback).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 23:09:12 -06:00

894 lines
27 KiB
Python

"""Tests for games API endpoints.
Tests the game management endpoints with mocked services.
The backend is stateless - RulesConfig comes from the request.
"""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import FastAPI, status
from fastapi.testclient import TestClient
from app.api import deps as api_deps
from app.api.games import router as games_router
from app.core.config import RulesConfig
from app.core.enums import GameEndReason, TurnPhase
from app.core.models.game_state import GameState, PlayerState
from app.db.models import User
from app.db.models.game import ActiveGame, GameType
from app.services.deck_service import DeckService
from app.services.game_service import (
GameActionResult,
GameAlreadyEndedError,
GameCreateResult,
GameNotFoundError,
GameService,
PlayerNotInGameError,
)
from app.services.game_state_manager import GameStateManager
from app.services.jwt_service import create_access_token
# =============================================================================
# WebSocket URL Builder Tests
# =============================================================================
class TestBuildWsUrl:
"""Tests for _build_ws_url helper function."""
def test_uses_forwarded_headers_when_present(self):
"""
Test that reverse proxy headers take priority.
When X-Forwarded-Host is present, the function should use it
along with X-Forwarded-Proto to build the WebSocket URL.
"""
from unittest.mock import MagicMock
from app.api.games import _build_ws_url
request = MagicMock()
request.headers = {
"x-forwarded-host": "play.mantimon.com",
"x-forwarded-proto": "https",
"host": "internal-server:8000",
}
request.url.scheme = "http"
request.url.netloc = "internal-server:8000"
result = _build_ws_url(request)
assert result == "wss://play.mantimon.com/socket.io/"
def test_uses_http_forwarded_proto(self):
"""
Test that http forwarded proto produces ws:// URL.
Non-HTTPS proxied requests should use ws:// not wss://.
"""
from unittest.mock import MagicMock
from app.api.games import _build_ws_url
request = MagicMock()
request.headers = {
"x-forwarded-host": "staging.mantimon.com",
"x-forwarded-proto": "http",
}
result = _build_ws_url(request)
assert result == "ws://staging.mantimon.com/socket.io/"
def test_falls_back_to_request_host_in_development(self):
"""
Test that direct request headers are used in development.
When no forwarded headers and in development mode, use the
request's Host header directly.
"""
from unittest.mock import MagicMock, patch
from app.api.games import _build_ws_url
# Create a proper mock for headers that supports .get()
headers_dict = {"host": "localhost:8000"}
request = MagicMock()
request.headers.get.side_effect = lambda key, default=None: headers_dict.get(key, default)
request.url.scheme = "http"
request.url.netloc = "localhost:8000"
# Mock settings to be in development mode
with patch("app.api.games.settings") as mock_settings:
mock_settings.is_development = True
mock_settings.base_url = "http://localhost:8000"
result = _build_ws_url(request)
assert result == "ws://localhost:8000/socket.io/"
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def test_user():
"""Create a test user object."""
user = User(
email="test@example.com",
display_name="Test User",
avatar_url="https://example.com/avatar.jpg",
oauth_provider="google",
oauth_id="google-123",
is_premium=False,
premium_until=None,
)
user.id = uuid4()
user.created_at = datetime.now(UTC)
user.updated_at = datetime.now(UTC)
user.last_login = None
user.linked_accounts = []
return user
@pytest.fixture
def opponent_user():
"""Create an opponent test user object."""
user = User(
email="opponent@example.com",
display_name="Opponent User",
avatar_url="https://example.com/opponent.jpg",
oauth_provider="google",
oauth_id="google-456",
is_premium=False,
premium_until=None,
)
user.id = uuid4()
user.created_at = datetime.now(UTC)
user.updated_at = datetime.now(UTC)
user.last_login = None
user.linked_accounts = []
return user
@pytest.fixture
def access_token(test_user):
"""Create a valid access token for the test user."""
return create_access_token(test_user.id)
@pytest.fixture
def auth_headers(access_token):
"""Create Authorization headers with Bearer token."""
return {"Authorization": f"Bearer {access_token}"}
@pytest.fixture
def mock_game_service():
"""Create a mock GameService."""
return MagicMock(spec=GameService)
@pytest.fixture
def mock_state_manager():
"""Create a mock GameStateManager."""
return MagicMock(spec=GameStateManager)
@pytest.fixture
def mock_deck_service():
"""Create a mock DeckService."""
return MagicMock(spec=DeckService)
@pytest.fixture
def mock_db_session():
"""Create a mock database session."""
return MagicMock()
@pytest.fixture
def app(test_user, mock_game_service, mock_state_manager, mock_deck_service, mock_db_session):
"""Create a test FastAPI app with mocked dependencies."""
test_app = FastAPI()
test_app.include_router(games_router, prefix="/api")
async def override_get_current_user():
return test_user
def override_get_game_service():
return mock_game_service
def override_get_state_manager():
return mock_state_manager
def override_get_deck_service():
return mock_deck_service
async def override_get_db():
yield mock_db_session
test_app.dependency_overrides[api_deps.get_current_user] = override_get_current_user
test_app.dependency_overrides[api_deps.get_game_service_dep] = override_get_game_service
test_app.dependency_overrides[api_deps.get_game_state_manager_dep] = override_get_state_manager
test_app.dependency_overrides[api_deps.get_deck_service] = override_get_deck_service
test_app.dependency_overrides[api_deps.get_db] = override_get_db
yield test_app
test_app.dependency_overrides.clear()
@pytest.fixture
def unauthenticated_app():
"""Create a test FastAPI app without auth override (for 401 tests)."""
test_app = FastAPI()
test_app.include_router(games_router, prefix="/api")
yield test_app
@pytest.fixture
def client(app):
"""Create a test client for the app."""
return TestClient(app)
@pytest.fixture
def unauthenticated_client(unauthenticated_app):
"""Create a test client without auth for 401 tests."""
return TestClient(unauthenticated_app)
def make_active_game(
game_id: str,
player1: User,
player2: User | None = None,
npc_id: str | None = None,
game_type: GameType = GameType.FREEPLAY,
turn_number: int = 1,
) -> ActiveGame:
"""Create an ActiveGame for testing."""
from uuid import UUID as UUIDType
game = ActiveGame(
game_type=game_type,
player1_id=player1.id,
player2_id=player2.id if player2 else None,
npc_id=npc_id,
rules_config={},
game_state={},
turn_number=turn_number,
started_at=datetime.now(UTC),
last_action_at=datetime.now(UTC),
)
# Set the game ID - parse string to UUID if needed
if isinstance(game_id, UUIDType):
game.id = game_id
else:
try:
game.id = UUIDType(game_id)
except ValueError:
game.id = uuid4()
# Set relationships for opponent name lookup
game.player1 = player1
game.player2 = player2
return game
def make_game_state(
game_id: str,
player1_id: str,
player2_id: str,
current_player_id: str | None = None,
turn_number: int = 1,
phase: TurnPhase = TurnPhase.MAIN,
winner_id: str | None = None,
end_reason: GameEndReason | None = None,
) -> GameState:
"""Create a GameState for testing."""
if current_player_id is None:
current_player_id = player1_id
return GameState(
game_id=game_id,
rules=RulesConfig(),
card_registry={},
players={
player1_id: PlayerState(player_id=player1_id),
player2_id: PlayerState(player_id=player2_id),
},
current_player_id=current_player_id,
turn_number=turn_number,
phase=phase,
winner_id=winner_id,
end_reason=end_reason,
turn_order=[player1_id, player2_id],
)
# =============================================================================
# POST /games Tests
# =============================================================================
class TestCreateGame:
"""Tests for POST /api/games endpoint."""
def test_creates_game_successfully(
self,
client: TestClient,
auth_headers,
mock_game_service,
test_user,
opponent_user,
):
"""
Test that endpoint creates a new game between two players.
Should return game_id and WebSocket URL for connecting.
"""
game_id = str(uuid4())
mock_game_service.create_game = AsyncMock(
return_value=GameCreateResult(
success=True,
game_id=game_id,
starting_player_id=str(test_user.id),
message="Game created successfully",
)
)
response = client.post(
"/api/games",
headers=auth_headers,
json={
"deck_id": str(uuid4()),
"opponent_id": str(opponent_user.id),
"opponent_deck_id": str(uuid4()),
"game_type": "freeplay",
},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["game_id"] == game_id
assert "ws_url" in data
assert "socket.io" in data["ws_url"]
assert data["starting_player_id"] == str(test_user.id)
def test_accepts_custom_rules_config(
self,
client: TestClient,
auth_headers,
mock_game_service,
opponent_user,
):
"""
Test that endpoint accepts custom RulesConfig from frontend.
The backend is stateless - rules come from the request.
"""
game_id = str(uuid4())
mock_game_service.create_game = AsyncMock(
return_value=GameCreateResult(
success=True,
game_id=game_id,
starting_player_id="player1",
)
)
response = client.post(
"/api/games",
headers=auth_headers,
json={
"deck_id": str(uuid4()),
"opponent_id": str(opponent_user.id),
"opponent_deck_id": str(uuid4()),
"rules_config": {
"prizes": {"count": 6},
"win_conditions": {"turn_timer_seconds": 60},
},
},
)
assert response.status_code == status.HTTP_201_CREATED
# Verify rules_config was passed to service
mock_game_service.create_game.assert_called_once()
call_kwargs = mock_game_service.create_game.call_args.kwargs
assert call_kwargs["rules_config"].prizes.count == 6
def test_returns_400_on_creation_failure(
self,
client: TestClient,
auth_headers,
mock_game_service,
opponent_user,
):
"""
Test that endpoint returns 400 when game creation fails.
Failed creation should return error message in response.
"""
mock_game_service.create_game = AsyncMock(
return_value=GameCreateResult(
success=False,
game_id=None,
message="Deck not found",
)
)
response = client.post(
"/api/games",
headers=auth_headers,
json={
"deck_id": str(uuid4()),
"opponent_id": str(opponent_user.id),
"opponent_deck_id": str(uuid4()),
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Deck not found" in response.json()["detail"]
def test_returns_400_on_exception(
self,
client: TestClient,
auth_headers,
mock_game_service,
opponent_user,
):
"""
Test that endpoint handles exceptions gracefully.
Exceptions should be caught and returned as 400 errors.
"""
mock_game_service.create_game = AsyncMock(
side_effect=Exception("Database connection failed")
)
response = client.post(
"/api/games",
headers=auth_headers,
json={
"deck_id": str(uuid4()),
"opponent_id": str(opponent_user.id),
"opponent_deck_id": str(uuid4()),
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Database connection failed" in response.json()["detail"]
def test_requires_authentication(self, unauthenticated_client: TestClient):
"""
Test that endpoint requires authentication.
Unauthenticated requests should return 401.
"""
response = unauthenticated_client.post(
"/api/games",
json={
"deck_id": str(uuid4()),
"opponent_id": str(uuid4()),
"opponent_deck_id": str(uuid4()),
},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# =============================================================================
# GET /games/me/active Tests
# =============================================================================
class TestListActiveGames:
"""Tests for GET /api/games/me/active endpoint."""
def test_returns_empty_list_for_no_active_games(
self,
client: TestClient,
auth_headers,
mock_state_manager,
):
"""
Test that endpoint returns empty list when user has no active games.
Users with no ongoing games should see an empty list.
"""
mock_state_manager.get_player_active_games = AsyncMock(return_value=[])
response = client.get("/api/games/me/active", headers=auth_headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["games"] == []
assert data["total"] == 0
def test_returns_active_games_list(
self,
client: TestClient,
auth_headers,
mock_state_manager,
test_user,
opponent_user,
):
"""
Test that endpoint returns list of user's active games.
Should include game summaries with opponent info and turn status.
"""
game_id = uuid4()
active_game = make_active_game(
game_id=str(game_id),
player1=test_user,
player2=opponent_user,
turn_number=5,
)
mock_state_manager.get_player_active_games = AsyncMock(return_value=[active_game])
mock_state_manager.load_from_cache = AsyncMock(return_value=None)
response = client.get("/api/games/me/active", headers=auth_headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data["games"]) == 1
assert data["total"] == 1
assert data["games"][0]["opponent_name"] == "Opponent User"
assert data["games"][0]["turn_number"] == 5
def test_determines_is_your_turn_from_cache(
self,
client: TestClient,
auth_headers,
mock_state_manager,
test_user,
opponent_user,
):
"""
Test that is_your_turn is determined from cached game state.
The endpoint should check Redis cache to determine whose turn it is.
"""
game_id = str(uuid4())
active_game = make_active_game(
game_id=game_id,
player1=test_user,
player2=opponent_user,
)
cached_state = make_game_state(
game_id=game_id,
player1_id=str(test_user.id),
player2_id=str(opponent_user.id),
current_player_id=str(test_user.id), # User's turn
)
mock_state_manager.get_player_active_games = AsyncMock(return_value=[active_game])
mock_state_manager.load_from_cache = AsyncMock(return_value=cached_state)
response = client.get("/api/games/me/active", headers=auth_headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["games"][0]["is_your_turn"] is True
def test_handles_npc_opponent(
self,
client: TestClient,
auth_headers,
mock_state_manager,
test_user,
):
"""
Test that endpoint handles campaign games with NPC opponents.
NPC ID should be used as opponent name when no player2.
"""
active_game = make_active_game(
game_id=str(uuid4()),
player1=test_user,
player2=None,
npc_id="grass_trainer_1",
game_type=GameType.CAMPAIGN,
)
mock_state_manager.get_player_active_games = AsyncMock(return_value=[active_game])
mock_state_manager.load_from_cache = AsyncMock(return_value=None)
response = client.get("/api/games/me/active", headers=auth_headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["games"][0]["opponent_name"] == "grass_trainer_1"
assert data["games"][0]["game_type"] == "campaign"
def test_requires_authentication(self, unauthenticated_client: TestClient):
"""
Test that endpoint requires authentication.
Unauthenticated requests should return 401.
"""
response = unauthenticated_client.get("/api/games/me/active")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# =============================================================================
# GET /games/{game_id} Tests
# =============================================================================
class TestGetGameInfo:
"""Tests for GET /api/games/{game_id} endpoint."""
def test_returns_game_info(
self,
client: TestClient,
auth_headers,
mock_game_service,
mock_state_manager,
test_user,
opponent_user,
):
"""
Test that endpoint returns game information.
Should return game metadata including turn status and phase.
"""
game_id = str(uuid4())
game_state = make_game_state(
game_id=game_id,
player1_id=str(test_user.id),
player2_id=str(opponent_user.id),
current_player_id=str(test_user.id),
turn_number=3,
phase=TurnPhase.MAIN,
)
active_game = make_active_game(
game_id=game_id,
player1=test_user,
player2=opponent_user,
turn_number=3,
)
mock_game_service.get_game_state = AsyncMock(return_value=game_state)
mock_state_manager.get_player_active_games = AsyncMock(return_value=[active_game])
response = client.get(f"/api/games/{game_id}", headers=auth_headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["game_id"] == game_id
assert data["turn_number"] == 3
assert data["phase"] == "main"
assert data["is_your_turn"] is True
assert data["is_game_over"] is False
def test_returns_404_for_nonexistent_game(
self,
client: TestClient,
auth_headers,
mock_game_service,
):
"""
Test that endpoint returns 404 for non-existent game.
Games that don't exist should return 404.
"""
mock_game_service.get_game_state = AsyncMock(side_effect=GameNotFoundError("game-123"))
response = client.get(f"/api/games/{uuid4()}", headers=auth_headers)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_returns_403_for_non_participant(
self,
client: TestClient,
auth_headers,
mock_game_service,
mock_state_manager,
opponent_user,
):
"""
Test that endpoint returns 403 if user is not a participant.
Users should not be able to see games they're not in.
"""
game_id = str(uuid4())
other_player_id = str(uuid4())
game_state = make_game_state(
game_id=game_id,
player1_id=other_player_id, # Not the test user
player2_id=str(opponent_user.id),
)
mock_game_service.get_game_state = AsyncMock(return_value=game_state)
response = client.get(f"/api/games/{game_id}", headers=auth_headers)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert "not a participant" in response.json()["detail"]
def test_returns_404_when_not_in_active_games(
self,
client: TestClient,
auth_headers,
mock_game_service,
mock_state_manager,
test_user,
opponent_user,
):
"""
Test that endpoint returns 404 when game exists in cache but not ActiveGame.
Edge case: game state exists but ActiveGame record is missing.
"""
game_id = str(uuid4())
game_state = make_game_state(
game_id=game_id,
player1_id=str(test_user.id),
player2_id=str(opponent_user.id),
)
mock_game_service.get_game_state = AsyncMock(return_value=game_state)
mock_state_manager.get_player_active_games = AsyncMock(return_value=[]) # Empty!
response = client.get(f"/api/games/{game_id}", headers=auth_headers)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert "not found in active games" in response.json()["detail"]
def test_shows_game_over_status(
self,
client: TestClient,
auth_headers,
mock_game_service,
mock_state_manager,
test_user,
opponent_user,
):
"""
Test that endpoint correctly reports game over status.
Ended games should show winner and end reason.
"""
game_id = str(uuid4())
game_state = make_game_state(
game_id=game_id,
player1_id=str(test_user.id),
player2_id=str(opponent_user.id),
winner_id=str(test_user.id),
end_reason=GameEndReason.PRIZES_TAKEN,
)
active_game = make_active_game(
game_id=game_id,
player1=test_user,
player2=opponent_user,
)
mock_game_service.get_game_state = AsyncMock(return_value=game_state)
mock_state_manager.get_player_active_games = AsyncMock(return_value=[active_game])
response = client.get(f"/api/games/{game_id}", headers=auth_headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_game_over"] is True
assert data["winner_id"] == str(test_user.id)
assert data["end_reason"] == "prizes_taken"
def test_requires_authentication(self, unauthenticated_client: TestClient):
"""
Test that endpoint requires authentication.
Unauthenticated requests should return 401.
"""
response = unauthenticated_client.get(f"/api/games/{uuid4()}")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# =============================================================================
# POST /games/{game_id}/resign Tests
# =============================================================================
class TestResignGame:
"""Tests for POST /api/games/{game_id}/resign endpoint."""
def test_resigns_successfully(
self,
client: TestClient,
auth_headers,
mock_game_service,
):
"""
Test that endpoint allows player to resign from game.
Successful resignation should return confirmation.
"""
game_id = str(uuid4())
mock_game_service.resign_game = AsyncMock(
return_value=GameActionResult(
success=True,
game_id=game_id,
action_type="resign",
game_over=True,
)
)
response = client.post(f"/api/games/{game_id}/resign", headers=auth_headers)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["success"] is True
assert data["game_id"] == game_id
assert "resigned" in data["message"].lower()
def test_returns_404_for_nonexistent_game(
self,
client: TestClient,
auth_headers,
mock_game_service,
):
"""
Test that endpoint returns 404 for non-existent game.
Cannot resign from a game that doesn't exist.
"""
mock_game_service.resign_game = AsyncMock(side_effect=GameNotFoundError("game-123"))
response = client.post(f"/api/games/{uuid4()}/resign", headers=auth_headers)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_returns_403_for_non_participant(
self,
client: TestClient,
auth_headers,
mock_game_service,
):
"""
Test that endpoint returns 403 if user is not a participant.
Only participants can resign from a game.
"""
game_id = str(uuid4())
mock_game_service.resign_game = AsyncMock(
side_effect=PlayerNotInGameError(game_id, "user-123")
)
response = client.post(f"/api/games/{game_id}/resign", headers=auth_headers)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_returns_400_for_ended_game(
self,
client: TestClient,
auth_headers,
mock_game_service,
):
"""
Test that endpoint returns 400 if game has already ended.
Cannot resign from a game that's already over.
"""
game_id = str(uuid4())
mock_game_service.resign_game = AsyncMock(side_effect=GameAlreadyEndedError(game_id))
response = client.post(f"/api/games/{game_id}/resign", headers=auth_headers)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "already ended" in response.json()["detail"]
def test_requires_authentication(self, unauthenticated_client: TestClient):
"""
Test that endpoint requires authentication.
Unauthenticated requests should return 401.
"""
response = unauthenticated_client.post(f"/api/games/{uuid4()}/resign")
assert response.status_code == status.HTTP_401_UNAUTHORIZED