test: сужен перехват ошибок PostgreSQL в conftest, самодостаточные тесты изоляции транзакции
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b1f76d0a7d
commit
c8c218e4ac
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user