"""Гонка 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.access import AppUser from src.models.directories import RegionalOffice from src.models.enums import PeriodQuarter, RelatedGroupKind from src.repositories import consolidations as consolidation_repo from tests.conftest import AuthClientFactory from tests.factories import ( make_company_group, make_consolidation, make_legal_entity, make_lock, 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" CONSOLIDATIONS_REPO = "src.repositories.consolidations" 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_caught( auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: """v5.15: email хранится строчным, поэтому UNIQUE ловит гонку и с дублем в другом регистре.""" 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_coded( response, "EMAIL_EXISTS", "Пользователь с таким email уже существует", "race.case@example.com", "Гонка" ) @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} @pytest.mark.integration async def test_consolidation_period_race( auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: """Два создания одного периода (ГК, год, квартал): pre-check пропустил, UNIQUE отклонил → `CONSOLIDATION_EXISTS`.""" office = ( await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == "Ростовский региональный филиал")) ).scalar_one() group = await make_company_group(seeded, office=office) gsk = await make_related_group(seeded, group=group) for number in range(2): entity = await make_legal_entity(seeded, name=f"ООО Член {number}", group=group) await make_membership(seeded, related_group=gsk, entity=entity) client = await auth_client("ka_rf@armka.local") body = {"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": 2024, "quarter": "Q3"} assert (await client.post("/consolidations", json=body)).status_code == HTTP_CREATED monkeypatch.setattr("src.repositories.consolidations.get_consolidation_for_group", _free(None)) response = await client.post("/consolidations", json=body) _assert_coded(response, "CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана", str(group.id)) @pytest.mark.integration async def test_consolidation_lock_race( auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: """Активную блокировку pre-check не увидел, частичный UNIQUE отклонил вставку → 409 `LOCKED_BY_OTHER` с владельцем. Вставка идёт под SAVEPOINT: транзакция запроса не отравлена, сервис перечитывает владельца и отвечает доменной ошибкой (а не общим 409 обработчика). """ office = ( await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == "Ростовский региональный филиал")) ).scalar_one() group = await make_company_group(seeded, office=office) entities = [await make_legal_entity(seeded, name=f"ООО Член {number}", group=group) for number in range(2)] owner = (await seeded.execute(select(AppUser).where(AppUser.email == "rka@armka.local"))).scalar_one() _, version = await make_consolidation( seeded, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=owner ) await make_lock(seeded, version=version, user=owner) original = consolidation_repo.active_lock calls = 0 async def blind_once(session: AsyncSession, version_id: uuid.UUID) -> Any: nonlocal calls calls += 1 return None if calls == 1 else await original(session, version_id) monkeypatch.setattr(f"{CONSOLIDATIONS_REPO}.active_lock", blind_once) client = await auth_client("ka_rf@armka.local") response = await client.post(f"/consolidations/versions/{version.id}/lock") assert response.status_code == HTTP_CONFLICT, response.text detail = response.json()["detail"] assert (detail["code"], detail["locked_by"], detail["locked_by_name"]) == ( "LOCKED_BY_OTHER", str(owner.id), owner.full_name, ) # транзакция запроса жива: следующий запрос того же клиента работает assert (await client.get(f"/consolidations/versions/{version.id}")).json()["lock"]["locked_by"] == str(owner.id)