From c8c218e4ac7b2cc96815997c6827bad3d573fc8f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 20:54:30 +0400 Subject: [PATCH] =?UTF-8?q?test:=20=D1=81=D1=83=D0=B6=D0=B5=D0=BD=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=85=D0=B2=D0=B0=D1=82=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BE=D0=BA=20PostgreSQL=20=D0=B2=20conftest,=20=D1=81?= =?UTF-8?q?=D0=B0=D0=BC=D0=BE=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BE=D0=BB=D1=8F=D1=86=D0=B8=D0=B8=20=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D0=B8?= 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 | 13 +++++++---- api/tests/factories.py | 4 +++- api/tests/test_money_types.py | 42 ++++++++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 93f6c5c..3f5c8df 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -10,6 +10,7 @@ import uuid from collections.abc import AsyncIterator from pathlib import Path +import asyncpg import pytest import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine @@ -32,7 +33,7 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item return skip = pytest.mark.skip(reason=reason) for item in items: - if "fixtures" in item.keywords: + if item.get_closest_marker("fixtures") is not None: item.add_marker(skip) @@ -51,10 +52,14 @@ async def migrated_db_url() -> AsyncIterator[str]: 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 и т.п. при недоступном сервере + 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") diff --git a/api/tests/factories.py b/api/tests/factories.py index 38a6cc8..150a585 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -24,11 +24,13 @@ 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) + entity = LegalEntity(inn=str(uuid.uuid4().int)[:10], 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() diff --git a/api/tests/test_money_types.py b/api/tests/test_money_types.py index 16a1228..732b0d1 100644 --- a/api/tests/test_money_types.py +++ b/api/tests/test_money_types.py @@ -8,12 +8,14 @@ import decimal import pytest from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from src.models.osv import OsvEntry from tests.factories import make_osv_entry AMOUNT = decimal.Decimal("106311116.46") +TWO_ENTRIES = 2 pytestmark = pytest.mark.integration @@ -41,3 +43,41 @@ async def test_rollback_isolation_next_test_sees_nothing(db_session: AsyncSessio """Вторая половина (порядок — по именам внутри модуля): данные предыдущего теста откатились.""" count = (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() assert count == 0 + + +async def _count_via_separate_connection(engine: AsyncEngine) -> int: + """Число строк osv_entry глазами другого соединения: видит только зафиксированное по-настоящему.""" + async with engine.connect() as other: + return (await other.execute(select(func.count()).select_from(OsvEntry))).scalar_one() + + +async def test_commit_in_test_is_not_persisted(db_session: AsyncSession, db_engine: AsyncEngine) -> None: + """Самодостаточная проверка `create_savepoint`: `commit()` в тесте не фиксирует данные для других соединений.""" + await make_osv_entry(db_session, closing_debit=AMOUNT) + await db_session.commit() + assert await _count_via_separate_connection(db_engine) == 0 + # свои данные сессия видит, и после освобождающего SAVEPOINT коммита она остаётся рабочей + assert (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() == 1 + await make_osv_entry(db_session, closing_debit=AMOUNT) + await db_session.commit() + assert await _count_via_separate_connection(db_engine) == 0 + + +async def test_rollback_after_integrity_error_keeps_session_usable(db_session: AsyncSession) -> None: + """Ошибка целостности + `rollback()` откатывают только текущий SAVEPOINT: внешняя транзакция жива, прежнее видно.""" + first = await make_osv_entry(db_session, closing_debit=AMOUNT) + await db_session.commit() # данные первой части лежат в внешней транзакции + duplicate = OsvEntry( + recognition_job_id=first.recognition_job_id, + document_id=first.document_id, + owner_legal_entity_id=first.owner_legal_entity_id, + row_number=first.row_number, # нарушает uq_osv_entry_document_id_row_number + ) + db_session.add(duplicate) + with pytest.raises(IntegrityError): + await db_session.flush() + await db_session.rollback() + count = (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() + assert count == 1 # закоммиченное в SAVEPOINT осталось, дубликат откатился + await make_osv_entry(db_session, closing_debit=AMOUNT) # сессия по-прежнему рабочая + assert (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one() == TWO_ENTRIES