"""Tests for SQLAlchemy model relationships and cascades. This module tests: - Foreign key relationships between models - Cascade delete behavior (deleting parent deletes children) - Relationship loading (lazy vs eager) - Orphan record prevention These tests ensure referential integrity is maintained and that deleting a user properly cleans up all associated data. """ import pytest from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db.models import ( ActiveGame, CampaignProgress, Collection, Deck, GameHistory, User, ) from tests.factories import ( ActiveGameFactory, CampaignProgressFactory, CollectionFactory, DeckFactory, GameHistoryFactory, UserFactory, ) # ============================================================================= # User Cascade Delete Tests # ============================================================================= class TestUserCascadeDelete: """Tests for cascade delete when removing a User. When a user is deleted, all their associated records should be deleted automatically via CASCADE. """ @pytest.mark.asyncio async def test_delete_user_cascades_to_collection(self, db_session: AsyncSession): """Test that deleting a user deletes their card collection. Collection entries have ON DELETE CASCADE to users. """ user = await UserFactory.create(db_session) entries = await CollectionFactory.create_for_user(db_session, user, card_count=5) entry_ids = [e.id for e in entries] # Delete the user await db_session.delete(user) await db_session.flush() # Verify collection entries are gone result = await db_session.execute(select(Collection).where(Collection.id.in_(entry_ids))) remaining = result.scalars().all() assert len(remaining) == 0 @pytest.mark.asyncio async def test_delete_user_cascades_to_decks(self, db_session: AsyncSession): """Test that deleting a user deletes their decks. Decks have ON DELETE CASCADE to users. """ user = await UserFactory.create(db_session) deck1 = await DeckFactory.create_for_user(db_session, user) deck2 = await DeckFactory.create_for_user(db_session, user) deck_ids = [deck1.id, deck2.id] # Delete the user await db_session.delete(user) await db_session.flush() # Verify decks are gone result = await db_session.execute(select(Deck).where(Deck.id.in_(deck_ids))) remaining = result.scalars().all() assert len(remaining) == 0 @pytest.mark.asyncio async def test_delete_user_cascades_to_campaign(self, db_session: AsyncSession): """Test that deleting a user deletes their campaign progress. CampaignProgress has ON DELETE CASCADE to users. """ user = await UserFactory.create(db_session) progress = await CampaignProgressFactory.create_for_user(db_session, user) progress_id = progress.id # Delete the user await db_session.delete(user) await db_session.flush() # Verify campaign progress is gone result = await db_session.execute( select(CampaignProgress).where(CampaignProgress.id == progress_id) ) remaining = result.scalar_one_or_none() assert remaining is None @pytest.mark.asyncio async def test_delete_user_cascades_to_active_games(self, db_session: AsyncSession): """Test that deleting player1 cascades to active games. ActiveGame.player1_id has ON DELETE CASCADE. """ user = await UserFactory.create(db_session) game = await ActiveGameFactory.create_campaign_game(db_session, user) game_id = game.id # Delete the user await db_session.delete(user) await db_session.flush() # Verify active game is gone result = await db_session.execute(select(ActiveGame).where(ActiveGame.id == game_id)) remaining = result.scalar_one_or_none() assert remaining is None @pytest.mark.asyncio async def test_delete_user_sets_null_on_player2(self, db_session: AsyncSession): """Test that deleting player2 sets player2_id to NULL. ActiveGame.player2_id has ON DELETE SET NULL (game continues). """ user1 = await UserFactory.create(db_session) user2 = await UserFactory.create(db_session) game = await ActiveGameFactory.create_pvp_game(db_session, user1, user2) game_id = game.id # Delete player2 await db_session.delete(user2) await db_session.flush() # Refresh game from DB await db_session.refresh(game) # Game still exists, but player2 is NULL assert game.id == game_id assert game.player1_id == user1.id assert game.player2_id is None @pytest.mark.asyncio async def test_delete_user_preserves_game_history(self, db_session: AsyncSession): """Test that game history is preserved when user is deleted. GameHistory uses ON DELETE SET NULL for player IDs to preserve records. Note: Must expire the history object before re-querying so SQLAlchemy fetches fresh data from DB instead of returning the cached instance. """ user1 = await UserFactory.create(db_session) user2 = await UserFactory.create(db_session) history = await GameHistoryFactory.create_pvp_game(db_session, user1, user2, winner=user1) history_id = history.id user2_id = user2.id # Expire history so re-query fetches fresh from DB (after FK cascade) db_session.expire(history) # Delete the winner (DB will SET NULL on game_history FK columns) await db_session.delete(user1) await db_session.flush() # Game history still exists with NULL player1_id and winner_id result = await db_session.execute(select(GameHistory).where(GameHistory.id == history_id)) preserved = result.scalar_one() assert preserved is not None assert preserved.player1_id is None # SET NULL by FK cascade assert preserved.player2_id == user2_id assert preserved.winner_id is None # SET NULL by FK cascade # ============================================================================= # Foreign Key Integrity Tests # ============================================================================= class TestForeignKeyIntegrity: """Tests for foreign key constraints. Ensures orphan records cannot be created. """ @pytest.mark.asyncio async def test_collection_requires_valid_user(self, db_session: AsyncSession): """Test that collection entries require an existing user. Cannot create collection entry with non-existent user_id. """ from uuid import uuid4 fake_user_id = uuid4() with pytest.raises(IntegrityError): await CollectionFactory.create(db_session, user_id=fake_user_id) @pytest.mark.asyncio async def test_deck_requires_valid_user(self, db_session: AsyncSession): """Test that decks require an existing user. Cannot create deck with non-existent user_id. """ from uuid import uuid4 fake_user_id = uuid4() with pytest.raises(IntegrityError): await DeckFactory.create(db_session, user_id=fake_user_id) @pytest.mark.asyncio async def test_campaign_requires_valid_user(self, db_session: AsyncSession): """Test that campaign progress requires an existing user. Cannot create campaign progress with non-existent user_id. """ from uuid import uuid4 fake_user_id = uuid4() with pytest.raises(IntegrityError): await CampaignProgressFactory.create(db_session, user_id=fake_user_id) @pytest.mark.asyncio async def test_active_game_requires_valid_player1(self, db_session: AsyncSession): """Test that active games require a valid player1. Cannot create game with non-existent player1_id. """ from uuid import uuid4 fake_user_id = uuid4() with pytest.raises(IntegrityError): await ActiveGameFactory.create(db_session, player1_id=fake_user_id) @pytest.mark.asyncio async def test_game_history_requires_valid_player1(self, db_session: AsyncSession): """Test that game history requires a valid player1. Cannot create history with non-existent player1_id. """ from uuid import uuid4 fake_user_id = uuid4() with pytest.raises(IntegrityError): await GameHistoryFactory.create(db_session, player1_id=fake_user_id) # ============================================================================= # Relationship Loading Tests # ============================================================================= class TestRelationshipLoading: """Tests for loading related objects. Ensures relationships can be loaded both lazily and eagerly in async context without issues. """ @pytest.mark.asyncio async def test_user_load_decks_eager(self, db_session: AsyncSession): """Test eagerly loading user's decks with selectinload. selectinload is the recommended strategy for async. Note: Must expire the user first since the decks were added after the user was loaded, and expire_on_commit=False keeps cached state. """ user = await UserFactory.create(db_session) user_id = user.id # Save ID before expire await DeckFactory.create_for_user(db_session, user, name="Deck 1") await DeckFactory.create_for_user(db_session, user, name="Deck 2") # Expire the user so selectinload fetches fresh data db_session.expire(user) # Query with eager loading result = await db_session.execute( select(User).where(User.id == user_id).options(selectinload(User.decks)) ) loaded_user = result.scalar_one() # Decks should already be loaded (no additional query) assert len(loaded_user.decks) == 2 assert {d.name for d in loaded_user.decks} == {"Deck 1", "Deck 2"} @pytest.mark.asyncio async def test_user_load_collection_eager(self, db_session: AsyncSession): """Test eagerly loading user's card collection. Collection can have many entries; eager load is efficient. """ user = await UserFactory.create(db_session) user_id = user.id await CollectionFactory.create_for_user(db_session, user, card_count=10) db_session.expire(user) result = await db_session.execute( select(User).where(User.id == user_id).options(selectinload(User.collection)) ) loaded_user = result.scalar_one() assert len(loaded_user.collection) == 10 @pytest.mark.asyncio async def test_user_load_campaign_progress(self, db_session: AsyncSession): """Test loading user's campaign progress (one-to-one). One-to-one relationships use uselist=False. """ user = await UserFactory.create(db_session) user_id = user.id await CampaignProgressFactory.create_for_user(db_session, user, mantibucks=500) db_session.expire(user) result = await db_session.execute( select(User).where(User.id == user_id).options(selectinload(User.campaign_progress)) ) loaded_user = result.scalar_one() assert loaded_user.campaign_progress is not None assert loaded_user.campaign_progress.mantibucks == 500 @pytest.mark.asyncio async def test_user_no_campaign_progress(self, db_session: AsyncSession): """Test that campaign_progress is None for new users. Users start without campaign progress until they begin campaign. """ user = await UserFactory.create(db_session) result = await db_session.execute( select(User).where(User.id == user.id).options(selectinload(User.campaign_progress)) ) loaded_user = result.scalar_one() assert loaded_user.campaign_progress is None @pytest.mark.asyncio async def test_load_multiple_relationships(self, db_session: AsyncSession): """Test loading multiple relationships in one query. Efficient loading of all user data at once. """ user = await UserFactory.create(db_session) user_id = user.id await DeckFactory.create_for_user(db_session, user) await CollectionFactory.create_for_user(db_session, user, card_count=3) await CampaignProgressFactory.create_for_user(db_session, user) db_session.expire(user) result = await db_session.execute( select(User) .where(User.id == user_id) .options( selectinload(User.decks), selectinload(User.collection), selectinload(User.campaign_progress), ) ) loaded_user = result.scalar_one() assert len(loaded_user.decks) == 1 assert len(loaded_user.collection) == 3 assert loaded_user.campaign_progress is not None @pytest.mark.asyncio async def test_collection_back_populates_user(self, db_session: AsyncSession): """Test that Collection.user back-populates correctly. The relationship is bidirectional. """ user = await UserFactory.create(db_session) entry = await CollectionFactory.create(db_session, user_id=user.id) # Refresh to load relationship await db_session.refresh(entry, ["user"]) assert entry.user is not None assert entry.user.id == user.id @pytest.mark.asyncio async def test_deck_back_populates_user(self, db_session: AsyncSession): """Test that Deck.user back-populates correctly.""" user = await UserFactory.create(db_session) deck = await DeckFactory.create_for_user(db_session, user) await db_session.refresh(deck, ["user"]) assert deck.user is not None assert deck.user.id == user.id # ============================================================================= # Isolation Verification Tests # ============================================================================= class TestTestIsolation: """Tests to verify that test isolation works correctly. These tests create data and verify it doesn't persist to other tests. If isolation fails, these tests will fail intermittently. """ @pytest.mark.asyncio async def test_isolation_creates_user(self, db_session: AsyncSession): """Create a user with a specific email to test isolation. This test creates a user that would conflict with the next test if isolation wasn't working. """ user = await UserFactory.create(db_session, email="isolation_test@example.com") assert user.id is not None # Verify we can query it within this test result = await db_session.execute( select(User).where(User.email == "isolation_test@example.com") ) found = result.scalar_one() assert found.id == user.id @pytest.mark.asyncio async def test_isolation_same_email_works(self, db_session: AsyncSession): """Create another user with the same email. This would fail with IntegrityError if the previous test's data wasn't rolled back. Success proves isolation works. """ # This should work because previous test was rolled back user = await UserFactory.create(db_session, email="isolation_test@example.com") assert user.id is not None @pytest.mark.asyncio async def test_isolation_no_leftover_data(self, db_session: AsyncSession): """Verify no data from previous tests exists. Query for users created by other tests - should find none. """ result = await db_session.execute( select(User).where(User.email == "isolation_test@example.com") ) users = result.scalars().all() # If isolation works, this is empty or just from this test # (which hasn't created one yet) assert len(users) == 0