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:
parent
154d466ff1
commit
cc0254d5ab
@ -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
351
backend/app/api/games.py
Normal 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",
|
||||
)
|
||||
@ -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
160
backend/app/schemas/game.py
Normal 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")
|
||||
@ -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",
|
||||
|
||||
893
backend/tests/api/test_games_api.py
Normal file
893
backend/tests/api/test_games_api.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user