diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 4d9992e..aa1aff9 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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)] diff --git a/backend/app/api/games.py b/backend/app/api/games.py new file mode 100644 index 0000000..74f5b9c --- /dev/null +++ b/backend/app/api/games.py @@ -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", + ) diff --git a/backend/app/main.py b/backend/app/main.py index 45f8a36..48ce2cb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/schemas/game.py b/backend/app/schemas/game.py new file mode 100644 index 0000000..d4919db --- /dev/null +++ b/backend/app/schemas/game.py @@ -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") diff --git a/backend/project_plans/PHASE_4_GAME_SERVICE.json b/backend/project_plans/PHASE_4_GAME_SERVICE.json index a7a4757..a5194c4 100644 --- a/backend/project_plans/PHASE_4_GAME_SERVICE.json +++ b/backend/project_plans/PHASE_4_GAME_SERVICE.json @@ -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", diff --git a/backend/tests/api/test_games_api.py b/backend/tests/api/test_games_api.py new file mode 100644 index 0000000..4a025fa --- /dev/null +++ b/backend/tests/api/test_games_api.py @@ -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