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

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

224 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Гонка 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}