From 070a398c65d1cabf891caee373d517be247cc194 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 02:02:14 +0400 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D0=B3=D0=BE=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=83=D0=BD=D0=B8=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=BE=D1=82=D0=B4=D0=B0=D1=91=D1=82=20=D1=82=D0=BE?= =?UTF-8?q?=D1=82=20=D0=B6=D0=B5=20409=20=D1=81=20=D0=BA=D0=BE=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC,=20=D1=87=D1=82=D0=BE=20=D0=B8=20pre-check=20(T6.5,=20T7?= =?UTF-8?q?.1,=20T8.2,=20T12.7,=20T15.3,=20T15.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реестр имя ограничения 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 13 +- api/src/common/constraint_conflicts.py | 73 ++++++++ api/src/core/exception_handlers.py | 35 +++- api/tests/test_api_constraint_races.py | 223 +++++++++++++++++++++++++ api/tests/test_exception_handlers.py | 128 ++++++++++++++ api/tests/test_openapi_routes.py | 8 +- 6 files changed, 469 insertions(+), 11 deletions(-) create mode 100644 api/src/common/constraint_conflicts.py create mode 100644 api/tests/test_api_constraint_races.py diff --git a/README.md b/README.md index 1748b0e..0b3be84 100644 --- a/README.md +++ b/README.md @@ -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 | объекта нет или он вне видимости | diff --git a/api/src/common/constraint_conflicts.py b/api/src/common/constraint_conflicts.py new file mode 100644 index 0000000..e462ef1 --- /dev/null +++ b/api/src/common/constraint_conflicts.py @@ -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 diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 25b1aa4..3c480a7 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -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": "нарушение целостности данных в БД"}, diff --git a/api/tests/test_api_constraint_races.py b/api/tests/test_api_constraint_races.py new file mode 100644 index 0000000..aa12fcd --- /dev/null +++ b/api/tests/test_api_constraint_races.py @@ -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} diff --git a/api/tests/test_exception_handlers.py b/api/tests/test_exception_handlers.py index b52f736..7080d25 100644 --- a/api/tests/test_exception_handlers.py +++ b/api/tests/test_exception_handlers.py @@ -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"] diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index d0d560b..56c350f 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -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