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 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")
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user