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>
This commit is contained in:
Cal Corum 2026-01-29 23:09:12 -06:00
parent 154d466ff1
commit cc0254d5ab
6 changed files with 1495 additions and 39 deletions

View File

@ -38,6 +38,8 @@ from app.repositories.postgres.deck import PostgresDeckRepository
from app.services.card_service import CardService, get_card_service
from app.services.collection_service import CollectionService
from app.services.deck_service import DeckService
from app.services.game_service import GameService, game_service
from app.services.game_state_manager import GameStateManager, game_state_manager
from app.services.jwt_service import verify_access_token
from app.services.user_service import user_service
@ -294,6 +296,43 @@ def get_card_service_dep() -> CardService:
return get_card_service()
def get_game_service_dep() -> GameService:
"""Get the GameService singleton.
GameService orchestrates game lifecycle operations between
WebSocket/REST layers and the core GameEngine.
Returns:
The GameService singleton instance.
Example:
@router.post("/games")
async def create_game(
game_service: GameService = Depends(get_game_service_dep),
):
result = await game_service.create_game(...)
"""
return game_service
def get_game_state_manager_dep() -> GameStateManager:
"""Get the GameStateManager singleton.
GameStateManager handles game state persistence across Redis and Postgres.
Returns:
The GameStateManager singleton instance.
Example:
@router.get("/games/me/active")
async def list_active_games(
state_manager: GameStateManager = Depends(get_game_state_manager_dep),
):
games = await state_manager.get_player_active_games(user_id)
"""
return game_state_manager
# =============================================================================
# Type Aliases for Cleaner Endpoint Signatures
# =============================================================================
@ -310,6 +349,8 @@ DbSession = Annotated[AsyncSession, Depends(get_db)]
DeckServiceDep = Annotated[DeckService, Depends(get_deck_service)]
CollectionServiceDep = Annotated[CollectionService, Depends(get_collection_service)]
CardServiceDep = Annotated[CardService, Depends(get_card_service_dep)]
GameServiceDep = Annotated[GameService, Depends(get_game_service_dep)]
GameStateManagerDep = Annotated[GameStateManager, Depends(get_game_state_manager_dep)]
# Admin authentication
AdminAuth = Annotated[None, Depends(verify_admin_token)]

351
backend/app/api/games.py Normal file
View File

