From 2b07b530a84c5e161699fc4e714618a3adb5588a Mon Sep 17 00:00:00 2001 From: Aleksandr Osovskii Date: Mon, 18 May 2026 12:38:57 +0300 Subject: [PATCH 1/6] Add client_network_contour to sync smartapp event --- pybotx/__init__.py | 2 + pybotx/models/enums.py | 27 +++++++++++++ pybotx/models/message/incoming_message.py | 2 + pybotx/models/sync_smartapp_event.py | 15 ++++++- tests/models/test_enums.py | 23 +++++++++++ tests/models/test_sync_smartapp_event.py | 49 +++++++++++++++++++++++ 6 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/models/test_sync_smartapp_event.py diff --git a/pybotx/__init__.py b/pybotx/__init__.py index 20eee9b0..fa50a5c4 100644 --- a/pybotx/__init__.py +++ b/pybotx/__init__.py @@ -102,6 +102,7 @@ AttachmentTypes, ChatLinkTypes, ChatTypes, + ClientNetworkContours, ClientPlatforms, ConferenceLinkTypes, MentionTypes, @@ -221,6 +222,7 @@ "ChatNotFoundError", "ChatLinkTypes", "ChatTypes", + "ClientNetworkContours", "ClientPlatforms", "ConferenceChangedEvent", "ConferenceCreatedEvent", diff --git a/pybotx/models/enums.py b/pybotx/models/enums.py index b0f3de99..c7638718 100644 --- a/pybotx/models/enums.py +++ b/pybotx/models/enums.py @@ -39,6 +39,11 @@ class ClientPlatforms(AutoName): AURORA = auto() +class ClientNetworkContours(AutoName): + INTERNAL = auto() + EXTERNAL = auto() + + class MentionTypes(AutoName): CONTACT = auto() CHAT = auto() @@ -136,6 +141,11 @@ class BotAPIClientPlatforms(Enum): AURORA = "aurora" +class BotAPIClientNetworkContours(StrEnum): + INTERNAL = "internal" + EXTERNAL = "external" + + class BotAPIEntityTypes(StrEnum): MENTION = "mention" FORWARD = "forward" @@ -208,6 +218,23 @@ def convert_client_platform_to_domain( return converted_type +def convert_client_network_contour_to_domain( + client_network_contour: BotAPIClientNetworkContours, +) -> ClientNetworkContours: + client_network_contours_mapping = { + BotAPIClientNetworkContours.INTERNAL: ClientNetworkContours.INTERNAL, + BotAPIClientNetworkContours.EXTERNAL: ClientNetworkContours.EXTERNAL, + } + + converted_type = client_network_contours_mapping.get(client_network_contour) + if converted_type is None: + raise NotImplementedError( + f"Unsupported client network contour: {client_network_contour}", + ) + + return converted_type + + def convert_mention_type_from_domain( mention_type: MentionTypes, ) -> BotAPIMentionTypes: diff --git a/pybotx/models/message/incoming_message.py b/pybotx/models/message/incoming_message.py index 3e5d10f4..ad6251a6 100644 --- a/pybotx/models/message/incoming_message.py +++ b/pybotx/models/message/incoming_message.py @@ -26,6 +26,7 @@ from pybotx.models.enums import ( BotAPIEntityTypes, BotAPIMentionTypes, + ClientNetworkContours, ClientPlatforms, convert_chat_type_to_domain, convert_client_platform_to_domain, @@ -68,6 +69,7 @@ class UserSender: is_chat_admin: bool | None is_chat_creator: bool | None device: UserDevice + client_network_contour: ClientNetworkContours | None = None @property def upn(self) -> str | None: diff --git a/pybotx/models/sync_smartapp_event.py b/pybotx/models/sync_smartapp_event.py index b4d519c6..cdc11fee 100644 --- a/pybotx/models/sync_smartapp_event.py +++ b/pybotx/models/sync_smartapp_event.py @@ -14,7 +14,9 @@ from pybotx.models.chats import Chat from pybotx.models.enums import ( BotAPIClientPlatforms, + BotAPIClientNetworkContours, ChatTypes, + convert_client_network_contour_to_domain, convert_client_platform_to_domain, ) from pybotx.models.message.incoming_message import UserDevice, UserSender @@ -25,6 +27,7 @@ class BotAPISyncSmartAppSender(VerifiedPayloadBaseModel): user_huid: UUID udid: UUID | None platform: BotAPIClientPlatforms | None + client_network_contour: BotAPIClientNetworkContours | None = None class BotAPISyncSmartAppPayload(VerifiedPayloadBaseModel): @@ -59,6 +62,14 @@ def to_domain(self, raw_smartapp_event: dict[str, Any]) -> SmartAppEvent: locale=None, ) + client_network_contour = ( + convert_client_network_contour_to_domain( + self.sender_info.client_network_contour, + ) + if self.sender_info.client_network_contour + else None + ) + sender = UserSender( huid=self.sender_info.user_huid, udid=self.sender_info.udid, @@ -68,6 +79,7 @@ def to_domain(self, raw_smartapp_event: dict[str, Any]) -> SmartAppEvent: username=None, is_chat_admin=None, is_chat_creator=None, + client_network_contour=client_network_contour, ) return SmartAppEvent( @@ -143,6 +155,5 @@ def jsonable_dict(self) -> dict[str, Any]: BotAPISyncSmartAppEventResponse = ( - BotAPISyncSmartAppEventResultResponse - | BotAPISyncSmartAppEventErrorResponse + BotAPISyncSmartAppEventResultResponse | BotAPISyncSmartAppEventErrorResponse ) diff --git a/tests/models/test_enums.py b/tests/models/test_enums.py index 3dc6d855..086920cd 100644 --- a/tests/models/test_enums.py +++ b/tests/models/test_enums.py @@ -3,9 +3,12 @@ from pybotx.models.enums import ( APIChatTypes, + BotAPIClientNetworkContours, + ClientNetworkContours, ChatTypes, convert_chat_type_from_domain, convert_chat_type_to_domain, + convert_client_network_contour_to_domain, ) @@ -32,3 +35,23 @@ def test__convert_chat_type_from_domain__unsupported_chat_type_raises_error() -> def test__convert_chat_type_to_domain__notes_maps_to_personal_chat() -> None: assert convert_chat_type_to_domain(APIChatTypes.NOTES) == ChatTypes.PERSONAL_CHAT assert convert_chat_type_to_domain("notes") == ChatTypes.PERSONAL_CHAT + + +def test__convert_client_network_contour_to_domain__successful_conversion() -> None: + assert ( + convert_client_network_contour_to_domain(BotAPIClientNetworkContours.INTERNAL) + == ClientNetworkContours.INTERNAL + ) + assert ( + convert_client_network_contour_to_domain(BotAPIClientNetworkContours.EXTERNAL) + == ClientNetworkContours.EXTERNAL + ) + + +def test__convert_client_network_contour_to_domain__unsupported_contour_raises_error() -> ( + None +): + unsupported_client_network_contour = Mock(spec=BotAPIClientNetworkContours) + + with pytest.raises(NotImplementedError, match="Unsupported client network contour"): + convert_client_network_contour_to_domain(unsupported_client_network_contour) diff --git a/tests/models/test_sync_smartapp_event.py b/tests/models/test_sync_smartapp_event.py new file mode 100644 index 00000000..4b179fee --- /dev/null +++ b/tests/models/test_sync_smartapp_event.py @@ -0,0 +1,49 @@ +from typing import Any + +import pytest + +from pybotx.models.enums import ClientNetworkContours +from pybotx.models.sync_smartapp_event import BotAPISyncSmartAppEvent + + +@pytest.mark.parametrize( + ("api_value", "domain_value"), + [ + ("internal", ClientNetworkContours.INTERNAL), + ("external", ClientNetworkContours.EXTERNAL), + ], +) +def test__sync_smartapp_event__client_network_contour_mapped_to_sender( + api_value: str, + domain_value: ClientNetworkContours, +) -> None: + payload = _sync_smartapp_event_payload(client_network_contour=api_value) + + event = BotAPISyncSmartAppEvent.model_validate(payload).to_domain(payload) + + assert event.sender.client_network_contour == domain_value + + +def _sync_smartapp_event_payload( + *, + client_network_contour: str, +) -> dict[str, Any]: + return { + "bot_id": "2a98219d-1f57-5dcb-920c-9a992bde01ec", + "group_chat_id": "1ee7fdcf-e258-03d6-2263-2764da127088", + "method": "menu", + "payload": { + "data": { + "camelCaseValue": "value2", + "under_score_value": "value1", + }, + "files": [], + "opts": {}, + }, + "sender_info": { + "client_network_contour": client_network_contour, + "platform": "web", + "udid": "9eb0ed48-2501-59b8-9ba1-9136ff6efc59", + "user_huid": "347fdc52-fd0f-5e1d-b06f-bdfdf1cc7164", + }, + } From a14644f94c0160f17d079472c074c594b40f3c7f Mon Sep 17 00:00:00 2001 From: Roman Bobrovskiy Date: Tue, 19 May 2026 13:12:32 +0300 Subject: [PATCH 2/6] feat: add support for voex_call chat type --- pybotx/client/chats_api/create_chat.py | 2 + pybotx/models/enums.py | 2 + pyproject.toml | 2 +- tests/client/chats_api/test_chat_info.py | 74 ++++++++++++++++++++++ tests/client/chats_api/test_create_chat.py | 7 +- tests/models/test_enums.py | 1 + 6 files changed, 86 insertions(+), 2 deletions(-) diff --git a/pybotx/client/chats_api/create_chat.py b/pybotx/client/chats_api/create_chat.py index 876b89ec..640c44d5 100644 --- a/pybotx/client/chats_api/create_chat.py +++ b/pybotx/client/chats_api/create_chat.py @@ -39,6 +39,8 @@ class BotXAPICreateChatRequestPayload(UnverifiedPayloadBaseModel): @model_validator(mode="before") def _convert_chat_type(cls, values: dict[str, Any]) -> dict[str, Any]: chat_type = values.get("chat_type") + if isinstance(chat_type, APIChatTypes) and chat_type == APIChatTypes.VOEX_CALL: + raise ValueError("Bot cannot create a chat of type 'voex_call'") if isinstance(chat_type, ChatTypes): values["chat_type"] = convert_chat_type_from_domain(chat_type) return values diff --git a/pybotx/models/enums.py b/pybotx/models/enums.py index c7638718..b458b56d 100644 --- a/pybotx/models/enums.py +++ b/pybotx/models/enums.py @@ -108,6 +108,7 @@ class APIChatTypes(Enum): GROUP_CHAT = "group_chat" CHANNEL = "channel" THREAD = "thread" + VOEX_CALL = "voex_call" class BotAPICommandTypes(StrEnum): @@ -365,6 +366,7 @@ def convert_chat_type_to_domain( APIChatTypes.GROUP_CHAT: ChatTypes.GROUP_CHAT, APIChatTypes.CHANNEL: ChatTypes.CHANNEL, APIChatTypes.THREAD: ChatTypes.THREAD, + APIChatTypes.VOEX_CALL: ChatTypes.GROUP_CHAT, } converted_type: IncomingChatTypes | None diff --git a/pyproject.toml b/pyproject.toml index e51a668d..4fcae241 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pybotx" -version = "0.76.2" +version = "0.76.3" description = "A python library for interacting with eXpress BotX API" authors = [ "Sidnev Nikolay ", diff --git a/tests/client/chats_api/test_chat_info.py b/tests/client/chats_api/test_chat_info.py index c1a54a46..83d8f0c9 100644 --- a/tests/client/chats_api/test_chat_info.py +++ b/tests/client/chats_api/test_chat_info.py @@ -149,6 +149,80 @@ async def test__chat_info__succeed( assert endpoint.called +async def test__chat_info__succeed_voex_call( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + datetime_formatter: Callable[[str], dt], + bot_factory: Any, +) -> None: + # - Arrange - + endpoint = mock_botx( + respx_mock, + host, + REQUEST, + ok_payload( + { + "chat_type": "voex_call", + "creator": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "description": None, + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "inserted_at": "2019-08-29T11:22:48.358586Z", + "members": [ + { + "admin": True, + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "user_kind": "user", + }, + { + "admin": False, + "user_huid": "705df263-6bfd-536a-9d51-13524afaab5c", + "user_kind": "botx", + }, + ], + "name": "Voex Chat Example", + "shared_history": False, + }, + ), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + chat_info = await bot.chat_info( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert_deep_equal( + chat_info, + ChatInfo( + chat_type=ChatTypes.GROUP_CHAT, + creator_id=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + description=None, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), + members=[ + ChatInfoMember( + is_admin=True, + huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + kind=UserKinds.RTS_USER, + ), + ChatInfoMember( + is_admin=False, + huid=UUID("705df263-6bfd-536a-9d51-13524afaab5c"), + kind=UserKinds.BOT, + ), + ], + name="Voex Chat Example", + shared_history=False, + ), + ) + + assert endpoint.called + + async def test__chat_info__notes_chat_type_mapped_to_personal_chat( respx_mock: MockRouter, host: str, diff --git a/tests/client/chats_api/test_create_chat.py b/tests/client/chats_api/test_create_chat.py index 7c73fa01..16f00df1 100644 --- a/tests/client/chats_api/test_create_chat.py +++ b/tests/client/chats_api/test_create_chat.py @@ -220,7 +220,12 @@ def test__create_chat_payload__convert_chat_type_validator() -> None: result = BotXAPICreateChatRequestPayload._convert_chat_type(values) # type: ignore[operator] assert result["chat_type"] == APIChatTypes.GROUP_CHAT - # Test with APIChatTypes value (should remain unchanged) + # Test with APIChatTypes.VOEX_CALL + values = {"chat_type": APIChatTypes.VOEX_CALL} # type: ignore[dict-item] + with pytest.raises(ValueError, match="Bot cannot create a chat of type 'voex_call'"): + BotXAPICreateChatRequestPayload._convert_chat_type(values) # type: ignore[operator] + + # Test with another APIChatTypes value (should remain unchanged) values = {"chat_type": APIChatTypes.CHAT} # type: ignore[dict-item] result = BotXAPICreateChatRequestPayload._convert_chat_type(values) # type: ignore[operator] assert result["chat_type"] == APIChatTypes.CHAT diff --git a/tests/models/test_enums.py b/tests/models/test_enums.py index 086920cd..7cadc43b 100644 --- a/tests/models/test_enums.py +++ b/tests/models/test_enums.py @@ -34,6 +34,7 @@ def test__convert_chat_type_from_domain__unsupported_chat_type_raises_error() -> def test__convert_chat_type_to_domain__notes_maps_to_personal_chat() -> None: assert convert_chat_type_to_domain(APIChatTypes.NOTES) == ChatTypes.PERSONAL_CHAT + assert convert_chat_type_to_domain(APIChatTypes.VOEX_CALL) == ChatTypes.GROUP_CHAT assert convert_chat_type_to_domain("notes") == ChatTypes.PERSONAL_CHAT From 8997d91288c937ed7fe2ee5d70703433238f3dec Mon Sep 17 00:00:00 2001 From: Roman Bobrovskiy Date: Mon, 8 Jun 2026 17:55:05 +0300 Subject: [PATCH 3/6] feat: voex call api --- pybotx/bot/bot.py | 60 ++++++++++++ pybotx/client/voex_api/__init__.py | 0 pybotx/client/voex_api/exceptions.py | 9 ++ pybotx/client/voex_api/get_call.py | 59 +++++++++++ pybotx/client/voex_api/get_conference.py | 63 ++++++++++++ pybotx/models/call.py | 8 ++ pybotx/models/conference.py | 10 ++ pyproject.toml | 2 +- tests/client/voex_api/__init__.py | 0 tests/client/voex_api/test_call.py | 113 +++++++++++++++++++++ tests/client/voex_api/test_conference.py | 120 +++++++++++++++++++++++ 11 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 pybotx/client/voex_api/__init__.py create mode 100644 pybotx/client/voex_api/exceptions.py create mode 100644 pybotx/client/voex_api/get_call.py create mode 100644 pybotx/client/voex_api/get_conference.py create mode 100644 pybotx/models/call.py create mode 100644 pybotx/models/conference.py create mode 100644 tests/client/voex_api/__init__.py create mode 100644 tests/client/voex_api/test_call.py create mode 100644 tests/client/voex_api/test_conference.py diff --git a/pybotx/bot/bot.py b/pybotx/bot/bot.py index aef8eb17..e09ff322 100644 --- a/pybotx/bot/bot.py +++ b/pybotx/bot/bot.py @@ -224,6 +224,14 @@ BotXAPIUsersAsCSVRequestPayload, UsersAsCSVMethod, ) +from pybotx.client.voex_api.get_call import ( + BotXAPIGetCallRequestPayload, + GetCallMethod, +) +from pybotx.client.voex_api.get_conference import ( + BotXAPIGetConferenceRequestPayload, + GetConferenceMethod, +) from pybotx.constants import BOTX_DEFAULT_TIMEOUT, STICKER_PACKS_PER_PAGE from pybotx.converters import optional_sequence_to_list from pybotx.image_validators import ( @@ -236,12 +244,14 @@ from pybotx.models.attachments import IncomingFileAttachment, OutgoingAttachment from pybotx.models.bot_account import BotAccountWithSecret from pybotx.models.bot_catalog import BotsListItem +from pybotx.models.call import Call from pybotx.models.chats import ChatInfo, ChatLink, ChatListItem from pybotx.models.commands import ( BotAPISystemEvent, BotAPIIncomingMessage, BotCommand, ) +from pybotx.models.conference import Conference from pybotx.models.enums import BotAPICommandTypes, ChatLinkTypes, ChatTypes from pybotx.models.message.edit_message import EditMessage from pybotx.models.message.markup import BubbleMarkup, KeyboardMarkup @@ -1395,6 +1405,56 @@ async def pin_message( await method.execute(payload) + async def get_call( + self, + *, + bot_id: UUID, + call_id: UUID, + ) -> Call: + """Get call. + + :param bot_id: Bot which should perform the request. + :param call_id: Call id. + + :return: Call. + """ + method = GetCallMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIGetCallRequestPayload.from_domain( + call_id=call_id, + ) + botx_call = await method.execute(payload) + + return botx_call.to_domain() + + async def get_conference( + self, + *, + bot_id: UUID, + call_id: UUID, + ) -> Conference: + """Get Conference. + + :param bot_id: Bot which should perform the request. + :param call_id: Call id. + + :return: Conference. + """ + method = GetConferenceMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIGetConferenceRequestPayload.from_domain( + call_id=call_id, + ) + botx_conference = await method.execute(payload) + + return botx_conference.to_domain() + async def unpin_message( self, *, diff --git a/pybotx/client/voex_api/__init__.py b/pybotx/client/voex_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pybotx/client/voex_api/exceptions.py b/pybotx/client/voex_api/exceptions.py new file mode 100644 index 00000000..08363e08 --- /dev/null +++ b/pybotx/client/voex_api/exceptions.py @@ -0,0 +1,9 @@ +from pybotx.client.exceptions.base import BaseClientError + + +class ConferenceNotFoundError(BaseClientError): + """Conference with specified call_id not found.""" + + +class CallNotFoundError(BaseClientError): + """Call with specified call_id not found.""" diff --git a/pybotx/client/voex_api/get_call.py b/pybotx/client/voex_api/get_call.py new file mode 100644 index 00000000..e5dee2cb --- /dev/null +++ b/pybotx/client/voex_api/get_call.py @@ -0,0 +1,59 @@ +from typing import Literal +from uuid import UUID + +from pybotx.client.authorized_botx_method import AuthorizedBotXMethod +from pybotx.client.botx_method import response_exception_thrower +from pybotx.client.voex_api.exceptions import CallNotFoundError +from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from pybotx.models.call import Call + + +class BotXAPIGetCallRequestPayload(UnverifiedPayloadBaseModel): + call_id: UUID + + @classmethod + def from_domain( + cls, + call_id: UUID, + ) -> "BotXAPIGetCallRequestPayload": + return cls(call_id=call_id) + + +class BotXAPIGetCallResult(VerifiedPayloadBaseModel): + id: UUID + members: list[UUID] + + +class BotXAPIGetCallResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIGetCallResult + + def to_domain(self) -> Call: + return Call( + id=self.result.id, + members=self.result.members, + ) + + +class GetCallMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(CallNotFoundError), + } + + async def execute( + self, + payload: BotXAPIGetCallRequestPayload, + ) -> BotXAPIGetCallResponsePayload: + jsonable_dict = payload.jsonable_dict() + path = f"/api/v3/botx/voex/calls/{jsonable_dict['call_id']}" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + ) + + return self._verify_and_extract_api_model( + BotXAPIGetCallResponsePayload, + response, + ) diff --git a/pybotx/client/voex_api/get_conference.py b/pybotx/client/voex_api/get_conference.py new file mode 100644 index 00000000..0fc8c680 --- /dev/null +++ b/pybotx/client/voex_api/get_conference.py @@ -0,0 +1,63 @@ +from typing import Literal +from uuid import UUID + +from pybotx.client.authorized_botx_method import AuthorizedBotXMethod +from pybotx.client.botx_method import response_exception_thrower +from pybotx.client.voex_api.exceptions import ConferenceNotFoundError +from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from pybotx.models.conference import Conference + + +class BotXAPIGetConferenceRequestPayload(UnverifiedPayloadBaseModel): + call_id: UUID + + @classmethod + def from_domain( + cls, + call_id: UUID, + ) -> "BotXAPIGetConferenceRequestPayload": + return cls(call_id=call_id) + + +class BotXAPIGetConferenceResult(VerifiedPayloadBaseModel): + id: UUID + name: str + link: str + members: list[UUID] + + +class BotXAPIGetConferenceResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIGetConferenceResult + + def to_domain(self) -> Conference: + return Conference( + id=self.result.id, + name=self.result.name, + link=self.result.link, + members=self.result.members, + ) + + +class GetConferenceMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(ConferenceNotFoundError), + } + + async def execute( + self, + payload: BotXAPIGetConferenceRequestPayload, + ) -> BotXAPIGetConferenceResponsePayload: + jsonable_dict = payload.jsonable_dict() + path = f"/api/v3/botx/voex/conferences/{jsonable_dict['call_id']}" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + ) + + return self._verify_and_extract_api_model( + BotXAPIGetConferenceResponsePayload, + response, + ) diff --git a/pybotx/models/call.py b/pybotx/models/call.py new file mode 100644 index 00000000..3dd45e11 --- /dev/null +++ b/pybotx/models/call.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(slots=True) +class Call: + id: UUID + members: list[UUID] diff --git a/pybotx/models/conference.py b/pybotx/models/conference.py new file mode 100644 index 00000000..fce0f4b6 --- /dev/null +++ b/pybotx/models/conference.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(slots=True) +class Conference: + id: UUID + name: str + link: str + members: list[UUID] diff --git a/pyproject.toml b/pyproject.toml index 4fcae241..0a190876 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pybotx" -version = "0.76.3" +version = "0.76.4" description = "A python library for interacting with eXpress BotX API" authors = [ "Sidnev Nikolay ", diff --git a/tests/client/voex_api/__init__.py b/tests/client/voex_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/client/voex_api/test_call.py b/tests/client/voex_api/test_call.py new file mode 100644 index 00000000..fd249532 --- /dev/null +++ b/tests/client/voex_api/test_call.py @@ -0,0 +1,113 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from pybotx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + lifespan_wrapper, +) +from pybotx.client.voex_api.exceptions import CallNotFoundError +from pybotx.models.call import Call + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__get_call__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + call_id = "a465f0f3-1354-491c-8f11-f400164295cb" + member1 = "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4" + member2 = "6fa5f1e9-1453-0ad7-2d6d-b791467e382a" + + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/voex/calls/{call_id}", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "id": call_id, + "members": [ + member1, + member2, + ], + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + call = await bot.get_call( + bot_id=bot_id, + call_id=UUID(call_id), + ) + + # - Assert - + assert call == Call( + id=UUID(call_id), + members=[ + UUID(member1), + UUID(member2), + ], + ) + + assert endpoint.called + + +async def test__get_call__call_not_found( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + call_id = "a465f0f3-1354-491c-8f11-f400164295cb" + + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/voex/calls/{call_id}", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "error_data": { + "call_id": call_id, + }, + "errors": ["Call with specified call_id not found."], + "reason": "not_found", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(CallNotFoundError) as exc: + await bot.get_call( + bot_id=bot_id, + call_id=UUID(call_id), + ) + + # - Assert - + assert "not_found" in str(exc.value) + assert endpoint.called diff --git a/tests/client/voex_api/test_conference.py b/tests/client/voex_api/test_conference.py new file mode 100644 index 00000000..572a94f8 --- /dev/null +++ b/tests/client/voex_api/test_conference.py @@ -0,0 +1,120 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from pybotx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + lifespan_wrapper, +) +from pybotx.client.voex_api.exceptions import ConferenceNotFoundError +from pybotx.models.conference import Conference + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__get_conference__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + call_id = "a465f0f3-1354-491c-8f11-f400164295cb" + member1 = "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4" + member2 = "6fa5f1e9-1453-0ad7-2d6d-b791467e382a" + + name = "Test Conference" + link = "https://example.com/join" + + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/voex/conferences/{call_id}", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "id": call_id, + "name": name, + "link": link, + "members": [ + member1, + member2, + ], + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + call = await bot.get_conference( + bot_id=bot_id, + call_id=UUID(call_id), + ) + + # - Assert - + assert call == Conference( + id=UUID(call_id), + name=name, + link=link, + members=[ + UUID(member1), + UUID(member2), + ], + ) + + assert endpoint.called + + +async def test__get_conference__call_not_found( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + call_id = "4f60d594-21cb-52b4-8b04-8eb73c05085e" + + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/voex/conferences/{call_id}", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "error_data": { + "call_id": call_id, + }, + "errors": ["Conference with specified call_id not found."], + "reason": "not_found", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ConferenceNotFoundError) as exc: + await bot.get_conference( + bot_id=bot_id, + call_id=UUID(call_id), + ) + + # - Assert - + assert "not_found" in str(exc.value) + assert endpoint.called From ef70e3e5a1d7dfd30e603fb2dd70fc74db8d784f Mon Sep 17 00:00:00 2001 From: osovskiyalexandr Date: Mon, 8 Jun 2026 17:55:52 +0300 Subject: [PATCH 4/6] fix: case insensitive file extension mimetype --- pybotx/models/attachments.py | 11 +++++++---- tests/test_attachments.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/pybotx/models/attachments.py b/pybotx/models/attachments.py index 9882eca3..14cccce4 100644 --- a/pybotx/models/attachments.py +++ b/pybotx/models/attachments.py @@ -501,6 +501,12 @@ def encode_rfc2397(content: bytes, mimetype: str) -> str: return f"data:{mimetype};base64,{b64_content}" +def get_mimetype_by_filename(filename: str) -> str: + extension = filename.rsplit(".", 1)[-1].lower() + + return EXTENSIONS_TO_MIMETYPES.get(extension, DEFAULT_MIMETYPE) + + class BotXAPIAttachment(UnverifiedPayloadBaseModel): file_name: str data: str @@ -512,10 +518,7 @@ def from_file_attachment( ) -> "BotXAPIAttachment": assert attachment.content is not None - mimetype = EXTENSIONS_TO_MIMETYPES.get( - attachment.filename.split(".")[-1], - DEFAULT_MIMETYPE, - ) + mimetype = get_mimetype_by_filename(attachment.filename) return cls( file_name=attachment.filename, diff --git a/tests/test_attachments.py b/tests/test_attachments.py index a3a87b6d..f4066c80 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -21,10 +21,12 @@ AttachmentVideo, AttachmentVoice, BotAPIAttachment, + BotXAPIAttachment, Contact, IncomingAttachment, Link, Location, + OutgoingAttachment, convert_api_attachment_to_domain, ) @@ -81,6 +83,21 @@ async def default_handler(message: IncomingMessage, bot: Bot) -> None: assert read_content == b"Hello, world!\n" +async def test__botx_api_attachment__uppercase_file_extension_mimetype() -> None: + # - Arrange - + attachment = OutgoingAttachment( + content=b"Hello, world!", + filename="image.PNG", + ) + + # - Act - + api_attachment = BotXAPIAttachment.from_file_attachment(attachment) + + # - Assert - + assert api_attachment.file_name == "image.PNG" + assert api_attachment.data == "data:image/png;base64,SGVsbG8sIHdvcmxkIQ==" + + API_AND_DOMAIN_NON_FILE_ATTACHMENTS = ( ( { From ef61b4dd830919546f79c0966e3ab4bede98f12c Mon Sep 17 00:00:00 2001 From: osovskiyalexandr Date: Mon, 8 Jun 2026 17:56:24 +0300 Subject: [PATCH 5/6] markup link button without command --- pybotx/models/message/markup.py | 2 +- tests/models/test_botx_api_markup.py | 35 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/pybotx/models/message/markup.py b/pybotx/models/message/markup.py index 587049dd..cb06c0b2 100644 --- a/pybotx/models/message/markup.py +++ b/pybotx/models/message/markup.py @@ -178,7 +178,7 @@ class BotXAPIButtonOptions(UnverifiedPayloadBaseModel): class BotXAPIButton(UnverifiedPayloadBaseModel): - command: str + command: Missing[str] label: str data: dict[str, Any] opts: BotXAPIButtonOptions diff --git a/tests/models/test_botx_api_markup.py b/tests/models/test_botx_api_markup.py index 850d4639..0d79c67b 100644 --- a/tests/models/test_botx_api_markup.py +++ b/tests/models/test_botx_api_markup.py @@ -1,11 +1,14 @@ import json +import warnings from typing import Any +from pybotx import BubbleMarkup from pybotx.models.message.markup import ( BotXAPIMarkup, BotXAPIButton, BotXAPIButtonOptions, + api_markup_from_domain, ) @@ -114,3 +117,35 @@ def test_botx_api_markup_jsonable_dict() -> None: ] ] assert jsonable_dict == expected_dict + + +def test_botx_api_markup_link_button_without_command() -> None: + # - Arrange - + markup = BubbleMarkup() + markup.add_button( + label="Open me", + link="https://example.com", + ) + + # - Act - + with warnings.catch_warnings(record=True) as captured_warnings: + warnings.simplefilter("always") + jsonable_dict = api_markup_from_domain(markup).jsonable_dict() + + # - Assert - + expected_dict: list[list[dict[str, Any]]] = [ + [ + { + "label": "Open me", + "data": {}, + "opts": { + "silent": True, + "align": "center", + "handler": "client", + "link": "https://example.com", + }, + } + ] + ] + assert jsonable_dict == expected_dict + assert not captured_warnings From d9bd95dcf163ba8ea78242f38d764e4a261a6d85 Mon Sep 17 00:00:00 2001 From: osovskiyalexandr Date: Mon, 8 Jun 2026 17:56:50 +0300 Subject: [PATCH 6/6] fix: users api actualize --- pybotx/__init__.py | 6 ++- pybotx/bot/bot.py | 20 ++++++- pybotx/client/exceptions/users.py | 4 ++ .../client/users_api/search_user_by_email.py | 25 +++++++-- .../client/users_api/search_user_by_emails.py | 11 +++- .../client/users_api/update_user_profile.py | 7 ++- pybotx/client/users_api/user_from_csv.py | 18 +++---- pybotx/client/users_api/user_from_search.py | 10 +++- .../users_api/test_search_user_by_emails.py | 42 +++++++++++++++ .../users_api/test_search_user_by_huid.py | 37 +++++++++++++ .../users_api/test_update_user_profile.py | 48 ++++++++++++++++- tests/client/users_api/test_users_as_csv.py | 53 +++++++++++++++++++ 12 files changed, 262 insertions(+), 19 deletions(-) diff --git a/pybotx/__init__.py b/pybotx/__init__.py index fa50a5c4..23074581 100644 --- a/pybotx/__init__.py +++ b/pybotx/__init__.py @@ -64,7 +64,10 @@ FinalRecipientsListEmptyError, StealthModeDisabledError, ) -from pybotx.client.exceptions.users import UserNotFoundError +from pybotx.client.exceptions.users import ( + UserNotFoundError, + UserProfileUpdateUnavailableError, +) from pybotx.client.smartapps_api.exceptions import SyncSmartAppEventHandlerNotFoundError from pybotx.client.smartapps_api.smartapp_manifest import ( SmartappManifest, @@ -301,6 +304,7 @@ "UserFromSearch", "UserKinds", "UserNotFoundError", + "UserProfileUpdateUnavailableError", "UserSender", "Video", "Voice", diff --git a/pybotx/bot/bot.py b/pybotx/bot/bot.py index e09ff322..0e1746fc 100644 --- a/pybotx/bot/bot.py +++ b/pybotx/bot/bot.py @@ -1481,11 +1481,15 @@ async def search_user_by_emails( *, bot_id: UUID, emails: list[str], + trusts_search: bool = False, + partial_response: bool = False, ) -> list[UserFromSearch]: """Search user by emails for search. :param bot_id: Bot which should perform the request. :param emails: User emails. + :param trusts_search: Search users on trusted servers. + :param partial_response: Return local results if trusted server lookup fails. :return: Search result with user information. """ @@ -1495,7 +1499,11 @@ async def search_user_by_emails( self._httpx_client, self._bot_accounts_storage, ) - payload = BotXAPISearchUserByEmailsRequestPayload.from_domain(emails=emails) + payload = BotXAPISearchUserByEmailsRequestPayload.from_domain( + emails=emails, + trusts_search=trusts_search, + partial_response=partial_response, + ) botx_api_users_from_search = await method.execute(payload) @@ -1507,6 +1515,8 @@ async def search_user_by_email_post( *, bot_id: UUID, email: str, + trusts_search: bool = False, + partial_response: bool = False, ) -> UserFromSearch: """Search user by email for search. @@ -1515,6 +1525,8 @@ async def search_user_by_email_post( :param bot_id: Bot which should perform the request. :param email: User email. + :param trusts_search: Search users on trusted servers. + :param partial_response: Return local results if trusted server lookup fails. :return: User information. """ @@ -1524,7 +1536,11 @@ async def search_user_by_email_post( self._httpx_client, self._bot_accounts_storage, ) - payload = BotXAPISearchUserByEmailRequestPayload.from_domain(email=email) + payload = BotXAPISearchUserByEmailRequestPayload.from_domain( + email=email, + trusts_search=trusts_search, + partial_response=partial_response, + ) botx_api_user_from_search = await method.execute(payload) diff --git a/pybotx/client/exceptions/users.py b/pybotx/client/exceptions/users.py index c6fc913f..a92a4310 100644 --- a/pybotx/client/exceptions/users.py +++ b/pybotx/client/exceptions/users.py @@ -11,3 +11,7 @@ class InvalidProfileDataError(BaseClientError): class NoUserKindSelectedError(BaseClientError): """No user kind selected.""" + + +class UserProfileUpdateUnavailableError(BaseClientError): + """User profile update service is unavailable.""" diff --git a/pybotx/client/users_api/search_user_by_email.py b/pybotx/client/users_api/search_user_by_email.py index ad11a589..1aacf10c 100644 --- a/pybotx/client/users_api/search_user_by_email.py +++ b/pybotx/client/users_api/search_user_by_email.py @@ -9,15 +9,27 @@ BotXAPISearchUserResponsePayload, ) from pybotx.logger import logger +from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel class BotXAPISearchUserByEmailRequestPayload(UnverifiedPayloadBaseModel): email: str + trusts_search: Missing[bool] = Undefined + partial_response: Missing[bool] = Undefined @classmethod - def from_domain(cls, email: str) -> "BotXAPISearchUserByEmailRequestPayload": - return cls(email=email) + def from_domain( + cls, + email: str, + trusts_search: bool = False, + partial_response: bool = False, + ) -> "BotXAPISearchUserByEmailRequestPayload": + return cls( + email=email, + trusts_search=trusts_search or Undefined, + partial_response=partial_response or Undefined, + ) class SearchUserByEmailMethod(AuthorizedBotXMethod): @@ -67,7 +79,14 @@ async def execute( path = "/api/v3/botx/users/by_email" email = payload.email - request_json = {"emails": [email]} + request_json = { + "emails": [email], + "trusts_search": payload.trusts_search, + "partial_response": payload.partial_response, + } + request_json = { + key: value for key, value in request_json.items() if value is not Undefined + } response = await self._botx_method_call( "POST", diff --git a/pybotx/client/users_api/search_user_by_emails.py b/pybotx/client/users_api/search_user_by_emails.py index 8981c355..8f024c4f 100644 --- a/pybotx/client/users_api/search_user_by_emails.py +++ b/pybotx/client/users_api/search_user_by_emails.py @@ -3,18 +3,27 @@ from pybotx.client.users_api.user_from_search import ( BotXAPISearchUserByEmailsResponsePayload, ) +from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel class BotXAPISearchUserByEmailsRequestPayload(UnverifiedPayloadBaseModel): emails: list[str] + trusts_search: Missing[bool] = Undefined + partial_response: Missing[bool] = Undefined @classmethod def from_domain( cls, emails: list[str], + trusts_search: bool = False, + partial_response: bool = False, ) -> "BotXAPISearchUserByEmailsRequestPayload": - return cls(emails=emails) + return cls( + emails=emails, + trusts_search=trusts_search or Undefined, + partial_response=partial_response or Undefined, + ) class SearchUserByEmailsMethod(AuthorizedBotXMethod): diff --git a/pybotx/client/users_api/update_user_profile.py b/pybotx/client/users_api/update_user_profile.py index 5f638f57..a257d42f 100644 --- a/pybotx/client/users_api/update_user_profile.py +++ b/pybotx/client/users_api/update_user_profile.py @@ -3,7 +3,11 @@ from pybotx.client.authorized_botx_method import AuthorizedBotXMethod from pybotx.client.botx_method import response_exception_thrower -from pybotx.client.exceptions.users import InvalidProfileDataError, UserNotFoundError +from pybotx.client.exceptions.users import ( + InvalidProfileDataError, + UserNotFoundError, + UserProfileUpdateUnavailableError, +) from pybotx.missing import Missing, Undefined from pybotx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel from pybotx.models.attachments import ( @@ -67,6 +71,7 @@ class UpdateUsersProfileMethod(AuthorizedBotXMethod): **AuthorizedBotXMethod.status_handlers, 400: response_exception_thrower(InvalidProfileDataError), 404: response_exception_thrower(UserNotFoundError), + 503: response_exception_thrower(UserProfileUpdateUnavailableError), } async def execute( diff --git a/pybotx/client/users_api/user_from_csv.py b/pybotx/client/users_api/user_from_csv.py index 275b9645..88b9bb38 100644 --- a/pybotx/client/users_api/user_from_csv.py +++ b/pybotx/client/users_api/user_from_csv.py @@ -23,17 +23,17 @@ class BotXAPIUserFromCSVResult(VerifiedPayloadBaseModel): company: str | None = Field(alias="Company") department: str | None = Field(alias="Department") position: str | None = Field(alias="Position") - avatar: str | None = Field(alias="Avatar") - avatar_preview: str | None = Field(alias="Avatar preview") - office: str | None = Field(alias="Office") + avatar: str | None = Field(default=None, alias="Avatar") + avatar_preview: str | None = Field(default=None, alias="Avatar preview") + office: str | None = Field(default=None, alias="Office") manager: str | None = Field(alias="Manager") manager_huid: UUID | None = Field(alias="Manager HUID") - description: str | None = Field(alias="Description") - phone: str | None = Field(alias="Phone") - other_phone: str | None = Field(alias="Other phone") - ip_phone: str | None = Field(alias="IP phone") - other_ip_phone: str | None = Field(alias="Other IP phone") - personnel_number: str | None = Field(alias="Personnel number") + description: str | None = Field(default=None, alias="Description") + phone: str | None = Field(default=None, alias="Phone") + other_phone: str | None = Field(default=None, alias="Other phone") + ip_phone: str | None = Field(default=None, alias="IP phone") + other_ip_phone: str | None = Field(default=None, alias="Other IP phone") + personnel_number: str | None = Field(default=None, alias="Personnel number") @field_validator( "email", diff --git a/pybotx/client/users_api/user_from_search.py b/pybotx/client/users_api/user_from_search.py index 92d257c5..17d6ba34 100644 --- a/pybotx/client/users_api/user_from_search.py +++ b/pybotx/client/users_api/user_from_search.py @@ -5,7 +5,7 @@ from pybotx.models.api_base import VerifiedPayloadBaseModel from pybotx.models.enums import APIUserKinds, convert_user_kind_to_domain from pybotx.models.users import UserFromSearch -from pydantic import Field +from pydantic import Field, field_validator class BotXAPISearchUserResult(VerifiedPayloadBaseModel): @@ -32,6 +32,14 @@ class BotXAPISearchUserResult(VerifiedPayloadBaseModel): created_at: datetime | None = None updated_at: datetime | None = None + @field_validator("ip_phone", "other_ip_phone", "other_phone", mode="before") + @classmethod + def convert_phone_to_string(cls, value: str | int | None) -> str | None: + if value is None: + return None + + return str(value) + class BotXAPISearchUserResponsePayload(VerifiedPayloadBaseModel): status: Literal["ok"] diff --git a/tests/client/users_api/test_search_user_by_emails.py b/tests/client/users_api/test_search_user_by_emails.py index 72d61425..3fefc536 100644 --- a/tests/client/users_api/test_search_user_by_emails.py +++ b/tests/client/users_api/test_search_user_by_emails.py @@ -85,3 +85,45 @@ async def test__search_user_by_email_without_data__succeed( # - Assert - assert_deep_equal(users, [user_from_search_without_data]) assert endpoint.called + + +async def test__search_user_by_email_with_trusts_search__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data: UserFromSearch, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + # - Arrange - + user_emails = ["ad_user@cts.com"] + + request = BotXRequest( + method="POST", + path="/api/v3/botx/users/by_email", + json={ + "emails": user_emails, + "trusts_search": True, + "partial_response": True, + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload([user_from_search_with_data_json]), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + users = await bot.search_user_by_emails( + bot_id=bot_id, + emails=user_emails, + trusts_search=True, + partial_response=True, + ) + + # - Assert - + assert_deep_equal(users, [user_from_search_with_data]) + assert endpoint.called diff --git a/tests/client/users_api/test_search_user_by_huid.py b/tests/client/users_api/test_search_user_by_huid.py index 51a47cc2..7ada442b 100644 --- a/tests/client/users_api/test_search_user_by_huid.py +++ b/tests/client/users_api/test_search_user_by_huid.py @@ -82,6 +82,43 @@ async def test__search_user_by_huid__succeed( assert endpoint.called +async def test__search_user_by_huid_with_numeric_ip_phone__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + user_from_search_with_data: UserFromSearch, + user_from_search_with_data_json: dict[str, Any], + bot_factory: Any, +) -> None: + # - Arrange - + user_from_search_with_data_json["ip_phone"] = 1271020 + + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/by_huid", + params={"user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + ok_payload(user_from_search_with_data_json), + HTTPStatus.OK, + ) + + # - Act - + async with bot_factory() as bot: + user = await bot.search_user_by_huid( + bot_id=bot_id, + huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"), + ) + + # - Assert - + assert user.ip_phone == "1271020" + assert_deep_equal(user, user_from_search_with_data) + assert endpoint.called + + async def test__search_user_by_huid_without_data__succeed( respx_mock: MockRouter, host: str, diff --git a/tests/client/users_api/test_update_user_profile.py b/tests/client/users_api/test_update_user_profile.py index da713334..06359d93 100644 --- a/tests/client/users_api/test_update_user_profile.py +++ b/tests/client/users_api/test_update_user_profile.py @@ -5,7 +5,10 @@ import pytest from respx.router import MockRouter -from pybotx.client.exceptions.users import InvalidProfileDataError +from pybotx.client.exceptions.users import ( + InvalidProfileDataError, + UserProfileUpdateUnavailableError, +) from pybotx.models.attachments import AttachmentImage from pybotx.models.enums import AttachmentTypes from tests.testkit import BotXRequest, error_payload, mock_botx, ok_payload @@ -168,3 +171,46 @@ async def test__update_user_profile__invalid_profile_data_error( # - Assert - assert endpoint.called + + +@pytest.mark.parametrize( + "reason", + [ + "error_from_ad_phonebook_service", + "unexpected_error", + ], +) +async def test__update_user_profile__service_unavailable_error( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, + reason: str, +) -> None: + # - Arrange - + request = BotXRequest( + method="PUT", + path="/api/v3/botx/users/update_profile", + json={ + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + }, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + error_payload(reason), + HTTPStatus.SERVICE_UNAVAILABLE, + ) + + # - Act - + async with bot_factory() as bot: + with pytest.raises(UserProfileUpdateUnavailableError) as exc: + await bot.update_user_profile( + bot_id=bot_id, + user_huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + ) + + # - Assert - + assert reason in str(exc.value) + assert endpoint.called diff --git a/tests/client/users_api/test_users_as_csv.py b/tests/client/users_api/test_users_as_csv.py index 95315b2c..95b98fdc 100644 --- a/tests/client/users_api/test_users_as_csv.py +++ b/tests/client/users_api/test_users_as_csv.py @@ -138,3 +138,56 @@ async def test__users_as_csv__succeed( ), ], ) + + +async def test__users_as_csv_with_documented_columns_only__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_factory: Any, +) -> None: + request = BotXRequest( + method="GET", + path="/api/v3/botx/users/users_as_csv", + params={"cts_user": True, "unregistered": True, "botx": False}, + ) + endpoint = mock_botx( + respx_mock, + host, + request, + response_json=None, + status=HTTPStatus.OK, + response_content=( + b"HUID,AD Login,Domain,AD E-mail,Name,Sync source,Active,Kind,Company,Department,Position,Manager,Manager HUID\n" + b"dbc8934f-d0d7-4a9e-89df-d45c137a851c,test_user_17,cts.example.com,,test_user_17,ad,true,cts_user,Company,Department,Position,Manager John,13a6909c-bce1-4dbf-8359-efb7ef8e5b34\n" + ), + ) + + users_from_csv = [] + + async with bot_factory() as bot: + async with bot.users_as_csv(bot_id=bot_id) as users: + async for user in users: + users_from_csv.append(user) + + assert endpoint.called + assert_deep_equal( + users_from_csv, + [ + UserFromCSV( + huid=UUID("dbc8934f-d0d7-4a9e-89df-d45c137a851c"), + ad_login="test_user_17", + ad_domain="cts.example.com", + username="test_user_17", + sync_source=SyncSourceTypes.AD, + active=True, + user_kind=UserKinds.CTS_USER, + email=None, + company="Company", + department="Department", + position="Position", + manager="Manager John", + manager_huid=UUID("13a6909c-bce1-4dbf-8359-efb7ef8e5b34"), + ), + ], + )