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 collections.abc import AsyncIterator
from pathlib import Path from pathlib import Path
import asyncpg
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine 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 return
skip = pytest.mark.skip(reason=reason) skip = pytest.mark.skip(reason=reason)
for item in items: for item in items:
if "fixtures" in item.keywords: if item.get_closest_marker("fixtures") is not None:
item.add_marker(skip) item.add_marker(skip)
@ -51,10 +52,14 @@ async def migrated_db_url() -> AsyncIterator[str]:
name = f"armcore_pytest_{uuid.uuid4().hex[:12]}" name = f"armcore_pytest_{uuid.uuid4().hex[:12]}"
try: try:
url = await create_scratch_db(name) url = await create_scratch_db(name)
except (OSError, ConnectionError) as error: except (OSError, asyncpg.InterfaceError) 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) 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: try:
rendered = url.render_as_string(hide_password=False) rendered = url.render_as_string(hide_password=False)
await alembic_async(rendered, "upgrade", "head") 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: async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry:
"""Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`. """Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`.
ИНН ЮЛ случайный, поэтому фабрику можно вызывать несколько раз в одном тесте.
`overrides` перекрывают поля `OsvEntry` (например, `closing_debit=Decimal("1.00")`). Данные остаются в `overrides` перекрывают поля `OsvEntry` (например, `closing_debit=Decimal("1.00")`). Данные остаются в
транзакции сессии: фикстура `db_session` откатывает их после теста. транзакции сессии: фикстура `db_session` откатывает их после теста.
""" """
now = datetime.datetime.now(datetime.UTC) 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) s3 = S3Object(bucket_role=S3BucketRole.uploads, bucket="uploads", key=f"{uuid.uuid4()}/osv.xlsx", created_at=now)
session.add_all([entity, s3]) session.add_all([entity, s3])
await session.flush() await session.flush()

View File

@ -8,12 +8,14 @@ import decimal
import pytest import pytest
from sqlalchemy import func, select 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 src.models.osv import OsvEntry
from tests.factories import make_osv_entry from tests.factories import make_osv_entry
AMOUNT = decimal.Decimal("106311116.46") AMOUNT = decimal.Decimal("106311116.46")
TWO_ENTRIES = 2
pytestmark = pytest.mark.integration 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() count = (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one()
assert count == 0 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