diff --git a/libzapi/__init__.py b/libzapi/__init__.py index f358f0b..b2fae63 100644 --- a/libzapi/__init__.py +++ b/libzapi/__init__.py @@ -4,6 +4,7 @@ from libzapi.application import AgentAvailability from libzapi.application import AssetManagement from libzapi.application import ZendeskStatus +from libzapi.application import Voice __all__ = [ "HelpCenter", @@ -12,4 +13,5 @@ "AgentAvailability", "AssetManagement", "ZendeskStatus", + "Voice", ] diff --git a/libzapi/application/__init__.py b/libzapi/application/__init__.py index 829652c..934f661 100644 --- a/libzapi/application/__init__.py +++ b/libzapi/application/__init__.py @@ -4,6 +4,7 @@ from libzapi.application.services.agent_availability import AgentAvailability from libzapi.application.services.asset_management import AssetManagement from libzapi.application.services.status import ZendeskStatus +from libzapi.application.services.voice import Voice __all__ = [ "HelpCenter", @@ -12,4 +13,5 @@ "AgentAvailability", "AssetManagement", "ZendeskStatus", + "Voice", ] diff --git a/libzapi/application/commands/voice/__init__.py b/libzapi/application/commands/voice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libzapi/application/commands/voice/address_cmds.py b/libzapi/application/commands/voice/address_cmds.py new file mode 100644 index 0000000..baabea5 --- /dev/null +++ b/libzapi/application/commands/voice/address_cmds.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateAddressCmd: + city: str + country_code: str + name: str + province: str + street: str + zip: str + state: str = "" + provider_reference: str = "" + + +@dataclass(frozen=True, slots=True) +class UpdateAddressCmd: + city: str | None = None + country_code: str | None = None + name: str | None = None + province: str | None = None + street: str | None = None + zip: str | None = None + state: str | None = None diff --git a/libzapi/application/commands/voice/availability_cmds.py b/libzapi/application/commands/voice/availability_cmds.py new file mode 100644 index 0000000..aae40e9 --- /dev/null +++ b/libzapi/application/commands/voice/availability_cmds.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class UpdateAvailabilityCmd: + agent_state: str | None = None + call_status: str | None = None + via: str | None = None diff --git a/libzapi/application/commands/voice/callback_request_cmds.py b/libzapi/application/commands/voice/callback_request_cmds.py new file mode 100644 index 0000000..0f9fee5 --- /dev/null +++ b/libzapi/application/commands/voice/callback_request_cmds.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class CreateCallbackRequestCmd: + phone_number_id: int + requester_phone_number: str + group_ids: list[int] = field(default_factory=list) diff --git a/libzapi/application/commands/voice/digital_line_cmds.py b/libzapi/application/commands/voice/digital_line_cmds.py new file mode 100644 index 0000000..5d91a40 --- /dev/null +++ b/libzapi/application/commands/voice/digital_line_cmds.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateDigitalLineCmd: + nickname: str = "" + line_type: str = "" + brand_id: int | None = None + default_group_id: int | None = None + group_ids: list[int] | None = None + + +@dataclass(frozen=True, slots=True) +class UpdateDigitalLineCmd: + nickname: str | None = None + default_group_id: int | None = None + group_ids: list[int] | None = None + recorded: bool | None = None + transcription: bool | None = None + schedule_id: int | None = None + priority: int | None = None diff --git a/libzapi/application/commands/voice/greeting_cmds.py b/libzapi/application/commands/voice/greeting_cmds.py new file mode 100644 index 0000000..8bc022a --- /dev/null +++ b/libzapi/application/commands/voice/greeting_cmds.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateGreetingCmd: + category_id: int + name: str + audio_name: str = "" + + +@dataclass(frozen=True, slots=True) +class UpdateGreetingCmd: + name: str | None = None diff --git a/libzapi/application/commands/voice/ivr_cmds.py b/libzapi/application/commands/voice/ivr_cmds.py new file mode 100644 index 0000000..78532f8 --- /dev/null +++ b/libzapi/application/commands/voice/ivr_cmds.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class CreateIvrCmd: + name: str + phone_number_ids: list[int] = field(default_factory=list) + + +@dataclass(frozen=True, slots=True) +class UpdateIvrCmd: + name: str | None = None + phone_number_ids: list[int] | None = None + + +@dataclass(frozen=True, slots=True) +class CreateIvrMenuCmd: + name: str + default: bool = False + greeting_id: int | None = None + + +@dataclass(frozen=True, slots=True) +class UpdateIvrMenuCmd: + name: str | None = None + default: bool | None = None + greeting_id: int | None = None + + +@dataclass(frozen=True, slots=True) +class CreateIvrRouteCmd: + action: str + keypress: str + options: dict = field(default_factory=dict) + tags: list[str] = field(default_factory=list) + + +@dataclass(frozen=True, slots=True) +class UpdateIvrRouteCmd: + action: str | None = None + keypress: str | None = None + options: dict | None = None + tags: list[str] | None = None diff --git a/libzapi/application/commands/voice/phone_number_cmds.py b/libzapi/application/commands/voice/phone_number_cmds.py new file mode 100644 index 0000000..d939250 --- /dev/null +++ b/libzapi/application/commands/voice/phone_number_cmds.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreatePhoneNumberCmd: + token: str + nickname: str = "" + address_sid: str | None = None + + +@dataclass(frozen=True, slots=True) +class UpdatePhoneNumberCmd: + nickname: str | None = None + default_group_id: int | None = None + group_ids: list[int] | None = None + priority: int | None = None + outbound_enabled: bool | None = None + voice_enabled: bool | None = None + sms_enabled: bool | None = None + recorded: bool | None = None + transcription: bool | None = None + greeting_ids: list[int] | None = None + schedule_id: int | None = None + ivr_id: int | None = None + call_recording_consent: str | None = None + failover_number: str | None = None diff --git a/libzapi/application/commands/voice/voice_settings_cmds.py b/libzapi/application/commands/voice/voice_settings_cmds.py new file mode 100644 index 0000000..c8692b8 --- /dev/null +++ b/libzapi/application/commands/voice/voice_settings_cmds.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class UpdateVoiceSettingsCmd: + agent_confirmation_when_forwarding: bool | None = None + agent_wrap_up_after_calls: bool | None = None + maximum_queue_size: int | None = None + maximum_queue_wait_time: int | None = None + only_during_business_hours: bool | None = None + recordings_public: bool | None = None diff --git a/libzapi/application/services/voice/__init__.py b/libzapi/application/services/voice/__init__.py new file mode 100644 index 0000000..c8e8ad0 --- /dev/null +++ b/libzapi/application/services/voice/__init__.py @@ -0,0 +1,47 @@ +import libzapi.infrastructure.api_clients.voice as api +from libzapi.application.services.voice.addresses_service import AddressesService +from libzapi.application.services.voice.availabilities_service import AvailabilitiesService +from libzapi.application.services.voice.callback_requests_service import CallbackRequestsService +from libzapi.application.services.voice.digital_lines_service import DigitalLinesService +from libzapi.application.services.voice.greetings_service import GreetingsService +from libzapi.application.services.voice.incremental_exports_service import IncrementalExportsService +from libzapi.application.services.voice.ivr_menus_service import IvrMenusService +from libzapi.application.services.voice.ivr_routes_service import IvrRoutesService +from libzapi.application.services.voice.ivrs_service import IvrsService +from libzapi.application.services.voice.lines_service import LinesService +from libzapi.application.services.voice.phone_numbers_service import PhoneNumbersService +from libzapi.application.services.voice.recordings_service import RecordingsService +from libzapi.application.services.voice.stats_service import StatsService +from libzapi.application.services.voice.voice_settings_service import VoiceSettingsService +from libzapi.infrastructure.http.auth import api_token_headers, oauth_headers +from libzapi.infrastructure.http.client import HttpClient + + +class Voice: + def __init__( + self, base_url: str, oauth_token: str | None = None, email: str | None = None, api_token: str | None = None + ): + if oauth_token: + headers = oauth_headers(oauth_token) + elif email and api_token: + headers = api_token_headers(email, api_token) + else: + raise ValueError("Provide oauth_token or email+api_token") + + http = HttpClient(base_url, headers=headers) + + # Initialize services + self.phone_numbers = PhoneNumbersService(api.PhoneNumberApiClient(http)) + self.digital_lines = DigitalLinesService(api.DigitalLineApiClient(http)) + self.lines = LinesService(api.LineApiClient(http)) + self.availabilities = AvailabilitiesService(api.AvailabilityApiClient(http)) + self.greetings = GreetingsService(api.GreetingApiClient(http)) + self.callback_requests = CallbackRequestsService(api.CallbackRequestApiClient(http)) + self.stats = StatsService(api.StatsApiClient(http)) + self.incremental_exports = IncrementalExportsService(api.IncrementalExportApiClient(http)) + self.recordings = RecordingsService(api.RecordingApiClient(http)) + self.addresses = AddressesService(api.AddressApiClient(http)) + self.voice_settings = VoiceSettingsService(api.VoiceSettingsApiClient(http)) + self.ivrs = IvrsService(api.IvrApiClient(http)) + self.ivr_menus = IvrMenusService(api.IvrMenuApiClient(http)) + self.ivr_routes = IvrRoutesService(api.IvrRouteApiClient(http)) diff --git a/libzapi/application/services/voice/addresses_service.py b/libzapi/application/services/voice/addresses_service.py new file mode 100644 index 0000000..1407765 --- /dev/null +++ b/libzapi/application/services/voice/addresses_service.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.address_cmds import CreateAddressCmd, UpdateAddressCmd +from libzapi.domain.models.voice.address import Address +from libzapi.infrastructure.api_clients.voice.address_api_client import AddressApiClient + + +class AddressesService: + def __init__(self, client: AddressApiClient) -> None: + self._client = client + + def list_all(self) -> Iterator[Address]: + return self._client.list_all() + + def get(self, address_id: int) -> Address: + return self._client.get(address_id=address_id) + + def create( + self, city: str, country_code: str, name: str, province: str, street: str, zip: str, **kwargs + ) -> Address: + cmd = CreateAddressCmd( + city=city, country_code=country_code, name=name, province=province, street=street, zip=zip, **kwargs + ) + return self._client.create(cmd=cmd) + + def update(self, address_id: int, **kwargs) -> Address: + cmd = UpdateAddressCmd(**kwargs) + return self._client.update(address_id=address_id, cmd=cmd) + + def delete(self, address_id: int) -> None: + self._client.delete(address_id=address_id) diff --git a/libzapi/application/services/voice/availabilities_service.py b/libzapi/application/services/voice/availabilities_service.py new file mode 100644 index 0000000..687a835 --- /dev/null +++ b/libzapi/application/services/voice/availabilities_service.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from libzapi.application.commands.voice.availability_cmds import UpdateAvailabilityCmd +from libzapi.domain.models.voice.availability import Availability +from libzapi.infrastructure.api_clients.voice.availability_api_client import AvailabilityApiClient + + +class AvailabilitiesService: + def __init__(self, client: AvailabilityApiClient) -> None: + self._client = client + + def get(self, agent_id: int) -> Availability: + return self._client.get(agent_id=agent_id) + + def update(self, agent_id: int, **kwargs) -> Availability: + cmd = UpdateAvailabilityCmd(**kwargs) + return self._client.update(agent_id=agent_id, cmd=cmd) diff --git a/libzapi/application/services/voice/callback_requests_service.py b/libzapi/application/services/voice/callback_requests_service.py new file mode 100644 index 0000000..fa4641e --- /dev/null +++ b/libzapi/application/services/voice/callback_requests_service.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from libzapi.application.commands.voice.callback_request_cmds import CreateCallbackRequestCmd +from libzapi.infrastructure.api_clients.voice.callback_request_api_client import CallbackRequestApiClient + + +class CallbackRequestsService: + def __init__(self, client: CallbackRequestApiClient) -> None: + self._client = client + + def create(self, phone_number_id: int, requester_phone_number: str, group_ids: list[int] | None = None) -> dict: + cmd = CreateCallbackRequestCmd( + phone_number_id=phone_number_id, + requester_phone_number=requester_phone_number, + group_ids=group_ids or [], + ) + return self._client.create(cmd=cmd) diff --git a/libzapi/application/services/voice/digital_lines_service.py b/libzapi/application/services/voice/digital_lines_service.py new file mode 100644 index 0000000..3157bd6 --- /dev/null +++ b/libzapi/application/services/voice/digital_lines_service.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from libzapi.application.commands.voice.digital_line_cmds import CreateDigitalLineCmd, UpdateDigitalLineCmd +from libzapi.domain.models.voice.digital_line import DigitalLine +from libzapi.infrastructure.api_clients.voice.digital_line_api_client import DigitalLineApiClient + + +class DigitalLinesService: + def __init__(self, client: DigitalLineApiClient) -> None: + self._client = client + + def get(self, digital_line_id: int) -> DigitalLine: + return self._client.get(digital_line_id=digital_line_id) + + def create(self, **kwargs) -> DigitalLine: + cmd = CreateDigitalLineCmd(**kwargs) + return self._client.create(cmd=cmd) + + def update(self, digital_line_id: int, **kwargs) -> DigitalLine: + cmd = UpdateDigitalLineCmd(**kwargs) + return self._client.update(digital_line_id=digital_line_id, cmd=cmd) + + def delete(self, digital_line_id: int) -> None: + self._client.delete(digital_line_id=digital_line_id) diff --git a/libzapi/application/services/voice/greetings_service.py b/libzapi/application/services/voice/greetings_service.py new file mode 100644 index 0000000..fa0330e --- /dev/null +++ b/libzapi/application/services/voice/greetings_service.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.greeting_cmds import CreateGreetingCmd, UpdateGreetingCmd +from libzapi.domain.models.voice.greeting import Greeting, GreetingCategory +from libzapi.infrastructure.api_clients.voice.greeting_api_client import GreetingApiClient + + +class GreetingsService: + def __init__(self, client: GreetingApiClient) -> None: + self._client = client + + def list_all(self) -> Iterator[Greeting]: + return self._client.list_all() + + def get(self, greeting_id: int) -> Greeting: + return self._client.get(greeting_id=greeting_id) + + def create(self, category_id: int, name: str, audio_name: str = "") -> Greeting: + cmd = CreateGreetingCmd(category_id=category_id, name=name, audio_name=audio_name) + return self._client.create(cmd=cmd) + + def update(self, greeting_id: int, **kwargs) -> Greeting: + cmd = UpdateGreetingCmd(**kwargs) + return self._client.update(greeting_id=greeting_id, cmd=cmd) + + def delete(self, greeting_id: int) -> None: + self._client.delete(greeting_id=greeting_id) + + def list_categories(self) -> Iterator[GreetingCategory]: + return self._client.list_categories() + + def get_category(self, category_id: int) -> GreetingCategory: + return self._client.get_category(category_id=category_id) diff --git a/libzapi/application/services/voice/incremental_exports_service.py b/libzapi/application/services/voice/incremental_exports_service.py new file mode 100644 index 0000000..b356310 --- /dev/null +++ b/libzapi/application/services/voice/incremental_exports_service.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.domain.models.voice.call import Call, CallLeg +from libzapi.infrastructure.api_clients.voice.incremental_export_api_client import IncrementalExportApiClient + + +class IncrementalExportsService: + def __init__(self, client: IncrementalExportApiClient) -> None: + self._client = client + + def calls(self, start_time: int) -> Iterator[Call]: + return self._client.calls(start_time=start_time) + + def legs(self, start_time: int) -> Iterator[CallLeg]: + return self._client.legs(start_time=start_time) diff --git a/libzapi/application/services/voice/ivr_menus_service.py b/libzapi/application/services/voice/ivr_menus_service.py new file mode 100644 index 0000000..06e2c12 --- /dev/null +++ b/libzapi/application/services/voice/ivr_menus_service.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.ivr_cmds import CreateIvrMenuCmd, UpdateIvrMenuCmd +from libzapi.domain.models.voice.ivr import IvrMenu +from libzapi.infrastructure.api_clients.voice.ivr_menu_api_client import IvrMenuApiClient + + +class IvrMenusService: + def __init__(self, client: IvrMenuApiClient) -> None: + self._client = client + + def list_all(self, ivr_id: int) -> Iterator[IvrMenu]: + return self._client.list_all(ivr_id=ivr_id) + + def get(self, ivr_id: int, menu_id: int) -> IvrMenu: + return self._client.get(ivr_id=ivr_id, menu_id=menu_id) + + def create(self, ivr_id: int, name: str, **kwargs) -> IvrMenu: + cmd = CreateIvrMenuCmd(name=name, **kwargs) + return self._client.create(ivr_id=ivr_id, cmd=cmd) + + def update(self, ivr_id: int, menu_id: int, **kwargs) -> IvrMenu: + cmd = UpdateIvrMenuCmd(**kwargs) + return self._client.update(ivr_id=ivr_id, menu_id=menu_id, cmd=cmd) + + def delete(self, ivr_id: int, menu_id: int) -> None: + self._client.delete(ivr_id=ivr_id, menu_id=menu_id) diff --git a/libzapi/application/services/voice/ivr_routes_service.py b/libzapi/application/services/voice/ivr_routes_service.py new file mode 100644 index 0000000..b9b4b87 --- /dev/null +++ b/libzapi/application/services/voice/ivr_routes_service.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.ivr_cmds import CreateIvrRouteCmd, UpdateIvrRouteCmd +from libzapi.domain.models.voice.ivr import IvrRoute +from libzapi.infrastructure.api_clients.voice.ivr_route_api_client import IvrRouteApiClient + + +class IvrRoutesService: + def __init__(self, client: IvrRouteApiClient) -> None: + self._client = client + + def list_all(self, ivr_id: int, menu_id: int) -> Iterator[IvrRoute]: + return self._client.list_all(ivr_id=ivr_id, menu_id=menu_id) + + def get(self, ivr_id: int, menu_id: int, route_id: int) -> IvrRoute: + return self._client.get(ivr_id=ivr_id, menu_id=menu_id, route_id=route_id) + + def create(self, ivr_id: int, menu_id: int, action: str, keypress: str, **kwargs) -> IvrRoute: + cmd = CreateIvrRouteCmd(action=action, keypress=keypress, **kwargs) + return self._client.create(ivr_id=ivr_id, menu_id=menu_id, cmd=cmd) + + def update(self, ivr_id: int, menu_id: int, route_id: int, **kwargs) -> IvrRoute: + cmd = UpdateIvrRouteCmd(**kwargs) + return self._client.update(ivr_id=ivr_id, menu_id=menu_id, route_id=route_id, cmd=cmd) + + def delete(self, ivr_id: int, menu_id: int, route_id: int) -> None: + self._client.delete(ivr_id=ivr_id, menu_id=menu_id, route_id=route_id) diff --git a/libzapi/application/services/voice/ivrs_service.py b/libzapi/application/services/voice/ivrs_service.py new file mode 100644 index 0000000..791d51d --- /dev/null +++ b/libzapi/application/services/voice/ivrs_service.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.ivr_cmds import CreateIvrCmd, UpdateIvrCmd +from libzapi.domain.models.voice.ivr import Ivr +from libzapi.infrastructure.api_clients.voice.ivr_api_client import IvrApiClient + + +class IvrsService: + def __init__(self, client: IvrApiClient) -> None: + self._client = client + + def list_all(self) -> Iterator[Ivr]: + return self._client.list_all() + + def get(self, ivr_id: int) -> Ivr: + return self._client.get(ivr_id=ivr_id) + + def create(self, name: str, phone_number_ids: list[int] | None = None) -> Ivr: + cmd = CreateIvrCmd(name=name, phone_number_ids=phone_number_ids or []) + return self._client.create(cmd=cmd) + + def update(self, ivr_id: int, **kwargs) -> Ivr: + cmd = UpdateIvrCmd(**kwargs) + return self._client.update(ivr_id=ivr_id, cmd=cmd) + + def delete(self, ivr_id: int) -> None: + self._client.delete(ivr_id=ivr_id) diff --git a/libzapi/application/services/voice/lines_service.py b/libzapi/application/services/voice/lines_service.py new file mode 100644 index 0000000..af04e57 --- /dev/null +++ b/libzapi/application/services/voice/lines_service.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.infrastructure.api_clients.voice.line_api_client import LineApiClient + + +class LinesService: + def __init__(self, client: LineApiClient) -> None: + self._client = client + + def list_all(self) -> Iterator[dict]: + return self._client.list_all() diff --git a/libzapi/application/services/voice/phone_numbers_service.py b/libzapi/application/services/voice/phone_numbers_service.py new file mode 100644 index 0000000..fb616b2 --- /dev/null +++ b/libzapi/application/services/voice/phone_numbers_service.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.phone_number_cmds import CreatePhoneNumberCmd, UpdatePhoneNumberCmd +from libzapi.domain.models.voice.phone_number import AvailablePhoneNumber, PhoneNumber +from libzapi.infrastructure.api_clients.voice.phone_number_api_client import PhoneNumberApiClient + + +class PhoneNumbersService: + def __init__(self, client: PhoneNumberApiClient) -> None: + self._client = client + + def list_all(self) -> Iterator[PhoneNumber]: + return self._client.list_all() + + def search( + self, + country: str, + area_code: str | None = None, + contains: str | None = None, + toll_free: bool | None = None, + ) -> list[AvailablePhoneNumber]: + return self._client.search(country=country, area_code=area_code, contains=contains, toll_free=toll_free) + + def get(self, phone_number_id: int) -> PhoneNumber: + return self._client.get(phone_number_id=phone_number_id) + + def create(self, token: str, nickname: str = "", address_sid: str | None = None) -> PhoneNumber: + cmd = CreatePhoneNumberCmd(token=token, nickname=nickname, address_sid=address_sid) + return self._client.create(cmd=cmd) + + def update(self, phone_number_id: int, **kwargs) -> PhoneNumber: + cmd = UpdatePhoneNumberCmd(**kwargs) + return self._client.update(phone_number_id=phone_number_id, cmd=cmd) + + def delete(self, phone_number_id: int) -> None: + self._client.delete(phone_number_id=phone_number_id) diff --git a/libzapi/application/services/voice/recordings_service.py b/libzapi/application/services/voice/recordings_service.py new file mode 100644 index 0000000..74f433c --- /dev/null +++ b/libzapi/application/services/voice/recordings_service.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from libzapi.infrastructure.api_clients.voice.recording_api_client import RecordingApiClient + + +class RecordingsService: + def __init__(self, client: RecordingApiClient) -> None: + self._client = client + + def delete_all(self, call_id: int) -> None: + self._client.delete_all(call_id=call_id) + + def delete_by_type(self, call_id: int, recording_type: str) -> None: + self._client.delete_by_type(call_id=call_id, recording_type=recording_type) diff --git a/libzapi/application/services/voice/stats_service.py b/libzapi/application/services/voice/stats_service.py new file mode 100644 index 0000000..4cd94f9 --- /dev/null +++ b/libzapi/application/services/voice/stats_service.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.domain.models.voice.stats import AccountOverview, AgentActivity, AgentsOverview, CurrentQueueActivity +from libzapi.infrastructure.api_clients.voice.stats_api_client import StatsApiClient + + +class StatsService: + def __init__(self, client: StatsApiClient) -> None: + self._client = client + + def account_overview(self, phone_number_ids: Iterable[int] | None = None) -> AccountOverview: + return self._client.account_overview(phone_number_ids=phone_number_ids) + + def agents_activity(self, group_ids: Iterable[int] | None = None) -> list[AgentActivity]: + return self._client.agents_activity(group_ids=group_ids) + + def agents_overview(self) -> AgentsOverview: + return self._client.agents_overview() + + def current_queue_activity(self, phone_number_ids: Iterable[int] | None = None) -> CurrentQueueActivity: + return self._client.current_queue_activity(phone_number_ids=phone_number_ids) diff --git a/libzapi/application/services/voice/voice_settings_service.py b/libzapi/application/services/voice/voice_settings_service.py new file mode 100644 index 0000000..a4862d9 --- /dev/null +++ b/libzapi/application/services/voice/voice_settings_service.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from libzapi.application.commands.voice.voice_settings_cmds import UpdateVoiceSettingsCmd +from libzapi.domain.models.voice.voice_settings import VoiceSettings +from libzapi.infrastructure.api_clients.voice.voice_settings_api_client import VoiceSettingsApiClient + + +class VoiceSettingsService: + def __init__(self, client: VoiceSettingsApiClient) -> None: + self._client = client + + def get(self) -> VoiceSettings: + return self._client.get() + + def update(self, **kwargs) -> VoiceSettings: + cmd = UpdateVoiceSettingsCmd(**kwargs) + return self._client.update(cmd=cmd) diff --git a/libzapi/domain/models/voice/__init__.py b/libzapi/domain/models/voice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libzapi/domain/models/voice/address.py b/libzapi/domain/models/voice/address.py new file mode 100644 index 0000000..2e57760 --- /dev/null +++ b/libzapi/domain/models/voice/address.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Address: + id: int + name: str = "" + city: str = "" + country_code: str = "" + province: str = "" + state: str = "" + street: str = "" + zip: str = "" + provider_reference: str = "" + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("address", str(self.id)) diff --git a/libzapi/domain/models/voice/availability.py b/libzapi/domain/models/voice/availability.py new file mode 100644 index 0000000..fd18d99 --- /dev/null +++ b/libzapi/domain/models/voice/availability.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Availability: + agent_state: str = "" + call_status: str | None = None + via: str | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("availability", self.agent_state) diff --git a/libzapi/domain/models/voice/call.py b/libzapi/domain/models/voice/call.py new file mode 100644 index 0000000..27f0645 --- /dev/null +++ b/libzapi/domain/models/voice/call.py @@ -0,0 +1,77 @@ +from dataclasses import dataclass, field +from datetime import datetime + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Call: + id: int + agent_id: int | None = None + direction: str = "" + duration: int = 0 + talk_time: int = 0 + hold_time: int = 0 + wait_time: int = 0 + wrap_up_time: int = 0 + time_to_answer: int = 0 + phone_number: str = "" + phone_number_id: int | None = None + ticket_id: int | None = None + voicemail: bool = False + callback: bool = False + callback_source: str = "" + completion_status: str = "" + call_charge: str = "" + minutes_billed: int = 0 + recording_time: int = 0 + outside_business_hours: bool = False + exceeded_queue_time: bool = False + overflowed: bool = False + overflowed_to: str = "" + call_channel: str = "" + line_type: str = "" + ivr_action: str = "" + ivr_hops: int = 0 + ivr_routed_to: str = "" + ivr_time_spent: int = 0 + quality_issues: list[str] = field(default_factory=list) + created_at: datetime | None = None + updated_at: datetime | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("call", str(self.id)) + + +@dataclass(frozen=True, slots=True) +class CallLeg: + id: int + call_id: int = 0 + agent_id: int | None = None + user_id: int | None = None + type: str = "" + duration: int = 0 + talk_time: int = 0 + hold_time: int = 0 + wrap_up_time: int = 0 + minutes_billed: int = 0 + call_charge: str = "" + completion_status: str = "" + available_via: str = "" + forwarded_to: str = "" + transferred_from: int | None = None + transferred_to: int | None = None + conference_from: int | None = None + conference_to: int | None = None + conference_time: int = 0 + consultation_from: int | None = None + consultation_to: int | None = None + consultation_time: int = 0 + quality_issues: list[str] = field(default_factory=list) + created_at: datetime | None = None + updated_at: datetime | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("call_leg", str(self.id)) diff --git a/libzapi/domain/models/voice/digital_line.py b/libzapi/domain/models/voice/digital_line.py new file mode 100644 index 0000000..4a20975 --- /dev/null +++ b/libzapi/domain/models/voice/digital_line.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +from datetime import datetime + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class DigitalLine: + id: int + nickname: str = "" + line_id: str = "" + line_type: str = "" + brand_id: int | None = None + recorded: bool = False + transcription: bool = False + default_group_id: int | None = None + group_ids: list[int] = field(default_factory=list) + greeting_ids: list[int] = field(default_factory=list) + default_greeting_ids: list[str] = field(default_factory=list) + schedule_id: int | None = None + priority: int = 0 + call_recording_consent: str = "" + outbound_number: str | None = None + bot_id: int | None = None + created_at: datetime | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("digital_line", str(self.id)) diff --git a/libzapi/domain/models/voice/greeting.py b/libzapi/domain/models/voice/greeting.py new file mode 100644 index 0000000..b3e89a6 --- /dev/null +++ b/libzapi/domain/models/voice/greeting.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Greeting: + id: str + name: str = "" + category_id: int | None = None + active: bool = False + default: bool = False + audio_name: str = "" + audio_url: str = "" + phone_number_ids: list[int] = field(default_factory=list) + ivr_ids: list[int] = field(default_factory=list) + upload_id: int | None = None + pending: bool = False + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("greeting", str(self.id)) + + +@dataclass(frozen=True, slots=True) +class GreetingCategory: + id: int + name: str = "" + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("greeting_category", str(self.id)) diff --git a/libzapi/domain/models/voice/ivr.py b/libzapi/domain/models/voice/ivr.py new file mode 100644 index 0000000..33861da --- /dev/null +++ b/libzapi/domain/models/voice/ivr.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class IvrRoute: + id: int + action: str = "" + keypress: str = "" + greeting_id: int | None = None + options: dict = field(default_factory=dict) + tags: list[str] = field(default_factory=list) + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("ivr_route", str(self.id)) + + +@dataclass(frozen=True, slots=True) +class IvrMenu: + id: int + name: str = "" + default: bool = False + greeting_id: int | None = None + routes: list[IvrRoute] = field(default_factory=list) + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("ivr_menu", str(self.id)) + + +@dataclass(frozen=True, slots=True) +class Ivr: + id: int + name: str = "" + phone_number_ids: list[int] = field(default_factory=list) + phone_number_names: list[str] = field(default_factory=list) + menus: list[IvrMenu] = field(default_factory=list) + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("ivr", str(self.id)) diff --git a/libzapi/domain/models/voice/phone_number.py b/libzapi/domain/models/voice/phone_number.py new file mode 100644 index 0000000..0609e07 --- /dev/null +++ b/libzapi/domain/models/voice/phone_number.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass, field +from datetime import datetime + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class PhoneNumber: + id: int + number: str = "" + display_number: str = "" + nickname: str = "" + country_code: str = "" + location: str = "" + toll_free: bool = False + external: bool = False + voice_enabled: bool = False + sms_enabled: bool = False + outbound_enabled: bool = False + recorded: bool = False + transcription: bool = False + priority: int = 0 + default_group_id: int | None = None + group_ids: list[int] = field(default_factory=list) + greeting_ids: list[int] = field(default_factory=list) + schedule_id: int | None = None + ivr_id: int | None = None + capabilities: dict | None = None + call_recording_consent: str = "" + failover_number: str = "" + token: str | None = None + created_at: datetime | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("phone_number", str(self.id)) + + +@dataclass(frozen=True, slots=True) +class AvailablePhoneNumber: + number: str = "" + display_number: str = "" + toll_free: bool = False + location: str = "" + country_code: str = "" + token: str = "" + price: str = "" + address_requirements: str = "" + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("available_phone_number", self.number) diff --git a/libzapi/domain/models/voice/stats.py b/libzapi/domain/models/voice/stats.py new file mode 100644 index 0000000..4be1251 --- /dev/null +++ b/libzapi/domain/models/voice/stats.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class AccountOverview: + average_call_duration: int = 0 + average_queue_wait_time: int = 0 + average_wrap_up_time: int = 0 + max_calls_waiting: int = 0 + max_queue_wait_time: int = 0 + total_call_duration: int = 0 + total_calls: int = 0 + total_voicemails: int = 0 + total_wrap_up_time: int = 0 + average_callback_wait_time: int = 0 + average_hold_time: int = 0 + average_time_to_answer: int = 0 + total_callback_calls: int = 0 + total_calls_abandoned_in_queue: int = 0 + total_calls_outside_business_hours: int = 0 + total_calls_with_exceeded_queue_wait_time: int = 0 + total_calls_with_requested_voicemail: int = 0 + total_hold_time: int = 0 + total_inbound_calls: int = 0 + total_outbound_calls: int = 0 + total_textback_requests: int = 0 + total_embeddable_callback_calls: int = 0 + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("account_overview", "current") + + +@dataclass(frozen=True, slots=True) +class AgentActivity: + agent_id: int = 0 + name: str = "" + agent_state: str = "" + call_status: str | None = None + via: str = "" + avatar_url: str = "" + forwarding_number: str = "" + available_time: int = 0 + away_time: int = 0 + online_time: int = 0 + transfers_only_time: int = 0 + calls_accepted: int = 0 + calls_denied: int = 0 + calls_missed: int = 0 + total_call_duration: int = 0 + total_talk_time: int = 0 + total_wrap_up_time: int = 0 + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("agent_activity", str(self.agent_id)) + + +@dataclass(frozen=True, slots=True) +class AgentsOverview: + average_wrap_up_time: int = 0 + total_calls_accepted: int = 0 + total_calls_denied: int = 0 + total_calls_missed: int = 0 + total_talk_time: int = 0 + total_wrap_up_time: int = 0 + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("agents_overview", "current") + + +@dataclass(frozen=True, slots=True) +class CurrentQueueActivity: + agents_online: int = 0 + average_wait_time: int = 0 + callbacks_waiting: int = 0 + calls_waiting: int = 0 + longest_wait_time: int = 0 + embeddable_callbacks_waiting: int = 0 + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("current_queue_activity", "current") diff --git a/libzapi/domain/models/voice/voice_settings.py b/libzapi/domain/models/voice/voice_settings.py new file mode 100644 index 0000000..f12b5f1 --- /dev/null +++ b/libzapi/domain/models/voice/voice_settings.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class VoiceSettings: + voice: bool = False + agent_confirmation_when_forwarding: bool = False + agent_wrap_up_after_calls: bool = False + maximum_queue_size: int = 0 + maximum_queue_wait_time: int = 0 + only_during_business_hours: bool = False + recordings_public: bool = False + voice_ai_enabled: bool = False + voice_ai_display_transcript: bool = False + voice_zendesk_qa_enabled: bool = False + knowledge_suggestions_enabled: bool = False + knowledge_suggestions_group_ids: list[int] = field(default_factory=list) + voice_transcriptions_pii_redaction: bool = False + voice_transcriptions_pci_redaction: bool = False + voice_transcriptions_boosted_keywords_enabled: bool = False + voice_transcriptions_boosted_keywords: str = "" + supported_locales: list[str] = field(default_factory=list) + voice_ai_enabled_lines: list[int] = field(default_factory=list) + voice_zendesk_qa_enabled_lines: list[int] = field(default_factory=list) + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("voice_settings", "global") diff --git a/libzapi/infrastructure/api_clients/voice/__init__.py b/libzapi/infrastructure/api_clients/voice/__init__.py index e69de29..f94e25f 100644 --- a/libzapi/infrastructure/api_clients/voice/__init__.py +++ b/libzapi/infrastructure/api_clients/voice/__init__.py @@ -0,0 +1,31 @@ +from libzapi.infrastructure.api_clients.voice.address_api_client import AddressApiClient +from libzapi.infrastructure.api_clients.voice.availability_api_client import AvailabilityApiClient +from libzapi.infrastructure.api_clients.voice.callback_request_api_client import CallbackRequestApiClient +from libzapi.infrastructure.api_clients.voice.digital_line_api_client import DigitalLineApiClient +from libzapi.infrastructure.api_clients.voice.greeting_api_client import GreetingApiClient +from libzapi.infrastructure.api_clients.voice.incremental_export_api_client import IncrementalExportApiClient +from libzapi.infrastructure.api_clients.voice.ivr_api_client import IvrApiClient +from libzapi.infrastructure.api_clients.voice.ivr_menu_api_client import IvrMenuApiClient +from libzapi.infrastructure.api_clients.voice.ivr_route_api_client import IvrRouteApiClient +from libzapi.infrastructure.api_clients.voice.line_api_client import LineApiClient +from libzapi.infrastructure.api_clients.voice.phone_number_api_client import PhoneNumberApiClient +from libzapi.infrastructure.api_clients.voice.recording_api_client import RecordingApiClient +from libzapi.infrastructure.api_clients.voice.stats_api_client import StatsApiClient +from libzapi.infrastructure.api_clients.voice.voice_settings_api_client import VoiceSettingsApiClient + +__all__ = [ + "AddressApiClient", + "AvailabilityApiClient", + "CallbackRequestApiClient", + "DigitalLineApiClient", + "GreetingApiClient", + "IncrementalExportApiClient", + "IvrApiClient", + "IvrMenuApiClient", + "IvrRouteApiClient", + "LineApiClient", + "PhoneNumberApiClient", + "RecordingApiClient", + "StatsApiClient", + "VoiceSettingsApiClient", +] diff --git a/libzapi/infrastructure/api_clients/voice/address_api_client.py b/libzapi/infrastructure/api_clients/voice/address_api_client.py new file mode 100644 index 0000000..6b096d6 --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/address_api_client.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.address_cmds import ( + CreateAddressCmd, + UpdateAddressCmd, +) +from libzapi.domain.models.voice.address import Address +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.voice.address_mapper import ( + to_payload_create, + to_payload_update, +) +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/channels/voice/addresses" + + +class AddressApiClient: + """HTTP adapter for Zendesk Voice Addresses""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self) -> Iterator[Address]: + data = self._http.get(_BASE) + for obj in data["addresses"]: + yield to_domain(data=obj, cls=Address) + + def get(self, address_id: int) -> Address: + data = self._http.get(f"{_BASE}/{int(address_id)}") + return to_domain(data=data["address"], cls=Address) + + def create(self, cmd: CreateAddressCmd) -> Address: + payload = to_payload_create(cmd) + data = self._http.post(_BASE, json=payload) + return to_domain(data=data["address"], cls=Address) + + def update(self, address_id: int, cmd: UpdateAddressCmd) -> Address: + payload = to_payload_update(cmd) + data = self._http.put(f"{_BASE}/{int(address_id)}", json=payload) + return to_domain(data=data["address"], cls=Address) + + def delete(self, address_id: int) -> None: + self._http.delete(f"{_BASE}/{int(address_id)}") diff --git a/libzapi/infrastructure/api_clients/voice/availability_api_client.py b/libzapi/infrastructure/api_clients/voice/availability_api_client.py new file mode 100644 index 0000000..a6f779a --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/availability_api_client.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from libzapi.application.commands.voice.availability_cmds import UpdateAvailabilityCmd +from libzapi.domain.models.voice.availability import Availability +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.voice.availability_mapper import to_payload_update +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/channels/voice/availabilities" + + +class AvailabilityApiClient: + """HTTP adapter for Zendesk Voice Agent Availability""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def get(self, agent_id: int) -> Availability: + data = self._http.get(f"{_BASE}/{int(agent_id)}") + return to_domain(data=data["availability"], cls=Availability) + + def update(self, agent_id: int, cmd: UpdateAvailabilityCmd) -> Availability: + payload = to_payload_update(cmd) + data = self._http.put(f"{_BASE}/{int(agent_id)}", json=payload) + return to_domain(data=data["availability"], cls=Availability) diff --git a/libzapi/infrastructure/api_clients/voice/callback_request_api_client.py b/libzapi/infrastructure/api_clients/voice/callback_request_api_client.py new file mode 100644 index 0000000..288cb3f --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/callback_request_api_client.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from libzapi.application.commands.voice.callback_request_cmds import CreateCallbackRequestCmd +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.voice.callback_request_mapper import to_payload_create + +_BASE = "/api/v2/channels/voice/callback_requests" + + +class CallbackRequestApiClient: + """HTTP adapter for Zendesk Voice Callback Requests""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def create(self, cmd: CreateCallbackRequestCmd) -> dict: + payload = to_payload_create(cmd) + return self._http.post(_BASE, json=payload) diff --git a/libzapi/infrastructure/api_clients/voice/digital_line_api_client.py b/libzapi/infrastructure/api_clients/voice/digital_line_api_client.py new file mode 100644 index 0000000..08205c4 --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/digital_line_api_client.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from libzapi.application.commands.voice.digital_line_cmds import ( + CreateDigitalLineCmd, + UpdateDigitalLineCmd, +) +from libzapi.domain.models.voice.digital_line import DigitalLine +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.voice.digital_line_mapper import ( + to_payload_create, + to_payload_update, +) +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/channels/voice/digital_lines" + + +class DigitalLineApiClient: + """HTTP adapter for Zendesk Voice Digital Lines""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def get(self, digital_line_id: int) -> DigitalLine: + data = self._http.get(f"{_BASE}/{int(digital_line_id)}") + return to_domain(data=data["digital_line"], cls=DigitalLine) + + def create(self, cmd: CreateDigitalLineCmd) -> DigitalLine: + payload = to_payload_create(cmd) + data = self._http.post(_BASE, json=payload) + return to_domain(data=data["digital_line"], cls=DigitalLine) + + def update(self, digital_line_id: int, cmd: UpdateDigitalLineCmd) -> DigitalLine: + payload = to_payload_update(cmd) + data = self._http.put(f"{_BASE}/{int(digital_line_id)}", json=payload) + return to_domain(data=data["digital_line"], cls=DigitalLine) + + def delete(self, digital_line_id: int) -> None: + self._http.delete(f"{_BASE}/{int(digital_line_id)}") diff --git a/libzapi/infrastructure/api_clients/voice/greeting_api_client.py b/libzapi/infrastructure/api_clients/voice/greeting_api_client.py new file mode 100644 index 0000000..28c6e45 --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/greeting_api_client.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.greeting_cmds import ( + CreateGreetingCmd, + UpdateGreetingCmd, +) +from libzapi.domain.models.voice.greeting import Greeting, GreetingCategory +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.http.pagination import yield_items +from libzapi.infrastructure.mappers.voice.greeting_mapper import ( + to_payload_create, + to_payload_update, +) +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/channels/voice/greetings" +_CATEGORIES_BASE = "/api/v2/channels/voice/greeting_categories" + + +class GreetingApiClient: + """HTTP adapter for Zendesk Voice Greetings""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self) -> Iterator[Greeting]: + for obj in yield_items( + get_json=self._http.get, + first_path=_BASE, + base_url=self._http.base_url, + items_key="greetings", + ): + yield to_domain(data=obj, cls=Greeting) + + def get(self, greeting_id: int) -> Greeting: + data = self._http.get(f"{_BASE}/{int(greeting_id)}") + return to_domain(data=data["greeting"], cls=Greeting) + + def create(self, cmd: CreateGreetingCmd) -> Greeting: + payload = to_payload_create(cmd) + data = self._http.post(_BASE, json=payload) + return to_domain(data=data["greeting"], cls=Greeting) + + def update(self, greeting_id: int, cmd: UpdateGreetingCmd) -> Greeting: + payload = to_payload_update(cmd) + data = self._http.put(f"{_BASE}/{int(greeting_id)}", json=payload) + return to_domain(data=data["greeting"], cls=Greeting) + + def delete(self, greeting_id: int) -> None: + self._http.delete(f"{_BASE}/{int(greeting_id)}") + + def list_categories(self) -> Iterator[GreetingCategory]: + data = self._http.get(_CATEGORIES_BASE) + for obj in data["greeting_categories"]: + yield to_domain(data=obj, cls=GreetingCategory) + + def get_category(self, category_id: int) -> GreetingCategory: + data = self._http.get(f"{_CATEGORIES_BASE}/{int(category_id)}") + return to_domain(data=data["greeting_category"], cls=GreetingCategory) diff --git a/libzapi/infrastructure/api_clients/voice/incremental_export_api_client.py b/libzapi/infrastructure/api_clients/voice/incremental_export_api_client.py new file mode 100644 index 0000000..042bef1 --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/incremental_export_api_client.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.domain.models.voice.call import Call, CallLeg +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.http.pagination import yield_items +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/channels/voice/stats/incremental" + + +class IncrementalExportApiClient: + """HTTP adapter for Zendesk Voice Incremental Exports""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def calls(self, start_time: int) -> Iterator[Call]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"{_BASE}/calls?start_time={int(start_time)}", + base_url=self._http.base_url, + items_key="calls", + ): + yield to_domain(data=obj, cls=Call) + + def legs(self, start_time: int) -> Iterator[CallLeg]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"{_BASE}/legs?start_time={int(start_time)}", + base_url=self._http.base_url, + items_key="legs", + ): + yield to_domain(data=obj, cls=CallLeg) diff --git a/libzapi/infrastructure/api_clients/voice/ivr_api_client.py b/libzapi/infrastructure/api_clients/voice/ivr_api_client.py new file mode 100644 index 0000000..954affc --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/ivr_api_client.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.ivr_cmds import CreateIvrCmd, UpdateIvrCmd +from libzapi.domain.models.voice.ivr import Ivr +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.voice.ivr_mapper import ( + to_payload_create_ivr, + to_payload_update_ivr, +) +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/channels/voice/ivr" + + +class IvrApiClient: + """HTTP adapter for Zendesk Voice IVR""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self) -> Iterator[Ivr]: + data = self._http.get(_BASE) + for obj in data["ivrs"]: + yield to_domain(data=obj, cls=Ivr) + + def get(self, ivr_id: int) -> Ivr: + data = self._http.get(f"{_BASE}/{int(ivr_id)}") + return to_domain(data=data["ivr"], cls=Ivr) + + def create(self, cmd: CreateIvrCmd) -> Ivr: + payload = to_payload_create_ivr(cmd) + data = self._http.post(_BASE, json=payload) + return to_domain(data=data["ivr"], cls=Ivr) + + def update(self, ivr_id: int, cmd: UpdateIvrCmd) -> Ivr: + payload = to_payload_update_ivr(cmd) + data = self._http.put(f"{_BASE}/{int(ivr_id)}", json=payload) + return to_domain(data=data["ivr"], cls=Ivr) + + def delete(self, ivr_id: int) -> None: + self._http.delete(f"{_BASE}/{int(ivr_id)}") diff --git a/libzapi/infrastructure/api_clients/voice/ivr_menu_api_client.py b/libzapi/infrastructure/api_clients/voice/ivr_menu_api_client.py new file mode 100644 index 0000000..20e9a49 --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/ivr_menu_api_client.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.ivr_cmds import CreateIvrMenuCmd, UpdateIvrMenuCmd +from libzapi.domain.models.voice.ivr import IvrMenu +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.voice.ivr_mapper import ( + to_payload_create_menu, + to_payload_update_menu, +) +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/channels/voice/ivr" + + +class IvrMenuApiClient: + """HTTP adapter for Zendesk Voice IVR Menus""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, ivr_id: int) -> Iterator[IvrMenu]: + data = self._http.get(f"{_BASE}/{int(ivr_id)}/menus") + items = data.get("menus") or data.get("ivr_menus") or [] + for obj in items: + yield to_domain(data=obj, cls=IvrMenu) + + def get(self, ivr_id: int, menu_id: int) -> IvrMenu: + data = self._http.get(f"{_BASE}/{int(ivr_id)}/menus/{int(menu_id)}") + return to_domain(data=data["menu"], cls=IvrMenu) + + def create(self, ivr_id: int, cmd: CreateIvrMenuCmd) -> IvrMenu: + payload = to_payload_create_menu(cmd) + data = self._http.post(f"{_BASE}/{int(ivr_id)}/menus", json=payload) + return to_domain(data=data["menu"], cls=IvrMenu) + + def update(self, ivr_id: int, menu_id: int, cmd: UpdateIvrMenuCmd) -> IvrMenu: + payload = to_payload_update_menu(cmd) + data = self._http.put(f"{_BASE}/{int(ivr_id)}/menus/{int(menu_id)}", json=payload) + return to_domain(data=data["menu"], cls=IvrMenu) + + def delete(self, ivr_id: int, menu_id: int) -> None: + self._http.delete(f"{_BASE}/{int(ivr_id)}/menus/{int(menu_id)}") diff --git a/libzapi/infrastructure/api_clients/voice/ivr_route_api_client.py b/libzapi/infrastructure/api_clients/voice/ivr_route_api_client.py new file mode 100644 index 0000000..5f393a8 --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/ivr_route_api_client.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.ivr_cmds import CreateIvrRouteCmd, UpdateIvrRouteCmd +from libzapi.domain.models.voice.ivr import IvrRoute +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.voice.ivr_mapper import ( + to_payload_create_route, + to_payload_update_route, +) +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/channels/voice/ivr" + + +class IvrRouteApiClient: + """HTTP adapter for Zendesk Voice IVR Routes""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, ivr_id: int, menu_id: int) -> Iterator[IvrRoute]: + data = self._http.get(f"{_BASE}/{int(ivr_id)}/menus/{int(menu_id)}/routes") + items = data.get("routes") or data.get("ivr_routes") or [] + for obj in items: + yield to_domain(data=obj, cls=IvrRoute) + + def get(self, ivr_id: int, menu_id: int, route_id: int) -> IvrRoute: + data = self._http.get(f"{_BASE}/{int(ivr_id)}/menus/{int(menu_id)}/routes/{int(route_id)}") + return to_domain(data=data["route"], cls=IvrRoute) + + def create(self, ivr_id: int, menu_id: int, cmd: CreateIvrRouteCmd) -> IvrRoute: + payload = to_payload_create_route(cmd) + data = self._http.post(f"{_BASE}/{int(ivr_id)}/menus/{int(menu_id)}/routes", json=payload) + return to_domain(data=data["route"], cls=IvrRoute) + + def update(self, ivr_id: int, menu_id: int, route_id: int, cmd: UpdateIvrRouteCmd) -> IvrRoute: + payload = to_payload_update_route(cmd) + data = self._http.put(f"{_BASE}/{int(ivr_id)}/menus/{int(menu_id)}/routes/{int(route_id)}", json=payload) + return to_domain(data=data["route"], cls=IvrRoute) + + def delete(self, ivr_id: int, menu_id: int, route_id: int) -> None: + self._http.delete(f"{_BASE}/{int(ivr_id)}/menus/{int(menu_id)}/routes/{int(route_id)}") diff --git a/libzapi/infrastructure/api_clients/voice/line_api_client.py b/libzapi/infrastructure/api_clients/voice/line_api_client.py new file mode 100644 index 0000000..1c20bf5 --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/line_api_client.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.http.pagination import yield_items + +_BASE = "/api/v2/channels/voice/lines" + + +class LineApiClient: + """HTTP adapter for Zendesk Voice Lines""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self) -> Iterator[dict]: + for obj in yield_items( + get_json=self._http.get, + first_path=_BASE, + base_url=self._http.base_url, + items_key="lines", + ): + yield obj diff --git a/libzapi/infrastructure/api_clients/voice/phone_number_api_client.py b/libzapi/infrastructure/api_clients/voice/phone_number_api_client.py new file mode 100644 index 0000000..5d35988 --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/phone_number_api_client.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.voice.phone_number_cmds import ( + CreatePhoneNumberCmd, + UpdatePhoneNumberCmd, +) +from libzapi.domain.models.voice.phone_number import AvailablePhoneNumber, PhoneNumber +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.http.pagination import yield_items +from libzapi.infrastructure.mappers.voice.phone_number_mapper import ( + to_payload_create, + to_payload_update, +) +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/channels/voice/phone_numbers" + + +class PhoneNumberApiClient: + """HTTP adapter for Zendesk Voice Phone Numbers""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self) -> Iterator[PhoneNumber]: + for obj in yield_items( + get_json=self._http.get, + first_path=_BASE, + base_url=self._http.base_url, + items_key="phone_numbers", + ): + yield to_domain(data=obj, cls=PhoneNumber) + + def search( + self, + country: str, + area_code: str | None = None, + contains: str | None = None, + toll_free: bool | None = None, + ) -> list[AvailablePhoneNumber]: + params: dict[str, str] = {"country": country} + if area_code is not None: + params["area_code"] = area_code + if contains is not None: + params["contains"] = contains + if toll_free is not None: + params["toll_free"] = str(toll_free).lower() + query = "&".join(f"{k}={v}" for k, v in params.items()) + data = self._http.get(f"{_BASE}/search?{query}") + return [to_domain(data=obj, cls=AvailablePhoneNumber) for obj in data["phone_numbers"]] + + def get(self, phone_number_id: int) -> PhoneNumber: + data = self._http.get(f"{_BASE}/{int(phone_number_id)}") + return to_domain(data=data["phone_number"], cls=PhoneNumber) + + def create(self, cmd: CreatePhoneNumberCmd) -> PhoneNumber: + payload = to_payload_create(cmd) + data = self._http.post(_BASE, json=payload) + return to_domain(data=data["phone_number"], cls=PhoneNumber) + + def update(self, phone_number_id: int, cmd: UpdatePhoneNumberCmd) -> PhoneNumber: + payload = to_payload_update(cmd) + data = self._http.put(f"{_BASE}/{int(phone_number_id)}", json=payload) + return to_domain(data=data["phone_number"], cls=PhoneNumber) + + def delete(self, phone_number_id: int) -> None: + self._http.delete(f"{_BASE}/{int(phone_number_id)}") diff --git a/libzapi/infrastructure/api_clients/voice/recording_api_client.py b/libzapi/infrastructure/api_clients/voice/recording_api_client.py new file mode 100644 index 0000000..5570832 --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/recording_api_client.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from libzapi.infrastructure.http.client import HttpClient + +_BASE = "/api/v2/channels/voice/calls" + + +class RecordingApiClient: + """HTTP adapter for Zendesk Voice Call Recordings""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def delete_all(self, call_id: int) -> None: + self._http.delete(f"{_BASE}/{int(call_id)}/recordings") + + def delete_by_type(self, call_id: int, recording_type: str) -> None: + self._http.delete(f"{_BASE}/{int(call_id)}/recordings/{recording_type}") diff --git a/libzapi/infrastructure/api_clients/voice/stats_api_client.py b/libzapi/infrastructure/api_clients/voice/stats_api_client.py new file mode 100644 index 0000000..1498444 --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/stats_api_client.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.domain.models.voice.stats import ( + AccountOverview, + AgentActivity, + AgentsOverview, + CurrentQueueActivity, +) +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/channels/voice/stats" + + +class StatsApiClient: + """HTTP adapter for Zendesk Voice Stats""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def account_overview(self, phone_number_ids: Iterable[int] | None = None) -> AccountOverview: + path = f"{_BASE}/account_overview" + if phone_number_ids is not None: + ids_str = ",".join(str(i) for i in phone_number_ids) + path = f"{path}?phone_number_ids={ids_str}" + data = self._http.get(path) + return to_domain(data=data["account_overview"], cls=AccountOverview) + + def agents_activity(self, group_ids: Iterable[int] | None = None) -> list[AgentActivity]: + path = f"{_BASE}/agents_activity" + if group_ids is not None: + ids_str = ",".join(str(i) for i in group_ids) + path = f"{path}?group_ids={ids_str}" + data = self._http.get(path) + return [to_domain(data=obj, cls=AgentActivity) for obj in data["agents_activity"]] + + def agents_overview(self) -> AgentsOverview: + data = self._http.get(f"{_BASE}/agents_overview") + return to_domain(data=data["agents_overview"], cls=AgentsOverview) + + def current_queue_activity(self, phone_number_ids: Iterable[int] | None = None) -> CurrentQueueActivity: + path = f"{_BASE}/current_queue_activity" + if phone_number_ids is not None: + ids_str = ",".join(str(i) for i in phone_number_ids) + path = f"{path}?phone_number_ids={ids_str}" + data = self._http.get(path) + return to_domain(data=data["current_queue_activity"], cls=CurrentQueueActivity) diff --git a/libzapi/infrastructure/api_clients/voice/voice_settings_api_client.py b/libzapi/infrastructure/api_clients/voice/voice_settings_api_client.py new file mode 100644 index 0000000..6d12173 --- /dev/null +++ b/libzapi/infrastructure/api_clients/voice/voice_settings_api_client.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from libzapi.application.commands.voice.voice_settings_cmds import UpdateVoiceSettingsCmd +from libzapi.domain.models.voice.voice_settings import VoiceSettings +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.voice.voice_settings_mapper import to_payload_update +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/channels/voice/settings" + + +class VoiceSettingsApiClient: + """HTTP adapter for Zendesk Voice Settings""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def get(self) -> VoiceSettings: + data = self._http.get(_BASE) + return to_domain(data=data["settings"], cls=VoiceSettings) + + def update(self, cmd: UpdateVoiceSettingsCmd) -> VoiceSettings: + payload = to_payload_update(cmd) + data = self._http.put(_BASE, json=payload) + return to_domain(data=data["settings"], cls=VoiceSettings) diff --git a/libzapi/infrastructure/mappers/voice/__init__.py b/libzapi/infrastructure/mappers/voice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libzapi/infrastructure/mappers/voice/address_mapper.py b/libzapi/infrastructure/mappers/voice/address_mapper.py new file mode 100644 index 0000000..efb3826 --- /dev/null +++ b/libzapi/infrastructure/mappers/voice/address_mapper.py @@ -0,0 +1,23 @@ +from libzapi.application.commands.voice.address_cmds import CreateAddressCmd, UpdateAddressCmd + + +def to_payload_create(cmd: CreateAddressCmd) -> dict: + payload: dict = { + "city": cmd.city, + "country_code": cmd.country_code, + "name": cmd.name, + "province": cmd.province, + "street": cmd.street, + "zip": cmd.zip, + } + if cmd.state: + payload["state"] = cmd.state + if cmd.provider_reference: + payload["provider_reference"] = cmd.provider_reference + return {"address": payload} + + +def to_payload_update(cmd: UpdateAddressCmd) -> dict: + fields = ("city", "country_code", "name", "province", "street", "zip", "state") + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"address": patch} diff --git a/libzapi/infrastructure/mappers/voice/availability_mapper.py b/libzapi/infrastructure/mappers/voice/availability_mapper.py new file mode 100644 index 0000000..ca190ec --- /dev/null +++ b/libzapi/infrastructure/mappers/voice/availability_mapper.py @@ -0,0 +1,7 @@ +from libzapi.application.commands.voice.availability_cmds import UpdateAvailabilityCmd + + +def to_payload_update(cmd: UpdateAvailabilityCmd) -> dict: + fields = ("agent_state", "call_status", "via") + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"availability": patch} diff --git a/libzapi/infrastructure/mappers/voice/callback_request_mapper.py b/libzapi/infrastructure/mappers/voice/callback_request_mapper.py new file mode 100644 index 0000000..174c153 --- /dev/null +++ b/libzapi/infrastructure/mappers/voice/callback_request_mapper.py @@ -0,0 +1,11 @@ +from libzapi.application.commands.voice.callback_request_cmds import CreateCallbackRequestCmd + + +def to_payload_create(cmd: CreateCallbackRequestCmd) -> dict: + payload: dict = { + "phone_number_id": cmd.phone_number_id, + "requester_phone_number": cmd.requester_phone_number, + } + if cmd.group_ids: + payload["group_ids"] = cmd.group_ids + return payload diff --git a/libzapi/infrastructure/mappers/voice/digital_line_mapper.py b/libzapi/infrastructure/mappers/voice/digital_line_mapper.py new file mode 100644 index 0000000..872f529 --- /dev/null +++ b/libzapi/infrastructure/mappers/voice/digital_line_mapper.py @@ -0,0 +1,13 @@ +from libzapi.application.commands.voice.digital_line_cmds import CreateDigitalLineCmd, UpdateDigitalLineCmd + + +def to_payload_create(cmd: CreateDigitalLineCmd) -> dict: + fields = ("nickname", "line_type", "brand_id", "default_group_id", "group_ids") + payload = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"digital_line": payload} + + +def to_payload_update(cmd: UpdateDigitalLineCmd) -> dict: + fields = ("nickname", "default_group_id", "group_ids", "recorded", "transcription", "schedule_id", "priority") + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"digital_line": patch} diff --git a/libzapi/infrastructure/mappers/voice/greeting_mapper.py b/libzapi/infrastructure/mappers/voice/greeting_mapper.py new file mode 100644 index 0000000..a063f91 --- /dev/null +++ b/libzapi/infrastructure/mappers/voice/greeting_mapper.py @@ -0,0 +1,14 @@ +from libzapi.application.commands.voice.greeting_cmds import CreateGreetingCmd, UpdateGreetingCmd + + +def to_payload_create(cmd: CreateGreetingCmd) -> dict: + payload: dict = {"category_id": cmd.category_id, "name": cmd.name} + if cmd.audio_name: + payload["audio_name"] = cmd.audio_name + return {"greeting": payload} + + +def to_payload_update(cmd: UpdateGreetingCmd) -> dict: + fields = ("name",) + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"greeting": patch} diff --git a/libzapi/infrastructure/mappers/voice/ivr_mapper.py b/libzapi/infrastructure/mappers/voice/ivr_mapper.py new file mode 100644 index 0000000..f0e1e6d --- /dev/null +++ b/libzapi/infrastructure/mappers/voice/ivr_mapper.py @@ -0,0 +1,51 @@ +from libzapi.application.commands.voice.ivr_cmds import ( + CreateIvrCmd, + UpdateIvrCmd, + CreateIvrMenuCmd, + UpdateIvrMenuCmd, + CreateIvrRouteCmd, + UpdateIvrRouteCmd, +) + + +def to_payload_create_ivr(cmd: CreateIvrCmd) -> dict: + payload: dict = {"name": cmd.name} + if cmd.phone_number_ids: + payload["phone_number_ids"] = cmd.phone_number_ids + return {"ivr": payload} + + +def to_payload_update_ivr(cmd: UpdateIvrCmd) -> dict: + fields = ("name", "phone_number_ids") + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"ivr": patch} + + +def to_payload_create_menu(cmd: CreateIvrMenuCmd) -> dict: + payload: dict = {"name": cmd.name} + if cmd.default: + payload["default"] = cmd.default + if cmd.greeting_id is not None: + payload["greeting_id"] = cmd.greeting_id + return {"menu": payload} + + +def to_payload_update_menu(cmd: UpdateIvrMenuCmd) -> dict: + fields = ("name", "default", "greeting_id") + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"menu": patch} + + +def to_payload_create_route(cmd: CreateIvrRouteCmd) -> dict: + payload: dict = {"action": cmd.action, "keypress": cmd.keypress} + if cmd.options: + payload["options"] = cmd.options + if cmd.tags: + payload["tags"] = cmd.tags + return {"route": payload} + + +def to_payload_update_route(cmd: UpdateIvrRouteCmd) -> dict: + fields = ("action", "keypress", "options", "tags") + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"route": patch} diff --git a/libzapi/infrastructure/mappers/voice/phone_number_mapper.py b/libzapi/infrastructure/mappers/voice/phone_number_mapper.py new file mode 100644 index 0000000..a93d8c4 --- /dev/null +++ b/libzapi/infrastructure/mappers/voice/phone_number_mapper.py @@ -0,0 +1,31 @@ +from libzapi.application.commands.voice.phone_number_cmds import CreatePhoneNumberCmd, UpdatePhoneNumberCmd + + +def to_payload_create(cmd: CreatePhoneNumberCmd) -> dict: + payload: dict = {"token": cmd.token} + if cmd.nickname: + payload["nickname"] = cmd.nickname + if cmd.address_sid is not None: + payload["address_sid"] = cmd.address_sid + return {"phone_number": payload} + + +def to_payload_update(cmd: UpdatePhoneNumberCmd) -> dict: + fields = ( + "nickname", + "default_group_id", + "group_ids", + "priority", + "outbound_enabled", + "voice_enabled", + "sms_enabled", + "recorded", + "transcription", + "greeting_ids", + "schedule_id", + "ivr_id", + "call_recording_consent", + "failover_number", + ) + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"phone_number": patch} diff --git a/libzapi/infrastructure/mappers/voice/voice_settings_mapper.py b/libzapi/infrastructure/mappers/voice/voice_settings_mapper.py new file mode 100644 index 0000000..5dbcd64 --- /dev/null +++ b/libzapi/infrastructure/mappers/voice/voice_settings_mapper.py @@ -0,0 +1,14 @@ +from libzapi.application.commands.voice.voice_settings_cmds import UpdateVoiceSettingsCmd + + +def to_payload_update(cmd: UpdateVoiceSettingsCmd) -> dict: + fields = ( + "agent_confirmation_when_forwarding", + "agent_wrap_up_after_calls", + "maximum_queue_size", + "maximum_queue_wait_time", + "only_during_business_hours", + "recordings_public", + ) + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"settings": patch} diff --git a/tests/conftest.py b/tests/conftest.py index af0e35e..6aad4dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest -from libzapi import Ticketing, HelpCenter, CustomData, AgentAvailability, AssetManagement +from libzapi import Ticketing, HelpCenter, CustomData, AgentAvailability, AssetManagement, Voice T = TypeVar("T") @@ -38,6 +38,12 @@ def asset_management(): return _generic_zendesk_client(AssetManagement) +@pytest.fixture(scope="session") +def voice(): + """Creates a real Voice client if environment variables are set.""" + return _generic_zendesk_client(Voice) + + def _generic_zendesk_client(client_cls: Type[T]) -> T: base_url = os.getenv("ZENDESK_URL") email = os.getenv("ZENDESK_EMAIL") diff --git a/tests/integration/voice/__init__.py b/tests/integration/voice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/voice/test_addresses.py b/tests/integration/voice/test_addresses.py new file mode 100644 index 0000000..5577bbc --- /dev/null +++ b/tests/integration/voice/test_addresses.py @@ -0,0 +1,32 @@ +import pytest +from libzapi import Voice + + +def test_list_addresses(voice: Voice): + addresses = list(voice.addresses.list_all()) + assert isinstance(addresses, list) + + +def test_list_and_get_address(voice: Voice): + addresses = list(voice.addresses.list_all()) + if not addresses: + pytest.skip("No addresses found") + address = voice.addresses.get(addresses[0].id) + assert address.id == addresses[0].id + + +def test_create_update_delete_address(voice: Voice): + address = voice.addresses.create( + city="São Paulo", + country_code="BR", + name="Integration Test Address", + province="SP", + street="Av. Paulista, 1000", + zip="01310-100", + ) + assert address.id is not None + try: + updated = voice.addresses.update(address.id, name="Updated Test Address") + assert updated.name == "Updated Test Address" + finally: + voice.addresses.delete(address.id) diff --git a/tests/integration/voice/test_availabilities.py b/tests/integration/voice/test_availabilities.py new file mode 100644 index 0000000..cea1831 --- /dev/null +++ b/tests/integration/voice/test_availabilities.py @@ -0,0 +1,7 @@ +import pytest +from libzapi import Voice + + +def test_get_availability(voice: Voice): + # Need a known agent ID - skip if unavailable + pytest.skip("Agent ID required for availability test") diff --git a/tests/integration/voice/test_digital_lines.py b/tests/integration/voice/test_digital_lines.py new file mode 100644 index 0000000..a6521e8 --- /dev/null +++ b/tests/integration/voice/test_digital_lines.py @@ -0,0 +1,7 @@ +import pytest +from libzapi import Voice + + +def test_get_digital_line(voice: Voice): + # Digital lines have no list endpoint, skip if ID unknown + pytest.skip("Digital line ID required - no list endpoint available") diff --git a/tests/integration/voice/test_greetings.py b/tests/integration/voice/test_greetings.py new file mode 100644 index 0000000..829e1db --- /dev/null +++ b/tests/integration/voice/test_greetings.py @@ -0,0 +1,36 @@ +import pytest +from libzapi import Voice + + +def test_list_greetings(voice: Voice): + greetings = list(voice.greetings.list_all()) + assert isinstance(greetings, list) + + +def test_list_and_get_greeting(voice: Voice): + greetings = list(voice.greetings.list_all()) + if not greetings: + pytest.skip("No greetings found") + greeting = voice.greetings.get(greetings[0].id) + assert greeting.id == greetings[0].id + + +def test_list_greeting_categories(voice: Voice): + categories = list(voice.greetings.list_categories()) + assert isinstance(categories, list) + assert len(categories) > 0 + + +def test_create_update_delete_greeting(voice: Voice): + categories = list(voice.greetings.list_categories()) + if not categories: + pytest.skip("No greeting categories found") + greeting = voice.greetings.create(category_id=categories[0].id, name="Integration Test Greeting") + assert greeting.id is not None + try: + updated = voice.greetings.update(greeting.id, name="Updated Test Greeting") + assert updated.name == "Updated Test Greeting" + fetched = voice.greetings.get(greeting.id) + assert fetched.id == greeting.id + finally: + voice.greetings.delete(greeting.id) diff --git a/tests/integration/voice/test_incremental_exports.py b/tests/integration/voice/test_incremental_exports.py new file mode 100644 index 0000000..ddf2819 --- /dev/null +++ b/tests/integration/voice/test_incremental_exports.py @@ -0,0 +1,14 @@ +import time +from libzapi import Voice + + +def test_incremental_calls(voice: Voice): + one_day_ago = int(time.time()) - 86400 + calls = list(voice.incremental_exports.calls(start_time=one_day_ago)) + assert isinstance(calls, list) + + +def test_incremental_legs(voice: Voice): + one_day_ago = int(time.time()) - 86400 + legs = list(voice.incremental_exports.legs(start_time=one_day_ago)) + assert isinstance(legs, list) diff --git a/tests/integration/voice/test_ivrs.py b/tests/integration/voice/test_ivrs.py new file mode 100644 index 0000000..bd920ee --- /dev/null +++ b/tests/integration/voice/test_ivrs.py @@ -0,0 +1,34 @@ +import pytest +from libzapi import Voice + + +def test_list_ivrs(voice: Voice): + ivrs = list(voice.ivrs.list_all()) + assert isinstance(ivrs, list) + + +def test_list_and_get_ivr(voice: Voice): + ivrs = list(voice.ivrs.list_all()) + if not ivrs: + pytest.skip("No IVRs found") + ivr = voice.ivrs.get(ivrs[0].id) + assert ivr.id == ivrs[0].id + + +def test_list_ivr_menus(voice: Voice): + ivrs = list(voice.ivrs.list_all()) + if not ivrs: + pytest.skip("No IVRs found") + menus = list(voice.ivr_menus.list_all(ivrs[0].id)) + assert isinstance(menus, list) + + +def test_list_ivr_routes(voice: Voice): + ivrs = list(voice.ivrs.list_all()) + if not ivrs: + pytest.skip("No IVRs found") + menus = list(voice.ivr_menus.list_all(ivrs[0].id)) + if not menus: + pytest.skip("No IVR menus found") + routes = list(voice.ivr_routes.list_all(ivrs[0].id, menus[0].id)) + assert isinstance(routes, list) diff --git a/tests/integration/voice/test_lines.py b/tests/integration/voice/test_lines.py new file mode 100644 index 0000000..1d18590 --- /dev/null +++ b/tests/integration/voice/test_lines.py @@ -0,0 +1,6 @@ +from libzapi import Voice + + +def test_list_lines(voice: Voice): + lines = list(voice.lines.list_all()) + assert isinstance(lines, list) diff --git a/tests/integration/voice/test_phone_numbers.py b/tests/integration/voice/test_phone_numbers.py new file mode 100644 index 0000000..07743ff --- /dev/null +++ b/tests/integration/voice/test_phone_numbers.py @@ -0,0 +1,20 @@ +import pytest +from libzapi import Voice + + +def test_list_phone_numbers(voice: Voice): + numbers = list(voice.phone_numbers.list_all()) + assert isinstance(numbers, list) + + +def test_list_and_get_phone_number(voice: Voice): + numbers = list(voice.phone_numbers.list_all()) + if not numbers: + pytest.skip("No phone numbers found") + number = voice.phone_numbers.get(numbers[0].id) + assert number.id == numbers[0].id + + +def test_search_available_numbers(voice: Voice): + results = voice.phone_numbers.search(country="US") + assert isinstance(results, list) diff --git a/tests/integration/voice/test_stats.py b/tests/integration/voice/test_stats.py new file mode 100644 index 0000000..e731335 --- /dev/null +++ b/tests/integration/voice/test_stats.py @@ -0,0 +1,23 @@ +from libzapi import Voice + + +def test_account_overview(voice: Voice): + overview = voice.stats.account_overview() + assert overview is not None + assert overview.total_calls >= 0 + + +def test_agents_activity(voice: Voice): + agents = voice.stats.agents_activity() + assert isinstance(agents, list) + + +def test_agents_overview(voice: Voice): + overview = voice.stats.agents_overview() + assert overview is not None + + +def test_current_queue_activity(voice: Voice): + activity = voice.stats.current_queue_activity() + assert activity is not None + assert activity.agents_online >= 0 diff --git a/tests/integration/voice/test_voice_settings.py b/tests/integration/voice/test_voice_settings.py new file mode 100644 index 0000000..7a88c06 --- /dev/null +++ b/tests/integration/voice/test_voice_settings.py @@ -0,0 +1,6 @@ +from libzapi import Voice + + +def test_get_voice_settings(voice: Voice): + settings = voice.voice_settings.get() + assert settings is not None diff --git a/tests/unit/voice/__init__.py b/tests/unit/voice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/voice/test_address.py b/tests/unit/voice/test_address.py new file mode 100644 index 0000000..e8a5eb7 --- /dev/null +++ b/tests/unit/voice/test_address.py @@ -0,0 +1,139 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.voice.address import Address +from libzapi.infrastructure.api_clients.voice.address_api_client import AddressApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.address_api_client" + +strategy = builds(Address, id=just(1)) + + +@given(strategy) +def test_logical_key(model: Address) -> None: + assert model.logical_key.as_str() == "address:1" + + +def test_list_all_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"addresses": [{}]} + client = AddressApiClient(https) + list(client.list_all()) + https.get.assert_called_with("/api/v2/channels/voice/addresses") + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"address": {}} + client = AddressApiClient(https) + client.get(1) + https.get.assert_called_with("/api/v2/channels/voice/addresses/1") + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"address": {"name": "a"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"address": {}} + client = AddressApiClient(https) + client.create(mocker.Mock()) + https.post.assert_called_with("/api/v2/channels/voice/addresses", json={"address": {"name": "a"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"address": {"name": "u"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {"address": {}} + client = AddressApiClient(https) + client.update(1, mocker.Mock()) + https.put.assert_called_with("/api/v2/channels/voice/addresses/1", json={"address": {"name": "u"}}) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = AddressApiClient(https) + client.delete(1) + https.delete.assert_called_with("/api/v2/channels/voice/addresses/1") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = AddressApiClient(https) + with pytest.raises(error_cls): + list(client.list_all()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = AddressApiClient(https) + with pytest.raises(error_cls): + client.get(1) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_create_raises_on_http_error(error_cls, mocker): + mocker.patch(f"{MODULE}.to_payload_create", return_value={"address": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.side_effect = error_cls("error") + client = AddressApiClient(https) + with pytest.raises(error_cls): + client.create(mocker.Mock()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.delete.side_effect = error_cls("error") + client = AddressApiClient(https) + with pytest.raises(error_cls): + client.delete(1) diff --git a/tests/unit/voice/test_availability.py b/tests/unit/voice/test_availability.py new file mode 100644 index 0000000..a6b6504 --- /dev/null +++ b/tests/unit/voice/test_availability.py @@ -0,0 +1,76 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.voice.availability import Availability +from libzapi.infrastructure.api_clients.voice.availability_api_client import AvailabilityApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.availability_api_client" + +strategy = builds(Availability, agent_state=just("online")) + + +@given(strategy) +def test_logical_key(model: Availability) -> None: + assert model.logical_key.as_str() == "availability:online" + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"availability": {}} + client = AvailabilityApiClient(https) + client.get(42) + https.get.assert_called_with("/api/v2/channels/voice/availabilities/42") + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"availability": {"agent_state": "online"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {"availability": {}} + client = AvailabilityApiClient(https) + client.update(42, mocker.Mock()) + https.put.assert_called_with( + "/api/v2/channels/voice/availabilities/42", json={"availability": {"agent_state": "online"}} + ) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = AvailabilityApiClient(https) + with pytest.raises(error_cls): + client.get(42) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_update_raises_on_http_error(error_cls, mocker): + mocker.patch(f"{MODULE}.to_payload_update", return_value={"availability": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.side_effect = error_cls("error") + client = AvailabilityApiClient(https) + with pytest.raises(error_cls): + client.update(42, mocker.Mock()) diff --git a/tests/unit/voice/test_callback_request.py b/tests/unit/voice/test_callback_request.py new file mode 100644 index 0000000..b577bc1 --- /dev/null +++ b/tests/unit/voice/test_callback_request.py @@ -0,0 +1,37 @@ +import pytest + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.infrastructure.api_clients.voice.callback_request_api_client import CallbackRequestApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.callback_request_api_client" + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_payload_create", return_value={"callback_request": {"phone": "+1"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"callback_request": {}} + client = CallbackRequestApiClient(https) + client.create(mocker.Mock()) + https.post.assert_called_with( + "/api/v2/channels/voice/callback_requests", json={"callback_request": {"phone": "+1"}} + ) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_create_raises_on_http_error(error_cls, mocker): + mocker.patch(f"{MODULE}.to_payload_create", return_value={"callback_request": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.side_effect = error_cls("error") + client = CallbackRequestApiClient(https) + with pytest.raises(error_cls): + client.create(mocker.Mock()) diff --git a/tests/unit/voice/test_digital_line.py b/tests/unit/voice/test_digital_line.py new file mode 100644 index 0000000..455515d --- /dev/null +++ b/tests/unit/voice/test_digital_line.py @@ -0,0 +1,111 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.voice.digital_line import DigitalLine +from libzapi.infrastructure.api_clients.voice.digital_line_api_client import DigitalLineApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.digital_line_api_client" + +strategy = builds(DigitalLine, id=just(1)) + + +@given(strategy) +def test_logical_key(model: DigitalLine) -> None: + assert model.logical_key.as_str() == "digital_line:1" + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"digital_line": {}} + client = DigitalLineApiClient(https) + client.get(1) + https.get.assert_called_with("/api/v2/channels/voice/digital_lines/1") + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"digital_line": {"nickname": "d"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"digital_line": {}} + client = DigitalLineApiClient(https) + client.create(mocker.Mock()) + https.post.assert_called_with("/api/v2/channels/voice/digital_lines", json={"digital_line": {"nickname": "d"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"digital_line": {"nickname": "u"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {"digital_line": {}} + client = DigitalLineApiClient(https) + client.update(1, mocker.Mock()) + https.put.assert_called_with("/api/v2/channels/voice/digital_lines/1", json={"digital_line": {"nickname": "u"}}) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = DigitalLineApiClient(https) + client.delete(1) + https.delete.assert_called_with("/api/v2/channels/voice/digital_lines/1") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = DigitalLineApiClient(https) + with pytest.raises(error_cls): + client.get(1) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_create_raises_on_http_error(error_cls, mocker): + mocker.patch(f"{MODULE}.to_payload_create", return_value={"digital_line": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.side_effect = error_cls("error") + client = DigitalLineApiClient(https) + with pytest.raises(error_cls): + client.create(mocker.Mock()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.delete.side_effect = error_cls("error") + client = DigitalLineApiClient(https) + with pytest.raises(error_cls): + client.delete(1) diff --git a/tests/unit/voice/test_greeting.py b/tests/unit/voice/test_greeting.py new file mode 100644 index 0000000..a53ce29 --- /dev/null +++ b/tests/unit/voice/test_greeting.py @@ -0,0 +1,164 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.voice.greeting import Greeting, GreetingCategory +from libzapi.infrastructure.api_clients.voice.greeting_api_client import GreetingApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.greeting_api_client" + +greeting_strategy = builds(Greeting, id=just("1")) +category_strategy = builds(GreetingCategory, id=just(1)) + + +@given(greeting_strategy) +def test_greeting_logical_key(model: Greeting) -> None: + assert model.logical_key.as_str() == "greeting:1" + + +@given(category_strategy) +def test_greeting_category_logical_key(model: GreetingCategory) -> None: + assert model.logical_key.as_str() == "greeting_category:1" + + +def test_list_all_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"greetings": []} + client = GreetingApiClient(https) + list(client.list_all()) + https.get.assert_called_with("/api/v2/channels/voice/greetings") + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"greeting": {}} + client = GreetingApiClient(https) + client.get(1) + https.get.assert_called_with("/api/v2/channels/voice/greetings/1") + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"greeting": {"name": "g"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"greeting": {}} + client = GreetingApiClient(https) + client.create(mocker.Mock()) + https.post.assert_called_with("/api/v2/channels/voice/greetings", json={"greeting": {"name": "g"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"greeting": {"name": "u"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {"greeting": {}} + client = GreetingApiClient(https) + client.update(1, mocker.Mock()) + https.put.assert_called_with("/api/v2/channels/voice/greetings/1", json={"greeting": {"name": "u"}}) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = GreetingApiClient(https) + client.delete(1) + https.delete.assert_called_with("/api/v2/channels/voice/greetings/1") + + +def test_list_categories_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"greeting_categories": [{}]} + client = GreetingApiClient(https) + list(client.list_categories()) + https.get.assert_called_with("/api/v2/channels/voice/greeting_categories") + + +def test_get_category_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"greeting_category": {}} + client = GreetingApiClient(https) + client.get_category(1) + https.get.assert_called_with("/api/v2/channels/voice/greeting_categories/1") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = GreetingApiClient(https) + with pytest.raises(error_cls): + list(client.list_all()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = GreetingApiClient(https) + with pytest.raises(error_cls): + client.get(1) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_create_raises_on_http_error(error_cls, mocker): + mocker.patch(f"{MODULE}.to_payload_create", return_value={"greeting": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.side_effect = error_cls("error") + client = GreetingApiClient(https) + with pytest.raises(error_cls): + client.create(mocker.Mock()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.delete.side_effect = error_cls("error") + client = GreetingApiClient(https) + with pytest.raises(error_cls): + client.delete(1) diff --git a/tests/unit/voice/test_incremental_export.py b/tests/unit/voice/test_incremental_export.py new file mode 100644 index 0000000..2586432 --- /dev/null +++ b/tests/unit/voice/test_incremental_export.py @@ -0,0 +1,76 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.voice.call import Call, CallLeg +from libzapi.infrastructure.api_clients.voice.incremental_export_api_client import IncrementalExportApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.incremental_export_api_client" + +call_strategy = builds(Call, id=just(1)) +leg_strategy = builds(CallLeg, id=just(1)) + + +@given(call_strategy) +def test_call_logical_key(model: Call) -> None: + assert model.logical_key.as_str() == "call:1" + + +@given(leg_strategy) +def test_call_leg_logical_key(model: CallLeg) -> None: + assert model.logical_key.as_str() == "call_leg:1" + + +def test_calls_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"calls": []} + client = IncrementalExportApiClient(https) + list(client.calls(start_time=100)) + https.get.assert_called_with("/api/v2/channels/voice/stats/incremental/calls?start_time=100") + + +def test_legs_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"legs": []} + client = IncrementalExportApiClient(https) + list(client.legs(start_time=100)) + https.get.assert_called_with("/api/v2/channels/voice/stats/incremental/legs?start_time=100") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_calls_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = IncrementalExportApiClient(https) + with pytest.raises(error_cls): + list(client.calls(start_time=100)) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_legs_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = IncrementalExportApiClient(https) + with pytest.raises(error_cls): + list(client.legs(start_time=100)) diff --git a/tests/unit/voice/test_ivr.py b/tests/unit/voice/test_ivr.py new file mode 100644 index 0000000..7bf6da6 --- /dev/null +++ b/tests/unit/voice/test_ivr.py @@ -0,0 +1,139 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.voice.ivr import Ivr +from libzapi.infrastructure.api_clients.voice.ivr_api_client import IvrApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.ivr_api_client" + +strategy = builds(Ivr, id=just(1)) + + +@given(strategy) +def test_logical_key(model: Ivr) -> None: + assert model.logical_key.as_str() == "ivr:1" + + +def test_list_all_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"ivrs": [{}]} + client = IvrApiClient(https) + list(client.list_all()) + https.get.assert_called_with("/api/v2/channels/voice/ivr") + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"ivr": {}} + client = IvrApiClient(https) + client.get(1) + https.get.assert_called_with("/api/v2/channels/voice/ivr/1") + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create_ivr", return_value={"ivr": {"name": "i"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"ivr": {}} + client = IvrApiClient(https) + client.create(mocker.Mock()) + https.post.assert_called_with("/api/v2/channels/voice/ivr", json={"ivr": {"name": "i"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update_ivr", return_value={"ivr": {"name": "u"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {"ivr": {}} + client = IvrApiClient(https) + client.update(1, mocker.Mock()) + https.put.assert_called_with("/api/v2/channels/voice/ivr/1", json={"ivr": {"name": "u"}}) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = IvrApiClient(https) + client.delete(1) + https.delete.assert_called_with("/api/v2/channels/voice/ivr/1") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = IvrApiClient(https) + with pytest.raises(error_cls): + list(client.list_all()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = IvrApiClient(https) + with pytest.raises(error_cls): + client.get(1) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_create_raises_on_http_error(error_cls, mocker): + mocker.patch(f"{MODULE}.to_payload_create_ivr", return_value={"ivr": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.side_effect = error_cls("error") + client = IvrApiClient(https) + with pytest.raises(error_cls): + client.create(mocker.Mock()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.delete.side_effect = error_cls("error") + client = IvrApiClient(https) + with pytest.raises(error_cls): + client.delete(1) diff --git a/tests/unit/voice/test_ivr_menu.py b/tests/unit/voice/test_ivr_menu.py new file mode 100644 index 0000000..6197f4c --- /dev/null +++ b/tests/unit/voice/test_ivr_menu.py @@ -0,0 +1,121 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.voice.ivr import IvrMenu +from libzapi.infrastructure.api_clients.voice.ivr_menu_api_client import IvrMenuApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.ivr_menu_api_client" + +strategy = builds(IvrMenu, id=just(1)) + + +@given(strategy) +def test_logical_key(model: IvrMenu) -> None: + assert model.logical_key.as_str() == "ivr_menu:1" + + +def test_list_all_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"menus": [{}]} + client = IvrMenuApiClient(https) + list(client.list_all(1)) + https.get.assert_called_with("/api/v2/channels/voice/ivr/1/menus") + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"menu": {}} + client = IvrMenuApiClient(https) + client.get(1, 2) + https.get.assert_called_with("/api/v2/channels/voice/ivr/1/menus/2") + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create_menu", return_value={"menu": {"name": "m"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"menu": {}} + client = IvrMenuApiClient(https) + client.create(1, mocker.Mock()) + https.post.assert_called_with("/api/v2/channels/voice/ivr/1/menus", json={"menu": {"name": "m"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update_menu", return_value={"menu": {"name": "u"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {"menu": {}} + client = IvrMenuApiClient(https) + client.update(1, 2, mocker.Mock()) + https.put.assert_called_with("/api/v2/channels/voice/ivr/1/menus/2", json={"menu": {"name": "u"}}) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = IvrMenuApiClient(https) + client.delete(1, 2) + https.delete.assert_called_with("/api/v2/channels/voice/ivr/1/menus/2") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = IvrMenuApiClient(https) + with pytest.raises(error_cls): + client.get(1, 2) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_create_raises_on_http_error(error_cls, mocker): + mocker.patch(f"{MODULE}.to_payload_create_menu", return_value={"menu": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.side_effect = error_cls("error") + client = IvrMenuApiClient(https) + with pytest.raises(error_cls): + client.create(1, mocker.Mock()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.delete.side_effect = error_cls("error") + client = IvrMenuApiClient(https) + with pytest.raises(error_cls): + client.delete(1, 2) diff --git a/tests/unit/voice/test_ivr_route.py b/tests/unit/voice/test_ivr_route.py new file mode 100644 index 0000000..adb7dda --- /dev/null +++ b/tests/unit/voice/test_ivr_route.py @@ -0,0 +1,121 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.voice.ivr import IvrRoute +from libzapi.infrastructure.api_clients.voice.ivr_route_api_client import IvrRouteApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.ivr_route_api_client" + +strategy = builds(IvrRoute, id=just(1)) + + +@given(strategy) +def test_logical_key(model: IvrRoute) -> None: + assert model.logical_key.as_str() == "ivr_route:1" + + +def test_list_all_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"routes": [{}]} + client = IvrRouteApiClient(https) + list(client.list_all(1, 2)) + https.get.assert_called_with("/api/v2/channels/voice/ivr/1/menus/2/routes") + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"route": {}} + client = IvrRouteApiClient(https) + client.get(1, 2, 3) + https.get.assert_called_with("/api/v2/channels/voice/ivr/1/menus/2/routes/3") + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create_route", return_value={"route": {"action": "a"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"route": {}} + client = IvrRouteApiClient(https) + client.create(1, 2, mocker.Mock()) + https.post.assert_called_with("/api/v2/channels/voice/ivr/1/menus/2/routes", json={"route": {"action": "a"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update_route", return_value={"route": {"action": "u"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {"route": {}} + client = IvrRouteApiClient(https) + client.update(1, 2, 3, mocker.Mock()) + https.put.assert_called_with("/api/v2/channels/voice/ivr/1/menus/2/routes/3", json={"route": {"action": "u"}}) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = IvrRouteApiClient(https) + client.delete(1, 2, 3) + https.delete.assert_called_with("/api/v2/channels/voice/ivr/1/menus/2/routes/3") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = IvrRouteApiClient(https) + with pytest.raises(error_cls): + client.get(1, 2, 3) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_create_raises_on_http_error(error_cls, mocker): + mocker.patch(f"{MODULE}.to_payload_create_route", return_value={"route": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.side_effect = error_cls("error") + client = IvrRouteApiClient(https) + with pytest.raises(error_cls): + client.create(1, 2, mocker.Mock()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.delete.side_effect = error_cls("error") + client = IvrRouteApiClient(https) + with pytest.raises(error_cls): + client.delete(1, 2, 3) diff --git a/tests/unit/voice/test_line.py b/tests/unit/voice/test_line.py new file mode 100644 index 0000000..084bfbf --- /dev/null +++ b/tests/unit/voice/test_line.py @@ -0,0 +1,31 @@ +import pytest + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.infrastructure.api_clients.voice.line_api_client import LineApiClient + + +def test_list_all_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"lines": []} + client = LineApiClient(https) + list(client.list_all()) + https.get.assert_called_with("/api/v2/channels/voice/lines") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = LineApiClient(https) + with pytest.raises(error_cls): + list(client.list_all()) diff --git a/tests/unit/voice/test_phone_number.py b/tests/unit/voice/test_phone_number.py new file mode 100644 index 0000000..db3c1d8 --- /dev/null +++ b/tests/unit/voice/test_phone_number.py @@ -0,0 +1,148 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.voice.phone_number import PhoneNumber +from libzapi.infrastructure.api_clients.voice.phone_number_api_client import PhoneNumberApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.phone_number_api_client" + +strategy = builds(PhoneNumber, id=just(1)) + + +@given(strategy) +def test_logical_key(model: PhoneNumber) -> None: + assert model.logical_key.as_str() == "phone_number:1" + + +def test_list_all_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"phone_numbers": []} + client = PhoneNumberApiClient(https) + list(client.list_all()) + https.get.assert_called_with("/api/v2/channels/voice/phone_numbers") + + +def test_search_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"phone_numbers": [{}]} + client = PhoneNumberApiClient(https) + client.search(country="US") + https.get.assert_called_with("/api/v2/channels/voice/phone_numbers/search?country=US") + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"phone_number": {}} + client = PhoneNumberApiClient(https) + client.get(1) + https.get.assert_called_with("/api/v2/channels/voice/phone_numbers/1") + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"phone_number": {"number": "+1"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"phone_number": {}} + client = PhoneNumberApiClient(https) + client.create(mocker.Mock()) + https.post.assert_called_with("/api/v2/channels/voice/phone_numbers", json={"phone_number": {"number": "+1"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"phone_number": {"nickname": "n"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {"phone_number": {}} + client = PhoneNumberApiClient(https) + client.update(1, mocker.Mock()) + https.put.assert_called_with("/api/v2/channels/voice/phone_numbers/1", json={"phone_number": {"nickname": "n"}}) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = PhoneNumberApiClient(https) + client.delete(1) + https.delete.assert_called_with("/api/v2/channels/voice/phone_numbers/1") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = PhoneNumberApiClient(https) + with pytest.raises(error_cls): + list(client.list_all()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = PhoneNumberApiClient(https) + with pytest.raises(error_cls): + client.get(1) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_create_raises_on_http_error(error_cls, mocker): + mocker.patch(f"{MODULE}.to_payload_create", return_value={"phone_number": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.side_effect = error_cls("error") + client = PhoneNumberApiClient(https) + with pytest.raises(error_cls): + client.create(mocker.Mock()) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.delete.side_effect = error_cls("error") + client = PhoneNumberApiClient(https) + with pytest.raises(error_cls): + client.delete(1) diff --git a/tests/unit/voice/test_recording.py b/tests/unit/voice/test_recording.py new file mode 100644 index 0000000..1b8210e --- /dev/null +++ b/tests/unit/voice/test_recording.py @@ -0,0 +1,38 @@ +import pytest + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.infrastructure.api_clients.voice.recording_api_client import RecordingApiClient + + +def test_delete_all_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = RecordingApiClient(https) + client.delete_all(1) + https.delete.assert_called_with("/api/v2/channels/voice/calls/1/recordings") + + +def test_delete_by_type_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = RecordingApiClient(https) + client.delete_by_type(1, "call") + https.delete.assert_called_with("/api/v2/channels/voice/calls/1/recordings/call") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_delete_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.delete.side_effect = error_cls("error") + client = RecordingApiClient(https) + with pytest.raises(error_cls): + client.delete_all(1) diff --git a/tests/unit/voice/test_stats.py b/tests/unit/voice/test_stats.py new file mode 100644 index 0000000..9588119 --- /dev/null +++ b/tests/unit/voice/test_stats.py @@ -0,0 +1,84 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.voice.stats import AccountOverview +from libzapi.infrastructure.api_clients.voice.stats_api_client import StatsApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.stats_api_client" + +strategy = builds(AccountOverview) + + +@given(strategy) +def test_logical_key(model: AccountOverview) -> None: + assert model.logical_key.as_str() == "account_overview:current" + + +def test_account_overview_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"account_overview": {}} + client = StatsApiClient(https) + client.account_overview() + https.get.assert_called_with("/api/v2/channels/voice/stats/account_overview") + + +def test_account_overview_with_phone_number_ids(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"account_overview": {}} + client = StatsApiClient(https) + client.account_overview(phone_number_ids=[1, 2]) + https.get.assert_called_with("/api/v2/channels/voice/stats/account_overview?phone_number_ids=1,2") + + +def test_agents_activity_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"agents_activity": []} + client = StatsApiClient(https) + client.agents_activity() + https.get.assert_called_with("/api/v2/channels/voice/stats/agents_activity") + + +def test_agents_overview_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"agents_overview": {}} + client = StatsApiClient(https) + client.agents_overview() + https.get.assert_called_with("/api/v2/channels/voice/stats/agents_overview") + + +def test_current_queue_activity_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"current_queue_activity": {}} + client = StatsApiClient(https) + client.current_queue_activity() + https.get.assert_called_with("/api/v2/channels/voice/stats/current_queue_activity") + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_account_overview_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = StatsApiClient(https) + with pytest.raises(error_cls): + client.account_overview() diff --git a/tests/unit/voice/test_voice_settings.py b/tests/unit/voice/test_voice_settings.py new file mode 100644 index 0000000..df56e7b --- /dev/null +++ b/tests/unit/voice/test_voice_settings.py @@ -0,0 +1,74 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.voice.voice_settings import VoiceSettings +from libzapi.infrastructure.api_clients.voice.voice_settings_api_client import VoiceSettingsApiClient + +MODULE = "libzapi.infrastructure.api_clients.voice.voice_settings_api_client" + +strategy = builds(VoiceSettings) + + +@given(strategy) +def test_logical_key(model: VoiceSettings) -> None: + assert model.logical_key.as_str() == "voice_settings:global" + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"settings": {}} + client = VoiceSettingsApiClient(https) + client.get() + https.get.assert_called_with("/api/v2/channels/voice/settings") + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"settings": {"voice": True}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {"settings": {}} + client = VoiceSettingsApiClient(https) + client.update(mocker.Mock()) + https.put.assert_called_with("/api/v2/channels/voice/settings", json={"settings": {"voice": True}}) + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + client = VoiceSettingsApiClient(https) + with pytest.raises(error_cls): + client.get() + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_update_raises_on_http_error(error_cls, mocker): + mocker.patch(f"{MODULE}.to_payload_update", return_value={"settings": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.side_effect = error_cls("error") + client = VoiceSettingsApiClient(https) + with pytest.raises(error_cls): + client.update(mocker.Mock()) diff --git a/uv.lock b/uv.lock index fabd18e..38a070d 100644 --- a/uv.lock +++ b/uv.lock @@ -586,7 +586,7 @@ wheels = [ [[package]] name = "libzapi" -version = "0.6.4" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "cattrs" },