"""Общие фикстуры тестов (spec data-model; design D-7, рулинг R-16). БД-фикстуры — только для тестов с маркером `integration` и ленивые: пока тест их не запросил, PostgreSQL не трогается (`make check` не требует БД). Тестовая БД сессии — отдельная свежая `armcore_pytest_` на сервере из `.env.test`: мигрируется alembic один раз на сессию и удаляется в конце; рабочая БД `armka` не используется. Изоляция тестов — внешняя транзакция с SAVEPOINT: `commit()` внутри теста ничего не фиксирует. `committed_sessionmaker` / `committed_app` / `committed_client` / `committed_storage` — для тестов, которым нужна сессия запроса «как в проде»: отдельная временная БД с ЗАФИКСИРОВАННЫМ seed и сессия `async with session.begin()` без SAVEPOINT-обёртки. Нужны там, где сервис делает явный `session.rollback()` (`dry_run` импорта): внутри `db_session` такой откат снял бы корневой SAVEPOINT вместе с seed. Приложение собирается той же `_build_application`, что и `app`; S3 — `FakeStorage` (объекты в словаре). """ import uuid from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from functools import partial from pathlib import Path from types import SimpleNamespace from typing import Any import asyncpg import httpx import pytest import pytest_asyncio from fastapi import FastAPI from sqlalchemy.ext.asyncio import ( AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker, 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.core.storage import ObjectNotFoundError from src.db.db import get_async_session, session_transaction from src.models.base import SCHEMA from src.services.session_audit import SessionAudit 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` = схема из настроек.""" engine = create_async_engine(migrated_db_url, connect_args={"server_settings": {"search_path": SCHEMA}}) 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]] @asynccontextmanager async def nested_transaction(session: AsyncSession) -> AsyncIterator[AsyncSession]: """«Отдельная транзакция» аудита входа поверх `db_session`: SAVEPOINT в той же внешней транзакции теста. LOGIN_FAILED пишется обработчиком исключений ПОСЛЕ отката SAVEPOINT запроса, поэтому запись видна тесту; что в проде она переживает откат транзакции запроса, проверяют тесты на `committed_app`. """ async with session.begin_nested(): yield session @pytest_asyncio.fixture async def seeded(db_session: AsyncSession) -> AsyncSession: """`db_session` со справочниками и dev-пользователями, без dev-ГК (`run_seed(dev=True, dev_groups=False)`): тесты API строят справочники сами и считают, что ГК/ЮЛ и филиала «Санкт-Петербургский РФ» в начале нет.""" await run_seed(db_session, dev=True, dev_groups=False) await db_session.flush() return db_session def _build_application( session_dependency: Callable[[], AsyncIterator[AsyncSession]], monkeypatch: pytest.MonkeyPatch, audit_transaction: Callable[[], AbstractAsyncContextManager[AsyncSession]], ) -> FastAPI: """Приложение на `build_api_router` с dev-эмитентом (`DEBUG` и `AUTH_DEV_ISSUER`) и переопределённой сессией БД. `audit_transaction` — отдельная транзакция аудита входа (`SessionAudit`, LOGIN/LOGIN_FAILED) на БД теста. Предупреждение «Включён 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)) application.dependency_overrides[get_async_session] = session_dependency application.state.token_verifier = build_verifier(settings) application.state.storage = None application.state.session_audit = SessionAudit(audit_transaction) return application @pytest.fixture def app(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> FastAPI: """Приложение поверх `db_session` (см. `_build_application`); без lifespan, `storage` отсутствует. Override сессии повторяет транзакцию запроса: `begin_nested()` — ошибка в запросе откатывает лишь до SAVEPOINT, данные теста сохраняются. """ async def override_session() -> AsyncIterator[AsyncSession]: async with db_session.begin_nested(): # как транзакция запроса: ошибка → откат до SAVEPOINT yield db_session return _build_application(override_session, monkeypatch, partial(nested_transaction, db_session)) async def _client_factory(application: FastAPI, stack: AsyncExitStack, email: str) -> httpx.AsyncClient: """Клиент с dev-токеном пользователя `email`, `base_url=.../api/v1`; закрывается вместе со `stack`.""" transport = httpx.ASGITransport(app=application) 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) @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: return await _client_factory(app, stack, email) yield factory class FakeStorage: """Заглушка `S3Storage` (протокол `ObjectStorage`): `put_object`/`get_bytes`/`stream`, объекты — в словаре.""" def __init__(self, bucket: str = "arm-ka") -> None: self.bucket = bucket self.objects: dict[str, bytes] = {} async def put_object(self, obj_name: str, file_data: bytes) -> None: self.objects[obj_name] = file_data async def get_bytes(self, obj_name: str) -> bytes: try: return self.objects[obj_name] except KeyError: raise ObjectNotFoundError(obj_name) from None async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncGenerator[bytes, None]: try: data = self.objects[obj_name] except KeyError: raise ObjectNotFoundError(obj_name) from None for start in range(0, len(data), chunk_size): yield data[start : start + chunk_size] @pytest_asyncio.fixture async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSession]]: """Отдельная временная БД: `alembic upgrade head` и seed (dev) ЗАФИКСИРОВАНЫ; удаляется после теста.""" name = f"armcore_import_{uuid.uuid4().hex[:12]}" url = (await create_scratch_db(name)).render_as_string(hide_password=False) engine = create_async_engine(url, connect_args={"server_settings": {"search_path": SCHEMA}}) try: await alembic_async(url, "upgrade", "head") maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session, session.begin(): await run_seed(session, dev=True, dev_groups=False) yield maker finally: await engine.dispose() await drop_scratch_db(name) @pytest.fixture def committed_app(committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch) -> FastAPI: """Приложение с сессией запроса как в проде (`session.begin()` без SAVEPOINT); `storage` — `FakeStorage`.""" async def production_like_session() -> AsyncIterator[AsyncSession]: async with committed_sessionmaker() as session, session.begin(): yield session application = _build_application( production_like_session, monkeypatch, partial(session_transaction, committed_sessionmaker) ) application.state.storage = FakeStorage() return application @pytest.fixture def committed_storage(committed_app: FastAPI) -> FakeStorage: """`FakeStorage` приложения `committed_app`: что реально «записано в S3».""" storage: Any = committed_app.state.storage assert isinstance(storage, FakeStorage) # noqa: S101 return storage @pytest_asyncio.fixture async def committed_client(committed_app: FastAPI) -> AsyncIterator[AuthClientFactory]: """Фабрика клиентов `committed_app` (как `auth_client`, но на БД с зафиксированным seed).""" async with AsyncExitStack() as stack: async def factory(email: str) -> httpx.AsyncClient: return await _client_factory(committed_app, stack, email) yield factory