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
457 lines
16 KiB
Python
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
|