arm-core/api/tests/test_api_legal_entities.py
Andrey Epifancev 347b46e32c feat(directories): ЮЛ/ИП — CRUD, перевод, вывод из состава, история, аудит
- domain/inn.py: нормализация и проверка ИНН (ЮЛ — 10, ИП — 12 цифр 0–9), ASSUMPTION(OQ-88)
- репозиторий: список ЮЛ с видимостью и фильтрами (company_group_id, outside_group, inn точно, q по name/inn),
  карточка, история (по убыванию valid_from), текущие членства, close_memberships (SCD2), алиасы
- LegalEntityService: create (INN_EXISTS, CRM_ID_EXISTS, ГК через scope → 404, филиал по умолчанию — филиал ГК),
  update (no-op без истории; снимок UPDATE), move (MOVE_TO_GROUP, закрытие членств, MEMBERSHIP_CHANGE),
  withdraw (CLOSE / REMOVE_FROM_GROUP), effective_date ≥ сегодня — ASSUMPTION(OQ-21)
- эндпоинты /directories/legal-entities: GET/POST, GET/PATCH /{id}, /{id}/move, /{id}/withdraw, /{id}/history
- тесты: unit ИНН, интеграционные сценарии спеки, Review Focus 1–3, откат мутации

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

536 lines
27 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.

