mantimon-tcg/backend/tests/db/test_relationships.py
Cal Corum 50684a1b11 Add database infrastructure with SQLAlchemy models and test suite
Phase 1 Database Implementation (DB-001 through DB-012):

Models:
- User: OAuth support (Google/Discord), premium subscriptions
- Collection: Card ownership with CardSource enum
- Deck: JSONB cards/energy_cards, validation state
- CampaignProgress: One-to-one with User, medals/NPCs as JSONB
- ActiveGame: In-progress games with GameType enum
- GameHistory: Completed games with EndReason enum, replay data

Infrastructure:
- Alembic migrations with sync psycopg2 (avoids async issues)
- Docker Compose for Postgres (5433) and Redis (6380)
- App config with Pydantic settings
- Redis client helper

Test Infrastructure:
- 68 database tests (47 model + 21 relationship)
- Async factory pattern for test data creation
- Sync TRUNCATE cleanup (solves pytest-asyncio event loop mismatch)
- Uses dev containers instead of testcontainers for reliability

Key technical decisions:
- passive_deletes=True for ON DELETE SET NULL relationships
- NullPool for test sessions (no connection reuse)
- expire_on_commit=False with manual expire() for relationship tests
2026-01-27 10:17:30 -06:00

457 lines
16 KiB
Python

"""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