arm-core/api/tests/test_api_constraint_races.py
Andrey Epifancev b2fa59061d feat(consolidation): политика редактируемости, блокировка с перехватом, ручные статусы Среза 4
- domain/policy: IN_WORK_STATUSES, ensure_version_editable (VERSION_NOT_EDITABLE / LOCK_REQUIRED / LOCKED_BY_OTHER)
- services/consolidation_locks: take/release, перехват с причиной, release_active_lock; FOR UPDATE версии первой (D-9),
  вставка под SAVEPOINT, нарушение uq_consolidation_lock_version_active -> 409 LOCKED_BY_OTHER с владельцем
- services/consolidation_status: MANUAL_TRANSITIONS, transition (единственное место смены статуса: история + аудит,
  CANCELLED снимает блокировку), ConsolidationStatusService.change
- маршруты POST/DELETE …/lock, POST …/status; реестр constraint_conflicts
- тесты: таблица ensure_version_editable, сценарии спеки, гонка двух POST …/lock, перехват во время правки и
  аннулирования (две сессии, Review Focus 4)
- минорные T3.1 (FINISHED_STATUSES из policy в фабриках), T3.2 (удалена CONSOLIDATION_NOT_FOUND)
- план: шаги Task 4; tasks.md 2.2, 2.3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 04:16:23 +04:00

302 lines
14 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.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)