"""Общие фикстуры тестов (spec data-model; design D-7, рулинг R-16). БД-фикстуры — только для тестов с маркером `integration` и ленивые: пока тест их не запросил, PostgreSQL не трогается (`make check` не требует БД). Тестовая БД сессии — отдельная свежая `armcore_pytest_` на сервере из `.env.test`: мигрируется alembic один раз на сессию и удаляется в конце; рабочая БД `armka` не используется. Изоляция тестов — внешняя транзакция с SAVEPOINT: `commit()` внутри теста ничего не фиксирует. """ import uuid from collections.abc import AsyncIterator from pathlib import Path import pytest import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine 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 "fixtures" in item.keywords: 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, ConnectionError) as error: pytest.fail(PG_UNAVAILABLE.format(error=error), pytrace=False) except Exception as error: # asyncpg: PostgresError/InterfaceError и т.п. при недоступном сервере pytest.fail(PG_UNAVAILABLE.format(error=f"{type(error).__name__}: {error}"), 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()