"""Общие фикстуры тестов (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 from types import SimpleNamespace 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, monkeypatch: pytest.MonkeyPatch) -> FastAPI: """Приложение на `build_api_router` с dev-эмитентом (`DEBUG` и `AUTH_DEV_ISSUER`) поверх `db_session`. Override сессии повторяет транзакцию запроса: `begin_nested()` — ошибка в запросе откатывает лишь до SAVEPOINT, данные теста сохраняются; без lifespan, `storage` отсутствует. Предупреждение «Включён dev-эмитент…» из `build_verifier` (`src.core.auth.logger`) подавлено (T3.1). """ monkeypatch.setattr("src.core.auth.logger", SimpleNamespace(warning=lambda *args, **kwargs: None)) 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