Реестр имя ограничения 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
224 lines
11 KiB
Python
224 lines
11 KiB
Python
"""Гонка 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}
|