arm-core/api/tests/conftest.py
Andrey Epifancev d85ecd22e4 test: подавить шум build_verifier в общей фикстуре app
Закрывает T3.1: предупреждение «Включён dev-эмитент…» из src.core.auth.logger
подавляется через monkeypatch в фикстуре app. Локальный autouse в
test_me_and_dev_token.py оставлен: его _build_app вызывает build_verifier без
фикстуры app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-04 21:22:32 +04:00

162 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Общие фикстуры тестов (spec data-model; design D-7, рулинг R-16).
БД-фикстуры — только для тестов с маркером `integration` и ленивые: пока тест их не запросил, PostgreSQL не
трогается (`make check` не требует БД). Тестовая БД сессии — отдельная свежая `armcore_pytest_<hex>` на сервере из
`.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_<hex>` с применённой миграцией `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