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
This commit is contained in:
Andrey Epifancev 2026-10-05 02:02:14 +04:00
parent def3d72935
commit 070a398c65
6 changed files with 469 additions and 11 deletions

View File

@ -251,7 +251,8 @@ curl -s 'localhost:8000/api/v1/directories/company-groups?q=бисквит' -H "
(`errors`) исправляются в файле и прогоняются заново.
3. Когда отчёт устраивает — тот же файл с `dry_run=false`. Один импорт — одна транзакция; результат — отчёт в ответе,
`audit_event(DIRECTORY_IMPORT)` с отчётом в `after_value` и файл в S3 (`uploads/imports/{audit_event_id}/{имя}`).
4. Повторный импорт того же файла при `on_existing=skip` идемпотентен: все строки получают коды `*_EXISTS`.
4. Повторный импорт того же файла при `on_existing=skip` идемпотентен: все ранее принятые строки получают коды `*_EXISTS`;
ранее пропущенные (ошибочные) строки остаются ошибками.
```bash
# шаг 1: пробный прогон (dry_run по умолчанию true, on_existing по умолчанию skip)
@ -374,6 +375,16 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о
другой формы: 401/403/413 — строка, 422 валидации тела — массив ошибок pydantic, 409 нарушения целостности БД — строка.
Объект вне видимости — 404 `NOT_FOUND` с тем же телом, что у несуществующего.
**Гонки уникальных ограничений.** Если параллельный запрос успел вставить запись между проверкой сервиса и INSERT,
БД отклоняет вставку (`unique_violation`), и ответ тот же 409 с тем же кодом, что у проверки (реестр —
`src/common/constraint_conflicts.py`): `CRM_ID_EXISTS` (ГК и ЮЛ), `INN_EXISTS`, `ALIAS_EXISTS`, `EMAIL_EXISTS`,
`MEMBER_EXISTS`/`MEMBER_OF_OTHER_GROUP`. В теле гонки — только код и постоянный текст, без дополнительных полей
(`legal_entity_id`, `related_group_id`) и без значений ключей. Особенности: повторное добавление в ту же группу
отдаёт `MEMBER_OF_OTHER_GROUP` с нейтральным текстом «ЮЛ уже входит в группу этого вида» (его раньше срабатывает индекс
«ЮЛ + вид»); гонка за один алиас у разных ЮЛ (`ALIAS_TAKEN`) в БД не защищена (уникален только `(ЮЛ, алиас)`);
email без учёта регистра проверяет только сервис, поэтому гонка с другим регистром остаётся открытым ограничением.
Любое иное нарушение целостности (FK, check, неизвестное unique) — прежняя строка `нарушение целостности данных в БД`.
| Код | HTTP | Где |
|---|---|---|
| `NOT_FOUND` | 404 | объекта нет или он вне видимости |

View File

@ -0,0 +1,73 @@
"""Гонки уникальных ограничений: имя нарушенного ограничения PostgreSQL → тот же код и текст 409, что у pre-check.
Сервисы проверяют уникальность до вставки (`INN_EXISTS`, `ALIAS_EXISTS`, …), но между проверкой и вставкой параллельный
запрос может успеть раньше — тогда БД отвечает `unique_violation` (SQLSTATE 23505), и `IntegrityError` доходит до
обработчика исключений. Реестр `UNIQUE_CONSTRAINT_CONFLICTS` сопоставляет имя ограничения доменному конфликту, чтобы
клиент видел тот же `{"detail": {"code", "message"}}`, что и без гонки. Текст сообщения — только постоянная часть
pre-check-а: динамические поля (`legal_entity_id`, `related_group_id`) в ответ гонки не попадают.
Имя ограничения берётся из структурированного поля драйвера (asyncpg: `__cause__.constraint_name`, psycopg:
`diag.constraint_name`), а не из текста ошибки: текст содержит DETAIL со значениями ключей (email, ИНН) — это ПДн.
"""
from dataclasses import dataclass
from typing import Any
from sqlalchemy.exc import DBAPIError
UNIQUE_VIOLATION_SQLSTATE = "23505"
@dataclass(frozen=True)
class ConstraintConflict:
"""Код и постоянный текст доменного конфликта 409."""
code: str
message: str
#: Имя уникального ограничения/индекса → конфликт. Ключи обязаны существовать в `Base.metadata`
#: (`tests/test_exception_handlers.py` падает при переименовании). Тексты совпадают с pre-check-ами сервисов.
UNIQUE_CONSTRAINT_CONFLICTS: dict[str, ConstraintConflict] = {
"uq_company_group_crm_id": ConstraintConflict("CRM_ID_EXISTS", "ГК с таким crm_id уже существует"),
"uq_legal_entity_crm_id": ConstraintConflict("CRM_ID_EXISTS", "ЮЛ с таким crm_id уже существует"),
"uq_legal_entity_inn": ConstraintConflict("INN_EXISTS", "ЮЛ с таким ИНН уже существует"),
"uq_legal_entity_alias_legal_entity_id_normalized_alias": ConstraintConflict(
"ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас"
),
"uq_app_user_email": ConstraintConflict("EMAIL_EXISTS", "Пользователь с таким email уже существует"),
"uq_related_group_member_group_entity_current": ConstraintConflict("MEMBER_EXISTS", "ЮЛ уже входит в эту группу"),
# Нарушается и при повторном добавлении в ту же группу (PostgreSQL проверяет индексы в порядке OID, а этот создан
# миграцией 0002 раньше второго), поэтому текст — общий для обоих случаев, а не «в другую группу» как у pre-check-а
"uq_related_group_member_entity_kind_current": ConstraintConflict(
"MEMBER_OF_OTHER_GROUP", "ЮЛ уже входит в группу этого вида"
),
}
def _sqlstate(orig: Any) -> str | None:
value = getattr(orig, "sqlstate", None) or getattr(orig, "pgcode", None)
return value if isinstance(value, str) else None
def constraint_name(exc: DBAPIError) -> str | None:
"""Имя нарушенного ограничения из полей драйвера (asyncpg — `__cause__`, psycopg — `diag`); текст не разбирается."""
orig = exc.orig
if orig is None:
return None
for source in (getattr(orig, "__cause__", None), orig):
name = getattr(source, "constraint_name", None)
if isinstance(name, str) and name:
return name
diag_name = getattr(getattr(source, "diag", None), "constraint_name", None)
if isinstance(diag_name, str) and diag_name:
return diag_name
return None
def resolve_unique_conflict(exc: DBAPIError) -> ConstraintConflict | None:
"""Конфликт из реестра, если это `unique_violation` по известному ограничению; иначе `None` (общий 409)."""
if _sqlstate(exc.orig) != UNIQUE_VIOLATION_SQLSTATE:
return None
name = constraint_name(exc)
return UNIQUE_CONSTRAINT_CONFLICTS.get(name) if name is not None else None

View File

@ -1,9 +1,11 @@
"""Обработчики исключений приложения: единый формат ответа `{"detail": ...}`.
Ошибки БД (`IntegrityError` → 409, прочие `SQLAlchemyError` → 500) пишутся в лог одной записью уровня ERROR без
текста исключения: `str()` ошибки SQLAlchemy/asyncpg содержит параметры запроса и DETAIL PostgreSQL со значениями
ключей (email, ИНН) — это персональные данные. В лог идут путь и метод запроса, классы ошибок, SQLSTATE, текст SQL
с плейсхолдерами (без значений, усечён) и стек вызовов без сообщения исключения.
`IntegrityError` → 409: для известных уникальных ограничений — доменный 409 с кодом из
`common/constraint_conflicts.py` (гонка pre-check/INSERT), иначе общая строка; прочие `SQLAlchemyError` → 500.
Ошибки БД пишутся в лог одной записью уровня ERROR без текста исключения: `str()` ошибки SQLAlchemy/asyncpg содержит
параметры запроса и DETAIL PostgreSQL со значениями ключей (email, ИНН) — это персональные данные. В лог идут путь и
метод запроса, классы ошибок, SQLSTATE, код разрешённого конфликта, текст SQL с плейсхолдерами (без значений, усечён) и
стек вызовов без сообщения исключения.
"""
import traceback
@ -15,23 +17,28 @@ from fastapi.responses import JSONResponse
from loguru import logger
from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError
from src.common.constraint_conflicts import resolve_unique_conflict
from src.common.exceptions import DomainError
LOG_STATEMENT_LIMIT = 200 # символов SQL-текста (с плейсхолдерами) в записи лога
def log_db_error(request: Request, exc: SQLAlchemyError, message: str) -> None:
"""Одна запись ERROR об ошибке БД при обработке запроса — без параметров запроса и текста ошибки драйвера."""
def log_db_error(request: Request, exc: SQLAlchemyError, message: str, *, resolved_code: str | None = None) -> None:
"""Одна запись ERROR об ошибке БД при обработке запроса — без параметров запроса и текста ошибки драйвера.
`resolved_code` — код доменного конфликта, в который отображена гонка уникальных ограничений (для диагностики).
"""
orig = exc.orig if isinstance(exc, DBAPIError) else None
statement = exc.statement if isinstance(exc, DBAPIError) else None
logger.error(
message + " path={path}, method={method}, error_type={error_type}, db_error_type={db_error_type}, "
"sqlstate={sqlstate}, statement={statement}\n{stack}",
"sqlstate={sqlstate}, resolved_code={resolved_code}, statement={statement}\n{stack}",
path=request.url.path,
method=request.method,
error_type=type(exc).__name__,
db_error_type=type(orig).__name__ if orig is not None else None,
sqlstate=getattr(orig, "sqlstate", None),
resolved_code=resolved_code,
statement=repr(statement[:LOG_STATEMENT_LIMIT]) if statement else None,
stack="".join(traceback.format_tb(exc.__traceback__)).rstrip(),
)
@ -56,7 +63,19 @@ def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(IntegrityError)
async def integrity_error_handler(request: Request, exc: IntegrityError):
log_db_error(request, exc, "Нарушение целостности данных в БД при обработке запроса.")
# Гонка pre-check/INSERT по известному уникальному ограничению → тот же 409 с кодом, что отдал бы pre-check
conflict = resolve_unique_conflict(exc)
log_db_error(
request,
exc,
"Нарушение целостности данных в БД при обработке запроса.",
resolved_code=conflict.code if conflict is not None else None,
)
if conflict is not None:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": {"code": conflict.code, "message": conflict.message}},
)
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": "нарушение целостности данных в БД"},

View File

@ -0,0 +1,223 @@
"""Гонка pre-check/INSERT по уникальным ограничениям отдаёт тот же 409 с кодом, что и pre-check (T6.5, T7.1, T12.7).
Гонка воспроизводится без параллелизма: pre-check сервиса подменяется заглушкой «свободно», а дубликат отправляется
обычным запросом — вставку отклоняет сама БД (`IntegrityError` → `integrity_error_handler`). В теле ответа — только код
и постоянный текст, без значений ключей и идентификаторов.
"""
import uuid
from typing import Any
import httpx
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.directories import RegionalOffice
from src.models.enums import RelatedGroupKind
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group
HTTP_OK = 200
HTTP_CREATED = 201
HTTP_CONFLICT = 409
GO = "ka_go@armka.local"
ADMIN = "admin@armka.local"
GROUPS = "/directories/company-groups"
ENTITIES = "/directories/legal-entities"
RELATED = "/directories/related-groups"
USERS = "/admin/users"
DIRECTORIES_REPO = "src.repositories.directories"
USERS_REPO = "src.repositories.users"
GENERIC_DETAIL = "нарушение целостности данных в БД"
def _free(value: Any) -> Any:
"""Заглушка pre-check-а: «занято/существует» не обнаружено."""
async def check(*_args: Any, **_kwargs: Any) -> Any:
return value
return check
def _assert_coded(response: httpx.Response, code: str, message: str, *secrets: str) -> None:
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json() == {"detail": {"code": code, "message": message}}
for secret in secrets:
assert secret not in response.text
@pytest.mark.integration
async def test_company_group_crm_id_race(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await make_company_group(seeded, crm_id="CRM-RACE-1")
client = await auth_client(GO)
monkeypatch.setattr(f"{DIRECTORIES_REPO}.crm_id_taken", _free(False))
response = await client.post(GROUPS, json={"name": "ГК Гонка", "crm_id": "CRM-RACE-1"})
_assert_coded(response, "CRM_ID_EXISTS", "ГК с таким crm_id уже существует", "CRM-RACE-1")
@pytest.mark.integration
async def test_company_group_crm_id_race_on_patch(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await make_company_group(seeded, crm_id="CRM-RACE-2")
other = await make_company_group(seeded, name="ГК Другая")
client = await auth_client(GO)
monkeypatch.setattr(f"{DIRECTORIES_REPO}.crm_id_taken", _free(False))
response = await client.patch(f"{GROUPS}/{other.id}", json={"crm_id": "CRM-RACE-2"})
_assert_coded(response, "CRM_ID_EXISTS", "ГК с таким crm_id уже существует", "CRM-RACE-2")
@pytest.mark.integration
async def test_legal_entity_inn_race(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
existing = await make_legal_entity(seeded, inn="7701234567")
client = await auth_client(GO)
monkeypatch.setattr(f"{DIRECTORIES_REPO}.get_legal_entity_by_inn", _free(None))
response = await client.post(ENTITIES, json={"inn": "7701234567", "entity_type": "ЮЛ", "name": "ООО Дубль"})
_assert_coded(response, "INN_EXISTS", "ЮЛ с таким ИНН уже существует", "7701234567", str(existing.id))
@pytest.mark.integration
async def test_legal_entity_crm_id_race(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
entity = await make_legal_entity(seeded, inn="7701234567")
entity.crm_id = "LE-RACE-1"
await seeded.flush()
client = await auth_client(GO)
monkeypatch.setattr(f"{DIRECTORIES_REPO}.legal_entity_crm_id_taken", _free(False))
response = await client.post(
ENTITIES, json={"inn": "7707083893", "entity_type": "ЮЛ", "name": "ООО Новое", "crm_id": "LE-RACE-1"}
)
_assert_coded(response, "CRM_ID_EXISTS", "ЮЛ с таким crm_id уже существует", "LE-RACE-1", "7707083893")
@pytest.mark.integration
async def test_alias_race(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
entity = await make_legal_entity(seeded)
client = await auth_client(GO)
first = await client.post(f"{ENTITIES}/{entity.id}/aliases", json={"alias": "ООО «Пекарня Гонка»"})
assert first.status_code == HTTP_CREATED, first.text
monkeypatch.setattr(f"{DIRECTORIES_REPO}.find_alias_owner", _free(None))
response = await client.post(f"{ENTITIES}/{entity.id}/aliases", json={"alias": "Пекарня Гонка, ООО"})
_assert_coded(response, "ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас", "пекарня гонка", str(entity.id))
@pytest.mark.integration
async def test_user_email_race(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
office = (await seeded.execute(select(RegionalOffice).limit(1))).scalar_one()
admin = await auth_client(ADMIN)
body = {"email": "race.user@example.com", "full_name": "Гонка Гонкин", "regional_office_id": str(office.id)}
assert (await admin.post(USERS, json=body)).status_code == HTTP_CREATED
monkeypatch.setattr(f"{USERS_REPO}.email_taken", _free(False))
response = await admin.post(USERS, json=body)
_assert_coded(
response, "EMAIL_EXISTS", "Пользователь с таким email уже существует", "race.user@example.com", "Гонка"
)
@pytest.mark.integration
async def test_user_email_race_with_other_case_is_not_caught(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Известное ограничение: DB-ограничение чувствительно к регистру — дубликат в другом регистре гонку не ловит."""
office = (await seeded.execute(select(RegionalOffice).limit(1))).scalar_one()
admin = await auth_client(ADMIN)
body = {"email": "Race.Case@example.com", "full_name": "Гонка Регистр", "regional_office_id": str(office.id)}
assert (await admin.post(USERS, json=body)).status_code == HTTP_CREATED
monkeypatch.setattr(f"{USERS_REPO}.email_taken", _free(False))
response = await admin.post(USERS, json={**body, "email": "race.case@example.com"})
assert response.status_code == HTTP_CREATED # индекс по точному значению: дубль в другом регистре проходит
@pytest.mark.integration
async def test_membership_same_group_race_reports_group_of_this_kind(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
group = await make_company_group(seeded)
entity = await make_legal_entity(seeded, group=group)
gsk = await make_related_group(seeded, group=group)
await make_membership(seeded, related_group=gsk, entity=entity)
client = await auth_client(GO)
monkeypatch.setattr(f"{DIRECTORIES_REPO}.current_membership_of_kind", _free(None))
response = await client.post(f"{RELATED}/{gsk.id}/members", json={"legal_entity_id": str(entity.id)})
# Первым срабатывает индекс (ЮЛ, вид), общий для «той же» и «другой» группы: код один, текст нейтральный
_assert_coded(response, "MEMBER_OF_OTHER_GROUP", "ЮЛ уже входит в группу этого вида", str(entity.id), str(gsk.id))
@pytest.mark.integration
async def test_membership_other_group_race(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
group = await make_company_group(seeded)
entity = await make_legal_entity(seeded, group=group)
first = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsk, name="ГСК 1")
second = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsk, name="ГСК 2")
await make_membership(seeded, related_group=first, entity=entity)
client = await auth_client(GO)
monkeypatch.setattr(f"{DIRECTORIES_REPO}.current_membership_of_kind", _free(None))
response = await client.post(f"{RELATED}/{second.id}/members", json={"legal_entity_id": str(entity.id)})
_assert_coded(response, "MEMBER_OF_OTHER_GROUP", "ЮЛ уже входит в группу этого вида", str(entity.id), str(first.id))
@pytest.mark.integration
async def test_race_rolls_back_and_next_request_works(
committed_client: AuthClientFactory, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Сессия как в проде (без SAVEPOINT): после проваленного flush откат, следующий запрос работает."""
client = await committed_client(GO)
created = await client.post(GROUPS, json={"name": "ГК Первая", "crm_id": "CRM-TX-1"})
assert created.status_code == HTTP_CREATED, created.text
monkeypatch.setattr(f"{DIRECTORIES_REPO}.crm_id_taken", _free(False))
raced = await client.post(GROUPS, json={"name": "ГК Вторая", "crm_id": "CRM-TX-1"})
_assert_coded(raced, "CRM_ID_EXISTS", "ГК с таким crm_id уже существует", "CRM-TX-1")
monkeypatch.undo()
follow_up = await client.post(GROUPS, json={"name": "ГК Третья", "crm_id": "CRM-TX-2"})
assert follow_up.status_code == HTTP_CREATED, follow_up.text
listing = await client.get(GROUPS)
assert listing.status_code == HTTP_OK
names = sorted(item["name"] for item in listing.json()["items"])
assert names == ["ГК Первая", "ГК Третья"] # неудавшаяся «Вторая» не сохранилась
@pytest.mark.integration
async def test_foreign_key_violation_stays_generic_conflict(
auth_client: AuthClientFactory, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Нарушение FK (не unique) — прежний общий 409 строкой."""
client = await auth_client(ADMIN)
monkeypatch.setattr(f"{DIRECTORIES_REPO}.get_regional_office", _free(object()))
response = await client.post(GROUPS, json={"name": "ГК Без филиала", "base_regional_office_id": str(uuid.uuid4())})
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json() == {"detail": GENERIC_DETAIL}

View File

@ -9,9 +9,12 @@ 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"
@ -104,3 +107,128 @@ async def test_long_statement_is_truncated(records: list[Any]) -> None:
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"]

View File

@ -72,8 +72,12 @@ def test_no_unexpected_routes_under_directories_and_admin(schema: dict[str, Any]
def test_components_have_no_duplicated_models(schema: dict[str, Any]) -> None:
"""Одна модель — одно имя: без автоимён вида `src__schemas__x__RegionalOfficeOut` и суффиксов `-Input`/`-Output`."""
"""Одна модель — одно имя: нет автоимён вида `src__schemas__x__RegionalOfficeOut` и суффиксов `-Input`/`-Output`.
Имена — ключи словаря, поэтому дубликатов быть не может; реальный страж — проверка на `__`/`-` (FastAPI даёт такие
имена, когда две разные модели совпали по имени или одна модель разошлась на вход/выход).
"""
names = list(schema["components"]["schemas"])
for model in SINGLE_MODELS:
assert names.count(model) == 1, model
assert model in names, model
assert not [name for name in names if "__" in name or "-" in name], names