- Repo-Struktur für Backend, Frontend, Dokumentation und Gitea-Workflows - FastAPI-Skeleton mit pydantic-settings und RFC-7807-artigem Fehlerformat - Vollständiges Datenmodell (15 Tabellen) als SQLAlchemy-2.0-Modelle - Alembic gegen die Async-Engine inklusive Erstmigration - Idempotenter Seed für den deutschen Kategoriebaum und Standardregeln - Endpunkte /api/health und /api/version - pytest-Infrastruktur mit transaktionsisolierten Fixtures Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
79 lines
2.4 KiB
Python
79 lines
2.4 KiB
Python
"""Gemeinsame Test-Fixtures. Nutzt eine separate Testdatenbank."""
|
|
|
|
import os
|
|
|
|
# Muss vor dem ersten Import der Anwendung gesetzt sein, da die Settings gecacht werden.
|
|
os.environ.setdefault(
|
|
"DATABASE_URL",
|
|
os.environ.get(
|
|
"TEST_DATABASE_URL",
|
|
"postgresql+asyncpg://moneyfy:moneyfy@127.0.0.1:5432/moneyfy_test",
|
|
),
|
|
)
|
|
os.environ.setdefault("SECRET_KEY", "test-secret-key")
|
|
os.environ.setdefault("ENVIRONMENT", "test")
|
|
|
|
from collections.abc import AsyncGenerator
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy.ext.asyncio import (
|
|
AsyncSession,
|
|
async_sessionmaker,
|
|
create_async_engine,
|
|
)
|
|
|
|
from app.core.config import settings
|
|
from app.db.session import get_session
|
|
from app.main import app as fastapi_app
|
|
from app.models import Base
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def anyio_backend() -> str:
|
|
return "asyncio"
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
async def engine():
|
|
"""Legt das Schema einmal pro Testlauf frisch an."""
|
|
test_engine = create_async_engine(str(settings.database_url), poolclass=None)
|
|
async with test_engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
yield test_engine
|
|
await test_engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
async def session(engine) -> AsyncGenerator[AsyncSession, None]:
|
|
"""Eine Session je Test, am Ende wird zurückgerollt."""
|
|
connection = await engine.connect()
|
|
transaction = await connection.begin()
|
|
# `create_savepoint` sorgt dafür, dass session.commit() nur den Savepoint freigibt
|
|
# und der abschließende Rollback wirklich alle Testdaten entfernt.
|
|
maker = async_sessionmaker(
|
|
bind=connection,
|
|
expire_on_commit=False,
|
|
autoflush=False,
|
|
join_transaction_mode="create_savepoint",
|
|
)
|
|
async with maker() as db_session:
|
|
yield db_session
|
|
await transaction.rollback()
|
|
await connection.close()
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
|
"""HTTP-Client gegen die App, alle Requests laufen in der Test-Transaktion."""
|
|
|
|
async def _override() -> AsyncGenerator[AsyncSession, None]:
|
|
yield session
|
|
|
|
fastapi_app.dependency_overrides[get_session] = _override
|
|
transport = ASGITransport(app=fastapi_app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
|
yield http_client
|
|
fastapi_app.dependency_overrides.clear()
|