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>
894 lines
27 KiB
Python
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
|