test: сужен перехват ошибок PostgreSQL в conftest, самодостаточные тесты изоляции транзакции

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 20:54:30 +04:00
parent b1f76d0a7d
commit c8c218e4ac
3 changed files with 53 additions and 6 deletions

View File

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

View File

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

View File

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