import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from app.core.app_settings import settings from app.core.security import hash_password from app.db.session import get_db from app.main import app from app.models.auth import Benutzer, BenutzerRolle, RolleTyp from app.models.objekt import Objekt from app.models.stammdaten import Bereich, Objekttyp, Standort # Erwartet eine bereits per Alembic migrierte Test-Datenbank (CI: install -> migrate -> pytest, # siehe testphasen.md Phase 0). Jeder Test läuft in einer Transaktion, die am Ende zurückgerollt # wird, damit Tests sich nicht gegenseitig beeinflussen. engine = create_async_engine(settings.database_url, pool_pre_ping=True) TestSessionLocal = async_sessionmaker(engine, expire_on_commit=False) @pytest_asyncio.fixture async def db_session(): """Jeder Test läuft in einer äußeren Transaktion, die am Ende zurückgerollt wird. Die Session ist per `join_transaction_mode="create_savepoint"` an die äußere Connection gebunden: ein `commit()` innerhalb des Tests (z. B. durch `get_db`, das jetzt selbst committet) schließt nur eine SAVEPOINT ab, nicht die äußere Transaktion – das äußere `connection.rollback()` verwirft am Ende trotzdem alles. (SQLAlchemy 2.0 "Joining a Session into an External Transaction" – Review-Fund fastapi-expert: ohne dieses Pattern hätte ein commit() in Endpunkten die Testisolation gebrochen.) """ async with engine.connect() as connection: await connection.begin() session = AsyncSession( bind=connection, expire_on_commit=False, join_transaction_mode="create_savepoint" ) try: yield session finally: await session.close() await connection.rollback() @pytest_asyncio.fixture async def client(db_session): async def _get_db_override(): yield db_session app.dependency_overrides[get_db] = _get_db_override transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac app.dependency_overrides.clear() @pytest_asyncio.fixture async def mitarbeiter_user(db_session): benutzer = Benutzer( name="Test Mitarbeiter", login="mitarbeiter1", passwort_hash=hash_password("test-passwort-123"), aktiv=True, ) db_session.add(benutzer) await db_session.flush() db_session.add(BenutzerRolle(benutzer_id=benutzer.id, rolle=RolleTyp.mitarbeiter)) await db_session.flush() return benutzer @pytest_asyncio.fixture async def admin_user(db_session): benutzer = Benutzer( name="Test Administration", login="admin1", passwort_hash=hash_password("test-passwort-123"), aktiv=True, ) db_session.add(benutzer) await db_session.flush() db_session.add(BenutzerRolle(benutzer_id=benutzer.id, rolle=RolleTyp.administration)) await db_session.flush() return benutzer async def login(client, username: str, password: str = "test-passwort-123") -> str: response = await client.post( "/api/v1/auth/login", data={"username": username, "password": password} ) assert response.status_code == 200, response.text return response.json()["access_token"] def auth_header(token: str) -> dict: return {"Authorization": f"Bearer {token}"} @pytest_asyncio.fixture async def hauptserver_id(db_session): # Seed-Migration 0002 legt genau einen Datensatz mit typ='haupt' an (Sprintplan E6). from sqlalchemy import select from app.models.auth import KnotenTyp, Systemknoten result = await db_session.execute( select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt) ) return result.scalar_one() @pytest_asyncio.fixture async def standort_factory(db_session): async def _make(name: str) -> Standort: standort = Standort(name=name) db_session.add(standort) await db_session.flush() return standort return _make @pytest_asyncio.fixture async def objekt_factory(db_session, hauptserver_id): async def _make(*, name: str, code: str, standort: Standort) -> Objekt: bereich = Bereich(name=f"Bereich-{code}") db_session.add(bereich) await db_session.flush() objekttyp = Objekttyp(bereich_id=bereich.id, name=f"Typ-{code}") db_session.add(objekttyp) await db_session.flush() objekt = Objekt( code=code, name=name, objekttyp_id=objekttyp.id, standort_id=standort.id, zustaendiger_server_id=hauptserver_id, ) db_session.add(objekt) await db_session.flush() return objekt return _make