@ -0,0 +1,351 @@
"""Games API router for Mantimon TCG.
This module provides REST endpoints for game management including
creating, retrieving, listing, and resigning from games.
WebSocket is the primary channel for real-time gameplay. These REST endpoints
handle game lifecycle operations that don't require real-time communication.
Endpoints:
POST /games - Create a new game between two players
GET /games/{game_id} - Get game info (for reconnection checks)
GET /games/me/active - List user's active games
POST /games/{game_id}/resign - Resign from a game via HTTP
"""
import logging
from fastapi import APIRouter, HTTPException, Request, status
from app.api.deps import (
CurrentUser,
DbSession,
DeckServiceDep,
GameServiceDep,
GameStateManagerDep,
)
from app.config import settings
from app.core.models.game_state import GameState
from app.schemas.game import (
ActiveGameListResponse,
ActiveGameSummary,
GameCreateRequest,
GameCreateResponse,
GameInfoResponse,
GameResignResponse,
)
from app.services.game_service import (
GameAlreadyEndedError,
GameNotFoundError,
PlayerNotInGameError,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/games", tags=["games"])
def _build_ws_url(request: Request) -> str:
"""Build WebSocket URL based on request.
Constructs the WebSocket URL for game connections. Handles reverse proxy
scenarios by checking X-Forwarded-* headers, falling back to settings.base_url
in production, or the raw request headers in development.
Header priority:
1. X-Forwarded-Host + X-Forwarded-Proto (behind reverse proxy)
2. settings.base_url (configured server URL)
3. Request Host header (direct connection, development)
Args:
request: The incoming HTTP request.
Returns:
WebSocket URL for game connections (ws:// or wss://).
"""
# Check for reverse proxy headers first (most reliable in production)
forwarded_host = request.headers.get("x-forwarded-host")
forwarded_proto = request.headers.get("x-forwarded-proto", "https")
if forwarded_host:
# Behind a reverse proxy - trust forwarded headers
scheme = "wss" if forwarded_proto == "https" else "ws"
return f"{scheme}://{forwarded_host}/socket.io/"
# Fall back to configured base_url (recommended for production)
if settings.base_url and not settings.is_development:
base = settings.base_url.rstrip("/")
# Convert http(s) to ws(s)
if base.startswith("https://"):
return base.replace("https://", "wss://") + "/socket.io/"
elif base.startswith("http://"):
return base.replace("http://", "ws://") + "/socket.io/"
# Development fallback - use request headers directly
scheme = "wss" if request.url.scheme == "https" else "ws"
host = request.headers.get("host", request.url.netloc)
return f"{scheme}://{host}/socket.io/"
@router.post("", response_model=GameCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_game(
game_in: GameCreateRequest,
request: Request,
current_user: CurrentUser,
game_service: GameServiceDep,
deck_service: DeckServiceDep,
) -> GameCreateResponse:
"""Create a new game between two players.
Creates a game using the specified decks and rules. Both players
should then connect via WebSocket to play.
Currently supports direct opponent specification for testing.
Future phases will add matchmaking and invite systems.
Args:
game_in: Game creation parameters (decks, opponent, rules).
request: HTTP request for building WebSocket URL.
current_user: Authenticated user creating the game.
game_service: GameService for game creation.
deck_service: DeckService for deck loading.
Returns:
GameCreateResponse with game_id and WebSocket URL.
Raises:
400: If deck validation fails or game creation error.
404: If decks not found.
"""
try:
result = await game_service.create_game(
player1_id=current_user.id,
player2_id=game_in.opponent_id,
deck1_id=game_in.deck_id,
deck2_id=game_in.opponent_deck_id,
rules_config=game_in.rules_config,
deck_service=deck_service,
game_type=game_in.game_type,
)
except Exception as e:
logger.error(f"Failed to create game: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to create game: {str(e)}",
) from e
if not result.success or result.game_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.message or "Failed to create game",
)
ws_url = _build_ws_url(request)
logger.info(
f"Game created: {result.game_id}, " f"players={current_user.id} vs {game_in.opponent_id}"
)
return GameCreateResponse(
game_id=result.game_id,
ws_url=ws_url,
starting_player_id=result.starting_player_id or "",
message="Game created successfully",
)
@router.get("/me/active", response_model=ActiveGameListResponse)
async def list_active_games(
current_user: CurrentUser,
state_manager: GameStateManagerDep,
db: DbSession,
) -> ActiveGameListResponse:
"""List the current user's active games.
Returns a summary of all ongoing games where the user is a participant.
Useful for the game lobby or reconnection UI.
Args:
current_user: Authenticated user.
state_manager: GameStateManager for querying active games.
db: Database session.
Returns:
ActiveGameListResponse with list of active game summaries.
"""
active_games = await state_manager.get_player_active_games(current_user.id, session=db)
game_summaries: list[ActiveGameSummary] = []
for game in active_games:
# Determine opponent name
if game.npc_id:
opponent_name = game.npc_id # TODO: Look up NPC display name in Phase 5
elif game.player2_id:
# Get opponent user for display name
if game.player1_id == current_user.id and game.player2:
opponent_name = game.player2.display_name or "Opponent"
elif game.player1:
opponent_name = game.player1.display_name or "Opponent"
else:
opponent_name = "Unknown"
else:
opponent_name = "Unknown"
# Determine if it's user's turn by loading game state from cache
is_your_turn = False
turn_number = game.turn_number
try:
cached_state = await state_manager.load_from_cache(str(game.id))
if cached_state:
is_your_turn = cached_state.current_player_id == str(current_user.id)
turn_number = cached_state.turn_number
except Exception as e:
# Fall back to DB turn info - log for visibility
logger.debug(f"Cache lookup failed for game {game.id}, using DB values: {e}")
game_summaries.append(
ActiveGameSummary(
game_id=str(game.id),
game_type=game.game_type,
opponent_name=opponent_name,
is_your_turn=is_your_turn,
turn_number=turn_number,
started_at=game.started_at,
last_action_at=game.last_action_at,
)
)
return ActiveGameListResponse(
games=game_summaries,
total=len(game_summaries),
)
@router.get("/{game_id}", response_model=GameInfoResponse)
async def get_game_info(
game_id: str,
current_user: CurrentUser,
game_service: GameServiceDep,
state_manager: GameStateManagerDep,
db: DbSession,
) -> GameInfoResponse:
"""Get information about a specific game.
Returns game metadata without full state (use WebSocket for live state).
Useful for reconnection checks and game status display.
Args:
game_id: Unique game identifier.
current_user: Authenticated user.
game_service: GameService for state access.
state_manager: GameStateManager for active game lookup.
db: Database session.
Returns:
GameInfoResponse with game metadata.
Raises:
404: If game not found.
403: If user is not a participant.
"""
# Try to get game state (checks cache first, then DB)
try:
state: GameState = await game_service.get_game_state(game_id)
except GameNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Game not found",
) from None
# Verify user is a participant
user_id_str = str(current_user.id)
if user_id_str not in state.players:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant in this game",
)
# Get additional metadata from ActiveGame record (required for timestamps/game_type)
active_games = await state_manager.get_player_active_games(current_user.id, session=db)
active_game = next((g for g in active_games if str(g.id) == game_id), None)
if active_game is None:
# Game exists in cache/state but not in ActiveGame table
# This shouldn't happen in normal operation, but handle gracefully
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Game not found in active games",
)
return GameInfoResponse(
game_id=state.game_id,
game_type=active_game.game_type,
player1_id=active_game.player1_id,
player2_id=active_game.player2_id,
npc_id=active_game.npc_id,
current_player_id=state.current_player_id,
turn_number=state.turn_number,
phase=state.phase,
is_your_turn=state.current_player_id == user_id_str,
is_game_over=state.winner_id is not None or state.end_reason is not None,
winner_id=state.winner_id,
end_reason=state.end_reason,
started_at=active_game.started_at,
last_action_at=active_game.last_action_at,
)
@router.post("/{game_id}/resign", response_model=GameResignResponse)
async def resign_game(
game_id: str,
current_user: CurrentUser,
game_service: GameServiceDep,
) -> GameResignResponse:
"""Resign from a game via HTTP.
This is a backup for WebSocket resignation. Preferred method is
via WebSocket game:resign event for immediate opponent notification.
Args:
game_id: Unique game identifier.
current_user: Authenticated user resigning.
game_service: GameService for resignation.
Returns:
GameResignResponse with success status.
Raises:
404: If game not found.
403: If user is not a participant.
400: If game has already ended.
"""
user_id_str = str(current_user.id)
try:
result = await game_service.resign_game(
game_id=game_id,
player_id=user_id_str,
)
except GameNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Game not found",
) from None
except PlayerNotInGameError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant in this game",
) from None
except GameAlreadyEndedError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Game has already ended",
) from None
logger.info(f"Player {current_user.id} resigned from game {game_id} via HTTP")
return GameResignResponse(
success=result.success,
game_id=game_id,
message="You have resigned from the game",
)

