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:
parent
def3d72935
commit
070a398c65
13
README.md
13
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 | объекта нет или он вне видимости |
|
||||
|
||||
73
api/src/common/constraint_conflicts.py
Normal file
73
api/src/common/constraint_conflicts.py
Normal 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
|
||||
@ -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": "нарушение целостности данных в БД"},
|
||||
|
||||
223
api/tests/test_api_constraint_races.py
Normal file
223
api/tests/test_api_constraint_races.py
Normal 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}
|
||||
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user