From c3cf3078669511c2e2d2a4a60a5ce97384457078 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 20:46:50 +0400 Subject: [PATCH] =?UTF-8?q?test:=20conftest=20=E2=80=94=20=D0=91=D0=94=20?= =?UTF-8?q?=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8=20=D1=81=20=D0=BC=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=D0=BC=D0=B8,=20=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D1=8F=20=D1=81?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=D0=BE=D0=BC=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82,=20=D0=BC=D0=B0=D1=80=D0=BA=D0=B5?= =?UTF-8?q?=D1=80=20fixtures,=20=D1=82=D0=B5=D1=81=D1=82=20=D1=87=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20Decimal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/conftest.py | 88 ++++++++++++++++++++++++++++++- api/tests/factories.py | 69 ++++++++++++++++++++++++ api/tests/fixtures_helpers.py | 28 ++++++++++ api/tests/pg_helpers.py | 29 ++++++++++ api/tests/test_fixtures_marker.py | 36 +++++++++++++ api/tests/test_migrations.py | 28 ++-------- api/tests/test_money_types.py | 43 +++++++++++++++ 7 files changed, 294 insertions(+), 27 deletions(-) create mode 100644 api/tests/factories.py create mode 100644 api/tests/fixtures_helpers.py create mode 100644 api/tests/test_fixtures_marker.py create mode 100644 api/tests/test_money_types.py diff --git a/api/tests/conftest.py b/api/tests/conftest.py index e5eeb18..93f6c5c 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,3 +1,87 @@ -"""Общие фикстуры тестов.""" +"""Общие фикстуры тестов (spec data-model; design D-7, рулинг R-16). -pytest_plugins = ["tests.pg_helpers"] +БД-фикстуры — только для тестов с маркером `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() diff --git a/api/tests/factories.py b/api/tests/factories.py new file mode 100644 index 0000000..38a6cc8 --- /dev/null +++ b/api/tests/factories.py @@ -0,0 +1,69 @@ +"""Фабрики тестовых данных для БД-тестов: минимальные цепочки FK поверх сессии (`flush`, без `commit`).""" + +import datetime +import decimal +import uuid +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.directories import LegalEntity +from src.models.enums import ( + DocumentStatus, + DocumentType, + FileStatus, + FileType, + RecognitionPipeline, + RecognitionStatus, + S3BucketRole, +) +from src.models.files import Document, RecognitionJob, S3Object, StoredFile +from src.models.osv import OsvEntry + + +async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry: + """Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`. + + `overrides` перекрывают поля `OsvEntry` (например, `closing_debit=Decimal("1.00")`). Данные остаются в + транзакции сессии: фикстура `db_session` откатывает их после теста. + """ + now = datetime.datetime.now(datetime.UTC) + entity = LegalEntity(inn="7700000001", name="ООО Тест", valid_from=now) + s3 = S3Object(bucket_role=S3BucketRole.uploads, bucket="uploads", key=f"{uuid.uuid4()}/osv.xlsx", created_at=now) + session.add_all([entity, s3]) + await session.flush() + file = StoredFile( + legal_entity_id=entity.id, file_type=FileType.XLSX, s3_object_id=s3.id, status=FileStatus.UPLOADED + ) + session.add(file) + await session.flush() + job = RecognitionJob( + file_id=file.id, + pipeline=RecognitionPipeline.tabular, + request_subject="recognition.tabular.request", + schema_version="1", + status=RecognitionStatus.queued, + ) + session.add(job) + await session.flush() + document = Document( + stored_file_id=file.id, + legal_entity_id=entity.id, + document_type=DocumentType.OSV, + osv_account_code="62", + status=DocumentStatus.RECOGNIZED, + ) + session.add(document) + await session.flush() + fields: dict[str, Any] = { + "recognition_job_id": job.id, + "document_id": document.id, + "owner_legal_entity_id": entity.id, + "row_number": 1, + "closing_debit": decimal.Decimal("0.00"), + } + fields.update(overrides) + entry = OsvEntry(**fields) + session.add(entry) + await session.flush() + return entry diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py new file mode 100644 index 0000000..242ece7 --- /dev/null +++ b/api/tests/fixtures_helpers.py @@ -0,0 +1,28 @@ +"""Поиск каталога реальных данных `fixtures` (соседний репозиторий workspace) для тестов с маркером `fixtures`.""" + +import os +from pathlib import Path + +FIXTURES_ENV = "ARM_KA_FIXTURES" + + +def resolve_fixtures_dir() -> Path: + """Каталог fixtures: `ARM_KA_FIXTURES`, иначе соседний `../../fixtures` относительно `arm-core/api`. + + Не проверяет существование: это делает вызывающий код (пропуск теста с причиной). + """ + explicit = os.environ.get(FIXTURES_ENV) + if explicit: + return Path(explicit) + return Path(__file__).resolve().parents[3] / "fixtures" + + +def fixtures_skip_reason() -> str | None: + """Причина пропуска тестов с маркером `fixtures` или `None`, если каталог доступен.""" + path = resolve_fixtures_dir() + if path.is_dir(): + return None + return ( + f"ARM_KA_FIXTURES недоступен: нет каталога {path} " + f"(задайте {FIXTURES_ENV} или положите fixtures рядом с arm-core)" + ) diff --git a/api/tests/pg_helpers.py b/api/tests/pg_helpers.py index a924716..8138471 100644 --- a/api/tests/pg_helpers.py +++ b/api/tests/pg_helpers.py @@ -4,8 +4,12 @@ в конце удаляет. Рабочую БД (`armka`) тесты не трогают. Подключается плагином в `conftest.py`. """ +import asyncio +import subprocess +import sys import uuid from collections.abc import AsyncIterator +from pathlib import Path import asyncpg import pytest_asyncio @@ -13,6 +17,8 @@ from sqlalchemy.engine import URL, make_url from src.core.config import settings +API_DIR = Path(__file__).resolve().parents[1] + def base_url() -> URL: """SQLAlchemy-URL рабочей БД из настроек (`.env.test`).""" @@ -23,6 +29,29 @@ def _asyncpg_dsn(url: URL) -> str: return url.set(drivername="postgresql").render_as_string(hide_password=False) +def run_alembic(url: str, *args: str) -> str: + """`alembic -x dburl= ` из каталога api/ отдельным процессом; вернуть stdout+stderr. + + `env.py` вызывает `asyncio.run(...)` — внутри event loop pytest-asyncio это невозможно. При ошибке — + `AssertionError` с выводом процесса (`ENV_FILE`/`APP_ENV` наследуются из окружения pytest-env). + """ + result = subprocess.run( + [sys.executable, "-m", "alembic", "-x", f"dburl={url}", *args], + cwd=API_DIR, + capture_output=True, + text=True, + check=False, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, f"alembic {' '.join(args)} завершился с кодом {result.returncode}:\n{output}" + return output + + +async def alembic_async(url: str, *args: str) -> str: + """`run_alembic` в потоке, чтобы не блокировать event loop.""" + return await asyncio.to_thread(run_alembic, url, *args) + + async def create_scratch_db(name: str) -> URL: """Создать пустую БД `name` на сервере рабочей БД; вернуть SQLAlchemy-URL на неё.""" url = base_url() diff --git a/api/tests/test_fixtures_marker.py b/api/tests/test_fixtures_marker.py new file mode 100644 index 0000000..6206e97 --- /dev/null +++ b/api/tests/test_fixtures_marker.py @@ -0,0 +1,36 @@ +"""Маркер `fixtures`: путь к реальным данным и пропуск с причиной (spec quality-checks, «Фикстуры … опциональны»).""" + +from pathlib import Path + +import pytest + +from tests.fixtures_helpers import FIXTURES_ENV, fixtures_skip_reason, resolve_fixtures_dir + + +@pytest.mark.fixtures +def test_fixtures_dir_contains_readme(fixtures_dir: Path) -> None: + """При доступных fixtures каталог существует и содержит README.md; иначе тест пропускается с причиной.""" + assert fixtures_dir.is_dir() + assert (fixtures_dir / "README.md").is_file() + + +def test_resolve_prefers_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv(FIXTURES_ENV, str(tmp_path)) + assert resolve_fixtures_dir() == tmp_path + assert fixtures_skip_reason() is None + + +def test_resolve_falls_back_to_sibling_dir(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(FIXTURES_ENV, raising=False) + path = resolve_fixtures_dir() + assert path.name == "fixtures" + assert path.parent == Path(__file__).resolve().parents[3] + + +def test_skip_reason_names_variable_and_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + missing = tmp_path / "нет-такого" + monkeypatch.setenv(FIXTURES_ENV, str(missing)) + reason = fixtures_skip_reason() + assert reason is not None + assert "ARM_KA_FIXTURES недоступен" in reason + assert str(missing) in reason diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 780717d..d33289f 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -6,11 +6,7 @@ CLI: `env.py` вызывает `asyncio.run(...)`, а внутри event loop py перенастраивает логирование тестов через `fileConfig`. Каждая проверка идёт на своей временной БД (`scratch_db_url`). """ -import asyncio -import subprocess -import sys import uuid -from pathlib import Path from typing import Any import pytest @@ -21,7 +17,8 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, c from src.models import Base from src.models.enums import ENUMS -from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db +from tests.pg_helpers import alembic_async as alembic +from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db, run_alembic from tests.test_models_part_01_03 import _check_related_group_member_uniqueness from tests.test_models_part_04_06 import _check_nulls_not_distinct, _make_file_with_job from tests.test_models_part_07_09 import ( @@ -31,30 +28,11 @@ from tests.test_models_part_07_09 import ( _check_active_lock_is_unique, ) -API_DIR = Path(__file__).resolve().parents[1] HEAD = "0001_dbml_v5_13" VERSION_TABLE = "alembic_version" FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job" -def _run_alembic(url: str, *args: str) -> str: - """`alembic -x dburl= ` из каталога api/; вернуть stdout+stderr, упасть с выводом при ошибке.""" - result = subprocess.run( - [sys.executable, "-m", "alembic", "-x", f"dburl={url}", *args], - cwd=API_DIR, - capture_output=True, - text=True, - check=False, - ) - output = result.stdout + result.stderr - assert result.returncode == 0, f"alembic {' '.join(args)} завершился с кодом {result.returncode}:\n{output}" - return output - - -async def alembic(url: str, *args: str) -> str: - return await asyncio.to_thread(_run_alembic, url, *args) - - async def _scalars(conn: AsyncConnection, sql: str) -> list[Any]: return list((await conn.execute(text(sql))).scalars()) @@ -321,7 +299,7 @@ async def test_upgrade_into_preprovisioned_schema_without_database_create(scratc def test_offline_sql_creates_schema_before_version_table() -> None: """Офлайн-режим (`upgrade head --sql`): условный CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов.""" - output = _run_alembic("postgresql+asyncpg://user:pass@localhost/offline", "upgrade", "head", "--sql") + output = run_alembic("postgresql+asyncpg://user:pass@localhost/offline", "upgrade", "head", "--sql") schema_at = output.index( "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'dkb') " "THEN CREATE SCHEMA dkb; END IF; END $$" diff --git a/api/tests/test_money_types.py b/api/tests/test_money_types.py new file mode 100644 index 0000000..16a1228 --- /dev/null +++ b/api/tests/test_money_types.py @@ -0,0 +1,43 @@ +"""Деньги — точные типы (spec data-model, «Деньги и проценты — точные типы») и изоляция БД-фикстур. + +Сценарий «Чтение денежной колонки»: `osv_entry.closing_debit = 106311116.46` читается как `Decimal("106311116.46")`. +Тесты идут на БД сессии (`migrated_db_url`, миграции alembic) в транзакции с откатом (`db_session`, design D-7, R-16). +""" + +import decimal + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.osv import OsvEntry +from tests.factories import make_osv_entry + +AMOUNT = decimal.Decimal("106311116.46") + +pytestmark = pytest.mark.integration + + +async def test_closing_debit_is_read_back_as_decimal(db_session: AsyncSession) -> None: + """Запись из БД, а не из identity map: тип `Decimal`, значение без потери точности.""" + entry = await make_osv_entry(db_session, closing_debit=AMOUNT) + entry_id = entry.id + db_session.expire_all() # забыть объект: значение читается только запросом + stored = (await db_session.execute(select(OsvEntry.closing_debit).where(OsvEntry.id == entry_id))).scalar_one() + assert type(stored) is decimal.Decimal + assert stored == AMOUNT + assert str(stored) == "106311116.46" + + +async def test_rollback_isolation_writes_and_commits(db_session: AsyncSession) -> None: + """Первая половина проверки изоляции: запись и `commit` внутри теста (SAVEPOINT, не настоящая фиксация).""" + await make_osv_entry(db_session, closing_debit=AMOUNT) + await db_session.commit() + count = (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() + assert count == 1 + + +async def test_rollback_isolation_next_test_sees_nothing(db_session: AsyncSession) -> None: + """Вторая половина (порядок — по именам внутри модуля): данные предыдущего теста откатились.""" + count = (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() + assert count == 0