View File

@ -21,6 +21,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.auth import router as auth_router
from app.api.collections import router as collections_router
from app.api.decks import router as decks_router
from app.api.games import router as games_router
from app.api.users import router as users_router
from app.config import settings
from app.db import close_db, init_db
@ -168,11 +169,11 @@ app.include_router(auth_router, prefix="/api")
app.include_router(users_router, prefix="/api")
app.include_router(collections_router, prefix="/api")
app.include_router(decks_router, prefix="/api")
app.include_router(games_router, prefix="/api")
# TODO: Add remaining routers in future phases
# from app.api import cards, games, campaign
# from app.api import cards, campaign
# app.include_router(cards.router, prefix="/api/cards", tags=["cards"])
# app.include_router(games.router, prefix="/api/games", tags=["games"])
# app.include_router(campaign.router, prefix="/api/campaign", tags=["campaign"])

160
backend/app/schemas/game.py Normal file
View File

@ -0,0 +1,160 @@
"""Game schemas for Mantimon TCG.
This module defines Pydantic models for game-related API requests
and responses. Games are real-time matches between players.
The backend is stateless - game rules come from the request via RulesConfig.
Frontend provides the rules appropriate for the game mode (campaign, freeplay, custom).
Example:
game = GameInfoResponse(
game_id="abc-123",
game_type=GameType.FREEPLAY,
player1_id=uuid4(),
player2_id=uuid4(),
current_player_id="player1-id",
turn_number=5,
phase=TurnPhase.MAIN,
is_your_turn=True,
started_at=datetime.now(UTC),
)
"""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
from app.core.config import RulesConfig
from app.core.enums import GameEndReason, TurnPhase
from app.db.models.game import GameType
class GameCreateRequest(BaseModel):
"""Request model for creating a new game.
For multiplayer games, opponent_id should be provided (Phase 6 matchmaking).
For campaign games, npc_id should be provided (Phase 5).
Currently supports direct opponent specification for testing.
Attributes:
deck_id: ID of the deck to use for this game.
opponent_id: UUID of the opponent player (for PvP games).
opponent_deck_id: UUID of opponent's deck (required if opponent_id provided).
rules_config: Game rules from the frontend (defaults to standard rules).
game_type: Type of game (freeplay, ranked, etc.).
"""
deck_id: UUID = Field(..., description="ID of deck to use")
opponent_id: UUID = Field(..., description="Opponent player ID")
opponent_deck_id: UUID = Field(..., description="Opponent's deck ID")
rules_config: RulesConfig = Field(
default_factory=RulesConfig, description="Game rules from frontend"
)
game_type: GameType = Field(default=GameType.FREEPLAY, description="Type of game")
class GameCreateResponse(BaseModel):
"""Response model for game creation.
Attributes:
game_id: Unique game identifier.
ws_url: WebSocket URL to connect for gameplay.
starting_player_id: ID of the player who goes first.
message: Status message.
"""
game_id: str = Field(..., description="Unique game identifier")
ws_url: str = Field(..., description="WebSocket URL for gameplay")
starting_player_id: str = Field(..., description="Player who goes first")
message: str = Field(default="Game created successfully", description="Status message")
class GameInfoResponse(BaseModel):
"""Response model for game info.
Provides metadata about a game without full state (use WebSocket for state).
Attributes:
game_id: Unique game identifier.
game_type: Type of game (campaign, freeplay, ranked).
player1_id: First player's user ID.
player2_id: Second player's user ID (None for campaign).
npc_id: NPC opponent ID for campaign games.
current_player_id: ID of player whose turn it is.
turn_number: Current turn number.
phase: Current turn phase.
is_your_turn: Whether it's the requesting user's turn.
is_game_over: Whether the game has ended.
winner_id: Winner's ID if game ended (None for ongoing or draw).
end_reason: Reason game ended, if applicable.
started_at: When the game started.
last_action_at: Timestamp of last action.
"""
game_id: str = Field(..., description="Unique game identifier")
game_type: GameType = Field(..., description="Type of game")
player1_id: UUID = Field(..., description="First player's user ID")
player2_id: UUID | None = Field(default=None, description="Second player's user ID")
npc_id: str | None = Field(default=None, description="NPC opponent ID")
current_player_id: str | None = Field(default=None, description="Current player's turn")
turn_number: int = Field(default=0, description="Current turn number")
phase: TurnPhase | None = Field(default=None, description="Current turn phase")
is_your_turn: bool = Field(default=False, description="Is it your turn")
is_game_over: bool = Field(default=False, description="Has game ended")
winner_id: str | None = Field(default=None, description="Winner's ID if ended")
end_reason: GameEndReason | None = Field(default=None, description="Why game ended")
started_at: datetime = Field(..., description="Game start time")
last_action_at: datetime = Field(..., description="Last action time")
model_config = {"from_attributes": True}
class ActiveGameSummary(BaseModel):
"""Summary of an active game for list endpoints.
Lighter-weight than GameInfoResponse for list views.
Attributes:
game_id: Unique game identifier.
game_type: Type of game.
opponent_name: Opponent's display name or NPC name.
is_your_turn: Whether it's your turn.
turn_number: Current turn number.
started_at: When the game started.
last_action_at: Timestamp of last action.
"""
game_id: str = Field(..., description="Unique game identifier")
game_type: GameType = Field(..., description="Type of game")
opponent_name: str = Field(..., description="Opponent's name")
is_your_turn: bool = Field(default=False, description="Is it your turn")
turn_number: int = Field(default=0, description="Current turn number")
started_at: datetime = Field(..., description="Game start time")
last_action_at: datetime = Field(..., description="Last action time")
class ActiveGameListResponse(BaseModel):
"""Response model for listing user's active games.
Attributes:
games: List of active game summaries.
total: Total number of active games.
"""
games: list[ActiveGameSummary] = Field(default_factory=list, description="Active games")
total: int = Field(default=0, ge=0, description="Total active games")
class GameResignResponse(BaseModel):
"""Response model for game resignation.
Attributes:
success: Whether resignation was successful.
game_id: The game that was resigned from.
message: Status message.
"""
success: bool = Field(..., description="Was resignation successful")
game_id: str = Field(..., description="Game ID")
message: str = Field(default="", description="Status message")

