Files
modbison/backend/tests/conftest.py
T
daddy af85048deb PC3 Phase 1.3: backend avatar loadout persistence
New avatar_loadouts table (one row per user, loadout stored as a single
JSONB document matching the client's AvatarLoadout.to_json() shape) and
GET/PUT /api/avatar/loadout.

The server has no copy of the asset catalog, so it validates only the
envelope (JSON object, non-empty base_body, object slots, size cap) --
which-asset-in-which-slot validation stays client-side. New accounts get a
server default (base_human_male_01, no slots) with is_default:true so the
client can tell "never customised" from "customised back to base".

- models/avatar_loadout.py + 0004 migration + User.avatar_loadout relation
- routers/avatar.py wired into main.py
- tests/test_avatar_loadout.py (7 tests: default, save/read, replace,
  validation, auth) -- full suite 68 passed
- lifted the duplicated _override_db fixture from 3 test files into
  conftest.py (new test files need it too)
- docs/architecture/avatar.md §9a: the persistence design

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 21:42:10 +00:00

77 lines
2.7 KiB
Python

"""Shared pytest fixtures.
The auth integration tests hit the *real* Postgres database (per
``DATABASE_URL``), so they persist data between runs. Without cleanup a
second run of the suite fails with 409 conflicts because the users it
registered on the first run already exist. The session-scoped fixture below
truncates all application tables once before the first test, making the suite
re-runnable.
Truncation order doesn't strictly matter because ``CASCADE`` follows foreign
keys, but the tables are listed explicitly for clarity.
"""
import asyncio
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.database import get_db
from app.main import app
TEST_TABLES = ("avatar_loadouts", "assets", "worlds", "sessions", "refresh_tokens", "users")
@pytest.fixture(scope="session", autouse=True)
def _reset_database():
"""Truncate all application tables once per test session."""
async def _truncate() -> None:
engine = create_async_engine(settings.DATABASE_URL)
async with engine.begin() as conn:
await conn.execute(
text(f"TRUNCATE TABLE {', '.join(TEST_TABLES)} CASCADE")
)
await engine.dispose()
asyncio.run(_truncate())
yield
@pytest.fixture(autouse=True)
def _override_db():
"""Override get_db so the app uses a fresh session per test.
Deliberately does NOT reuse app.database's shared engine: FastAPI's
(sync) TestClient runs each request through anyio's blocking-portal
machinery, which can hand different requests different event loops.
asyncpg connections are bound to the loop they were created on, so
reusing one from the shared pool across requests raises "attached to a
different loop" the moment a test makes more than one request. NullPool
sidesteps it: every checkout is a brand-new connection.
This lived (identically) in three separate test files before it was
lifted here; any file still defining its own local `_override_db`
simply shadows this one with the same behaviour.
"""
test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def _get_db():
async_session = test_session_factory()
try:
yield async_session
await async_session.commit()
except Exception:
await async_session.rollback()
raise
finally:
await async_session.close()
app.dependency_overrides[get_db] = _get_db
yield
app.dependency_overrides.clear()