test: conftest — БД сессии с миграциями, транзакция с откатом на тест, маркер fixtures, тест чтения Decimal

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 20:46:50 +04:00
parent 78e4a95327
commit c3cf307866
7 changed files with 294 additions and 27 deletions

View File

@ -1,3 +1,87 @@
"""Общие фикстуры тестов."""
"""Общие фикстуры тестов (spec data-model; design D-7, рулинг R-16).
pytest_plugins = ["tests.pg_helpers"]
БД-фикстуры — только для тестов с маркером `integration` и ленивые: пока тест их не запросил, PostgreSQL не
трогается (`make check` не требует БД). Тестовая БД сессии — отдельная свежая `armcore_pytest_<hex>` на сервере из
`.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_<hex>` с применённой миграцией `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()

69
api/tests/factories.py Normal file
View File

@ -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

View File

@ -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)"
)

View File

@ -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=<url> <args>` из каталога 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()

View File

@ -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

View File

@ -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=<url> <args>` из каталога 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 $$"

View File

@ -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