"""Общие фикстуры тестов (spec data-model; design D-7, рулинг R-16). БД-фикстуры — только для тестов с маркером `integration` и ленивые: пока тест их не запросил, PostgreSQL не трогается (`make check` не требует БД). Тестовая БД сессии — отдельная свежая `armcore_pytest_` на сервере из `.env.test`: мигрируется alembic один раз на сессию и удаляется в конце; рабочая БД `armka` не используется. Изоляция тестов — внешняя транзакция с SAVEPOINT: `commit()` внутри теста ничего не фиксирует. """ import uuid from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AsyncExitStack from pathlib import Path import asyncpg import httpx import pytest import pytest_asyncio from fastapi import FastAPI from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine from scripts.seed import run_seed from src.api.router import build_api_router from src.core.auth import build_verifier from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers from src.db.db import get_async_session from tests.fixtures_helpers import fixtures_skip_reason, resolve_fixtures_dir from tests.pg_helpers import ( # scratch_db_url — реэкспорт фикстуры, чтобы pytest её нашёл alembic_async, create_scratch_db, drop_scratch_db, scratch_db_url, # noqa: F401 ) PG_UNAVAILABLE = "PostgreSQL недоступен (make up / .env.test): {error}" def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: """Тесты с маркером `fixtures` пропускаются с явной причиной, если каталог fixtures недоступен.""" reason = fixtures_skip_reason() if reason is None: return skip = pytest.mark.skip(reason=reason) for item in items: if item.get_closest_marker("fixtures") is not None: item.add_marker(skip) @pytest.fixture(scope="session") def fixtures_dir() -> Path: """Каталог реальных данных (`ARM_KA_FIXTURES` или соседний `fixtures`); без него тест пропускается.""" reason = fixtures_skip_reason() if reason is not None: pytest.skip(reason) return resolve_fixtures_dir() @pytest_asyncio.fixture(scope="session") async def migrated_db_url() -> AsyncIterator[str]: """URL свежей БД `armcore_pytest_` с применённой миграцией `alembic upgrade head`; удаляется в конце сессии.""" name = f"armcore_pytest_{uuid.uuid4().hex[:12]}" try: url = await create_scratch_db(name) except (OSError, asyncpg.InterfaceError) as error: # соединение не установлено: сервер не запущен, порт закрыт pytest.fail(PG_UNAVAILABLE.format(error=f"{type(error).__name__}: {error}"), pytrace=False) except asyncpg.PostgresError as error: # сервер ответил отказом: учётные данные, роль, права на CREATE DATABASE pytest.fail( f"PostgreSQL отказал при создании тестовой БД ({type(error).__name__}: {error}); " "проверьте учётные данные и право CREATE DATABASE в .env.test", pytrace=False, ) try: rendered = url.render_as_string(hide_password=False) await alembic_async(rendered, "upgrade", "head") yield rendered finally: await drop_scratch_db(name) @pytest_asyncio.fixture(scope="session") async def db_engine(migrated_db_url: str) -> AsyncIterator[AsyncEngine]: """Async-движок на мигрированную БД сессии; `search_path = dkb`.""" engine = create_async_engine(migrated_db_url, connect_args={"server_settings": {"search_path": "dkb"}}) try: yield engine finally: await engine.dispose() @pytest_asyncio.fixture async def db_session(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]: """Сессия в внешней транзакции с откатом: `commit()` в тесте освобождает лишь SAVEPOINT, данные не остаются.""" connection: AsyncConnection = await db_engine.connect() transaction = await connection.begin() session = AsyncSession(bind=connection, join_transaction_mode="create_savepoint", expire_on_commit=False) try: yield session finally: await session.close() await transaction.rollback() await connection.close() AuthClientFactory = Callable[[str], Awaitable[httpx.AsyncClient]] @pytest_asyncio.fixture async def seeded(db_session: AsyncSession) -> AsyncSession: """`db_session` с засеянными справочниками и dev-пользователями (`run_seed(dev=True)`).""" await run_seed(db_session, dev=True) await db_session.flush() return db_session @pytest.fixture def app(db_session: AsyncSession) -> FastAPI: """Приложение на `build_api_router` с dev-эмитентом (`DEBUG` и `AUTH_DEV_ISSUER`) поверх `db_session`. Override сессии повторяет транзакцию запроса: `begin_nested()` — ошибка в запросе откатывает лишь до SAVEPOINT, данные теста сохраняются; без lifespan, `storage` отсутствует. """ settings = get_settings().model_copy(update={"DEBUG": True, "AUTH_DEV_ISSUER": True}) application = FastAPI() register_exception_handlers(application) application.include_router(build_api_router(settings)) async def override_session() -> AsyncIterator[AsyncSession]: async with db_session.begin_nested(): # как транзакция запроса: ошибка → откат до SAVEPOINT yield db_session application.dependency_overrides[get_async_session] = override_session application.state.token_verifier = build_verifier(settings) application.state.storage = None return application @pytest_asyncio.fixture async def auth_client(app: FastAPI, seeded: AsyncSession) -> AsyncIterator[AuthClientFactory]: # noqa: ARG001 """Фабрика клиентов: `await auth_client("ka_rf@armka.local")` — клиент с dev-токеном, `base_url=.../api/v1`. Клиенты закрываются в teardown. Dev-пользователи seed: ka_rf, rka, client_manager, ka_go, curator_go, expert, admin (`@armka.local`). """ async with AsyncExitStack() as stack: async def factory(email: str) -> httpx.AsyncClient: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as anonymous: response = await anonymous.post("/api/v1/dev/token", json={"email": email}) assert response.status_code == 200, response.text # noqa: PLR2004,S101 token = response.json()["access_token"] client = httpx.AsyncClient( transport=transport, base_url="http://test/api/v1", headers={"Authorization": f"Bearer {token}"} ) return await stack.enter_async_context(client) yield factory