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 aef8eb17..0e1746fc 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, *, @@ -1421,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. """ @@ -1435,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) @@ -1447,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. @@ -1455,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. """ @@ -1464,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/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/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/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/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/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/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"), + ), + ], + ) 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 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 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 = ( ( {