"""API ЮЛ/ИП (spec directories-api, directory-policy, audit-trail): CRUD, перевод, вывод из состава, история, откат."""
import datetime
import uuid
from collections.abc import Sequence
from typing import Any
import httpx
import pytest
from fastapi import FastAPI
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.audit_ex import AuditEvent
from src.models.directories import (
LegalEntity,
LegalEntityAlias,
LegalEntityHistory,
RegionalOffice,
RelatedGroupMember,
)
from src.models.enums import AuditAction, AuditSection, EntityType, 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_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
HTTP_CONFLICT = 409
HTTP_UNPROCESSABLE = 422
HTTP_SERVER_ERROR = 500
ROSTOV = "Ростовский региональный филиал"
KRASNODAR = "Краснодарский региональный филиал"
ENTITIES = "/directories/legal-entities"
ENTITY_NOT_FOUND = "ЮЛ не найдено"
GROUP_NOT_FOUND = "ГК не найдена"
def _today() -> datetime.date:
"""«Сегодня» сервиса — дата UTC (тест не зависит от часового пояса машины)."""
return datetime.datetime.now(datetime.UTC).date()
async def _office(session: AsyncSession, name: str) -> RegionalOffice:
return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == name))).scalar_one()
async def _events(session: AsyncSession, *actions: AuditAction) -> Sequence[AuditEvent]:
stmt = select(AuditEvent).where(AuditEvent.action.in_(actions)).order_by(AuditEvent.occurred_at, AuditEvent.id)
return (await session.execute(stmt)).scalars().all()
async def _history_count(session: AsyncSession) -> int:
return (await session.execute(select(func.count()).select_from(LegalEntityHistory))).scalar_one()
async def _create(client: httpx.AsyncClient, **body: Any) -> dict[str, Any]:
response = await client.post(ENTITIES, json=body)
assert response.status_code == HTTP_CREATED, response.text
created: dict[str, Any] = response.json()
return created
@pytest.mark.integration
async def test_sole_proprietor_with_ten_digits_is_invalid(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
client = await auth_client("ka_go@armka.local")
response = await client.post(ENTITIES, json={"inn": "3528052718", "entity_type": "ИП", "name": "ИП Иванов"})
assert response.status_code == HTTP_UNPROCESSABLE
assert response.json()["detail"]["code"] == "INVALID_INN"
assert (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one() == 0
assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
@pytest.mark.integration
async def test_duplicate_inn_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
client = await auth_client("ka_go@armka.local")
await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Первое")
response = await client.post(ENTITIES, json={"inn": "35 2805 2718", "entity_type": "ЮЛ", "name": "ООО Второе"})
assert response.status_code == HTTP_CONFLICT
assert response.json()["detail"]["code"] == "INN_EXISTS"
assert len(await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)) == 1
@pytest.mark.integration
async def test_create_audit_and_default_office_from_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
rostov = await _office(seeded, ROSTOV)
group = await make_company_group(seeded, name="ГК Ростов", office=rostov)
client = await auth_client("ka_go@armka.local")
created = await _create(
client, inn="0123456789", entity_type="ЮЛ", name=" ООО Ноль ", company_group_id=str(group.id), crm_id="1-AB"
)
assert created["inn"] == "0123456789" # Review Focus 2: строка, ведущий ноль сохранён
assert created["name"] == "ООО Ноль"
assert created["regional_office_id"] == str(rostov.id)
assert (created["outside_group"], created["closed"]) == (False, False)
assert created["valid_from"]
(event,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
assert (event.section, event.object_type, str(event.object_id)) == (
AuditSection.DIRECTORIES,
"legal_entity",
created["id"],
)
assert event.after_value == {
"inn": "0123456789",
"entity_type": "ЮЛ",
"name": "ООО Ноль",
"crm_id": "1-AB",
"company_group_id": str(group.id),
"regional_office_id": str(rostov.id),
}
assert event.before_value is None
assert await _history_count(seeded) == 0 # при создании история не пишется (D-6)
duplicate_crm = await client.post(
ENTITIES, json={"inn": "3528052718", "entity_type": "ЮЛ", "name": "Х", "crm_id": "1-AB"}
)
assert duplicate_crm.status_code == HTTP_CONFLICT
assert duplicate_crm.json()["detail"]["code"] == "CRM_ID_EXISTS"
@pytest.mark.integration
async def test_mono_borrower_visible_to_own_branch_and_head_office(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
rostov = await _office(seeded, ROSTOV)
branch = await auth_client("ka_rf@armka.local") # Ростовский филиал
created = await _create(branch, inn="6164000001", entity_type="ЮЛ", name="ООО Монозаёмщик")
assert created["company_group_id"] is None
assert created["outside_group"] is True
assert created["regional_office_id"] == str(rostov.id)
for email in ("ka_rf@armka.local", "ka_go@armka.local"):
client = await auth_client(email)
page = (await client.get(ENTITIES, params={"outside_group": "true"})).json()
assert created["id"] in {item["id"] for item in page["items"]}, email
assert (await client.get(f"{ENTITIES}/{created['id']}")).status_code == HTTP_OK
foreign = await auth_client("client_manager@armka.local") # Краснодарский филиал
page = (await foreign.get(ENTITIES)).json()
assert created["id"] not in {item["id"] for item in page["items"]}
card = await foreign.get(f"{ENTITIES}/{created['id']}")
missing = await foreign.get(f"{ENTITIES}/{uuid.uuid4()}")
assert card.status_code == missing.status_code == HTTP_NOT_FOUND
assert card.json() == missing.json()
assert card.json()["detail"]["message"] == ENTITY_NOT_FOUND
@pytest.mark.integration
async def test_move_closes_memberships_and_writes_history(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
rostov = await _office(seeded, ROSTOV)
old_group = await make_company_group(seeded, name="ГК Старая", office=rostov)
new_group = await make_company_group(seeded, name="ГК Новая", office=rostov)
entity = await make_legal_entity(seeded, name="ООО Переводимое", group=old_group)
gsk = await make_related_group(seeded, group=old_group, kind=RelatedGroupKind.gsk)
gsz = await make_related_group(seeded, group=old_group, kind=RelatedGroupKind.gsz, name="ГСЗ Тест")
gsk_member = await make_membership(seeded, related_group=gsk, entity=entity)
gsz_member = await make_membership(seeded, related_group=gsz, entity=entity)
old_valid_from = entity.valid_from
client = await auth_client("ka_rf@armka.local")
effective = _today()
response = await client.post(
f"{ENTITIES}/{entity.id}/move",
json={"company_group_id": str(new_group.id), "effective_date": effective.isoformat()},
)
assert response.status_code == HTTP_OK, response.text
assert response.json()["company_group_id"] == str(new_group.id)
for member in (gsk_member, gsz_member):
await seeded.refresh(member)
assert member.is_current is False
assert member.valid_to is not None
history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
assert len(history) == 1
snapshot = history[0]
assert snapshot["event_type"] == "MOVE_TO_GROUP"
assert snapshot["effective_date"] == effective.isoformat()
assert snapshot["company_group_id"] == str(old_group.id) # снимок — прежнее состояние
assert datetime.datetime.fromisoformat(snapshot["valid_from"]) == old_valid_from
assert snapshot["valid_to"] is not None and snapshot["changed_by"] is not None
(change,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
assert change.before_value == {"company_group_id": str(old_group.id)}
assert change.after_value == {"company_group_id": str(new_group.id)}
assert change.changed_field == "company_group_id"
memberships = await _events(seeded, AuditAction.MEMBERSHIP_CHANGE)
assert {(e.object_type, e.object_id) for e in memberships} == {
("related_group_member", gsk_member.id),
("related_group_member", gsz_member.id),
}
assert all(e.before_value["is_current"] is True and e.after_value["is_current"] is False for e in memberships)
card = (await client.get(f"{ENTITIES}/{entity.id}")).json()
assert card["memberships"] == []
@pytest.mark.integration
async def test_branch_user_cannot_move_to_foreign_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Review Focus 3: целевая ГК чужого филиала — 404, как несуществующая (не 422, без утечки)."""
rostov, krasnodar = await _office(seeded, ROSTOV), await _office(seeded, KRASNODAR)
own = await make_company_group(seeded, name="ГК Своя", office=rostov)
foreign = await make_company_group(seeded, name="ГК Чужая", office=krasnodar)
entity = await make_legal_entity(seeded, group=own)
client = await auth_client("ka_rf@armka.local")
body = {"effective_date": _today().isoformat()}
to_foreign = await client.post(f"{ENTITIES}/{entity.id}/move", json={**body, "company_group_id": str(foreign.id)})
to_missing = await client.post(f"{ENTITIES}/{entity.id}/move", json={**body, "company_group_id": str(uuid.uuid4())})
assert to_foreign.status_code == to_missing.status_code == HTTP_NOT_FOUND
assert to_foreign.json() == to_missing.json()
assert to_foreign.json()["detail"]["message"] == GROUP_NOT_FOUND
await seeded.refresh(entity)
assert entity.company_group_id == own.id
assert await _history_count(seeded) == 0
assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
create_in_foreign = await client.post(
ENTITIES, json={"inn": "6164000002", "entity_type": "ЮЛ", "name": "ООО Х", "company_group_id": str(foreign.id)}
)
assert create_in_foreign.status_code == HTTP_NOT_FOUND
assert create_in_foreign.json() == to_missing.json()
@pytest.mark.integration
async def test_effective_date_in_past_is_invalid(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded, name="ГК А")
target = await make_company_group(seeded, name="ГК Б")
entity = await make_legal_entity(seeded, group=group)
client = await auth_client("ka_go@armka.local")
yesterday = (_today() - datetime.timedelta(days=1)).isoformat()
move = await client.post(
f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(target.id), "effective_date": yesterday}
)
assert move.status_code == HTTP_UNPROCESSABLE
assert move.json()["detail"]["code"] == "EFFECTIVE_DATE_IN_PAST"
withdraw = await client.post(
f"{ENTITIES}/{entity.id}/withdraw", json={"event": "CLOSE", "effective_date": yesterday}
)
assert withdraw.status_code == HTTP_UNPROCESSABLE
assert withdraw.json()["detail"]["code"] == "EFFECTIVE_DATE_IN_PAST"
await seeded.refresh(entity)
assert (entity.company_group_id, entity.close_date) == (group.id, None)
assert await _history_count(seeded) == 0
@pytest.mark.integration
async def test_withdraw_close(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded, name="ГК Закрытие")
entity = await make_legal_entity(seeded, name="ООО Закрываемое", group=group)
other = await make_legal_entity(seeded, name="ООО Живое", group=group)
gsk = await make_related_group(seeded, group=group)
member = await make_membership(seeded, related_group=gsk, entity=entity)
client = await auth_client("ka_go@armka.local")
close_date = _today() + datetime.timedelta(days=3)
response = await client.post(
f"{ENTITIES}/{entity.id}/withdraw",
json={"event": "CLOSE", "effective_date": close_date.isoformat(), "comment": "Ликвидация"},
)
assert response.status_code == HTTP_OK, response.text
body = response.json()
assert body["close_date"] == close_date.isoformat()
assert body["company_group_id"] is None
assert body["closed"] is True
closed = (await client.get(ENTITIES, params={"closed": "true"})).json()
assert [item["id"] for item in closed["items"]] == [str(entity.id)]
assert closed["items"][0]["closed"] is True
active_ids = {item["id"] for item in (await client.get(ENTITIES, params={"closed": "false"})).json()["items"]}
assert str(other.id) in active_ids and str(entity.id) not in active_ids
await seeded.refresh(member)
assert member.is_current is False
history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
assert [(row["event_type"], row["effective_date"], row["company_group_id"]) for row in history] == [
("CLOSE", close_date.isoformat(), str(group.id))
]
(change,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
assert change.comment == "Ликвидация"
assert change.before_value == {"company_group_id": str(group.id), "close_date": None}
assert change.after_value == {"company_group_id": None, "close_date": close_date.isoformat()}
assert len(await _events(seeded, AuditAction.MEMBERSHIP_CHANGE)) == 1
again = await client.post(
f"{ENTITIES}/{entity.id}/withdraw", json={"event": "CLOSE", "effective_date": close_date.isoformat()}
)
assert again.status_code == HTTP_CONFLICT
assert again.json()["detail"]["code"] == "ALREADY_CLOSED"
target = await make_company_group(seeded, name="ГК Куда")
move_closed = await client.post(
f"{ENTITIES}/{entity.id}/move",
json={"company_group_id": str(target.id), "effective_date": _today().isoformat()},
)
assert move_closed.status_code == HTTP_CONFLICT
assert move_closed.json()["detail"]["code"] == "LEGAL_ENTITY_CLOSED"
@pytest.mark.integration
async def test_withdraw_remove_from_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded, name="ГК Вывод")
entity = await make_legal_entity(seeded, group=group)
client = await auth_client("ka_go@armka.local")
today = _today().isoformat()
response = await client.post(
f"{ENTITIES}/{entity.id}/withdraw", json={"event": "REMOVE_FROM_GROUP", "effective_date": today}
)
assert response.status_code == HTTP_OK, response.text
assert (response.json()["company_group_id"], response.json()["close_date"]) == (None, None)
assert response.json()["outside_group"] is True
history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json()
assert [row["event_type"] for row in history] == ["REMOVE_FROM_GROUP"]
again = await client.post(
f"{ENTITIES}/{entity.id}/withdraw", json={"event": "REMOVE_FROM_GROUP", "effective_date": today}
)
assert again.status_code == HTTP_CONFLICT
assert again.json()["detail"]["code"] == "NOT_IN_COMPANY_GROUP"
same = await client.post(f"{ENTITIES}/{entity.id}/withdraw", json={"event": "UNKNOWN", "effective_date": today})
assert same.status_code == HTTP_UNPROCESSABLE
@pytest.mark.integration
async def test_move_to_same_group_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded, name="ГК Та же")
entity = await make_legal_entity(seeded, group=group)
client = await auth_client("ka_go@armka.local")
response = await client.post(
f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(group.id), "effective_date": _today().isoformat()}
)
assert response.status_code == HTTP_CONFLICT
assert response.json()["detail"]["code"] == "SAME_COMPANY_GROUP"
@pytest.mark.integration
async def test_noop_patch_writes_no_history_and_no_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Review Focus 1: тело с теми же значениями не плодит историю и аудит."""
client = await auth_client("ka_go@armka.local")
created = await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Без изменений", crm_id="1-NOOP")
audit_before = len(await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE))
response = await client.patch(
f"{ENTITIES}/{created['id']}", json={"name": " ООО Без изменений ", "entity_type": "ЮЛ", "crm_id": "1-NOOP"}
)
assert response.status_code == HTTP_OK
assert response.json()["valid_from"] == created["valid_from"]
assert await _history_count(seeded) == 0
assert len(await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)) == audit_before
@pytest.mark.integration
async def test_patch_writes_update_snapshot_and_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
client = await auth_client("ka_go@armka.local")
created = await _create(client, inn="3528052718", entity_type="ЮЛ", name="Имя 0")
for index in (1, 2):
response = await client.patch(f"{ENTITIES}/{created['id']}", json={"name": f"Имя {index}"})
assert response.status_code == HTTP_OK, response.text
assert response.json()["valid_from"] != created["valid_from"]
history = (await client.get(f"{ENTITIES}/{created['id']}/history")).json()
assert [(row["name"], row["event_type"]) for row in history] == [("Имя 1", "UPDATE"), ("Имя 0", "UPDATE")] # R-3
assert history[0]["effective_date"] is None
assert set(history[0]) == {
"id",
"inn",
"crm_id",
"entity_type",
"name",
"company_group_id",
"regional_office_id",
"close_date",
"event_type",
"effective_date",
"valid_from",
"valid_to",
"changed_by",
}
last = (await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE))[-1]
assert (last.changed_field, last.before_value, last.after_value) == ("name", {"name": "Имя 1"}, {"name": "Имя 2"})
wrong_type = await client.patch(f"{ENTITIES}/{created['id']}", json={"entity_type": "ИП"})
assert wrong_type.status_code == HTTP_UNPROCESSABLE
assert wrong_type.json()["detail"]["code"] == "INVALID_INN" # 10 цифр не ИНН ИП
extra = await client.patch(f"{ENTITIES}/{created['id']}", json={"inn": "0000000000"})
assert extra.status_code == HTTP_UNPROCESSABLE # extra="forbid": ИНН не меняется PATCH
@pytest.mark.integration
async def test_inn_filter_strips_spaces_and_q_searches_name_and_inn(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Review Focus 2 и R-4: `inn` — точное сравнение после strip; `q` — подстрока name/inn, LIKE экранирован."""
target = await make_legal_entity(seeded, name="ООО Вологодский", inn="3528052718")
await make_legal_entity(seeded, name="ООО 100% Другое", inn="3528052719")
client = await auth_client("ka_go@armka.local")
by_inn = (await client.get(ENTITIES, params={"inn": " 3528052718 "})).json()
assert [item["id"] for item in by_inn["items"]] == [str(target.id)]
assert by_inn["total"] == 1
assert (await client.get(ENTITIES, params={"inn": "352805271"})).json()["total"] == 0 # не подстрока
assert {item["name"] for item in (await client.get(ENTITIES, params={"q": "вологод"})).json()["items"]} == {
"ООО Вологодский"
}
by_inn_part = (await client.get(ENTITIES, params={"q": "52805271"})).json()
assert by_inn_part["total"] == 2 # noqa: PLR2004
assert [item["name"] for item in by_inn_part["items"]] == ["ООО 100% Другое", "ООО Вологодский"]
percent = (await client.get(ENTITIES, params={"q": "%"})).json()
assert [item["name"] for item in percent["items"]] == ["ООО 100% Другое"]
@pytest.mark.integration
async def test_list_filters_by_group_and_outside_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded, name="ГК Фильтр")
in_group = await make_legal_entity(seeded, name="ООО В группе", group=group)
mono = await make_legal_entity(seeded, name="ООО Сам по себе")
client = await auth_client("ka_go@armka.local")
by_group = (await client.get(ENTITIES, params={"company_group_id": str(group.id)})).json()
assert [item["id"] for item in by_group["items"]] == [str(in_group.id)]
outside = {item["id"] for item in (await client.get(ENTITIES, params={"outside_group": "true"})).json()["items"]}
assert str(mono.id) in outside and str(in_group.id) not in outside
inside = {item["id"] for item in (await client.get(ENTITIES, params={"outside_group": "false"})).json()["items"]}
assert str(in_group.id) in inside and str(mono.id) not in inside
@pytest.mark.integration
async def test_card_has_current_memberships_and_aliases(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group = await make_company_group(seeded, name="ГК Карточка")
entity = await make_legal_entity(seeded, name="ООО Карточка", group=group)
gsk = await make_related_group(seeded, group=group, name="ГСК Карточка")
old_gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ Старая")
await make_membership(seeded, related_group=gsk, entity=entity)
await make_membership(seeded, related_group=old_gsz, entity=entity, is_current=False)
seeded.add(
LegalEntityAlias(
legal_entity_id=entity.id,
alias="ООО «Карточка»",
normalized_alias="карточка",
created_at=datetime.datetime.now(datetime.UTC),
)
)
await seeded.flush()
client = await auth_client("ka_go@armka.local")
card = (await client.get(f"{ENTITIES}/{entity.id}")).json()
assert [(m["related_group_id"], m["code"], m["kind"], m["name"]) for m in card["memberships"]] == [
(str(gsk.id), gsk.code, "gsk", "ГСК Карточка")
]
assert card["memberships"][0]["valid_from"]
assert [(a["alias"], a["normalized_alias"]) for a in card["aliases"]] == [("ООО «Карточка»", "карточка")]
assert card["entity_type"] == EntityType.ЮЛ.value
@pytest.mark.integration
async def test_foreign_entity_operations_are_404(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
krasnodar = await _office(seeded, KRASNODAR)
foreign_group = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar)
entity = await make_legal_entity(seeded, group=foreign_group)
client = await auth_client("ka_rf@armka.local")
missing = await client.get(f"{ENTITIES}/{uuid.uuid4()}")
today = _today().isoformat()
for call in (
client.get(f"{ENTITIES}/{entity.id}"),
client.patch(f"{ENTITIES}/{entity.id}", json={"name": "Взлом"}),
client.post(
f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(foreign_group.id), "effective_date": today}
),
client.post(f"{ENTITIES}/{entity.id}/withdraw", json={"event": "CLOSE", "effective_date": today}),
client.get(f"{ENTITIES}/{entity.id}/history"),
):
response = await call
assert response.status_code == HTTP_NOT_FOUND
assert response.json() == missing.json()
@pytest.mark.integration
async def test_view_only_user_cannot_create(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
client = await auth_client("expert@armka.local")
response = await client.post(ENTITIES, json={"inn": "3528052718", "entity_type": "ЮЛ", "name": "ООО Эксперта"})
assert response.status_code == HTTP_FORBIDDEN
assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)
assert (await client.get(ENTITIES)).status_code == HTTP_OK
@pytest.mark.integration
async def test_failed_move_rolls_back_everything(
app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""spec «Ошибка внутри мутации»: сбой закрытия членств → 500, ЮЛ в прежней ГК, истории и аудита нет."""
old_group = await make_company_group(seeded, name="ГК Откат")
new_group = await make_company_group(seeded, name="ГК Цель")
entity = await make_legal_entity(seeded, group=old_group)
gsk = await make_related_group(seeded, group=old_group)
member = await make_membership(seeded, related_group=gsk, entity=entity)
entity_id, member_id, old_group_id = entity.id, member.id, old_group.id # откат SAVEPOINT протухит ORM-объекты
authorized = await auth_client("ka_go@armka.local")
async def broken_close_memberships(*args: Any, **kwargs: Any) -> list[RelatedGroupMember]:
raise RuntimeError("сбой БД при закрытии членств")
monkeypatch.setattr("src.repositories.directories.close_memberships", broken_close_memberships)
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport, base_url=authorized.base_url, headers=authorized.headers
) as client:
response = await client.post(
f"{ENTITIES}/{entity_id}/move",
json={"company_group_id": str(new_group.id), "effective_date": _today().isoformat()},
)
assert response.status_code == HTTP_SERVER_ERROR
# SAVEPOINT запроса откатился — проверяем в той же сессии (populate_existing: читать из БД, не из identity map)
reloaded = (
await seeded.execute(
select(LegalEntity).where(LegalEntity.id == entity_id).execution_options(populate_existing=True)
)
).scalar_one()
assert reloaded.company_group_id == old_group_id
reloaded_member = (
await seeded.execute(
select(RelatedGroupMember)
.where(RelatedGroupMember.id == member_id)
.execution_options(populate_existing=True)
)
).scalar_one()
assert reloaded_member.is_current is True
assert reloaded_member.valid_to is None
assert await _history_count(seeded) == 0
assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE, AuditAction.MEMBERSHIP_CHANGE)