arm-core/api/tests/test_exception_handlers.py
Andrey Epifancev 070a398c65 fix(api): гонка уникальных ограничений отдаёт тот же 409 с кодом, что и pre-check (T6.5, T7.1, T8.2, T12.7, T15.3, T15.4)
Реестр имя ограничения PostgreSQL -> (код, постоянный текст) в common/constraint_conflicts.py;
integrity_error_handler для unique_violation (23505) по известному ограничению отдаёт
{"detail": {"code", "message"}}, иначе прежняя общая строка. Имя берётся из полей драйвера
(asyncpg __cause__.constraint_name, psycopg diag), не из текста ошибки; resolved_code пишется в лог.
Тесты: unit обработчика/реестра (ключи есть в Base.metadata), интеграционные гонки (ГК/ЮЛ crm_id,
ИНН, алиас, email, членство, откат и следующий запрос, FK остаётся общим 409).
T15.3: README шаг 4 импорта; T15.4: test_components_have_no_duplicated_models.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 02:02:14 +04:00

235 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Обработчики ошибок БД (`src/core/exception_handlers.py`, финальное ревью I-2): ответ прежний, в лог — одна запись
ERROR без параметров запроса и DETAIL PostgreSQL (персональные данные)."""
from collections.abc import Iterator
from http import HTTPStatus
from typing import Any
import httpx
import pytest
from fastapi import FastAPI
from loguru import logger
from sqlalchemy import UniqueConstraint
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
from src.common.constraint_conflicts import UNIQUE_CONSTRAINT_CONFLICTS
from src.core.exception_handlers import LOG_STATEMENT_LIMIT, register_exception_handlers
from src.models.base import Base
SECRET = "ivan.petrov@rshb.ru"
class _DriverError(Exception):
"""Ошибка драйвера: как у asyncpg, текст содержит DETAIL со значением ключа, есть `sqlstate`."""
sqlstate = "23505"
def _app() -> FastAPI:
app = FastAPI()
register_exception_handlers(app)
@app.get("/integrity")
async def integrity() -> None:
raise IntegrityError(
"INSERT INTO dkb.app_user (email) VALUES ($1)",
(SECRET,),
_DriverError(f"duplicate key\nDETAIL: Key (email)=({SECRET}) already exists."),
)
@app.get("/operational")
async def operational() -> None:
raise OperationalError("SELECT " + "x" * 1000 + " WHERE email = $1", (SECRET,), ConnectionError(SECRET))
@app.get("/generic")
async def generic() -> None:
raise SQLAlchemyError(f"ошибка без DBAPI: {SECRET}")
return app
@pytest.fixture
def records() -> Iterator[list[Any]]:
"""Записи loguru уровня DEBUG и выше за время теста (временный sink)."""
collected: list[Any] = []
handler_id = logger.add(lambda message: collected.append(message.record), level="DEBUG")
try:
yield collected
finally:
logger.remove(handler_id)
async def _get(path: str) -> httpx.Response:
transport = httpx.ASGITransport(app=_app())
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get(path)
@pytest.mark.parametrize(
("path", "status", "detail", "error_type"),
[
("/integrity", HTTPStatus.CONFLICT, "нарушение целостности данных в БД", "IntegrityError"),
("/operational", HTTPStatus.INTERNAL_SERVER_ERROR, "ошибка базы данных", "OperationalError"),
("/generic", HTTPStatus.INTERNAL_SERVER_ERROR, "ошибка базы данных", "SQLAlchemyError"),
],
)
async def test_db_error_logged_once_without_pii(
records: list[Any], path: str, status: HTTPStatus, detail: str, error_type: str
) -> None:
response = await _get(path)
assert response.status_code == status
assert response.json() == {"detail": detail}
errors = [r for r in records if r["level"].no >= logger.level("ERROR").no]
assert len(errors) == 1
(record,) = errors
assert record["level"].name == "ERROR"
message = record["message"]
assert f"path={path}" in message
assert "method=GET" in message
assert f"error_type={error_type}" in message
assert record["exception"] is None # текст исключения (параметры, DETAIL) в лог не попадает
assert SECRET not in message
assert "test_exception_handlers.py" in message # стек вызовов — есть
async def test_integrity_error_log_has_driver_class_sqlstate_and_statement(records: list[Any]) -> None:
await _get("/integrity")
(record,) = [r for r in records if r["level"].name == "ERROR"]
assert "db_error_type=_DriverError" in record["message"]
assert "sqlstate=23505" in record["message"]
assert "statement='INSERT INTO dkb.app_user (email) VALUES ($1)'" in record["message"]
async def test_long_statement_is_truncated(records: list[Any]) -> None:
await _get("/operational")
(record,) = [r for r in records if r["level"].name == "ERROR"]
statement = record["extra"]["statement"]
assert len(statement) <= LOG_STATEMENT_LIMIT + 2 # repr добавляет кавычки
assert "WHERE email" not in statement
# --- гонки уникальных ограничений: реестр `common/constraint_conflicts.py` -------------------------------------------
class _AsyncpgCause(Exception):
"""Как `asyncpg.UniqueViolationError`: структурированное имя ограничения; текст — с DETAIL и значением."""
def __init__(self, constraint_name: str | None, sqlstate: str = "23505") -> None:
super().__init__(f'duplicate key value violates unique constraint "x"\nDETAIL: Key (email)=({SECRET}) exists.')
self.constraint_name = constraint_name
self.sqlstate = sqlstate
class _AdaptedDriverError(Exception):
"""Как `AsyncAdapt_asyncpg_dbapi.IntegrityError`: `sqlstate` на адаптере, `__cause__` — исключение asyncpg."""
def __init__(self, cause: _AsyncpgCause) -> None:
super().__init__(str(cause))
self.sqlstate = cause.sqlstate
self.__cause__ = cause
class _Diag:
def __init__(self, constraint_name: str) -> None:
self.constraint_name = constraint_name
class _PsycopgError(Exception):
"""Как `psycopg.errors.UniqueViolation`: `sqlstate` и `diag.constraint_name` на самом исключении."""
sqlstate = "23505"
def __init__(self, constraint_name: str) -> None:
super().__init__(f"duplicate key: {SECRET}")
self.diag = _Diag(constraint_name)
def _integrity(orig: Exception) -> IntegrityError:
return IntegrityError("INSERT INTO dkb.app_user (email) VALUES ($1)", (SECRET,), orig)
def _app_raising(exc: Exception) -> FastAPI:
app = FastAPI()
register_exception_handlers(app)
@app.get("/boom")
async def boom() -> None:
raise exc
return app
async def _get_raising(exc: Exception) -> httpx.Response:
transport = httpx.ASGITransport(app=_app_raising(exc))
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get("/boom")
def test_registry_keys_exist_as_unique_constraints_or_indexes_in_metadata() -> None:
"""Переименование ограничения в ORM ломает реестр молча (гонка снова отдаёт общий 409) — ловим здесь."""
unique_names: set[str] = set()
for table in Base.metadata.tables.values():
unique_names |= {c.name for c in table.constraints if isinstance(c, UniqueConstraint) and c.name}
unique_names |= {i.name for i in table.indexes if i.unique and i.name}
assert set(UNIQUE_CONSTRAINT_CONFLICTS) <= unique_names
def test_registry_codes_and_messages_match_service_precheck_texts() -> None:
codes = {name: conflict.code for name, conflict in UNIQUE_CONSTRAINT_CONFLICTS.items()}
assert codes == {
"uq_company_group_crm_id": "CRM_ID_EXISTS",
"uq_legal_entity_crm_id": "CRM_ID_EXISTS",
"uq_legal_entity_inn": "INN_EXISTS",
"uq_legal_entity_alias_legal_entity_id_normalized_alias": "ALIAS_EXISTS",
"uq_app_user_email": "EMAIL_EXISTS",
"uq_related_group_member_group_entity_current": "MEMBER_EXISTS",
"uq_related_group_member_entity_kind_current": "MEMBER_OF_OTHER_GROUP",
}
# у pre-check-ов постоянные тексты — тот же `message`, динамических значений в реестре нет
assert UNIQUE_CONSTRAINT_CONFLICTS["uq_legal_entity_inn"].message == "ЮЛ с таким ИНН уже существует"
assert UNIQUE_CONSTRAINT_CONFLICTS["uq_app_user_email"].message == "Пользователь с таким email уже существует"
@pytest.mark.parametrize(
"orig",
[
_AdaptedDriverError(_AsyncpgCause("uq_app_user_email")),
_PsycopgError("uq_app_user_email"),
],
ids=["asyncpg-cause", "psycopg-diag"],
)
async def test_known_unique_violation_gives_coded_conflict_and_logs_code(records: list[Any], orig: Exception) -> None:
response = await _get_raising(_integrity(orig))
assert response.status_code == HTTPStatus.CONFLICT
assert response.json() == {
"detail": {"code": "EMAIL_EXISTS", "message": "Пользователь с таким email уже существует"}
}
assert SECRET not in response.text
(record,) = [r for r in records if r["level"].name == "ERROR"]
assert "resolved_code=EMAIL_EXISTS" in record["message"]
assert SECRET not in record["message"]
assert "uq_app_user_email" not in response.text
@pytest.mark.parametrize(
"orig",
[
_AdaptedDriverError(_AsyncpgCause("uq_unknown_constraint")), # unique, но не из реестра
_AdaptedDriverError(_AsyncpgCause(None)), # имя не определилось
_AdaptedDriverError(_AsyncpgCause("uq_app_user_email", sqlstate="23503")), # FK, не unique
_AdaptedDriverError(_AsyncpgCause("uq_app_user_email", sqlstate="23514")), # check violation
_DriverError("no constraint name"),
],
ids=["unknown-unique", "no-name", "foreign-key", "check", "bare-driver-error"],
)
async def test_other_integrity_errors_stay_generic(records: list[Any], orig: Exception) -> None:
response = await _get_raising(_integrity(orig))
assert response.status_code == HTTPStatus.CONFLICT
assert response.json() == {"detail": "нарушение целостности данных в БД"}
(record,) = [r for r in records if r["level"].name == "ERROR"]
assert "resolved_code=None" in record["message"]
assert SECRET not in record["message"]