DB_SCHEMA (по умолчанию dkb, валидация идентификатора) читают models/base.py (SCHEMA), db/db.py (search_path),
alembic/env.py и миграции 0001-0005 (SCHEMA и строковые FK f"{SCHEMA}.x.id" вместо литералов). В контуре -
OPENBAO__APP__DB_SCHEMA. Офлайн-SQL `alembic upgrade head --sql` при значении по умолчанию совпадает побайтно.
Тесты: валидация настройки, миграции и сессия приложения в схеме dkb_alt; .env.example/.env.test/README/CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
284 lines
14 KiB
Python
284 lines
14 KiB
Python
"""Общие фикстуры тестов (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 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_<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` = схема из настроек."""
|
||
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
|