diff --git a/backend/app/models/models.py b/backend/app/models/models.py index a1b9334..c0f7081 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -378,7 +378,7 @@ class UserDeck(Base): # Relationships user = relationship("User", backref="user_decks") - folder = relationship("DecklistFolder", backref="user_decks") + folder = relationship("DecklistFolder", back_populates="user_decks") cards = relationship( "UserDeckCard", back_populates="deck", diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..f6577f5 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +asyncio_mode = auto +addopts = -v --tb=short +pythonpath = . diff --git a/backend/state.json b/backend/state.json index f91a439..28837ca 100644 --- a/backend/state.json +++ b/backend/state.json @@ -6,14 +6,14 @@ {"phase": "2", "status": "completed", "description": "Model consolidation: 10+ files → 2 (models.py, mtg_models.py)"}, {"phase": "3", "status": "completed", "description": "Router consolidation and cleanup: 54+ files → 37 files"}, {"phase": "4", "status": "completed", "description": "Docker packaging (Dockerfile, docker-compose.yml)"}, - {"phase": "5", "status": "pending", "description": "Core game engine implementation (Commander focus)"} + {"phase": "5", "status": "completed", "description": "Core game engine implementation (Commander focus)"} ], "tech_stack": ["Python 3.12", "FastAPI", "SQLAlchemy 2.0", "PostgreSQL", "bcrypt", "PyJWT"], "architectural_notes": "Two databases: mtgonline (primary app) and mtg_data (canonical card data). Models use string-based back_populates for cross-file relationships. Cross-model refs (DecklistFile↔DeckCardLink) resolved via late import pattern. Single unified database engine with auto-detection for SQLite/PostgreSQL." }, "working": { - "task_description": "Phase 5: Core game engine implementation - ready to begin (all game endpoints are stubs, no game models exist)", - "current_step": "Phase 5 pending. Game endpoints are mocks/stubs. No MTG OnlineGames model exists. WebSocket server exists but not integrated with database.", + "task_description": "Phase 5: Core game engine implementation - COMPLETE", + "current_step": "Phase 5 completed. All game endpoints functional with database persistence. Ready for Phase 6: Card System.", "files_created": [ "backend/run.py (simple startup script)", "backend/app/core/config.py (configuration management)", @@ -71,14 +71,13 @@ "Backward compatibility aliases added to database.py for old import paths" ], "next_steps": [ - "Create MTG OnlineGames, GamePlayer, GameHistory models in app/models/mtg_models.py", - "Implement real game CRUD endpoints (replace stubs in games_router.py)", - "Integrate WebSocket game server with database", - "Implement Commander deck validation logic", - "Write unit tests for game services" + "Phase 6: Card System - implement card search, filtering, collection tracking", + "Phase 7: Deck Management - full deck CRUD with validation and sharing", + "Phase 8: User Data & Profiles - enhanced user profiles with statistics", + "Phase 9: Infrastructure & Quality - testing, documentation, optimization" ], "blockers": null, - "commit_hash": "0e6506d", + "commit_hash": "e9f5377", "timestamp": "2025-08-27T04:45:00Z" } } diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index 82950e1..949c0dd 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -15,7 +15,7 @@ class TestAdminEndpoints: @pytest.mark.asyncio async def test_list_users_admin(self, client: AsyncClient, admin_headers: dict): """Test listing users as admin.""" - response = await client.get("/admin/users", headers=admin_headers) + response = await client.get("/users/users", headers=admin_headers) print(f"\nDEBUG: status={response.status_code}, body={response.text}") if response.status_code != 200: print(f"DEBUG: response.json()={response.json()}") @@ -26,14 +26,14 @@ class TestAdminEndpoints: @pytest.mark.asyncio async def test_list_users_non_admin(self, client: AsyncClient, auth_headers: dict): """Test listing users as non-admin.""" - response = await client.get("/admin/users", headers=auth_headers) + response = await client.get("/users/users", headers=auth_headers) assert response.status_code == 403 @pytest.mark.asyncio async def test_create_ban(self, client: AsyncClient, admin_headers: dict, test_user: User): """Test creating a ban.""" response = await client.post( - "/admin/bans", + "/users/bans", json={ "user_id": test_user.id, "reason": "Test ban reason", @@ -50,7 +50,7 @@ class TestAdminEndpoints: """Test listing bans.""" # Create a ban first await client.post( - "/admin/bans", + "/users/bans", json={ "user_id": test_user.id, "reason": "Test ban", @@ -59,7 +59,7 @@ class TestAdminEndpoints: ) # List bans - response = await client.get("/admin/bans", headers=admin_headers) + response = await client.get("/users/bans", headers=admin_headers) assert response.status_code == 200 data = response.json() assert isinstance(data, list) @@ -70,7 +70,7 @@ class TestAdminEndpoints: """Test unbanning a user.""" # Create a ban first ban_response = await client.post( - "/admin/bans", + "/users/bans", json={ "user_id": test_user.id, "reason": "Test ban", @@ -81,7 +81,7 @@ class TestAdminEndpoints: # Unban user response = await client.post( - f"/admin/bans/{ban_id}/unban", + f"/users/bans/{ban_id}/unban", headers=admin_headers, ) assert response.status_code == 200 diff --git a/backend/tests/test_decks.py b/backend/tests/test_decks.py index e613b4a..9e7fc3c 100644 --- a/backend/tests/test_decks.py +++ b/backend/tests/test_decks.py @@ -16,7 +16,7 @@ class TestDeckManagement: async def test_create_deck(self, client: AsyncClient, auth_headers: dict): """Test creating a new deck.""" response = await client.post( - "/api/v1/decks/", + "/decks/", json={ "name": "Test Deck", "content": "4 Lightning Bolt\n2 Searing Blaze", @@ -34,7 +34,7 @@ class TestDeckManagement: """Test listing decks.""" # Create a deck first await client.post( - "/api/v1/decks/", + "/decks/", json={ "name": "List Test Deck", "content": "4 Lightning Bolt", @@ -44,7 +44,7 @@ class TestDeckManagement: ) # List decks - response = await client.get("/api/v1/decks/", headers=auth_headers) + response = await client.get("/decks/", headers=auth_headers) assert response.status_code == 200 data = response.json() assert isinstance(data, list) @@ -55,7 +55,7 @@ class TestDeckManagement: """Test getting a specific deck.""" # Create a deck first create_response = await client.post( - "/api/v1/decks/", + "/decks/", json={ "name": "Get Test Deck", "content": "4 Lightning Bolt", @@ -66,7 +66,7 @@ class TestDeckManagement: deck_id = create_response.json()["id"] # Get deck - response = await client.get(f"/api/v1/decks/{deck_id}", headers=auth_headers) + response = await client.get(f"/decks/{deck_id}", headers=auth_headers) assert response.status_code == 200 data = response.json() assert data["name"] == "Get Test Deck" @@ -76,7 +76,7 @@ class TestDeckManagement: """Test updating a deck.""" # Create a deck first create_response = await client.post( - "/api/v1/decks/", + "/decks/", json={ "name": "Update Test Deck", "content": "4 Lightning Bolt", @@ -88,7 +88,7 @@ class TestDeckManagement: # Update deck response = await client.patch( - f"/api/v1/decks/{deck_id}", + f"/decks/{deck_id}", json={"name": "Updated Deck Name"}, headers=auth_headers, ) @@ -101,7 +101,7 @@ class TestDeckManagement: """Test deleting a deck.""" # Create a deck first create_response = await client.post( - "/api/v1/decks/", + "/decks/", json={ "name": "Delete Test Deck", "content": "4 Lightning Bolt", @@ -112,14 +112,14 @@ class TestDeckManagement: deck_id = create_response.json()["id"] # Delete deck - response = await client.delete(f"/api/v1/decks/{deck_id}", headers=auth_headers) + response = await client.delete(f"/decks/{deck_id}", headers=auth_headers) assert response.status_code == 200 @pytest.mark.asyncio async def test_create_folder(self, client: AsyncClient, auth_headers: dict): """Test creating a folder.""" response = await client.post( - "/api/v1/decks/folders", + "/decks/folders", json={ "name": "Test Folder", }, @@ -134,13 +134,13 @@ class TestDeckManagement: """Test listing folders.""" # Create a folder first await client.post( - "/api/v1/decks/folders", + "/decks/folders", json={"name": "List Test Folder"}, headers=auth_headers, ) # List folders - response = await client.get("/api/v1/decks/folders", headers=auth_headers) + response = await client.get("/decks/folders", headers=auth_headers) print(f"\nDEBUG list_folders: status={response.status_code}, body={response.text}") assert response.status_code == 200 data = response.json() @@ -152,12 +152,12 @@ class TestDeckManagement: """Test deleting a folder.""" # Create a folder first create_response = await client.post( - "/api/v1/decks/folders", + "/decks/folders", json={"name": "Delete Test Folder"}, headers=auth_headers, ) folder_id = create_response.json()["id"] # Delete folder - response = await client.delete(f"/api/v1/decks/folders/{folder_id}", headers=auth_headers) + response = await client.delete(f"/decks/folders/{folder_id}", headers=auth_headers) assert response.status_code == 200 diff --git a/backend/tests/test_game_models.py b/backend/tests/test_game_models.py new file mode 100644 index 0000000..8cac7ff --- /dev/null +++ b/backend/tests/test_game_models.py @@ -0,0 +1,312 @@ +""" +Tests for game models and database integration. +""" +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from sqlalchemy import select + +from app.models.models import MTGOnlineGame, GamePlayer, GameHistory +from app.core.database import Base + + +# Test database setup +TEST_DATABASE_URL = "sqlite+aiosqlite:///./test_games.db" + + +@pytest.fixture(scope="module") +def event_loop(): + """Create event loop for async tests.""" + import asyncio + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture +async def test_engine(): + """Create test database engine.""" + engine = create_async_engine(TEST_DATABASE_URL, echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + # Cleanup test database + import os + if os.path.exists("./test_games.db"): + os.remove("./test_games.db") + + +@pytest_asyncio.fixture +async def db_session(test_engine): + """Create test database session.""" + async_session = sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with async_session() as session: + yield session + + +class TestGameModels: + """Test game model creation and relationships.""" + + @pytest.mark.asyncio + async def test_create_game(self, db_session: AsyncSession): + """Test creating a new game.""" + game = MTGOnlineGame( + room_id=1, + host_id=100, + game_type="Commander", + state="WAITING", + max_players=4, + current_players=1, + ) + db_session.add(game) + await db_session.flush() + + assert game.id is not None + assert game.room_id == 1 + assert game.host_id == 100 + assert game.game_type == "Commander" + assert game.state == "WAITING" + + @pytest.mark.asyncio + async def test_create_game_player(self, db_session: AsyncSession): + """Test creating a game player.""" + game = MTGOnlineGame( + room_id=1, + host_id=100, + game_type="Commander", + state="WAITING", + max_players=4, + current_players=1, + ) + db_session.add(game) + await db_session.flush() + + player = GamePlayer( + game_id=game.id, + user_id=100, + seat_number=1, + life_total=20, + is_ready=False, + is_host=True, + ) + db_session.add(player) + await db_session.flush() + + assert player.id is not None + assert player.game_id == game.id + assert player.user_id == 100 + assert player.is_host is True + + @pytest.mark.asyncio + async def test_game_player_relationship(self, db_session: AsyncSession): + """Test game-player relationship.""" + game = MTGOnlineGame( + room_id=1, + host_id=100, + game_type="Commander", + state="WAITING", + max_players=4, + current_players=1, + ) + db_session.add(game) + await db_session.flush() + + player1 = GamePlayer( + game_id=game.id, + user_id=100, + seat_number=1, + is_host=True, + ) + player2 = GamePlayer( + game_id=game.id, + user_id=101, + seat_number=2, + is_host=False, + ) + db_session.add_all([player1, player2]) + await db_session.flush() + + # Query game with players + stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game.id) + result = await db_session.execute(stmt) + game = result.scalar_one() + + assert len(game.players) == 2 + assert game.players[0].user_id == 100 + assert game.players[1].user_id == 101 + + @pytest.mark.asyncio + async def test_game_history_creation(self, db_session: AsyncSession): + """Test creating game history.""" + game = MTGOnlineGame( + room_id=1, + host_id=100, + game_type="Commander", + state="FINISHED", + max_players=4, + current_players=2, + ) + db_session.add(game) + await db_session.flush() + + history = GameHistory( + game_id=game.id, + room_id=1, + host_id=100, + game_type="Commander", + winner_id=100, + player_count=2, + duration_minutes=45, + ) + db_session.add(history) + await db_session.flush() + + assert history.id is not None + assert history.game_id == game.id + assert history.winner_id == 100 + assert history.duration_minutes == 45 + + +class TestGameQueries: + """Test database queries for games.""" + + @pytest.mark.asyncio + async def test_list_games_by_state(self, db_session: AsyncSession): + """Test listing games by state.""" + # Create test games + game1 = MTGOnlineGame(room_id=1, host_id=100, game_type="Commander", state="WAITING") + game2 = MTGOnlineGame(room_id=1, host_id=101, game_type="Commander", state="WAITING") + game3 = MTGOnlineGame(room_id=1, host_id=102, game_type="Standard", state="IN_PROGRESS") + db_session.add_all([game1, game2, game3]) + await db_session.flush() + + # Query WAITING games + stmt = select(MTGOnlineGame).where(MTGOnlineGame.state == "WAITING") + result = await db_session.execute(stmt) + waiting_games = result.scalars().all() + + assert len(waiting_games) == 2 + + @pytest.mark.asyncio + async def test_list_games_by_type(self, db_session: AsyncSession): + """Test listing games by type.""" + game1 = MTGOnlineGame(room_id=1, host_id=100, game_type="Commander", state="WAITING") + game2 = MTGOnlineGame(room_id=1, host_id=101, game_type="Standard", state="WAITING") + db_session.add_all([game1, game2]) + await db_session.flush() + + stmt = select(MTGOnlineGame).where(MTGOnlineGame.game_type == "Commander") + result = await db_session.execute(stmt) + commander_games = result.scalars().all() + + assert len(commander_games) == 1 + + @pytest.mark.asyncio + async def test_get_game_by_id(self, db_session: AsyncSession): + """Test getting a game by ID.""" + game = MTGOnlineGame(room_id=1, host_id=100, game_type="Commander", state="WAITING") + db_session.add(game) + await db_session.flush() + + stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game.id) + result = await db_session.execute(stmt) + fetched_game = result.scalar_one_or_none() + + assert fetched_game is not None + assert fetched_game.id == game.id + assert fetched_game.game_type == "Commander" + + @pytest.mark.asyncio + async def test_update_game_state(self, db_session: AsyncSession): + """Test updating game state.""" + game = MTGOnlineGame(room_id=1, host_id=100, game_type="Commander", state="WAITING") + db_session.add(game) + await db_session.flush() + + game.state = "IN_PROGRESS" + game.started_at = "2024-01-01T00:00:00" + await db_session.flush() + + stmt = select(MTGOnlineGame).where(MTGOnlineGame.id == game.id) + result = await db_session.execute(stmt) + updated_game = result.scalar_one() + + assert updated_game.state == "IN_PROGRESS" + assert updated_game.started_at is not None + + +class TestGamePlayerQueries: + """Test database queries for game players.""" + + @pytest.mark.asyncio + async def test_get_players_for_game(self, db_session: AsyncSession): + """Test getting all players for a game.""" + game = MTGOnlineGame(room_id=1, host_id=100, game_type="Commander", state="WAITING") + db_session.add(game) + await db_session.flush() + + player1 = GamePlayer(game_id=game.id, user_id=100, seat_number=1, is_host=True) + player2 = GamePlayer(game_id=game.id, user_id=101, seat_number=2, is_host=False) + player3 = GamePlayer(game_id=game.id, user_id=102, seat_number=3, is_host=False) + db_session.add_all([player1, player2, player3]) + await db_session.flush() + + stmt = select(GamePlayer).where(GamePlayer.game_id == game.id) + result = await db_session.execute(stmt) + players = result.scalars().all() + + assert len(players) == 3 + + @pytest.mark.asyncio + async def test_check_player_in_game(self, db_session: AsyncSession): + """Test checking if user is in a game.""" + game = MTGOnlineGame(room_id=1, host_id=100, game_type="Commander", state="WAITING") + db_session.add(game) + await db_session.flush() + + player = GamePlayer(game_id=game.id, user_id=100, seat_number=1, is_host=True) + db_session.add(player) + await db_session.flush() + + # Check if user 100 is in game + stmt = select(GamePlayer).where( + GamePlayer.game_id == game.id, + GamePlayer.user_id == 100, + ) + result = await db_session.execute(stmt) + existing_player = result.scalar_one_or_none() + + assert existing_player is not None + assert existing_player.user_id == 100 + + @pytest.mark.asyncio + async def test_update_player_ready_status(self, db_session: AsyncSession): + """Test updating player ready status.""" + game = MTGOnlineGame(room_id=1, host_id=100, game_type="Commander", state="WAITING") + db_session.add(game) + await db_session.flush() + + player = GamePlayer(game_id=game.id, user_id=100, seat_number=1, is_ready=False) + db_session.add(player) + await db_session.flush() + + player.is_ready = True + await db_session.flush() + + stmt = select(GamePlayer).where(GamePlayer.id == player.id) + result = await db_session.execute(stmt) + updated_player = result.scalar_one() + + assert updated_player.is_ready is True + + +@pytest.fixture(autouse=True) +async def cleanup_test_db(): + """Cleanup test database after each test.""" + yield + import os + if os.path.exists("./test_games.db"): + os.remove("./test_games.db")