"""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)