View File

@ -9,7 +9,7 @@
"description": "Real-time gameplay infrastructure - WebSocket communication, game lifecycle management, reconnection handling, and turn timeout system",
"totalEstimatedHours": 45,
"totalTasks": 18,
"completedTasks": 12,
"completedTasks": 16,
"status": "in_progress",
"masterPlan": "../PROJECT_PLAN_MASTER.json"
},
@ -371,22 +371,24 @@
"description": "Manage turn time limits with warnings and automatic actions",
"category": "services",
"priority": 12,
"completed": false,
"tested": false,
"completed": true,
"tested": true,
"dependencies": ["GS-003", "WS-006"],
"files": [
{"path": "app/services/turn_timeout_service.py", "status": "create"}
{"path": "app/services/turn_timeout_service.py", "status": "created"},
{"path": "app/core/config.py", "status": "modified", "note": "Added turn_timer_warning_thresholds and turn_timer_grace_seconds to WinConditionsConfig"},
{"path": "tests/unit/services/test_turn_timeout_service.py", "status": "created"}
],
"details": [
"Store turn deadline in Redis: turn_timeout:{game_id} -> deadline_timestamp",
"Methods: start_turn_timer, cancel_timer, get_remaining_time, extend_timer",
"Background task checks for expired timers (polling or Redis keyspace notifications)",
"When timer expires: emit warning at 30s, auto-pass or loss at 0s",
"Configurable timeout per game type (campaign more lenient)",
"Grace period on reconnect (15s extension)"
"Store turn deadline in Redis: turn_timeout:{game_id} -> hash with deadline, player_id, timeout_seconds, warnings_sent, warning_thresholds",
"Methods: start_turn_timer, cancel_timer, get_remaining_time, extend_timer, get_pending_warning, mark_warning_sent, check_expired_timers",
"Polling approach for timer checking (background task calls check_expired_timers)",
"Percentage-based warnings configurable via turn_timer_warning_thresholds (default [50, 25])",
"Grace period on reconnect via turn_timer_grace_seconds (default 15)",
"35 unit tests with full coverage"
],
"estimatedHours": 4,
"notes": "Consider Redis EXPIRE with keyspace notification for efficiency"
"notes": "Used polling approach instead of keyspace notifications for simplicity. Warnings are percentage-based for better scaling across different timeout durations."
},
{
"id": "TO-002",
@ -394,23 +396,25 @@
"description": "Start/stop turn timers on turn boundaries",
"category": "integration",
"priority": 13,
"completed": false,
"tested": false,
"completed": true,
"tested": true,
"dependencies": ["TO-001", "GS-003"],
"files": [
{"path": "app/services/game_service.py", "status": "modify"},
{"path": "app/socketio/game_namespace.py", "status": "modify"}
{"path": "app/services/game_service.py", "status": "modified"},
{"path": "tests/unit/services/test_game_service.py", "status": "modified"}
],
"details": [
"Start timer after turn_start in execute_action",
"Cancel timer when action ends turn",
"Extend timer on reconnect",
"Handle timeout: auto-pass or declare loss based on config",
"Emit turn_timeout warning to current player",
"Update ActiveGame.turn_deadline for persistence"
"Timer starts when SETUP phase ends (first real turn begins), not at game creation",
"Timer starts on turn change (player switches turns)",
"Timer canceled when game ends (win_result received)",
"Extend timer on reconnect (via join_game grace period)",
"handle_timeout method for timeout handling (declares loss)",
"GameActionResult and GameJoinResult include turn_timeout_seconds and turn_deadline",
"5 new integration tests in TestTurnTimerIntegration class",
"Mock timeout service added to test fixtures for DI pattern"
],
"estimatedHours": 2,
"notes": "Timeout should trigger auto-pass first, loss only after N timeouts"
"notes": "Timer deliberately NOT started during SETUP phase - only starts when first real turn begins (SETUP -> DRAW/MAIN transition)"
},
{
"id": "RC-001",
@ -418,24 +422,27 @@
"description": "Handle client reconnection to ongoing games",
"category": "reconnection",
"priority": 14,
"completed": false,
"tested": false,
"completed": true,
"tested": true,
"dependencies": ["WS-005", "GS-004"],
"files": [
{"path": "app/socketio/game_namespace.py", "status": "modify"},
{"path": "app/services/connection_manager.py", "status": "modify"}
{"path": "app/socketio/game_namespace.py", "status": "modified", "note": "Added handle_reconnect method for auto-rejoin"},
{"path": "app/socketio/server.py", "status": "modified", "note": "Connect event now calls handle_reconnect and emits game:reconnected"},
{"path": "app/services/connection_manager.py", "status": "modified", "note": "Added get_user_active_game method"},
{"path": "tests/unit/socketio/test_game_namespace.py", "status": "modified", "note": "Added 9 tests for TestHandleReconnect"},
{"path": "tests/unit/services/test_connection_manager.py", "status": "modified", "note": "Added 4 tests for get_user_active_game"}
],
"details": [
"On connect: Check for active game via ConnectionManager/GameStateManager",
"Auto-rejoin game room if active game exists",
"On connect: Check for active game via GameStateManager.get_player_active_games()",
"Auto-rejoin game room if active game exists (game:reconnected event)",
"Send full game state to reconnecting player",
"Include pending actions (forced_actions queue)",
"Extend turn timer by grace period",
"Extend turn timer by grace period (via GameService.join_game)",
"Notify opponent of reconnection",
"Handle rapid disconnect/reconnect (debounce notifications)"
"Handle rapid disconnect/reconnect (debounce notifications documented as TODO)"
],
"estimatedHours": 3,
"notes": "Client should store game_id locally for quick resume"
"notes": "Debounce for rapid reconnect noted as future enhancement. Client should store game_id locally for quick resume."
},
{
"id": "API-001",
@ -443,12 +450,15 @@
"description": "HTTP endpoints for game creation and status checks",
"category": "api",
"priority": 15,
"completed": false,
"tested": false,
"completed": true,
"tested": true,
"dependencies": ["GS-002", "GS-004"],
"files": [
{"path": "app/api/games.py", "status": "create"},
{"path": "app/main.py", "status": "modify"}
{"path": "app/api/games.py", "status": "created"},
{"path": "app/api/deps.py", "status": "modified", "note": "Added GameServiceDep and GameStateManagerDep"},
{"path": "app/schemas/game.py", "status": "created"},
{"path": "app/main.py", "status": "modified"},
{"path": "tests/api/test_games_api.py", "status": "created", "note": "21 unit tests"}
],
"details": [
"POST /games - Create new game (returns game_id, connect via WebSocket)",
@ -456,10 +466,10 @@
"GET /games/me/active - List user's active games",
"POST /games/{game_id}/resign - Resign via HTTP (backup to WS)",
"Authentication required for all endpoints",
"Rate limiting on game creation"
"Rate limiting on game creation - deferred to production hardening"
],
"estimatedHours": 3,
"notes": "WebSocket is primary for gameplay, REST for management"
"notes": "WebSocket is primary for gameplay, REST for management. 21 unit tests with full coverage of happy paths and error cases."
},
{
"id": "TEST-001",

View File

@ -0,0 +1,893 @@
"""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