Закрывает 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
162 lines
7.8 KiB
Python
162 lines
7.8 KiB
Python
"""Общие фикстуры тестов (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
|