test: conftest — БД сессии с миграциями, транзакция с откатом на тест, маркер fixtures, тест чтения Decimal
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
78e4a95327
commit
c3cf307866
@ -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
69
api/tests/factories.py
Normal 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
|
||||
28
api/tests/fixtures_helpers.py
Normal file
28
api/tests/fixtures_helpers.py
Normal 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)"
|
||||
)
|
||||
@ -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()
|
||||
|
||||
36
api/tests/test_fixtures_marker.py
Normal file
36
api/tests/test_fixtures_marker.py
Normal 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
|
||||
@ -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 $$"
|
||||
|
||||
43
api/tests/test_money_types.py
Normal file
43
api/tests/test_money_types.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user