arm-core/api/tests/test_api_aliases.py
Andrey Epifancev 7007c51f63 feat(directories): алиасы ЮЛ с нормализацией §3
normalize_alias (lower, ё→е, без пунктуации и ОПФ), POST/DELETE
/directories/legal-entities/{id}/aliases, конфликты ALIAS_EXISTS /
ALIAS_TAKEN (с legal_entity_id владельца), EMPTY_ALIAS, аудит
LEGAL_ENTITY_CHANGE по legal_entity_alias.

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

234 lines
11 KiB
Python
Raw Permalink 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 «Алиасы ЮЛ», audit-trail, directory-policy)."""
import uuid
from collections.abc import Sequence
from typing import Any
import httpx
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.audit_ex import AuditEvent
from src.models.directories import LegalEntityAlias, RegionalOffice
from src.models.enums import AuditAction, AuditSection
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_legal_entity
HTTP_OK = 200
HTTP_CREATED = 201
HTTP_NO_CONTENT = 204
HTTP_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
HTTP_CONFLICT = 409
HTTP_UNPROCESSABLE = 422
ENTITIES = "/directories/legal-entities"
OBJECT_TYPE = "legal_entity_alias"
KRASNODAR = "Краснодарский региональный филиал"
async def _events(session: AsyncSession) -> Sequence[AuditEvent]:
stmt = (
select(AuditEvent).where(AuditEvent.object_type == OBJECT_TYPE).order_by(AuditEvent.occurred_at, AuditEvent.id)
)
return (await session.execute(stmt)).scalars().all()
async def _alias_count(session: AsyncSession) -> int:
return (await session.execute(select(func.count()).select_from(LegalEntityAlias))).scalar_one()
async def _add(client: httpx.AsyncClient, entity_id: Any, alias: str) -> httpx.Response:
return await client.post(f"{ENTITIES}/{entity_id}/aliases", json={"alias": alias})
@pytest.mark.integration
async def test_alias_is_normalized_and_repeat_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Сценарий спеки «Нормализация»."""
entity = await make_legal_entity(seeded, name="ООО Пекарня")
client = await auth_client("ka_go@armka.local")
created = await _add(client, entity.id, "ООО «Пекарня Счастливочная»")
assert created.status_code == HTTP_CREATED, created.text
body = created.json()
assert (body["alias"], body["normalized_alias"]) == ("ООО «Пекарня Счастливочная»", "пекарня счастливочная")
assert body["created_at"]
repeat = await _add(client, entity.id, "Пекарня Счастливочная, ООО")
assert repeat.status_code == HTTP_CONFLICT
assert repeat.json()["detail"]["code"] == "ALIAS_EXISTS"
assert await _alias_count(seeded) == 1
assert len(await _events(seeded)) == 1 # неудачная попытка аудита не пишет
card = (await client.get(f"{ENTITIES}/{entity.id}")).json()
assert [alias["id"] for alias in card["aliases"]] == [body["id"]]
@pytest.mark.integration
async def test_create_alias_audit_and_created_by(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
entity = await make_legal_entity(seeded)
client = await auth_client("ka_go@armka.local")
body = (await _add(client, entity.id, 'АО "РУССКИЙ БИСКВИТ"')).json()
(event,) = await _events(seeded)
assert event.action == AuditAction.LEGAL_ENTITY_CHANGE
assert (event.section, str(event.object_id)) == (AuditSection.DIRECTORIES, body["id"])
assert event.before_value is None
assert event.after_value == {
"legal_entity_id": str(entity.id),
"alias": 'АО "РУССКИЙ БИСКВИТ"',
"normalized_alias": "русский бисквит",
}
row = (await seeded.execute(select(LegalEntityAlias))).scalar_one()
assert row.created_by == event.user_id
assert row.created_by is not None
@pytest.mark.integration
async def test_alias_of_another_entity_is_taken_with_owner_id_only(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
owner = await make_legal_entity(seeded, name="ООО Владелец")
other = await make_legal_entity(seeded, name="ООО Другое")
client = await auth_client("ka_go@armka.local")
assert (await _add(client, owner.id, "ООО «Пекарня»")).status_code == HTTP_CREATED
response = await _add(client, other.id, "пекарня, ао")
assert response.status_code == HTTP_CONFLICT
detail = response.json()["detail"]
assert detail["code"] == "ALIAS_TAKEN"
assert detail["legal_entity_id"] == str(owner.id)
assert set(detail) == {"code", "message", "legal_entity_id"} # ни имени, ни ИНН владельца
assert "Владелец" not in response.text
assert owner.inn not in response.text
assert await _alias_count(seeded) == 1
assert len(await _events(seeded)) == 1
@pytest.mark.integration
async def test_taken_alias_of_invisible_owner_discloses_only_id(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Владелец в чужом филиале: пользователь филиала получает только его `legal_entity_id` (решение по спеке)."""
krasnodar = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == KRASNODAR))).scalar_one()
foreign_group = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar)
foreign = await make_legal_entity(seeded, name="ООО Чужое", group=foreign_group)
go_client = await auth_client("ka_go@armka.local")
assert (await _add(go_client, foreign.id, "Пекарня")).status_code == HTTP_CREATED
rostov = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name.like("Ростов%")))).scalar_one()
own_group = await make_company_group(seeded, name="ГК Ростов", office=rostov)
own = await make_legal_entity(seeded, name="ООО Своё", group=own_group)
rostov_client = await auth_client("ka_rf@armka.local")
response = await _add(rostov_client, own.id, "ООО Пекарня")
assert response.status_code == HTTP_CONFLICT
assert response.json()["detail"] == {
"code": "ALIAS_TAKEN",
"message": response.json()["detail"]["message"],
"legal_entity_id": str(foreign.id),
}
assert "Чужое" not in response.text
@pytest.mark.integration
async def test_empty_normalization_is_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
entity = await make_legal_entity(seeded)
client = await auth_client("ka_go@armka.local")
for alias in ("ООО", "«»", " "):
response = await _add(client, entity.id, alias)
assert response.status_code == HTTP_UNPROCESSABLE
assert response.json()["detail"]["code"] == "EMPTY_ALIAS"
assert await _alias_count(seeded) == 0
assert not await _events(seeded)
@pytest.mark.integration
async def test_alias_body_validation(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
entity = await make_legal_entity(seeded)
client = await auth_client("ka_go@armka.local")
assert (await _add(client, entity.id, "")).status_code == HTTP_UNPROCESSABLE
assert (await _add(client, entity.id, "а" * 501)).status_code == HTTP_UNPROCESSABLE
assert (await client.post(f"{ENTITIES}/{entity.id}/aliases", json={})).status_code == HTTP_UNPROCESSABLE
assert await _alias_count(seeded) == 0
@pytest.mark.integration
async def test_delete_alias_writes_audit_with_before_value(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
entity = await make_legal_entity(seeded)
editor = await auth_client("ka_go@armka.local")
alias_id = (await _add(editor, entity.id, "ООО «Пекарня»")).json()["id"]
# DIRECTORY_DELETE есть только у администратора; редактор справочников получает 403 и алиас остаётся
forbidden = await editor.delete(f"{ENTITIES}/{entity.id}/aliases/{alias_id}")
assert forbidden.status_code == HTTP_FORBIDDEN
assert await _alias_count(seeded) == 1
admin = await auth_client("admin@armka.local")
response = await admin.delete(f"{ENTITIES}/{entity.id}/aliases/{alias_id}")
assert response.status_code == HTTP_NO_CONTENT
assert response.content == b""
assert await _alias_count(seeded) == 0
created, deleted = await _events(seeded)
assert deleted.action == AuditAction.LEGAL_ENTITY_CHANGE
assert (deleted.section, str(deleted.object_id)) == (AuditSection.DIRECTORIES, alias_id)
assert deleted.before_value == {
"legal_entity_id": str(entity.id),
"alias": "ООО «Пекарня»",
"normalized_alias": "пекарня",
}
assert deleted.after_value is None
assert created.object_id == deleted.object_id # запись о создании остаётся (append-only)
# освободившийся алиас можно добавить снова
assert (await _add(editor, entity.id, "Пекарня")).status_code == HTTP_CREATED
@pytest.mark.integration
async def test_delete_unknown_or_foreign_alias_is_404(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
first = await make_legal_entity(seeded)
second = await make_legal_entity(seeded)
editor = await auth_client("ka_go@armka.local")
alias_id = (await _add(editor, first.id, "Первое")).json()["id"]
admin = await auth_client("admin@armka.local")
for entity_id, target in ((second.id, alias_id), (first.id, uuid.uuid4()), (uuid.uuid4(), alias_id)):
response = await admin.delete(f"{ENTITIES}/{entity_id}/aliases/{target}")
assert response.status_code == HTTP_NOT_FOUND
assert await _alias_count(seeded) == 1
assert len(await _events(seeded)) == 1
@pytest.mark.integration
async def test_invisible_entity_aliases_are_404_like_missing(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
krasnodar = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == KRASNODAR))).scalar_one()
foreign_group = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar)
foreign = await make_legal_entity(seeded, group=foreign_group)
go_client = await auth_client("ka_go@armka.local")
alias_id = (await _add(go_client, foreign.id, "Чужой алиас")).json()["id"]
client = await auth_client("ka_rf@armka.local")
missing = await _add(client, uuid.uuid4(), "Что угодно")
assert missing.status_code == HTTP_NOT_FOUND
response = await _add(client, foreign.id, "Что угодно")
assert response.status_code == HTTP_NOT_FOUND
assert response.json() == missing.json()
assert await _alias_count(seeded) == 1
manager = await auth_client("client_manager@armka.local") # Краснодар видит ЮЛ, но права DELETE нет
assert (await manager.delete(f"{ENTITIES}/{foreign.id}/aliases/{alias_id}")).status_code == HTTP_FORBIDDEN
@pytest.mark.integration
async def test_expert_cannot_add_alias(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
entity = await make_legal_entity(seeded)
client = await auth_client("expert@armka.local")
response = await _add(client, entity.id, "Пекарня")
assert response.status_code == HTTP_FORBIDDEN
assert await _alias_count(seeded) == 0
assert not await _events(seeded)