af85048deb
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
77 lines
2.7 KiB
Python
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()
|