arm-core/api/tests/conftest.py
Andrey Epifancev c793b42fba test(import): вынести committed_client в conftest, добиты проверки dry_run, безопасное имя файла (T10.1–T10.4, T10.6)
- T10.6: committed_sessionmaker/committed_app/committed_client/committed_storage и FakeStorage — в conftest.py,
  приложение собирается общей _build_application (как и app).
- T10.1: dry_run не пишет в S3 (objects == {}), боевой прогон — ровно один объект.
- T10.2: dry_run на реальном xlsx РБ (21 строка, таблицы не изменились).
- T10.3: dry_run с on_existing=update не оставляет истории/аудита, имена прежние.
- T10.4: safe_filename отбрасывает управляющие символы и ограничивает длину (200) с сохранением расширения.

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

248 lines
12 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()` внутри теста ничего не фиксирует.
`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 AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack
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.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
def _build_application(
session_dependency: Callable[[], AsyncIterator[AsyncSession]], monkeypatch: pytest.MonkeyPatch
) -> FastAPI:
"""Приложение на `build_api_router` с dev-эмитентом (`DEBUG` и `AUTH_DEV_ISSUER`) и переопределённой сессией БД.
Предупреждение «Включён 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
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)
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`: тот же интерфейс `bucket`/`put_object(obj_name, file_data)`, объекты — в словаре."""
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
@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": "dkb"}})
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)
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)
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