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.card_service import CardService, get_card_service
|
||||||
from app.services.collection_service import CollectionService
|
from app.services.collection_service import CollectionService
|
||||||
from app.services.deck_service import DeckService
|
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.jwt_service import verify_access_token
|
||||||
from app.services.user_service import user_service
|
from app.services.user_service import user_service
|
||||||
|
|
||||||
@ -294,6 +296,43 @@ def get_card_service_dep() -> CardService:
|
|||||||
return get_card_service()
|
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
|
# Type Aliases for Cleaner Endpoint Signatures
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -310,6 +349,8 @@ DbSession = Annotated[AsyncSession, Depends(get_db)]
|
|||||||
DeckServiceDep = Annotated[DeckService, Depends(get_deck_service)]
|
DeckServiceDep = Annotated[DeckService, Depends(get_deck_service)]
|
||||||
CollectionServiceDep = Annotated[CollectionService, Depends(get_collection_service)]
|
CollectionServiceDep = Annotated[CollectionService, Depends(get_collection_service)]
|
||||||
CardServiceDep = Annotated[CardService, Depends(get_card_service_dep)]
|
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
|
# Admin authentication
|
||||||
AdminAuth = Annotated[None, Depends(verify_admin_token)]
|
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.auth import router as auth_router
|
||||||
from app.api.collections import router as collections_router
|
from app.api.collections import router as collections_router
|
||||||
from app.api.decks import router as decks_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.api.users import router as users_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import close_db, init_db
|
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(users_router, prefix="/api")
|
||||||
app.include_router(collections_router, prefix="/api")
|
app.include_router(collections_router, prefix="/api")
|
||||||
app.include_router(decks_router, prefix="/api")
|
app.include_router(decks_router, prefix="/api")
|
||||||
|
app.include_router(games_router, prefix="/api")
|
||||||
|
|
||||||
# TODO: Add remaining routers in future phases
|
# 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(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"])
|
# 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",
|
"description": "Real-time gameplay infrastructure - WebSocket communication, game lifecycle management, reconnection handling, and turn timeout system",
|
||||||
"totalEstimatedHours": 45,
|
"totalEstimatedHours": 45,
|
||||||
"totalTasks": 18,
|
"totalTasks": 18,
|
||||||
"completedTasks": 12,
|
"completedTasks": 16,
|
||||||
"status": "in_progress",
|
"status": "in_progress",
|
||||||
"masterPlan": "../PROJECT_PLAN_MASTER.json"
|
"masterPlan": "../PROJECT_PLAN_MASTER.json"
|
||||||
},
|
},
|
||||||
@ -371,22 +371,24 @@
|
|||||||
"description": "Manage turn time limits with warnings and automatic actions",
|
"description": "Manage turn time limits with warnings and automatic actions",
|
||||||
"category": "services",
|
"category": "services",
|
||||||
"priority": 12,
|
"priority": 12,
|
||||||
"completed": false,
|
"completed": true,
|
||||||
"tested": false,
|
"tested": true,
|
||||||
"dependencies": ["GS-003", "WS-006"],
|
"dependencies": ["GS-003", "WS-006"],
|
||||||
"files": [
|
"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": [
|
"details": [
|
||||||
"Store turn deadline in Redis: turn_timeout:{game_id} -> deadline_timestamp",
|
"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",
|
"Methods: start_turn_timer, cancel_timer, get_remaining_time, extend_timer, get_pending_warning, mark_warning_sent, check_expired_timers",
|
||||||
"Background task checks for expired timers (polling or Redis keyspace notifications)",
|
"Polling approach for timer checking (background task calls check_expired_timers)",
|
||||||
"When timer expires: emit warning at 30s, auto-pass or loss at 0s",
|
"Percentage-based warnings configurable via turn_timer_warning_thresholds (default [50, 25])",
|
||||||
"Configurable timeout per game type (campaign more lenient)",
|
"Grace period on reconnect via turn_timer_grace_seconds (default 15)",
|
||||||
"Grace period on reconnect (15s extension)"
|
"35 unit tests with full coverage"
|
||||||
],
|
],
|
||||||
"estimatedHours": 4,
|
"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",
|
"id": "TO-002",
|
||||||
@ -394,23 +396,25 @@
|
|||||||
"description": "Start/stop turn timers on turn boundaries",
|
"description": "Start/stop turn timers on turn boundaries",
|
||||||
"category": "integration",
|
"category": "integration",
|
||||||
"priority": 13,
|
"priority": 13,
|
||||||
"completed": false,
|
"completed": true,
|
||||||
"tested": false,
|
"tested": true,
|
||||||
"dependencies": ["TO-001", "GS-003"],
|
"dependencies": ["TO-001", "GS-003"],
|
||||||
"files": [
|
"files": [
|
||||||
{"path": "app/services/game_service.py", "status": "modify"},
|
{"path": "app/services/game_service.py", "status": "modified"},
|
||||||
{"path": "app/socketio/game_namespace.py", "status": "modify"}
|
{"path": "tests/unit/services/test_game_service.py", "status": "modified"}
|
||||||
],
|
],
|
||||||
"details": [
|
"details": [
|
||||||
"Start timer after turn_start in execute_action",
|
"Timer starts when SETUP phase ends (first real turn begins), not at game creation",
|
||||||
"Cancel timer when action ends turn",
|
"Timer starts on turn change (player switches turns)",
|
||||||
"Extend timer on reconnect",
|
"Timer canceled when game ends (win_result received)",
|
||||||
"Handle timeout: auto-pass or declare loss based on config",
|
"Extend timer on reconnect (via join_game grace period)",
|
||||||
"Emit turn_timeout warning to current player",
|
"handle_timeout method for timeout handling (declares loss)",
|
||||||
"Update ActiveGame.turn_deadline for persistence"
|
"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,
|
"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",
|
"id": "RC-001",
|
||||||
@ -418,24 +422,27 @@
|
|||||||
"description": "Handle client reconnection to ongoing games",
|
"description": "Handle client reconnection to ongoing games",
|
||||||
"category": "reconnection",
|
"category": "reconnection",
|
||||||
"priority": 14,
|
"priority": 14,
|
||||||
"completed": false,
|
"completed": true,
|
||||||
"tested": false,
|
"tested": true,
|
||||||
"dependencies": ["WS-005", "GS-004"],
|
"dependencies": ["WS-005", "GS-004"],
|
||||||
"files": [
|
"files": [
|
||||||
{"path": "app/socketio/game_namespace.py", "status": "modify"},
|
{"path": "app/socketio/game_namespace.py", "status": "modified", "note": "Added handle_reconnect method for auto-rejoin"},
|
||||||
{"path": "app/services/connection_manager.py", "status": "modify"}
|
{"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": [
|
"details": [
|
||||||
"On connect: Check for active game via ConnectionManager/GameStateManager",
|
"On connect: Check for active game via GameStateManager.get_player_active_games()",
|
||||||
"Auto-rejoin game room if active game exists",
|
"Auto-rejoin game room if active game exists (game:reconnected event)",
|
||||||
"Send full game state to reconnecting player",
|
"Send full game state to reconnecting player",
|
||||||
"Include pending actions (forced_actions queue)",
|
"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",
|
"Notify opponent of reconnection",
|
||||||
"Handle rapid disconnect/reconnect (debounce notifications)"
|
"Handle rapid disconnect/reconnect (debounce notifications documented as TODO)"
|
||||||
],
|
],
|
||||||
"estimatedHours": 3,
|
"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",
|
"id": "API-001",
|
||||||
@ -443,12 +450,15 @@
|
|||||||
"description": "HTTP endpoints for game creation and status checks",
|
"description": "HTTP endpoints for game creation and status checks",
|
||||||
"category": "api",
|
"category": "api",
|
||||||
"priority": 15,
|
"priority": 15,
|
||||||
"completed": false,
|
"completed": true,
|
||||||
"tested": false,
|
"tested": true,
|
||||||
"dependencies": ["GS-002", "GS-004"],
|
"dependencies": ["GS-002", "GS-004"],
|
||||||
"files": [
|
"files": [
|
||||||
{"path": "app/api/games.py", "status": "create"},
|
{"path": "app/api/games.py", "status": "created"},
|
||||||
{"path": "app/main.py", "status": "modify"}
|
{"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": [
|
"details": [
|
||||||
"POST /games - Create new game (returns game_id, connect via WebSocket)",
|
"POST /games - Create new game (returns game_id, connect via WebSocket)",
|
||||||
@ -456,10 +466,10 @@
|
|||||||
"GET /games/me/active - List user's active games",
|
"GET /games/me/active - List user's active games",
|
||||||
"POST /games/{game_id}/resign - Resign via HTTP (backup to WS)",
|
"POST /games/{game_id}/resign - Resign via HTTP (backup to WS)",
|
||||||
"Authentication required for all endpoints",
|
"Authentication required for all endpoints",
|
||||||
"Rate limiting on game creation"
|
"Rate limiting on game creation - deferred to production hardening"
|
||||||
],
|
],
|
||||||
"estimatedHours": 3,
|
"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",
|
"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