diff --git a/libzapi/__init__.py b/libzapi/__init__.py index f358f0b..43f5dd7 100644 --- a/libzapi/__init__.py +++ b/libzapi/__init__.py @@ -3,6 +3,7 @@ from libzapi.application import CustomData from libzapi.application import AgentAvailability from libzapi.application import AssetManagement +from libzapi.application import Conversations from libzapi.application import ZendeskStatus __all__ = [ @@ -11,5 +12,6 @@ "CustomData", "AgentAvailability", "AssetManagement", + "Conversations", "ZendeskStatus", ] diff --git a/libzapi/application/__init__.py b/libzapi/application/__init__.py index 829652c..0287bca 100644 --- a/libzapi/application/__init__.py +++ b/libzapi/application/__init__.py @@ -3,6 +3,7 @@ from libzapi.application.services.custom_data import CustomData from libzapi.application.services.agent_availability import AgentAvailability from libzapi.application.services.asset_management import AssetManagement +from libzapi.application.services.conversations import Conversations from libzapi.application.services.status import ZendeskStatus __all__ = [ @@ -11,5 +12,6 @@ "CustomData", "AgentAvailability", "AssetManagement", + "Conversations", "ZendeskStatus", ] diff --git a/libzapi/application/commands/conversations/__init__.py b/libzapi/application/commands/conversations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libzapi/application/commands/conversations/app_cmds.py b/libzapi/application/commands/conversations/app_cmds.py new file mode 100644 index 0000000..b5017c1 --- /dev/null +++ b/libzapi/application/commands/conversations/app_cmds.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class CreateAppCmd: + displayName: str + metadata: dict = field(default_factory=dict) + settings: dict | None = None + + +@dataclass(frozen=True, slots=True) +class UpdateAppCmd: + displayName: str | None = None + metadata: dict | None = None + settings: dict | None = None diff --git a/libzapi/application/commands/conversations/conversation_cmds.py b/libzapi/application/commands/conversations/conversation_cmds.py new file mode 100644 index 0000000..89ed471 --- /dev/null +++ b/libzapi/application/commands/conversations/conversation_cmds.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class CreateConversationCmd: + type: str = "personal" + displayName: str = "" + description: str = "" + iconUrl: str = "" + metadata: dict = field(default_factory=dict) + participants: list[dict] = field(default_factory=list) + + +@dataclass(frozen=True, slots=True) +class UpdateConversationCmd: + displayName: str | None = None + description: str | None = None + iconUrl: str | None = None + metadata: dict | None = None diff --git a/libzapi/application/commands/conversations/integration_cmds.py b/libzapi/application/commands/conversations/integration_cmds.py new file mode 100644 index 0000000..ccdab30 --- /dev/null +++ b/libzapi/application/commands/conversations/integration_cmds.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class CreateIntegrationCmd: + type: str + displayName: str = "" + extra: dict = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class UpdateIntegrationCmd: + displayName: str | None = None + extra: dict | None = None diff --git a/libzapi/application/commands/conversations/message_cmds.py b/libzapi/application/commands/conversations/message_cmds.py new file mode 100644 index 0000000..76ff6e4 --- /dev/null +++ b/libzapi/application/commands/conversations/message_cmds.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class PostMessageCmd: + author: dict + content: dict + metadata: dict | None = None diff --git a/libzapi/application/commands/conversations/switchboard_action_cmds.py b/libzapi/application/commands/conversations/switchboard_action_cmds.py new file mode 100644 index 0000000..95f88f5 --- /dev/null +++ b/libzapi/application/commands/conversations/switchboard_action_cmds.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class PassControlCmd: + switchboardIntegration: str + metadata: dict | None = None + + +@dataclass(frozen=True, slots=True) +class OfferControlCmd: + switchboardIntegration: str + metadata: dict | None = None diff --git a/libzapi/application/commands/conversations/switchboard_cmds.py b/libzapi/application/commands/conversations/switchboard_cmds.py new file mode 100644 index 0000000..5804b74 --- /dev/null +++ b/libzapi/application/commands/conversations/switchboard_cmds.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateSwitchboardCmd: + enabled: bool = True + + +@dataclass(frozen=True, slots=True) +class UpdateSwitchboardCmd: + enabled: bool | None = None + defaultSwitchboardIntegrationId: str | None = None + + +@dataclass(frozen=True, slots=True) +class CreateSwitchboardIntegrationCmd: + name: str + integrationId: str + integrationType: str = "" + deliverStandbyEvents: bool = False + + +@dataclass(frozen=True, slots=True) +class UpdateSwitchboardIntegrationCmd: + name: str | None = None + deliverStandbyEvents: bool | None = None + nextSwitchboardIntegrationId: str | None = None + messageHistoryCount: int | None = None diff --git a/libzapi/application/commands/conversations/user_cmds.py b/libzapi/application/commands/conversations/user_cmds.py new file mode 100644 index 0000000..716d66a --- /dev/null +++ b/libzapi/application/commands/conversations/user_cmds.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateUserCmd: + externalId: str + profile: dict | None = None + metadata: dict | None = None + + +@dataclass(frozen=True, slots=True) +class UpdateUserCmd: + profile: dict | None = None + metadata: dict | None = None diff --git a/libzapi/application/commands/conversations/webhook_cmds.py b/libzapi/application/commands/conversations/webhook_cmds.py new file mode 100644 index 0000000..eec0fb1 --- /dev/null +++ b/libzapi/application/commands/conversations/webhook_cmds.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateWebhookCmd: + target: str + triggers: list[str] + includeFullUser: bool = False + includeFullSource: bool = False + + +@dataclass(frozen=True, slots=True) +class UpdateWebhookCmd: + target: str | None = None + triggers: list[str] | None = None + includeFullUser: bool | None = None + includeFullSource: bool | None = None diff --git a/libzapi/application/services/conversations/__init__.py b/libzapi/application/services/conversations/__init__.py new file mode 100644 index 0000000..3e5d120 --- /dev/null +++ b/libzapi/application/services/conversations/__init__.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import libzapi.infrastructure.api_clients.conversations as api +from libzapi.application.services.conversations.activities_service import ActivitiesService +from libzapi.application.services.conversations.app_keys_service import AppKeysService +from libzapi.application.services.conversations.apps_service import AppsService +from libzapi.application.services.conversations.attachments_service import AttachmentsService +from libzapi.application.services.conversations.clients_service import ClientsService +from libzapi.application.services.conversations.conversations_service import ConversationsService +from libzapi.application.services.conversations.devices_service import DevicesService +from libzapi.application.services.conversations.integration_api_keys_service import IntegrationApiKeysService +from libzapi.application.services.conversations.integrations_service import IntegrationsService +from libzapi.application.services.conversations.messages_service import MessagesService +from libzapi.application.services.conversations.participants_service import ParticipantsService +from libzapi.application.services.conversations.switchboard_actions_service import SwitchboardActionsService +from libzapi.application.services.conversations.switchboard_integrations_service import SwitchboardIntegrationsService +from libzapi.application.services.conversations.switchboards_service import SwitchboardsService +from libzapi.application.services.conversations.users_service import UsersService +from libzapi.application.services.conversations.webhooks_service import WebhooksService +from libzapi.infrastructure.http.auth import basic_key_headers +from libzapi.infrastructure.http.client import HttpClient + + +class Conversations: + def __init__(self, base_url: str, key_id: str, key_secret: str, app_id: str): + headers = basic_key_headers(key_id, key_secret) + http = HttpClient(f"{base_url.rstrip('/')}/sc", headers=headers) + self.app_id = app_id + + self.apps = AppsService(api.AppApiClient(http)) + self.app_keys = AppKeysService(api.AppKeyApiClient(http), app_id) + self.conversations_ = ConversationsService(api.ConversationApiClient(http), app_id) + self.messages = MessagesService(api.MessageApiClient(http), app_id) + self.participants = ParticipantsService(api.ParticipantApiClient(http), app_id) + self.activities = ActivitiesService(api.ActivityApiClient(http), app_id) + self.switchboard_actions = SwitchboardActionsService(api.SwitchboardActionApiClient(http), app_id) + self.integrations = IntegrationsService(api.IntegrationApiClient(http), app_id) + self.integration_api_keys = IntegrationApiKeysService(api.IntegrationApiKeyApiClient(http), app_id) + self.webhooks = WebhooksService(api.WebhookApiClient(http), app_id) + self.switchboards = SwitchboardsService(api.SwitchboardApiClient(http), app_id) + self.switchboard_integrations = SwitchboardIntegrationsService( + api.SwitchboardIntegrationApiClient(http), app_id + ) + self.users = UsersService(api.UserApiClient(http), app_id) + self.clients = ClientsService(api.ClientApiClient(http), app_id) + self.devices = DevicesService(api.DeviceApiClient(http), app_id) + self.attachments = AttachmentsService(api.AttachmentApiClient(http), app_id) diff --git a/libzapi/application/services/conversations/activities_service.py b/libzapi/application/services/conversations/activities_service.py new file mode 100644 index 0000000..51b9d25 --- /dev/null +++ b/libzapi/application/services/conversations/activities_service.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from libzapi.infrastructure.api_clients.conversations.activity_api_client import ActivityApiClient + + +class ActivitiesService: + """High-level service for Sunshine Conversations Activities.""" + + def __init__(self, client: ActivityApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def post(self, conversation_id: str, author: dict, type: str): + return self._client.post(self._app_id, conversation_id, author=author, type=type) diff --git a/libzapi/application/services/conversations/app_keys_service.py b/libzapi/application/services/conversations/app_keys_service.py new file mode 100644 index 0000000..8807e3c --- /dev/null +++ b/libzapi/application/services/conversations/app_keys_service.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from libzapi.infrastructure.api_clients.conversations.app_key_api_client import AppKeyApiClient + + +class AppKeysService: + """High-level service for Sunshine Conversations App Keys.""" + + def __init__(self, client: AppKeyApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_all(self): + return self._client.list_all(self._app_id) + + def get(self, key_id: str): + return self._client.get(self._app_id, key_id) + + def create(self, display_name: str): + return self._client.create(self._app_id, display_name=display_name) + + def delete(self, key_id: str) -> None: + self._client.delete(self._app_id, key_id) diff --git a/libzapi/application/services/conversations/apps_service.py b/libzapi/application/services/conversations/apps_service.py new file mode 100644 index 0000000..8428f45 --- /dev/null +++ b/libzapi/application/services/conversations/apps_service.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from libzapi.application.commands.conversations.app_cmds import CreateAppCmd, UpdateAppCmd +from libzapi.infrastructure.api_clients.conversations.app_api_client import AppApiClient + + +class AppsService: + """High-level service for Sunshine Conversations Apps.""" + + def __init__(self, client: AppApiClient) -> None: + self._client = client + + def list_all(self): + return self._client.list_all() + + def get(self, app_id: str): + return self._client.get(app_id) + + def create(self, display_name: str, **kwargs): + cmd = CreateAppCmd(displayName=display_name, **kwargs) + return self._client.create(cmd) + + def update(self, app_id: str, **kwargs): + cmd = UpdateAppCmd(**kwargs) + return self._client.update(app_id, cmd) + + def delete(self, app_id: str) -> None: + self._client.delete(app_id) diff --git a/libzapi/application/services/conversations/attachments_service.py b/libzapi/application/services/conversations/attachments_service.py new file mode 100644 index 0000000..cd3aa63 --- /dev/null +++ b/libzapi/application/services/conversations/attachments_service.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from libzapi.infrastructure.api_clients.conversations.attachment_api_client import AttachmentApiClient + + +class AttachmentsService: + """High-level service for Sunshine Conversations Attachments.""" + + def __init__(self, client: AttachmentApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def upload(self, file): + return self._client.upload(self._app_id, file) + + def delete(self, media_url: str) -> None: + self._client.delete(self._app_id, media_url) diff --git a/libzapi/application/services/conversations/clients_service.py b/libzapi/application/services/conversations/clients_service.py new file mode 100644 index 0000000..ed7c38c --- /dev/null +++ b/libzapi/application/services/conversations/clients_service.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from libzapi.infrastructure.api_clients.conversations.client_api_client import ClientApiClient + + +class ClientsService: + """High-level service for Sunshine Conversations Clients.""" + + def __init__(self, client: ClientApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_all(self, user_id: str): + return self._client.list_all(self._app_id, user_id) + + def create(self, user_id: str, payload: dict): + return self._client.create(self._app_id, user_id, payload=payload) + + def remove(self, user_id: str, client_id: str) -> None: + self._client.remove(self._app_id, user_id, client_id) diff --git a/libzapi/application/services/conversations/conversations_service.py b/libzapi/application/services/conversations/conversations_service.py new file mode 100644 index 0000000..d86f7c2 --- /dev/null +++ b/libzapi/application/services/conversations/conversations_service.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from libzapi.application.commands.conversations.conversation_cmds import CreateConversationCmd, UpdateConversationCmd +from libzapi.infrastructure.api_clients.conversations.conversation_api_client import ConversationApiClient + + +class ConversationsService: + """High-level service for Sunshine Conversations Conversations.""" + + def __init__(self, client: ConversationApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_by_user(self, user_id: str): + return self._client.list_by_user(self._app_id, user_id) + + def get(self, conversation_id: str): + return self._client.get(self._app_id, conversation_id) + + def create(self, type: str = "personal", **kwargs): + cmd = CreateConversationCmd(type=type, **kwargs) + return self._client.create(self._app_id, cmd) + + def update(self, conversation_id: str, **kwargs): + cmd = UpdateConversationCmd(**kwargs) + return self._client.update(self._app_id, conversation_id, cmd) + + def delete(self, conversation_id: str) -> None: + self._client.delete(self._app_id, conversation_id) diff --git a/libzapi/application/services/conversations/devices_service.py b/libzapi/application/services/conversations/devices_service.py new file mode 100644 index 0000000..3a4719c --- /dev/null +++ b/libzapi/application/services/conversations/devices_service.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from libzapi.infrastructure.api_clients.conversations.device_api_client import DeviceApiClient + + +class DevicesService: + """High-level service for Sunshine Conversations Devices.""" + + def __init__(self, client: DeviceApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_all(self, user_id: str): + return self._client.list_all(self._app_id, user_id) + + def get(self, user_id: str, device_id: str): + return self._client.get(self._app_id, user_id, device_id) diff --git a/libzapi/application/services/conversations/integration_api_keys_service.py b/libzapi/application/services/conversations/integration_api_keys_service.py new file mode 100644 index 0000000..8ba805f --- /dev/null +++ b/libzapi/application/services/conversations/integration_api_keys_service.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from libzapi.infrastructure.api_clients.conversations.integration_api_key_api_client import IntegrationApiKeyApiClient + + +class IntegrationApiKeysService: + """High-level service for Sunshine Conversations Integration API Keys.""" + + def __init__(self, client: IntegrationApiKeyApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_all(self, integration_id: str): + return self._client.list_all(self._app_id, integration_id) + + def get(self, integration_id: str, key_id: str): + return self._client.get(self._app_id, integration_id, key_id) + + def create(self, integration_id: str, display_name: str): + return self._client.create(self._app_id, integration_id, display_name=display_name) + + def delete(self, integration_id: str, key_id: str) -> None: + self._client.delete(self._app_id, integration_id, key_id) diff --git a/libzapi/application/services/conversations/integrations_service.py b/libzapi/application/services/conversations/integrations_service.py new file mode 100644 index 0000000..789708a --- /dev/null +++ b/libzapi/application/services/conversations/integrations_service.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from libzapi.application.commands.conversations.integration_cmds import CreateIntegrationCmd, UpdateIntegrationCmd +from libzapi.infrastructure.api_clients.conversations.integration_api_client import IntegrationApiClient + + +class IntegrationsService: + """High-level service for Sunshine Conversations Integrations.""" + + def __init__(self, client: IntegrationApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_all(self): + return self._client.list_all(self._app_id) + + def get(self, integration_id: str): + return self._client.get(self._app_id, integration_id) + + def create(self, type: str, **kwargs): + cmd = CreateIntegrationCmd(type=type, **kwargs) + return self._client.create(self._app_id, cmd) + + def update(self, integration_id: str, **kwargs): + cmd = UpdateIntegrationCmd(**kwargs) + return self._client.update(self._app_id, integration_id, cmd) + + def delete(self, integration_id: str) -> None: + self._client.delete(self._app_id, integration_id) diff --git a/libzapi/application/services/conversations/messages_service.py b/libzapi/application/services/conversations/messages_service.py new file mode 100644 index 0000000..4779647 --- /dev/null +++ b/libzapi/application/services/conversations/messages_service.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from libzapi.application.commands.conversations.message_cmds import PostMessageCmd +from libzapi.infrastructure.api_clients.conversations.message_api_client import MessageApiClient + + +class MessagesService: + """High-level service for Sunshine Conversations Messages.""" + + def __init__(self, client: MessageApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_all(self, conversation_id: str): + return self._client.list_all(self._app_id, conversation_id) + + def post(self, conversation_id: str, author: dict, content: dict, **kwargs): + cmd = PostMessageCmd(author=author, content=content, **kwargs) + return self._client.post(self._app_id, conversation_id, cmd) + + def delete_all(self, conversation_id: str) -> None: + self._client.delete_all(self._app_id, conversation_id) + + def delete(self, conversation_id: str, message_id: str) -> None: + self._client.delete(self._app_id, conversation_id, message_id) diff --git a/libzapi/application/services/conversations/participants_service.py b/libzapi/application/services/conversations/participants_service.py new file mode 100644 index 0000000..00dbb01 --- /dev/null +++ b/libzapi/application/services/conversations/participants_service.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from libzapi.infrastructure.api_clients.conversations.participant_api_client import ParticipantApiClient + + +class ParticipantsService: + """High-level service for Sunshine Conversations Participants.""" + + def __init__(self, client: ParticipantApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_all(self, conversation_id: str): + return self._client.list_all(self._app_id, conversation_id) + + def join(self, conversation_id: str, user_id: str): + return self._client.join(self._app_id, conversation_id, user_id) + + def leave(self, conversation_id: str, user_id: str) -> None: + self._client.leave(self._app_id, conversation_id, user_id) diff --git a/libzapi/application/services/conversations/switchboard_actions_service.py b/libzapi/application/services/conversations/switchboard_actions_service.py new file mode 100644 index 0000000..d913aec --- /dev/null +++ b/libzapi/application/services/conversations/switchboard_actions_service.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from libzapi.application.commands.conversations.switchboard_action_cmds import OfferControlCmd, PassControlCmd +from libzapi.infrastructure.api_clients.conversations.switchboard_action_api_client import SwitchboardActionApiClient + + +class SwitchboardActionsService: + """High-level service for Sunshine Conversations Switchboard Actions.""" + + def __init__(self, client: SwitchboardActionApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def accept_control(self, conversation_id: str, metadata: dict | None = None): + return self._client.accept_control(self._app_id, conversation_id, metadata=metadata) + + def offer_control(self, conversation_id: str, switchboard_integration: str, metadata: dict | None = None): + cmd = OfferControlCmd(switchboardIntegration=switchboard_integration, metadata=metadata) + return self._client.offer_control(self._app_id, conversation_id, cmd) + + def pass_control(self, conversation_id: str, switchboard_integration: str, metadata: dict | None = None): + cmd = PassControlCmd(switchboardIntegration=switchboard_integration, metadata=metadata) + return self._client.pass_control(self._app_id, conversation_id, cmd) + + def release_control(self, conversation_id: str, metadata: dict | None = None): + return self._client.release_control(self._app_id, conversation_id, metadata=metadata) diff --git a/libzapi/application/services/conversations/switchboard_integrations_service.py b/libzapi/application/services/conversations/switchboard_integrations_service.py new file mode 100644 index 0000000..82373d9 --- /dev/null +++ b/libzapi/application/services/conversations/switchboard_integrations_service.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from libzapi.application.commands.conversations.switchboard_cmds import ( + CreateSwitchboardIntegrationCmd, + UpdateSwitchboardIntegrationCmd, +) +from libzapi.infrastructure.api_clients.conversations.switchboard_integration_api_client import ( + SwitchboardIntegrationApiClient, +) + + +class SwitchboardIntegrationsService: + """High-level service for Sunshine Conversations Switchboard Integrations.""" + + def __init__(self, client: SwitchboardIntegrationApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_all(self, switchboard_id: str): + return self._client.list_all(self._app_id, switchboard_id) + + def create(self, switchboard_id: str, name: str, integration_id: str, **kwargs): + cmd = CreateSwitchboardIntegrationCmd(name=name, integrationId=integration_id, **kwargs) + return self._client.create(self._app_id, switchboard_id, cmd) + + def update(self, switchboard_id: str, switchboard_integration_id: str, **kwargs): + cmd = UpdateSwitchboardIntegrationCmd(**kwargs) + return self._client.update(self._app_id, switchboard_id, switchboard_integration_id, cmd) + + def delete(self, switchboard_id: str, switchboard_integration_id: str) -> None: + self._client.delete(self._app_id, switchboard_id, switchboard_integration_id) diff --git a/libzapi/application/services/conversations/switchboards_service.py b/libzapi/application/services/conversations/switchboards_service.py new file mode 100644 index 0000000..3587ecd --- /dev/null +++ b/libzapi/application/services/conversations/switchboards_service.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from libzapi.application.commands.conversations.switchboard_cmds import CreateSwitchboardCmd, UpdateSwitchboardCmd +from libzapi.infrastructure.api_clients.conversations.switchboard_api_client import SwitchboardApiClient + + +class SwitchboardsService: + """High-level service for Sunshine Conversations Switchboards.""" + + def __init__(self, client: SwitchboardApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_all(self): + return self._client.list_all(self._app_id) + + def create(self, **kwargs): + cmd = CreateSwitchboardCmd(**kwargs) + return self._client.create(self._app_id, cmd) + + def update(self, switchboard_id: str, **kwargs): + cmd = UpdateSwitchboardCmd(**kwargs) + return self._client.update(self._app_id, switchboard_id, cmd) + + def delete(self, switchboard_id: str) -> None: + self._client.delete(self._app_id, switchboard_id) diff --git a/libzapi/application/services/conversations/users_service.py b/libzapi/application/services/conversations/users_service.py new file mode 100644 index 0000000..a7d717a --- /dev/null +++ b/libzapi/application/services/conversations/users_service.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from libzapi.application.commands.conversations.user_cmds import CreateUserCmd, UpdateUserCmd +from libzapi.infrastructure.api_clients.conversations.user_api_client import UserApiClient + + +class UsersService: + """High-level service for Sunshine Conversations Users.""" + + def __init__(self, client: UserApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_by_email(self, email: str): + return self._client.list_by_email(self._app_id, email) + + def get(self, user_id: str): + return self._client.get(self._app_id, user_id) + + def create(self, external_id: str, **kwargs): + cmd = CreateUserCmd(externalId=external_id, **kwargs) + return self._client.create(self._app_id, cmd) + + def update(self, user_id: str, **kwargs): + cmd = UpdateUserCmd(**kwargs) + return self._client.update(self._app_id, user_id, cmd) + + def delete(self, user_id: str) -> None: + self._client.delete(self._app_id, user_id) + + def delete_personal_info(self, user_id: str) -> None: + self._client.delete_personal_info(self._app_id, user_id) + + def sync(self, zendesk_id: str): + return self._client.sync(self._app_id, zendesk_id) diff --git a/libzapi/application/services/conversations/webhooks_service.py b/libzapi/application/services/conversations/webhooks_service.py new file mode 100644 index 0000000..3fa02f6 --- /dev/null +++ b/libzapi/application/services/conversations/webhooks_service.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from libzapi.application.commands.conversations.webhook_cmds import CreateWebhookCmd, UpdateWebhookCmd +from libzapi.infrastructure.api_clients.conversations.webhook_api_client import WebhookApiClient + + +class WebhooksService: + """High-level service for Sunshine Conversations Webhooks.""" + + def __init__(self, client: WebhookApiClient, app_id: str) -> None: + self._client = client + self._app_id = app_id + + def list_all(self, integration_id: str): + return self._client.list_all(self._app_id, integration_id) + + def get(self, integration_id: str, webhook_id: str): + return self._client.get(self._app_id, integration_id, webhook_id) + + def create(self, integration_id: str, target: str, triggers: list[str], **kwargs): + cmd = CreateWebhookCmd(target=target, triggers=triggers, **kwargs) + return self._client.create(self._app_id, integration_id, cmd) + + def update(self, integration_id: str, webhook_id: str, **kwargs): + cmd = UpdateWebhookCmd(**kwargs) + return self._client.update(self._app_id, integration_id, webhook_id, cmd) + + def delete(self, integration_id: str, webhook_id: str) -> None: + self._client.delete(self._app_id, integration_id, webhook_id) diff --git a/libzapi/domain/models/conversations/__init__.py b/libzapi/domain/models/conversations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libzapi/domain/models/conversations/app.py b/libzapi/domain/models/conversations/app.py new file mode 100644 index 0000000..11c41ae --- /dev/null +++ b/libzapi/domain/models/conversations/app.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class AppSettings: + multiConvoEnabled: bool = False + maskCreditCardNumbers: bool = False + useAnimalNames: bool = False + echoPostback: bool = False + ignoreAutoConversationStart: bool = False + conversationRetentionSeconds: int | None = None + appLocalizationEnabled: bool = False + + +@dataclass(frozen=True, slots=True) +class App: + id: str + displayName: str = "" + metadata: dict = field(default_factory=dict) + settings: AppSettings | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("app", self.id) diff --git a/libzapi/domain/models/conversations/app_key.py b/libzapi/domain/models/conversations/app_key.py new file mode 100644 index 0000000..68cab42 --- /dev/null +++ b/libzapi/domain/models/conversations/app_key.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class AppKey: + id: str + displayName: str = "" + secret: str = "" + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("app_key", self.id) diff --git a/libzapi/domain/models/conversations/attachment.py b/libzapi/domain/models/conversations/attachment.py new file mode 100644 index 0000000..c66ce87 --- /dev/null +++ b/libzapi/domain/models/conversations/attachment.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Attachment: + mediaUrl: str = "" + mediaType: str = "" + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("sunco_attachment", self.mediaUrl) diff --git a/libzapi/domain/models/conversations/client.py b/libzapi/domain/models/conversations/client.py new file mode 100644 index 0000000..d8f9341 --- /dev/null +++ b/libzapi/domain/models/conversations/client.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Client: + id: str + type: str = "" + status: str = "" + integrationId: str = "" + externalId: str = "" + displayName: str = "" + avatarUrl: str = "" + lastSeen: str = "" + linkedAt: str = "" + raw: dict | None = None + info: dict | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("sunco_client", self.id) diff --git a/libzapi/domain/models/conversations/conversation.py b/libzapi/domain/models/conversations/conversation.py new file mode 100644 index 0000000..836d5f3 --- /dev/null +++ b/libzapi/domain/models/conversations/conversation.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Conversation: + id: str + type: str = "" + displayName: str = "" + description: str = "" + iconUrl: str = "" + metadata: dict = field(default_factory=dict) + activeSwitchboardIntegration: dict | None = None + pendingSwitchboardIntegration: dict | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("conversation", self.id) diff --git a/libzapi/domain/models/conversations/device.py b/libzapi/domain/models/conversations/device.py new file mode 100644 index 0000000..4deadcb --- /dev/null +++ b/libzapi/domain/models/conversations/device.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Device: + id: str + type: str = "" + guid: str = "" + clientId: str = "" + status: str = "" + integrationId: str = "" + lastSeen: str = "" + pushNotificationToken: str = "" + appVersion: str = "" + info: dict | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("device", self.id) diff --git a/libzapi/domain/models/conversations/integration.py b/libzapi/domain/models/conversations/integration.py new file mode 100644 index 0000000..516ea3c --- /dev/null +++ b/libzapi/domain/models/conversations/integration.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Integration: + id: str + type: str = "" + status: str = "" + displayName: str = "" + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("sunco_integration", self.id) diff --git a/libzapi/domain/models/conversations/integration_api_key.py b/libzapi/domain/models/conversations/integration_api_key.py new file mode 100644 index 0000000..78e1718 --- /dev/null +++ b/libzapi/domain/models/conversations/integration_api_key.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class IntegrationApiKey: + id: str + displayName: str = "" + secret: str = "" + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("integration_api_key", self.id) diff --git a/libzapi/domain/models/conversations/message.py b/libzapi/domain/models/conversations/message.py new file mode 100644 index 0000000..491e557 --- /dev/null +++ b/libzapi/domain/models/conversations/message.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Author: + type: str = "" + userId: str = "" + userExternalId: str = "" + displayName: str = "" + avatarUrl: str = "" + + +@dataclass(frozen=True, slots=True) +class Source: + type: str = "" + integrationId: str = "" + originalMessageId: str = "" + originalMessageTimestamp: str = "" + + +@dataclass(frozen=True, slots=True) +class Message: + id: str + received: str = "" + deleted: bool = False + author: Author | None = None + content: dict | None = None + source: Source | None = None + metadata: dict | None = None + quotedMessage: dict | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("message", self.id) diff --git a/libzapi/domain/models/conversations/participant.py b/libzapi/domain/models/conversations/participant.py new file mode 100644 index 0000000..309696e --- /dev/null +++ b/libzapi/domain/models/conversations/participant.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Participant: + id: str + userId: str = "" + userExternalId: str = "" + unreadCount: int = 0 + lastRead: str = "" + clientAssociations: list[dict] = field(default_factory=list) + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("participant", self.id) diff --git a/libzapi/domain/models/conversations/switchboard.py b/libzapi/domain/models/conversations/switchboard.py new file mode 100644 index 0000000..208b418 --- /dev/null +++ b/libzapi/domain/models/conversations/switchboard.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Switchboard: + id: str + enabled: bool = False + defaultSwitchboardIntegrationId: str = "" + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("switchboard", self.id) + + +@dataclass(frozen=True, slots=True) +class SwitchboardIntegration: + id: str + name: str = "" + integrationId: str = "" + integrationType: str = "" + deliverStandbyEvents: bool = False + nextSwitchboardIntegrationId: str = "" + messageHistoryCount: int | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("switchboard_integration", self.id) diff --git a/libzapi/domain/models/conversations/user.py b/libzapi/domain/models/conversations/user.py new file mode 100644 index 0000000..6b81d79 --- /dev/null +++ b/libzapi/domain/models/conversations/user.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class User: + id: str + externalId: str = "" + zendeskId: str = "" + signedUpAt: str = "" + profile: dict | None = None + metadata: dict | None = None + authenticated: bool = False + identities: list[dict] = field(default_factory=list) + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("sunco_user", self.id) diff --git a/libzapi/domain/models/conversations/webhook.py b/libzapi/domain/models/conversations/webhook.py new file mode 100644 index 0000000..f96ca01 --- /dev/null +++ b/libzapi/domain/models/conversations/webhook.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Webhook: + id: str + target: str = "" + triggers: list[str] = field(default_factory=list) + secret: str = "" + version: str = "" + includeFullUser: bool = False + includeFullSource: bool = False + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("sunco_webhook", self.id) diff --git a/libzapi/infrastructure/api_clients/conversations/__init__.py b/libzapi/infrastructure/api_clients/conversations/__init__.py new file mode 100644 index 0000000..0f55b4b --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/__init__.py @@ -0,0 +1,37 @@ +from libzapi.infrastructure.api_clients.conversations.activity_api_client import ActivityApiClient +from libzapi.infrastructure.api_clients.conversations.app_api_client import AppApiClient +from libzapi.infrastructure.api_clients.conversations.app_key_api_client import AppKeyApiClient +from libzapi.infrastructure.api_clients.conversations.attachment_api_client import AttachmentApiClient +from libzapi.infrastructure.api_clients.conversations.client_api_client import ClientApiClient +from libzapi.infrastructure.api_clients.conversations.conversation_api_client import ConversationApiClient +from libzapi.infrastructure.api_clients.conversations.device_api_client import DeviceApiClient +from libzapi.infrastructure.api_clients.conversations.integration_api_client import IntegrationApiClient +from libzapi.infrastructure.api_clients.conversations.integration_api_key_api_client import IntegrationApiKeyApiClient +from libzapi.infrastructure.api_clients.conversations.message_api_client import MessageApiClient +from libzapi.infrastructure.api_clients.conversations.participant_api_client import ParticipantApiClient +from libzapi.infrastructure.api_clients.conversations.switchboard_action_api_client import SwitchboardActionApiClient +from libzapi.infrastructure.api_clients.conversations.switchboard_api_client import SwitchboardApiClient +from libzapi.infrastructure.api_clients.conversations.switchboard_integration_api_client import ( + SwitchboardIntegrationApiClient, +) +from libzapi.infrastructure.api_clients.conversations.user_api_client import UserApiClient +from libzapi.infrastructure.api_clients.conversations.webhook_api_client import WebhookApiClient + +__all__ = [ + "ActivityApiClient", + "AppApiClient", + "AppKeyApiClient", + "AttachmentApiClient", + "ClientApiClient", + "ConversationApiClient", + "DeviceApiClient", + "IntegrationApiClient", + "IntegrationApiKeyApiClient", + "MessageApiClient", + "ParticipantApiClient", + "SwitchboardActionApiClient", + "SwitchboardApiClient", + "SwitchboardIntegrationApiClient", + "UserApiClient", + "WebhookApiClient", +] diff --git a/libzapi/infrastructure/api_clients/conversations/_pagination.py b/libzapi/infrastructure/api_clients/conversations/_pagination.py new file mode 100644 index 0000000..47fc1bf --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/_pagination.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Iterator + + +def sunco_yield_items(http_client, first_path: str, items_key: str, page_size: int = 100) -> Iterator[dict]: + """Paginator for Sunshine Conversations API. + + Uses ``get_raw`` to send URLs with literal brackets (``page[size]``) + that the ``requests`` library would otherwise percent-encode. + Subsequent pages use ``links.next`` from the API response. + """ + sep = "&" if "?" in first_path else "?" + url = f"{http_client.base_url}{first_path}{sep}page[size]={page_size}" + data = http_client.get_raw(url) + for obj in data.get(items_key, []) or []: + yield obj + + while True: + links = data.get("links") or {} + nxt = links.get("next") if isinstance(links, dict) else None + if not nxt: + break + data = http_client.get_raw(nxt) + for obj in data.get(items_key, []) or []: + yield obj diff --git a/libzapi/infrastructure/api_clients/conversations/activity_api_client.py b/libzapi/infrastructure/api_clients/conversations/activity_api_client.py new file mode 100644 index 0000000..76e47c5 --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/activity_api_client.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from libzapi.infrastructure.http.client import HttpClient + + +class ActivityApiClient: + """HTTP adapter for Sunshine Conversations Activity.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def post(self, app_id: str, conversation_id: str, author: dict, type: str) -> dict: + path = f"/v2/apps/{app_id}/conversations/{conversation_id}/activity" + return self._http.post(path, {"author": author, "type": type}) diff --git a/libzapi/infrastructure/api_clients/conversations/app_api_client.py b/libzapi/infrastructure/api_clients/conversations/app_api_client.py new file mode 100644 index 0000000..50d9825 --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/app_api_client.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.conversations.app_cmds import CreateAppCmd, UpdateAppCmd +from libzapi.domain.models.conversations.app import App +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.api_clients.conversations._pagination import sunco_yield_items +from libzapi.infrastructure.mappers.conversations.app_mapper import to_payload_create, to_payload_update +from libzapi.infrastructure.serialization.parse import to_domain + + +class AppApiClient: + """HTTP adapter for Sunshine Conversations Apps.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self) -> Iterator[App]: + for obj in sunco_yield_items( + http_client=self._http, + first_path="/v2/apps", + items_key="apps", + ): + yield to_domain(data=obj, cls=App) + + def get(self, app_id: str) -> App: + data = self._http.get(f"/v2/apps/{app_id}") + return to_domain(data=data["app"], cls=App) + + def create(self, cmd: CreateAppCmd) -> App: + payload = to_payload_create(cmd) + data = self._http.post("/v2/apps", payload) + return to_domain(data=data["app"], cls=App) + + def update(self, app_id: str, cmd: UpdateAppCmd) -> App: + payload = to_payload_update(cmd) + data = self._http.patch(f"/v2/apps/{app_id}", payload) + return to_domain(data=data["app"], cls=App) + + def delete(self, app_id: str) -> None: + self._http.delete(f"/v2/apps/{app_id}") diff --git a/libzapi/infrastructure/api_clients/conversations/app_key_api_client.py b/libzapi/infrastructure/api_clients/conversations/app_key_api_client.py new file mode 100644 index 0000000..e08852b --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/app_key_api_client.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.domain.models.conversations.app_key import AppKey +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.api_clients.conversations._pagination import sunco_yield_items +from libzapi.infrastructure.serialization.parse import to_domain + + +class AppKeyApiClient: + """HTTP adapter for Sunshine Conversations App Keys.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, app_id: str) -> Iterator[AppKey]: + for obj in sunco_yield_items( + http_client=self._http, + first_path=f"/v2/apps/{app_id}/keys", + items_key="keys", + ): + yield to_domain(data=obj, cls=AppKey) + + def get(self, app_id: str, key_id: str) -> AppKey: + data = self._http.get(f"/v2/apps/{app_id}/keys/{key_id}") + return to_domain(data=data["key"], cls=AppKey) + + def create(self, app_id: str, display_name: str) -> AppKey: + data = self._http.post(f"/v2/apps/{app_id}/keys", {"displayName": display_name}) + return to_domain(data=data["key"], cls=AppKey) + + def delete(self, app_id: str, key_id: str) -> None: + self._http.delete(f"/v2/apps/{app_id}/keys/{key_id}") diff --git a/libzapi/infrastructure/api_clients/conversations/attachment_api_client.py b/libzapi/infrastructure/api_clients/conversations/attachment_api_client.py new file mode 100644 index 0000000..8fd402d --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/attachment_api_client.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from libzapi.domain.models.conversations.attachment import Attachment +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.serialization.parse import to_domain + + +class AttachmentApiClient: + """HTTP adapter for Sunshine Conversations Attachments.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def upload(self, app_id: str, file: tuple) -> Attachment: + data = self._http.post_multipart(f"/v2/apps/{app_id}/attachments", files={"source": file}) + return to_domain(data=data["attachment"], cls=Attachment) + + def delete(self, app_id: str, media_url: str) -> dict: + return self._http.post(f"/v2/apps/{app_id}/attachments/remove", {"mediaUrl": media_url}) diff --git a/libzapi/infrastructure/api_clients/conversations/client_api_client.py b/libzapi/infrastructure/api_clients/conversations/client_api_client.py new file mode 100644 index 0000000..2cd2659 --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/client_api_client.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.domain.models.conversations.client import Client +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.api_clients.conversations._pagination import sunco_yield_items +from libzapi.infrastructure.serialization.parse import to_domain + + +class ClientApiClient: + """HTTP adapter for Sunshine Conversations Clients.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, app_id: str, user_id_or_external_id: str) -> Iterator[Client]: + for obj in sunco_yield_items( + http_client=self._http, + first_path=f"/v2/apps/{app_id}/users/{user_id_or_external_id}/clients", + items_key="clients", + ): + yield to_domain(data=obj, cls=Client) + + def create(self, app_id: str, user_id_or_external_id: str, payload: dict) -> Client: + data = self._http.post(f"/v2/apps/{app_id}/users/{user_id_or_external_id}/clients", payload) + return to_domain(data=data["client"], cls=Client) + + def remove(self, app_id: str, user_id_or_external_id: str, client_id: str) -> None: + self._http.delete(f"/v2/apps/{app_id}/users/{user_id_or_external_id}/clients/{client_id}") diff --git a/libzapi/infrastructure/api_clients/conversations/conversation_api_client.py b/libzapi/infrastructure/api_clients/conversations/conversation_api_client.py new file mode 100644 index 0000000..0f1c1ac --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/conversation_api_client.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.conversations.conversation_cmds import CreateConversationCmd, UpdateConversationCmd +from libzapi.domain.models.conversations.conversation import Conversation +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.api_clients.conversations._pagination import sunco_yield_items +from libzapi.infrastructure.mappers.conversations.conversation_mapper import to_payload_create, to_payload_update +from libzapi.infrastructure.serialization.parse import to_domain + + +class ConversationApiClient: + """HTTP adapter for Sunshine Conversations Conversations.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_by_user(self, app_id: str, user_id: str) -> Iterator[Conversation]: + for obj in sunco_yield_items( + http_client=self._http, + first_path=f"/v2/apps/{app_id}/conversations?filter[userId]={user_id}", + items_key="conversations", + ): + yield to_domain(data=obj, cls=Conversation) + + def get(self, app_id: str, conversation_id: str) -> Conversation: + data = self._http.get(f"/v2/apps/{app_id}/conversations/{conversation_id}") + return to_domain(data=data["conversation"], cls=Conversation) + + def create(self, app_id: str, cmd: CreateConversationCmd) -> Conversation: + payload = to_payload_create(cmd) + data = self._http.post(f"/v2/apps/{app_id}/conversations", payload) + return to_domain(data=data["conversation"], cls=Conversation) + + def update(self, app_id: str, conversation_id: str, cmd: UpdateConversationCmd) -> Conversation: + payload = to_payload_update(cmd) + data = self._http.patch(f"/v2/apps/{app_id}/conversations/{conversation_id}", payload) + return to_domain(data=data["conversation"], cls=Conversation) + + def delete(self, app_id: str, conversation_id: str) -> None: + self._http.delete(f"/v2/apps/{app_id}/conversations/{conversation_id}") diff --git a/libzapi/infrastructure/api_clients/conversations/device_api_client.py b/libzapi/infrastructure/api_clients/conversations/device_api_client.py new file mode 100644 index 0000000..b00e69b --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/device_api_client.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.domain.models.conversations.device import Device +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.serialization.parse import to_domain + + +class DeviceApiClient: + """HTTP adapter for Sunshine Conversations Devices.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, app_id: str, user_id_or_external_id: str) -> Iterator[Device]: + data = self._http.get(f"/v2/apps/{app_id}/users/{user_id_or_external_id}/devices") + for obj in data.get("devices", []): + yield to_domain(data=obj, cls=Device) + + def get(self, app_id: str, user_id_or_external_id: str, device_id: str) -> Device: + data = self._http.get(f"/v2/apps/{app_id}/users/{user_id_or_external_id}/devices/{device_id}") + return to_domain(data=data["device"], cls=Device) diff --git a/libzapi/infrastructure/api_clients/conversations/integration_api_client.py b/libzapi/infrastructure/api_clients/conversations/integration_api_client.py new file mode 100644 index 0000000..bf95725 --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/integration_api_client.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.conversations.integration_cmds import CreateIntegrationCmd, UpdateIntegrationCmd +from libzapi.domain.models.conversations.integration import Integration +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.api_clients.conversations._pagination import sunco_yield_items +from libzapi.infrastructure.mappers.conversations.integration_mapper import to_payload_create, to_payload_update +from libzapi.infrastructure.serialization.parse import to_domain + + +class IntegrationApiClient: + """HTTP adapter for Sunshine Conversations Integrations.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, app_id: str) -> Iterator[Integration]: + for obj in sunco_yield_items( + http_client=self._http, + first_path=f"/v2/apps/{app_id}/integrations", + items_key="integrations", + ): + yield to_domain(data=obj, cls=Integration) + + def get(self, app_id: str, integration_id: str) -> Integration: + data = self._http.get(f"/v2/apps/{app_id}/integrations/{integration_id}") + return to_domain(data=data["integration"], cls=Integration) + + def create(self, app_id: str, cmd: CreateIntegrationCmd) -> Integration: + payload = to_payload_create(cmd) + data = self._http.post(f"/v2/apps/{app_id}/integrations", payload) + return to_domain(data=data["integration"], cls=Integration) + + def update(self, app_id: str, integration_id: str, cmd: UpdateIntegrationCmd) -> Integration: + payload = to_payload_update(cmd) + data = self._http.patch(f"/v2/apps/{app_id}/integrations/{integration_id}", payload) + return to_domain(data=data["integration"], cls=Integration) + + def delete(self, app_id: str, integration_id: str) -> None: + self._http.delete(f"/v2/apps/{app_id}/integrations/{integration_id}") diff --git a/libzapi/infrastructure/api_clients/conversations/integration_api_key_api_client.py b/libzapi/infrastructure/api_clients/conversations/integration_api_key_api_client.py new file mode 100644 index 0000000..bd75acf --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/integration_api_key_api_client.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.domain.models.conversations.integration_api_key import IntegrationApiKey +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.api_clients.conversations._pagination import sunco_yield_items +from libzapi.infrastructure.serialization.parse import to_domain + + +class IntegrationApiKeyApiClient: + """HTTP adapter for Sunshine Conversations Integration API Keys.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, app_id: str, integration_id: str) -> Iterator[IntegrationApiKey]: + for obj in sunco_yield_items( + http_client=self._http, + first_path=f"/v2/apps/{app_id}/integrations/{integration_id}/keys", + items_key="keys", + ): + yield to_domain(data=obj, cls=IntegrationApiKey) + + def get(self, app_id: str, integration_id: str, key_id: str) -> IntegrationApiKey: + data = self._http.get(f"/v2/apps/{app_id}/integrations/{integration_id}/keys/{key_id}") + return to_domain(data=data["key"], cls=IntegrationApiKey) + + def create(self, app_id: str, integration_id: str, display_name: str) -> IntegrationApiKey: + data = self._http.post( + f"/v2/apps/{app_id}/integrations/{integration_id}/keys", + {"displayName": display_name}, + ) + return to_domain(data=data["key"], cls=IntegrationApiKey) + + def delete(self, app_id: str, integration_id: str, key_id: str) -> None: + self._http.delete(f"/v2/apps/{app_id}/integrations/{integration_id}/keys/{key_id}") diff --git a/libzapi/infrastructure/api_clients/conversations/message_api_client.py b/libzapi/infrastructure/api_clients/conversations/message_api_client.py new file mode 100644 index 0000000..9946720 --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/message_api_client.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.conversations.message_cmds import PostMessageCmd +from libzapi.domain.models.conversations.message import Message +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.api_clients.conversations._pagination import sunco_yield_items +from libzapi.infrastructure.mappers.conversations.message_mapper import to_payload_create +from libzapi.infrastructure.serialization.parse import to_domain + + +class MessageApiClient: + """HTTP adapter for Sunshine Conversations Messages.""" + + _BASE = "/v2/apps/{app_id}/conversations/{conversation_id}/messages" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, app_id: str, conversation_id: str) -> Iterator[Message]: + path = self._BASE.format(app_id=app_id, conversation_id=conversation_id) + for obj in sunco_yield_items( + http_client=self._http, + first_path=path, + items_key="messages", + ): + yield to_domain(data=obj, cls=Message) + + def post(self, app_id: str, conversation_id: str, cmd: PostMessageCmd) -> Message: + path = self._BASE.format(app_id=app_id, conversation_id=conversation_id) + payload = to_payload_create(cmd) + data = self._http.post(path, payload) + return to_domain(data=data["message"], cls=Message) + + def delete_all(self, app_id: str, conversation_id: str) -> None: + path = self._BASE.format(app_id=app_id, conversation_id=conversation_id) + self._http.delete(path) + + def delete(self, app_id: str, conversation_id: str, message_id: str) -> None: + path = self._BASE.format(app_id=app_id, conversation_id=conversation_id) + self._http.delete(f"{path}/{message_id}") diff --git a/libzapi/infrastructure/api_clients/conversations/participant_api_client.py b/libzapi/infrastructure/api_clients/conversations/participant_api_client.py new file mode 100644 index 0000000..9b93507 --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/participant_api_client.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.domain.models.conversations.participant import Participant +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.serialization.parse import to_domain + + +class ParticipantApiClient: + """HTTP adapter for Sunshine Conversations Participants.""" + + _BASE = "/v2/apps/{app_id}/conversations/{conversation_id}/participants" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, app_id: str, conversation_id: str) -> Iterator[Participant]: + path = self._BASE.format(app_id=app_id, conversation_id=conversation_id) + data = self._http.get(path) + for obj in data.get("participants", []): + yield to_domain(data=obj, cls=Participant) + + def join(self, app_id: str, conversation_id: str, user_id: str) -> dict: + path = self._BASE.format(app_id=app_id, conversation_id=conversation_id) + return self._http.post(f"{path}/join", {"userId": user_id}) + + def leave(self, app_id: str, conversation_id: str, user_id: str) -> None: + path = self._BASE.format(app_id=app_id, conversation_id=conversation_id) + self._http.post(f"{path}/leave", {"userId": user_id}) diff --git a/libzapi/infrastructure/api_clients/conversations/switchboard_action_api_client.py b/libzapi/infrastructure/api_clients/conversations/switchboard_action_api_client.py new file mode 100644 index 0000000..110a4ac --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/switchboard_action_api_client.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from libzapi.application.commands.conversations.switchboard_action_cmds import OfferControlCmd, PassControlCmd +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.conversations.switchboard_action_mapper import ( + to_payload_offer_control, + to_payload_pass_control, +) + + +class SwitchboardActionApiClient: + """HTTP adapter for Sunshine Conversations Switchboard Actions.""" + + _BASE = "/v2/apps/{app_id}/conversations/{conversation_id}" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def accept_control(self, app_id: str, conversation_id: str, metadata: dict | None = None) -> dict: + path = self._BASE.format(app_id=app_id, conversation_id=conversation_id) + payload: dict = {} + if metadata is not None: + payload["metadata"] = metadata + return self._http.post(f"{path}/acceptControl", payload) + + def offer_control(self, app_id: str, conversation_id: str, cmd: OfferControlCmd) -> dict: + path = self._BASE.format(app_id=app_id, conversation_id=conversation_id) + payload = to_payload_offer_control(cmd) + return self._http.post(f"{path}/offerControl", payload) + + def pass_control(self, app_id: str, conversation_id: str, cmd: PassControlCmd) -> dict: + path = self._BASE.format(app_id=app_id, conversation_id=conversation_id) + payload = to_payload_pass_control(cmd) + return self._http.post(f"{path}/passControl", payload) + + def release_control(self, app_id: str, conversation_id: str, metadata: dict | None = None) -> dict: + path = self._BASE.format(app_id=app_id, conversation_id=conversation_id) + payload: dict = {} + if metadata is not None: + payload["metadata"] = metadata + return self._http.post(f"{path}/releaseControl", payload) diff --git a/libzapi/infrastructure/api_clients/conversations/switchboard_api_client.py b/libzapi/infrastructure/api_clients/conversations/switchboard_api_client.py new file mode 100644 index 0000000..b14e0bb --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/switchboard_api_client.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.conversations.switchboard_cmds import CreateSwitchboardCmd, UpdateSwitchboardCmd +from libzapi.domain.models.conversations.switchboard import Switchboard +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.conversations.switchboard_mapper import ( + to_payload_create_switchboard, + to_payload_update_switchboard, +) +from libzapi.infrastructure.serialization.parse import to_domain + + +class SwitchboardApiClient: + """HTTP adapter for Sunshine Conversations Switchboards.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, app_id: str) -> Iterator[Switchboard]: + data = self._http.get(f"/v2/apps/{app_id}/switchboards") + for obj in data.get("switchboards", []): + yield to_domain(data=obj, cls=Switchboard) + + def create(self, app_id: str, cmd: CreateSwitchboardCmd) -> Switchboard: + payload = to_payload_create_switchboard(cmd) + data = self._http.post(f"/v2/apps/{app_id}/switchboards", payload) + return to_domain(data=data["switchboard"], cls=Switchboard) + + def update(self, app_id: str, switchboard_id: str, cmd: UpdateSwitchboardCmd) -> Switchboard: + payload = to_payload_update_switchboard(cmd) + data = self._http.patch(f"/v2/apps/{app_id}/switchboards/{switchboard_id}", payload) + return to_domain(data=data["switchboard"], cls=Switchboard) + + def delete(self, app_id: str, switchboard_id: str) -> None: + self._http.delete(f"/v2/apps/{app_id}/switchboards/{switchboard_id}") diff --git a/libzapi/infrastructure/api_clients/conversations/switchboard_integration_api_client.py b/libzapi/infrastructure/api_clients/conversations/switchboard_integration_api_client.py new file mode 100644 index 0000000..c306578 --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/switchboard_integration_api_client.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.conversations.switchboard_cmds import ( + CreateSwitchboardIntegrationCmd, + UpdateSwitchboardIntegrationCmd, +) +from libzapi.domain.models.conversations.switchboard import SwitchboardIntegration +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.api_clients.conversations._pagination import sunco_yield_items +from libzapi.infrastructure.mappers.conversations.switchboard_mapper import ( + to_payload_create_switchboard_integration, + to_payload_update_switchboard_integration, +) +from libzapi.infrastructure.serialization.parse import to_domain + + +class SwitchboardIntegrationApiClient: + """HTTP adapter for Sunshine Conversations Switchboard Integrations.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, app_id: str, switchboard_id: str) -> Iterator[SwitchboardIntegration]: + for obj in sunco_yield_items( + http_client=self._http, + first_path=f"/v2/apps/{app_id}/switchboards/{switchboard_id}/switchboardIntegrations", + items_key="switchboardIntegrations", + ): + yield to_domain(data=obj, cls=SwitchboardIntegration) + + def create(self, app_id: str, switchboard_id: str, cmd: CreateSwitchboardIntegrationCmd) -> SwitchboardIntegration: + payload = to_payload_create_switchboard_integration(cmd) + data = self._http.post( + f"/v2/apps/{app_id}/switchboards/{switchboard_id}/switchboardIntegrations", + payload, + ) + return to_domain(data=data["switchboardIntegration"], cls=SwitchboardIntegration) + + def update( + self, + app_id: str, + switchboard_id: str, + switchboard_integration_id: str, + cmd: UpdateSwitchboardIntegrationCmd, + ) -> SwitchboardIntegration: + payload = to_payload_update_switchboard_integration(cmd) + data = self._http.patch( + f"/v2/apps/{app_id}/switchboards/{switchboard_id}/switchboardIntegrations/{switchboard_integration_id}", + payload, + ) + return to_domain(data=data["switchboardIntegration"], cls=SwitchboardIntegration) + + def delete(self, app_id: str, switchboard_id: str, switchboard_integration_id: str) -> None: + self._http.delete( + f"/v2/apps/{app_id}/switchboards/{switchboard_id}/switchboardIntegrations/{switchboard_integration_id}" + ) diff --git a/libzapi/infrastructure/api_clients/conversations/user_api_client.py b/libzapi/infrastructure/api_clients/conversations/user_api_client.py new file mode 100644 index 0000000..e75894a --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/user_api_client.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.conversations.user_cmds import CreateUserCmd, UpdateUserCmd +from libzapi.domain.models.conversations.user import User +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.api_clients.conversations._pagination import sunco_yield_items +from libzapi.infrastructure.mappers.conversations.user_mapper import to_payload_create, to_payload_update +from libzapi.infrastructure.serialization.parse import to_domain + + +class UserApiClient: + """HTTP adapter for Sunshine Conversations Users.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_by_email(self, app_id: str, email: str) -> Iterator[User]: + for obj in sunco_yield_items( + http_client=self._http, + first_path=f"/v2/apps/{app_id}/users?filter[identities.email]={email}", + items_key="users", + ): + yield to_domain(data=obj, cls=User) + + def get(self, app_id: str, user_id_or_external_id: str) -> User: + data = self._http.get(f"/v2/apps/{app_id}/users/{user_id_or_external_id}") + return to_domain(data=data["user"], cls=User) + + def create(self, app_id: str, cmd: CreateUserCmd) -> User: + payload = to_payload_create(cmd) + data = self._http.post(f"/v2/apps/{app_id}/users", payload) + return to_domain(data=data["user"], cls=User) + + def update(self, app_id: str, user_id_or_external_id: str, cmd: UpdateUserCmd) -> User: + payload = to_payload_update(cmd) + data = self._http.patch(f"/v2/apps/{app_id}/users/{user_id_or_external_id}", payload) + return to_domain(data=data["user"], cls=User) + + def delete(self, app_id: str, user_id_or_external_id: str) -> None: + self._http.delete(f"/v2/apps/{app_id}/users/{user_id_or_external_id}") + + def delete_personal_info(self, app_id: str, user_id_or_external_id: str) -> None: + self._http.delete(f"/v2/apps/{app_id}/users/{user_id_or_external_id}/personalinformation") + + def sync(self, app_id: str, zendesk_id: str) -> dict: + return self._http.post(f"/v2/apps/{app_id}/users/{zendesk_id}/sync", {}) diff --git a/libzapi/infrastructure/api_clients/conversations/webhook_api_client.py b/libzapi/infrastructure/api_clients/conversations/webhook_api_client.py new file mode 100644 index 0000000..5f2f4a5 --- /dev/null +++ b/libzapi/infrastructure/api_clients/conversations/webhook_api_client.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.conversations.webhook_cmds import CreateWebhookCmd, UpdateWebhookCmd +from libzapi.domain.models.conversations.webhook import Webhook +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.api_clients.conversations._pagination import sunco_yield_items +from libzapi.infrastructure.mappers.conversations.webhook_mapper import to_payload_create, to_payload_update +from libzapi.infrastructure.serialization.parse import to_domain + + +class WebhookApiClient: + """HTTP adapter for Sunshine Conversations Webhooks.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, app_id: str, integration_id: str) -> Iterator[Webhook]: + for obj in sunco_yield_items( + http_client=self._http, + first_path=f"/v2/apps/{app_id}/integrations/{integration_id}/webhooks", + items_key="webhooks", + ): + yield to_domain(data=obj, cls=Webhook) + + def get(self, app_id: str, integration_id: str, webhook_id: str) -> Webhook: + data = self._http.get(f"/v2/apps/{app_id}/integrations/{integration_id}/webhooks/{webhook_id}") + return to_domain(data=data["webhook"], cls=Webhook) + + def create(self, app_id: str, integration_id: str, cmd: CreateWebhookCmd) -> Webhook: + payload = to_payload_create(cmd) + data = self._http.post(f"/v2/apps/{app_id}/integrations/{integration_id}/webhooks", payload) + return to_domain(data=data["webhook"], cls=Webhook) + + def update(self, app_id: str, integration_id: str, webhook_id: str, cmd: UpdateWebhookCmd) -> Webhook: + payload = to_payload_update(cmd) + data = self._http.patch( + f"/v2/apps/{app_id}/integrations/{integration_id}/webhooks/{webhook_id}", + payload, + ) + return to_domain(data=data["webhook"], cls=Webhook) + + def delete(self, app_id: str, integration_id: str, webhook_id: str) -> None: + self._http.delete(f"/v2/apps/{app_id}/integrations/{integration_id}/webhooks/{webhook_id}") diff --git a/libzapi/infrastructure/http/auth.py b/libzapi/infrastructure/http/auth.py index b65249a..ec427fc 100644 --- a/libzapi/infrastructure/http/auth.py +++ b/libzapi/infrastructure/http/auth.py @@ -8,3 +8,8 @@ def oauth_headers(token: str) -> dict[str, str]: def api_token_headers(email: str, api_token: str) -> dict[str, str]: basic = b64encode(f"{email}/token:{api_token}".encode()).decode() return {"Authorization": f"Basic {basic}"} + + +def basic_key_headers(key_id: str, key_secret: str) -> dict[str, str]: + basic = b64encode(f"{key_id}:{key_secret}".encode()).decode() + return {"Authorization": f"Basic {basic}"} diff --git a/libzapi/infrastructure/http/client.py b/libzapi/infrastructure/http/client.py index fe2e745..4ae0d21 100644 --- a/libzapi/infrastructure/http/client.py +++ b/libzapi/infrastructure/http/client.py @@ -26,8 +26,22 @@ def __init__(self, base_url: str, headers: dict[str, str], timeout: float = 30.0 self.session.mount("https://", adapter) self.timeout = timeout - def get(self, path: str) -> dict: - resp = self.session.get(f"{self.base_url}{path}", timeout=self.timeout) + def get(self, path: str, params: dict | None = None) -> dict: + resp = self.session.get(f"{self.base_url}{path}", params=params, timeout=self.timeout) + self._raise(resp) + return resp.json() + + def get_raw(self, url: str) -> dict: + """GET with a pre-built URL, bypassing query-param encoding. + + Some APIs (e.g. Sunshine Conversations) require literal brackets + in query parameters like ``page[size]`` which ``requests`` + would otherwise percent-encode. + """ + req = requests.Request("GET", url, headers=self.session.headers) + prepared = req.prepare() + prepared.url = url # override to prevent percent-encoding of brackets + resp = self.session.send(prepared, timeout=self.timeout) self._raise(resp) return resp.json() diff --git a/libzapi/infrastructure/mappers/conversations/__init__.py b/libzapi/infrastructure/mappers/conversations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libzapi/infrastructure/mappers/conversations/app_mapper.py b/libzapi/infrastructure/mappers/conversations/app_mapper.py new file mode 100644 index 0000000..32ae5f7 --- /dev/null +++ b/libzapi/infrastructure/mappers/conversations/app_mapper.py @@ -0,0 +1,15 @@ +from libzapi.application.commands.conversations.app_cmds import CreateAppCmd, UpdateAppCmd + + +def to_payload_create(cmd: CreateAppCmd) -> dict: + payload: dict = {"displayName": cmd.displayName} + if cmd.metadata: + payload["metadata"] = cmd.metadata + if cmd.settings: + payload["settings"] = cmd.settings + return payload + + +def to_payload_update(cmd: UpdateAppCmd) -> dict: + fields = ("displayName", "metadata", "settings") + return {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} diff --git a/libzapi/infrastructure/mappers/conversations/conversation_mapper.py b/libzapi/infrastructure/mappers/conversations/conversation_mapper.py new file mode 100644 index 0000000..170f9df --- /dev/null +++ b/libzapi/infrastructure/mappers/conversations/conversation_mapper.py @@ -0,0 +1,21 @@ +from libzapi.application.commands.conversations.conversation_cmds import CreateConversationCmd, UpdateConversationCmd + + +def to_payload_create(cmd: CreateConversationCmd) -> dict: + payload: dict = {"type": cmd.type} + if cmd.displayName: + payload["displayName"] = cmd.displayName + if cmd.description: + payload["description"] = cmd.description + if cmd.iconUrl: + payload["iconUrl"] = cmd.iconUrl + if cmd.metadata: + payload["metadata"] = cmd.metadata + if cmd.participants: + payload["participants"] = cmd.participants + return payload + + +def to_payload_update(cmd: UpdateConversationCmd) -> dict: + fields = ("displayName", "description", "iconUrl", "metadata") + return {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} diff --git a/libzapi/infrastructure/mappers/conversations/integration_mapper.py b/libzapi/infrastructure/mappers/conversations/integration_mapper.py new file mode 100644 index 0000000..3a294c0 --- /dev/null +++ b/libzapi/infrastructure/mappers/conversations/integration_mapper.py @@ -0,0 +1,19 @@ +from libzapi.application.commands.conversations.integration_cmds import CreateIntegrationCmd, UpdateIntegrationCmd + + +def to_payload_create(cmd: CreateIntegrationCmd) -> dict: + payload: dict = {"type": cmd.type} + if cmd.displayName: + payload["displayName"] = cmd.displayName + if cmd.extra: + payload.update(cmd.extra) + return payload + + +def to_payload_update(cmd: UpdateIntegrationCmd) -> dict: + payload: dict = {} + if cmd.displayName is not None: + payload["displayName"] = cmd.displayName + if cmd.extra is not None: + payload.update(cmd.extra) + return payload diff --git a/libzapi/infrastructure/mappers/conversations/message_mapper.py b/libzapi/infrastructure/mappers/conversations/message_mapper.py new file mode 100644 index 0000000..025f508 --- /dev/null +++ b/libzapi/infrastructure/mappers/conversations/message_mapper.py @@ -0,0 +1,8 @@ +from libzapi.application.commands.conversations.message_cmds import PostMessageCmd + + +def to_payload_create(cmd: PostMessageCmd) -> dict: + payload: dict = {"author": cmd.author, "content": cmd.content} + if cmd.metadata: + payload["metadata"] = cmd.metadata + return payload diff --git a/libzapi/infrastructure/mappers/conversations/switchboard_action_mapper.py b/libzapi/infrastructure/mappers/conversations/switchboard_action_mapper.py new file mode 100644 index 0000000..64370f4 --- /dev/null +++ b/libzapi/infrastructure/mappers/conversations/switchboard_action_mapper.py @@ -0,0 +1,15 @@ +from libzapi.application.commands.conversations.switchboard_action_cmds import OfferControlCmd, PassControlCmd + + +def to_payload_pass_control(cmd: PassControlCmd) -> dict: + payload: dict = {"switchboardIntegration": cmd.switchboardIntegration} + if cmd.metadata: + payload["metadata"] = cmd.metadata + return payload + + +def to_payload_offer_control(cmd: OfferControlCmd) -> dict: + payload: dict = {"switchboardIntegration": cmd.switchboardIntegration} + if cmd.metadata: + payload["metadata"] = cmd.metadata + return payload diff --git a/libzapi/infrastructure/mappers/conversations/switchboard_mapper.py b/libzapi/infrastructure/mappers/conversations/switchboard_mapper.py new file mode 100644 index 0000000..62c0ca9 --- /dev/null +++ b/libzapi/infrastructure/mappers/conversations/switchboard_mapper.py @@ -0,0 +1,32 @@ +from libzapi.application.commands.conversations.switchboard_cmds import ( + CreateSwitchboardCmd, + CreateSwitchboardIntegrationCmd, + UpdateSwitchboardCmd, + UpdateSwitchboardIntegrationCmd, +) + + +def to_payload_create_switchboard(cmd: CreateSwitchboardCmd) -> dict: + return {"enabled": cmd.enabled} + + +def to_payload_update_switchboard(cmd: UpdateSwitchboardCmd) -> dict: + fields = ("enabled", "defaultSwitchboardIntegrationId") + return {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + + +def to_payload_create_switchboard_integration(cmd: CreateSwitchboardIntegrationCmd) -> dict: + payload: dict = { + "name": cmd.name, + "integrationId": cmd.integrationId, + } + if cmd.integrationType: + payload["integrationType"] = cmd.integrationType + if cmd.deliverStandbyEvents: + payload["deliverStandbyEvents"] = cmd.deliverStandbyEvents + return payload + + +def to_payload_update_switchboard_integration(cmd: UpdateSwitchboardIntegrationCmd) -> dict: + fields = ("name", "deliverStandbyEvents", "nextSwitchboardIntegrationId", "messageHistoryCount") + return {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} diff --git a/libzapi/infrastructure/mappers/conversations/user_mapper.py b/libzapi/infrastructure/mappers/conversations/user_mapper.py new file mode 100644 index 0000000..78214cd --- /dev/null +++ b/libzapi/infrastructure/mappers/conversations/user_mapper.py @@ -0,0 +1,15 @@ +from libzapi.application.commands.conversations.user_cmds import CreateUserCmd, UpdateUserCmd + + +def to_payload_create(cmd: CreateUserCmd) -> dict: + payload: dict = {"externalId": cmd.externalId} + if cmd.profile: + payload["profile"] = cmd.profile + if cmd.metadata: + payload["metadata"] = cmd.metadata + return payload + + +def to_payload_update(cmd: UpdateUserCmd) -> dict: + fields = ("profile", "metadata") + return {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} diff --git a/libzapi/infrastructure/mappers/conversations/webhook_mapper.py b/libzapi/infrastructure/mappers/conversations/webhook_mapper.py new file mode 100644 index 0000000..1c383e0 --- /dev/null +++ b/libzapi/infrastructure/mappers/conversations/webhook_mapper.py @@ -0,0 +1,18 @@ +from libzapi.application.commands.conversations.webhook_cmds import CreateWebhookCmd, UpdateWebhookCmd + + +def to_payload_create(cmd: CreateWebhookCmd) -> dict: + payload: dict = { + "target": cmd.target, + "triggers": cmd.triggers, + } + if cmd.includeFullUser: + payload["includeFullUser"] = cmd.includeFullUser + if cmd.includeFullSource: + payload["includeFullSource"] = cmd.includeFullSource + return payload + + +def to_payload_update(cmd: UpdateWebhookCmd) -> dict: + fields = ("target", "triggers", "includeFullUser", "includeFullSource") + return {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} diff --git a/tests/conftest.py b/tests/conftest.py index af0e35e..5564a19 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, Conversations T = TypeVar("T") @@ -38,6 +38,20 @@ def asset_management(): return _generic_zendesk_client(AssetManagement) +@pytest.fixture(scope="session") +def conversations(): + """Creates a real Conversations client if environment variables are set.""" + base_url = os.getenv("ZENDESK_URL") + key_id = os.getenv("SUNCO_KEY_ID") + key_secret = os.getenv("SUNCO_KEY_SECRET") + app_id = os.getenv("SUNCO_APP_ID") + + if not (base_url and key_id and key_secret and app_id): + pytest.skip("Sunshine Conversations credentials not provided. Skipping live API tests.") + + return Conversations(base_url=base_url, key_id=key_id, key_secret=key_secret, app_id=app_id) + + 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/conversations/__init__.py b/tests/integration/conversations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conversations/test_apps.py b/tests/integration/conversations/test_apps.py new file mode 100644 index 0000000..73c845a --- /dev/null +++ b/tests/integration/conversations/test_apps.py @@ -0,0 +1,16 @@ +import pytest + +from libzapi import Conversations + + +def test_list_apps(conversations: Conversations): + try: + apps = list(conversations.apps.list_all()) + assert isinstance(apps, list) + except Exception: + pytest.skip("Insufficient permissions — app-scoped key cannot list all apps") + + +def test_get_app(conversations: Conversations): + app = conversations.apps.get(conversations.app_id) + assert app.id == conversations.app_id diff --git a/tests/integration/conversations/test_clients.py b/tests/integration/conversations/test_clients.py new file mode 100644 index 0000000..e093770 --- /dev/null +++ b/tests/integration/conversations/test_clients.py @@ -0,0 +1,13 @@ +import uuid + +from libzapi import Conversations + + +def test_list_clients(conversations: Conversations): + ext_id = f"sdk-test-{uuid.uuid4().hex[:8]}" + user = conversations.users.create(external_id=ext_id) + try: + clients = list(conversations.clients.list_all(user.id)) + assert isinstance(clients, list) + finally: + conversations.users.delete(user.id) diff --git a/tests/integration/conversations/test_conversations.py b/tests/integration/conversations/test_conversations.py new file mode 100644 index 0000000..1ab6c0b --- /dev/null +++ b/tests/integration/conversations/test_conversations.py @@ -0,0 +1,21 @@ +import uuid + +from libzapi import Conversations + + +def test_create_get_list_conversation(conversations: Conversations): + ext_id = f"sdk-test-{uuid.uuid4().hex[:8]}" + user = conversations.users.create(external_id=ext_id) + try: + conv = conversations.conversations_.create(type="personal", participants=[{"userId": user.id}]) + assert conv.id is not None + + fetched = conversations.conversations_.get(conv.id) + assert fetched.id == conv.id + + convs = list(conversations.conversations_.list_by_user(user.id)) + assert isinstance(convs, list) + assert any(c.id == conv.id for c in convs) + finally: + # Deleting the user also removes their personal conversations + conversations.users.delete(user.id) diff --git a/tests/integration/conversations/test_devices.py b/tests/integration/conversations/test_devices.py new file mode 100644 index 0000000..8ed2624 --- /dev/null +++ b/tests/integration/conversations/test_devices.py @@ -0,0 +1,13 @@ +import uuid + +from libzapi import Conversations + + +def test_list_devices(conversations: Conversations): + ext_id = f"sdk-test-{uuid.uuid4().hex[:8]}" + user = conversations.users.create(external_id=ext_id) + try: + devices = list(conversations.devices.list_all(user.id)) + assert isinstance(devices, list) + finally: + conversations.users.delete(user.id) diff --git a/tests/integration/conversations/test_integrations.py b/tests/integration/conversations/test_integrations.py new file mode 100644 index 0000000..acee779 --- /dev/null +++ b/tests/integration/conversations/test_integrations.py @@ -0,0 +1,16 @@ +import pytest + +from libzapi import Conversations + + +def test_list_integrations(conversations: Conversations): + integrations = list(conversations.integrations.list_all()) + assert isinstance(integrations, list) + + +def test_list_and_get_integration(conversations: Conversations): + integrations = list(conversations.integrations.list_all()) + if not integrations: + pytest.skip("No integrations found") + integration = conversations.integrations.get(integrations[0].id) + assert integration.id == integrations[0].id diff --git a/tests/integration/conversations/test_messages.py b/tests/integration/conversations/test_messages.py new file mode 100644 index 0000000..b5e8928 --- /dev/null +++ b/tests/integration/conversations/test_messages.py @@ -0,0 +1,14 @@ +import uuid + +from libzapi import Conversations + + +def test_list_messages(conversations: Conversations): + ext_id = f"sdk-test-{uuid.uuid4().hex[:8]}" + user = conversations.users.create(external_id=ext_id) + try: + conv = conversations.conversations_.create(type="personal", participants=[{"userId": user.id}]) + messages = list(conversations.messages.list_all(conv.id)) + assert isinstance(messages, list) + finally: + conversations.users.delete(user.id) diff --git a/tests/integration/conversations/test_participants.py b/tests/integration/conversations/test_participants.py new file mode 100644 index 0000000..7f186a7 --- /dev/null +++ b/tests/integration/conversations/test_participants.py @@ -0,0 +1,15 @@ +import uuid + +from libzapi import Conversations + + +def test_list_participants(conversations: Conversations): + ext_id = f"sdk-test-{uuid.uuid4().hex[:8]}" + user = conversations.users.create(external_id=ext_id) + try: + conv = conversations.conversations_.create(type="personal", participants=[{"userId": user.id}]) + participants = list(conversations.participants.list_all(conv.id)) + assert isinstance(participants, list) + assert len(participants) >= 1 + finally: + conversations.users.delete(user.id) diff --git a/tests/integration/conversations/test_switchboards.py b/tests/integration/conversations/test_switchboards.py new file mode 100644 index 0000000..31c78b4 --- /dev/null +++ b/tests/integration/conversations/test_switchboards.py @@ -0,0 +1,6 @@ +from libzapi import Conversations + + +def test_list_switchboards(conversations: Conversations): + switchboards = list(conversations.switchboards.list_all()) + assert isinstance(switchboards, list) diff --git a/tests/integration/conversations/test_users.py b/tests/integration/conversations/test_users.py new file mode 100644 index 0000000..07a9168 --- /dev/null +++ b/tests/integration/conversations/test_users.py @@ -0,0 +1,14 @@ +import uuid + +from libzapi import Conversations + + +def test_create_get_delete_user(conversations: Conversations): + ext_id = f"sdk-test-{uuid.uuid4().hex[:8]}" + user = conversations.users.create(external_id=ext_id) + assert user.id is not None + try: + fetched = conversations.users.get(user.id) + assert fetched.id == user.id + finally: + conversations.users.delete(user.id) diff --git a/tests/integration/conversations/test_webhooks.py b/tests/integration/conversations/test_webhooks.py new file mode 100644 index 0000000..5f8a205 --- /dev/null +++ b/tests/integration/conversations/test_webhooks.py @@ -0,0 +1,14 @@ +import pytest + +from libzapi import Conversations + + +def test_list_webhooks(conversations: Conversations): + integrations = list(conversations.integrations.list_all()) + if not integrations: + pytest.skip("No integrations found") + try: + webhooks = list(conversations.webhooks.list_all(integrations[0].id)) + assert isinstance(webhooks, list) + except Exception: + pytest.skip("Insufficient permissions to list webhooks for this integration") diff --git a/tests/unit/conversations/__init__.py b/tests/unit/conversations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/conversations/test_activity.py b/tests/unit/conversations/test_activity.py new file mode 100644 index 0000000..d06206f --- /dev/null +++ b/tests/unit/conversations/test_activity.py @@ -0,0 +1,46 @@ +import pytest + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.infrastructure.api_clients.conversations.activity_api_client import ActivityApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.activity_api_client" + + +# ── post ──────────────────────────────────────────────────────────────── + + +def test_post_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {} + + client = ActivityApiClient(https) + client.post("app-1", "conv-1", {"type": "business", "userId": "u-1"}, "typing:start") + + https.post.assert_called_with( + "/v2/apps/app-1/conversations/conv-1/activity", + {"author": {"type": "business", "userId": "u-1"}, "type": "typing:start"}, + ) + + +# ── error propagation: post ──────────────────────────────────────────── + + +@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_post_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = ActivityApiClient(https) + + with pytest.raises(error_cls): + client.post("app-1", "conv-1", {"type": "business"}, "typing:start") diff --git a/tests/unit/conversations/test_app.py b/tests/unit/conversations/test_app.py new file mode 100644 index 0000000..352ddb8 --- /dev/null +++ b/tests/unit/conversations/test_app.py @@ -0,0 +1,186 @@ +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.conversations.app import App +from libzapi.infrastructure.api_clients.conversations.app_api_client import AppApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.app_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(App, id=just("abc")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "app:abc" + + +# ── list_all ──────────────────────────────────────────────────────────── + + +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/sc" + https.get_raw.return_value = {"apps": [{}]} + + client = AppApiClient(https) + list(client.list_all()) + + https.get_raw.assert_called_with("https://example.zendesk.com/sc/v2/apps?page[size]=100") + + +# ── get ───────────────────────────────────────────────────────────────── + + +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/sc" + https.get.return_value = {"app": {}} + + client = AppApiClient(https) + client.get("app-1") + + https.get.assert_called_with("/v2/apps/app-1") + + +# ── create ────────────────────────────────────────────────────────────── + + +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={"displayName": "My App"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {"app": {}} + + client = AppApiClient(https) + client.create(mocker.Mock()) + + https.post.assert_called_with("/v2/apps", {"displayName": "My App"}) + + +# ── update ────────────────────────────────────────────────────────────── + + +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={"displayName": "Updated"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.patch.return_value = {"app": {}} + + client = AppApiClient(https) + client.update("app-1", mocker.Mock()) + + https.patch.assert_called_with("/v2/apps/app-1", {"displayName": "Updated"}) + + +# ── delete ────────────────────────────────────────────────────────────── + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = AppApiClient(https) + client.delete("app-1") + + https.delete.assert_called_with("/v2/apps/app-1") + + +# ── error propagation: list_all (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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.side_effect = error_cls("error") + + client = AppApiClient(https) + + with pytest.raises(error_cls): + list(client.list_all()) + + +# ── error propagation: 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_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get.side_effect = error_cls("error") + + client = AppApiClient(https) + + with pytest.raises(error_cls): + client.get("app-1") + + +# ── error propagation: create (post) ────────────────────────────────── + + +@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={}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = AppApiClient(https) + + with pytest.raises(error_cls): + client.create(mocker.Mock()) + + +# ── error propagation: delete ────────────────────────────────────────── + + +@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/sc" + https.delete.side_effect = error_cls("error") + + client = AppApiClient(https) + + with pytest.raises(error_cls): + client.delete("app-1") diff --git a/tests/unit/conversations/test_app_key.py b/tests/unit/conversations/test_app_key.py new file mode 100644 index 0000000..2968721 --- /dev/null +++ b/tests/unit/conversations/test_app_key.py @@ -0,0 +1,168 @@ +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.conversations.app_key import AppKey +from libzapi.infrastructure.api_clients.conversations.app_key_api_client import AppKeyApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.app_key_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(AppKey, id=just("key-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "app_key:key-1" + + +# ── list_all ──────────────────────────────────────────────────────────── + + +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/sc" + https.get_raw.return_value = {"keys": [{}]} + + client = AppKeyApiClient(https) + list(client.list_all("app-1")) + + https.get_raw.assert_called_with("https://example.zendesk.com/sc/v2/apps/app-1/keys?page[size]=100") + + +# ── get ───────────────────────────────────────────────────────────────── + + +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/sc" + https.get.return_value = {"key": {}} + + client = AppKeyApiClient(https) + client.get("app-1", "key-1") + + https.get.assert_called_with("/v2/apps/app-1/keys/key-1") + + +# ── create ────────────────────────────────────────────────────────────── + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {"key": {}} + + client = AppKeyApiClient(https) + client.create("app-1", "My Key") + + https.post.assert_called_with("/v2/apps/app-1/keys", {"displayName": "My Key"}) + + +# ── delete ────────────────────────────────────────────────────────────── + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = AppKeyApiClient(https) + client.delete("app-1", "key-1") + + https.delete.assert_called_with("/v2/apps/app-1/keys/key-1") + + +# ── error propagation: list_all (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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.side_effect = error_cls("error") + + client = AppKeyApiClient(https) + + with pytest.raises(error_cls): + list(client.list_all("app-1")) + + +# ── error propagation: 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_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get.side_effect = error_cls("error") + + client = AppKeyApiClient(https) + + with pytest.raises(error_cls): + client.get("app-1", "key-1") + + +# ── error propagation: create (post) ────────────────────────────────── + + +@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): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = AppKeyApiClient(https) + + with pytest.raises(error_cls): + client.create("app-1", "My Key") + + +# ── error propagation: delete ────────────────────────────────────────── + + +@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/sc" + https.delete.side_effect = error_cls("error") + + client = AppKeyApiClient(https) + + with pytest.raises(error_cls): + client.delete("app-1", "key-1") diff --git a/tests/unit/conversations/test_attachment.py b/tests/unit/conversations/test_attachment.py new file mode 100644 index 0000000..2660a43 --- /dev/null +++ b/tests/unit/conversations/test_attachment.py @@ -0,0 +1,100 @@ +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.conversations.attachment import Attachment +from libzapi.infrastructure.api_clients.conversations.attachment_api_client import AttachmentApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.attachment_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(Attachment, mediaUrl=just("https://cdn.example.com/file.png")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "sunco_attachment:https://cdn.example.com/file.png" + + +# ── upload (multipart) ───────────────────────────────────────────────── + + +def test_upload_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post_multipart.return_value = {"attachment": {}} + + client = AttachmentApiClient(https) + file_tuple = ("image.png", b"content", "image/png") + client.upload("app-1", file_tuple) + + https.post_multipart.assert_called_with( + "/v2/apps/app-1/attachments", + files={"source": file_tuple}, + ) + + +# ── delete (POST to /remove) ────────────────────────────────────────── + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {} + + client = AttachmentApiClient(https) + client.delete("app-1", "https://cdn.example.com/file.png") + + https.post.assert_called_with( + "/v2/apps/app-1/attachments/remove", + {"mediaUrl": "https://cdn.example.com/file.png"}, + ) + + +# ── error propagation: upload (post_multipart) ──────────────────────── + + +@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_upload_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post_multipart.side_effect = error_cls("error") + + client = AttachmentApiClient(https) + + with pytest.raises(error_cls): + client.upload("app-1", ("image.png", b"content", "image/png")) + + +# ── error propagation: delete (post) ────────────────────────────────── + + +@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/sc" + https.post.side_effect = error_cls("error") + + client = AttachmentApiClient(https) + + with pytest.raises(error_cls): + client.delete("app-1", "https://cdn.example.com/file.png") diff --git a/tests/unit/conversations/test_client.py b/tests/unit/conversations/test_client.py new file mode 100644 index 0000000..bc51041 --- /dev/null +++ b/tests/unit/conversations/test_client.py @@ -0,0 +1,130 @@ +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.conversations.client import Client +from libzapi.infrastructure.api_clients.conversations.client_api_client import ClientApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.client_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(Client, id=just("cli-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "sunco_client:cli-1" + + +# ── list_all ──────────────────────────────────────────────────────────── + + +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/sc" + https.get_raw.return_value = {"clients": [{}]} + + client = ClientApiClient(https) + list(client.list_all("app-1", "usr-1")) + + https.get_raw.assert_called_with("https://example.zendesk.com/sc/v2/apps/app-1/users/usr-1/clients?page[size]=100") + + +# ── create ────────────────────────────────────────────────────────────── + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {"client": {}} + + client = ClientApiClient(https) + client.create("app-1", "usr-1", {"platform": "web"}) + + https.post.assert_called_with("/v2/apps/app-1/users/usr-1/clients", {"platform": "web"}) + + +# ── remove ────────────────────────────────────────────────────────────── + + +def test_remove_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = ClientApiClient(https) + client.remove("app-1", "usr-1", "cli-1") + + https.delete.assert_called_with("/v2/apps/app-1/users/usr-1/clients/cli-1") + + +# ── error propagation: list_all (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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.side_effect = error_cls("error") + + client = ClientApiClient(https) + + with pytest.raises(error_cls): + list(client.list_all("app-1", "usr-1")) + + +# ── error propagation: create (post) ────────────────────────────────── + + +@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): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = ClientApiClient(https) + + with pytest.raises(error_cls): + client.create("app-1", "usr-1", {"platform": "web"}) + + +# ── error propagation: remove (delete) ──────────────────────────────── + + +@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_remove_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.delete.side_effect = error_cls("error") + + client = ClientApiClient(https) + + with pytest.raises(error_cls): + client.remove("app-1", "usr-1", "cli-1") diff --git a/tests/unit/conversations/test_conversation.py b/tests/unit/conversations/test_conversation.py new file mode 100644 index 0000000..2101d64 --- /dev/null +++ b/tests/unit/conversations/test_conversation.py @@ -0,0 +1,188 @@ +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.conversations.conversation import Conversation +from libzapi.infrastructure.api_clients.conversations.conversation_api_client import ConversationApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.conversation_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(Conversation, id=just("conv-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "conversation:conv-1" + + +# ── list_by_user ──────────────────────────────────────────────────────── + + +def test_list_by_user_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.return_value = {"conversations": [{}]} + + client = ConversationApiClient(https) + list(client.list_by_user("app-1", "usr-1")) + + https.get_raw.assert_called_with( + "https://example.zendesk.com/sc/v2/apps/app-1/conversations?filter[userId]=usr-1&page[size]=100" + ) + + +# ── get ───────────────────────────────────────────────────────────────── + + +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/sc" + https.get.return_value = {"conversation": {}} + + client = ConversationApiClient(https) + client.get("app-1", "conv-1") + + https.get.assert_called_with("/v2/apps/app-1/conversations/conv-1") + + +# ── create ────────────────────────────────────────────────────────────── + + +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={"type": "personal"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {"conversation": {}} + + client = ConversationApiClient(https) + client.create("app-1", mocker.Mock()) + + https.post.assert_called_with("/v2/apps/app-1/conversations", {"type": "personal"}) + + +# ── update ────────────────────────────────────────────────────────────── + + +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={"displayName": "Updated"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.patch.return_value = {"conversation": {}} + + client = ConversationApiClient(https) + client.update("app-1", "conv-1", mocker.Mock()) + + https.patch.assert_called_with("/v2/apps/app-1/conversations/conv-1", {"displayName": "Updated"}) + + +# ── delete ────────────────────────────────────────────────────────────── + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = ConversationApiClient(https) + client.delete("app-1", "conv-1") + + https.delete.assert_called_with("/v2/apps/app-1/conversations/conv-1") + + +# ── error propagation: list_all (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_list_by_user_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.side_effect = error_cls("error") + + client = ConversationApiClient(https) + + with pytest.raises(error_cls): + list(client.list_by_user("app-1", "usr-1")) + + +# ── error propagation: 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_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get.side_effect = error_cls("error") + + client = ConversationApiClient(https) + + with pytest.raises(error_cls): + client.get("app-1", "conv-1") + + +# ── error propagation: create (post) ────────────────────────────────── + + +@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={}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = ConversationApiClient(https) + + with pytest.raises(error_cls): + client.create("app-1", mocker.Mock()) + + +# ── error propagation: delete ────────────────────────────────────────── + + +@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/sc" + https.delete.side_effect = error_cls("error") + + client = ConversationApiClient(https) + + with pytest.raises(error_cls): + client.delete("app-1", "conv-1") diff --git a/tests/unit/conversations/test_device.py b/tests/unit/conversations/test_device.py new file mode 100644 index 0000000..d100983 --- /dev/null +++ b/tests/unit/conversations/test_device.py @@ -0,0 +1,94 @@ +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.conversations.device import Device +from libzapi.infrastructure.api_clients.conversations.device_api_client import DeviceApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.device_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(Device, id=just("dev-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "device:dev-1" + + +# ── list_all ──────────────────────────────────────────────────────────── + + +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/sc" + https.get.return_value = {"devices": [{}]} + + client = DeviceApiClient(https) + list(client.list_all("app-1", "usr-1")) + + https.get.assert_called_with("/v2/apps/app-1/users/usr-1/devices") + + +# ── get ───────────────────────────────────────────────────────────────── + + +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/sc" + https.get.return_value = {"device": {}} + + client = DeviceApiClient(https) + client.get("app-1", "usr-1", "dev-1") + + https.get.assert_called_with("/v2/apps/app-1/users/usr-1/devices/dev-1") + + +# ── error propagation: list_all (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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get.side_effect = error_cls("error") + + client = DeviceApiClient(https) + + with pytest.raises(error_cls): + list(client.list_all("app-1", "usr-1")) + + +# ── error propagation: 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_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get.side_effect = error_cls("error") + + client = DeviceApiClient(https) + + with pytest.raises(error_cls): + client.get("app-1", "usr-1", "dev-1") diff --git a/tests/unit/conversations/test_integration.py b/tests/unit/conversations/test_integration.py new file mode 100644 index 0000000..cd7ab2b --- /dev/null +++ b/tests/unit/conversations/test_integration.py @@ -0,0 +1,186 @@ +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.conversations.integration import Integration +from libzapi.infrastructure.api_clients.conversations.integration_api_client import IntegrationApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.integration_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(Integration, id=just("int-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "sunco_integration:int-1" + + +# ── list_all ──────────────────────────────────────────────────────────── + + +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/sc" + https.get_raw.return_value = {"integrations": [{}]} + + client = IntegrationApiClient(https) + list(client.list_all("app-1")) + + https.get_raw.assert_called_with("https://example.zendesk.com/sc/v2/apps/app-1/integrations?page[size]=100") + + +# ── get ───────────────────────────────────────────────────────────────── + + +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/sc" + https.get.return_value = {"integration": {}} + + client = IntegrationApiClient(https) + client.get("app-1", "int-1") + + https.get.assert_called_with("/v2/apps/app-1/integrations/int-1") + + +# ── create ────────────────────────────────────────────────────────────── + + +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={"type": "web"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {"integration": {}} + + client = IntegrationApiClient(https) + client.create("app-1", mocker.Mock()) + + https.post.assert_called_with("/v2/apps/app-1/integrations", {"type": "web"}) + + +# ── update ────────────────────────────────────────────────────────────── + + +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={"displayName": "Updated"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.patch.return_value = {"integration": {}} + + client = IntegrationApiClient(https) + client.update("app-1", "int-1", mocker.Mock()) + + https.patch.assert_called_with("/v2/apps/app-1/integrations/int-1", {"displayName": "Updated"}) + + +# ── delete ────────────────────────────────────────────────────────────── + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = IntegrationApiClient(https) + client.delete("app-1", "int-1") + + https.delete.assert_called_with("/v2/apps/app-1/integrations/int-1") + + +# ── error propagation: list_all (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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.side_effect = error_cls("error") + + client = IntegrationApiClient(https) + + with pytest.raises(error_cls): + list(client.list_all("app-1")) + + +# ── error propagation: 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_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get.side_effect = error_cls("error") + + client = IntegrationApiClient(https) + + with pytest.raises(error_cls): + client.get("app-1", "int-1") + + +# ── error propagation: create (post) ────────────────────────────────── + + +@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={}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = IntegrationApiClient(https) + + with pytest.raises(error_cls): + client.create("app-1", mocker.Mock()) + + +# ── error propagation: delete ────────────────────────────────────────── + + +@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/sc" + https.delete.side_effect = error_cls("error") + + client = IntegrationApiClient(https) + + with pytest.raises(error_cls): + client.delete("app-1", "int-1") diff --git a/tests/unit/conversations/test_integration_api_key.py b/tests/unit/conversations/test_integration_api_key.py new file mode 100644 index 0000000..bf26f49 --- /dev/null +++ b/tests/unit/conversations/test_integration_api_key.py @@ -0,0 +1,173 @@ +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.conversations.integration_api_key import IntegrationApiKey +from libzapi.infrastructure.api_clients.conversations.integration_api_key_api_client import IntegrationApiKeyApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.integration_api_key_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(IntegrationApiKey, id=just("iak-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "integration_api_key:iak-1" + + +# ── list_all ──────────────────────────────────────────────────────────── + + +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/sc" + https.get_raw.return_value = {"keys": [{}]} + + client = IntegrationApiKeyApiClient(https) + list(client.list_all("app-1", "int-1")) + + https.get_raw.assert_called_with( + "https://example.zendesk.com/sc/v2/apps/app-1/integrations/int-1/keys?page[size]=100" + ) + + +# ── get ───────────────────────────────────────────────────────────────── + + +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/sc" + https.get.return_value = {"key": {}} + + client = IntegrationApiKeyApiClient(https) + client.get("app-1", "int-1", "key-1") + + https.get.assert_called_with("/v2/apps/app-1/integrations/int-1/keys/key-1") + + +# ── create ────────────────────────────────────────────────────────────── + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {"key": {}} + + client = IntegrationApiKeyApiClient(https) + client.create("app-1", "int-1", "My Key") + + https.post.assert_called_with( + "/v2/apps/app-1/integrations/int-1/keys", + {"displayName": "My Key"}, + ) + + +# ── delete ────────────────────────────────────────────────────────────── + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = IntegrationApiKeyApiClient(https) + client.delete("app-1", "int-1", "key-1") + + https.delete.assert_called_with("/v2/apps/app-1/integrations/int-1/keys/key-1") + + +# ── error propagation: list_all (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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.side_effect = error_cls("error") + + client = IntegrationApiKeyApiClient(https) + + with pytest.raises(error_cls): + list(client.list_all("app-1", "int-1")) + + +# ── error propagation: 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_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get.side_effect = error_cls("error") + + client = IntegrationApiKeyApiClient(https) + + with pytest.raises(error_cls): + client.get("app-1", "int-1", "key-1") + + +# ── error propagation: create (post) ────────────────────────────────── + + +@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): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = IntegrationApiKeyApiClient(https) + + with pytest.raises(error_cls): + client.create("app-1", "int-1", "My Key") + + +# ── error propagation: delete ────────────────────────────────────────── + + +@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/sc" + https.delete.side_effect = error_cls("error") + + client = IntegrationApiKeyApiClient(https) + + with pytest.raises(error_cls): + client.delete("app-1", "int-1", "key-1") diff --git a/tests/unit/conversations/test_message.py b/tests/unit/conversations/test_message.py new file mode 100644 index 0000000..3caec01 --- /dev/null +++ b/tests/unit/conversations/test_message.py @@ -0,0 +1,150 @@ +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.conversations.message import Message +from libzapi.infrastructure.api_clients.conversations.message_api_client import MessageApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.message_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(Message, id=just("msg-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "message:msg-1" + + +# ── list_all ──────────────────────────────────────────────────────────── + + +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/sc" + https.get_raw.return_value = {"messages": [{}]} + + client = MessageApiClient(https) + list(client.list_all("app-1", "conv-1")) + + https.get_raw.assert_called_with( + "https://example.zendesk.com/sc/v2/apps/app-1/conversations/conv-1/messages?page[size]=100" + ) + + +# ── post ──────────────────────────────────────────────────────────────── + + +def test_post_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"author": {}, "content": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {"message": {}} + + client = MessageApiClient(https) + client.post("app-1", "conv-1", mocker.Mock()) + + https.post.assert_called_with( + "/v2/apps/app-1/conversations/conv-1/messages", + {"author": {}, "content": {}}, + ) + + +# ── delete_all ────────────────────────────────────────────────────────── + + +def test_delete_all_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = MessageApiClient(https) + client.delete_all("app-1", "conv-1") + + https.delete.assert_called_with("/v2/apps/app-1/conversations/conv-1/messages") + + +# ── delete ────────────────────────────────────────────────────────────── + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = MessageApiClient(https) + client.delete("app-1", "conv-1", "msg-1") + + https.delete.assert_called_with("/v2/apps/app-1/conversations/conv-1/messages/msg-1") + + +# ── error propagation: list_all (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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.side_effect = error_cls("error") + + client = MessageApiClient(https) + + with pytest.raises(error_cls): + list(client.list_all("app-1", "conv-1")) + + +# ── error propagation: post ──────────────────────────────────────────── + + +@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_post_raises_on_http_error(error_cls, mocker): + mocker.patch(f"{MODULE}.to_payload_create", return_value={}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = MessageApiClient(https) + + with pytest.raises(error_cls): + client.post("app-1", "conv-1", mocker.Mock()) + + +# ── error propagation: delete ────────────────────────────────────────── + + +@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/sc" + https.delete.side_effect = error_cls("error") + + client = MessageApiClient(https) + + with pytest.raises(error_cls): + client.delete("app-1", "conv-1", "msg-1") diff --git a/tests/unit/conversations/test_participant.py b/tests/unit/conversations/test_participant.py new file mode 100644 index 0000000..878d396 --- /dev/null +++ b/tests/unit/conversations/test_participant.py @@ -0,0 +1,112 @@ +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.conversations.participant import Participant +from libzapi.infrastructure.api_clients.conversations.participant_api_client import ParticipantApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.participant_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(Participant, id=just("part-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "participant:part-1" + + +# ── list_all ──────────────────────────────────────────────────────────── + + +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/sc" + https.get.return_value = {"participants": [{}]} + + client = ParticipantApiClient(https) + list(client.list_all("app-1", "conv-1")) + + https.get.assert_called_with("/v2/apps/app-1/conversations/conv-1/participants") + + +# ── join ──────────────────────────────────────────────────────────────── + + +def test_join_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {} + + client = ParticipantApiClient(https) + client.join("app-1", "conv-1", "user-1") + + https.post.assert_called_with( + "/v2/apps/app-1/conversations/conv-1/participants/join", + {"userId": "user-1"}, + ) + + +# ── leave ─────────────────────────────────────────────────────────────── + + +def test_leave_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = ParticipantApiClient(https) + client.leave("app-1", "conv-1", "user-1") + + https.post.assert_called_with( + "/v2/apps/app-1/conversations/conv-1/participants/leave", + {"userId": "user-1"}, + ) + + +# ── error propagation: list_all (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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get.side_effect = error_cls("error") + + client = ParticipantApiClient(https) + + with pytest.raises(error_cls): + list(client.list_all("app-1", "conv-1")) + + +# ── error propagation: join (post) ──────────────────────────────────── + + +@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_join_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = ParticipantApiClient(https) + + with pytest.raises(error_cls): + client.join("app-1", "conv-1", "user-1") diff --git a/tests/unit/conversations/test_switchboard.py b/tests/unit/conversations/test_switchboard.py new file mode 100644 index 0000000..130c026 --- /dev/null +++ b/tests/unit/conversations/test_switchboard.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.conversations.switchboard import Switchboard +from libzapi.infrastructure.api_clients.conversations.switchboard_api_client import SwitchboardApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.switchboard_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(Switchboard, id=just("sb-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "switchboard:sb-1" + + +# ── list_all ──────────────────────────────────────────────────────────── + + +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/sc" + https.get.return_value = {"switchboards": [{}]} + + client = SwitchboardApiClient(https) + list(client.list_all("app-1")) + + https.get.assert_called_with("/v2/apps/app-1/switchboards") + + +# ── create ────────────────────────────────────────────────────────────── + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create_switchboard", return_value={"enabled": True}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {"switchboard": {}} + + client = SwitchboardApiClient(https) + client.create("app-1", mocker.Mock()) + + https.post.assert_called_with("/v2/apps/app-1/switchboards", {"enabled": True}) + + +# ── update ────────────────────────────────────────────────────────────── + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update_switchboard", return_value={"enabled": False}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.patch.return_value = {"switchboard": {}} + + client = SwitchboardApiClient(https) + client.update("app-1", "sb-1", mocker.Mock()) + + https.patch.assert_called_with("/v2/apps/app-1/switchboards/sb-1", {"enabled": False}) + + +# ── delete ────────────────────────────────────────────────────────────── + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = SwitchboardApiClient(https) + client.delete("app-1", "sb-1") + + https.delete.assert_called_with("/v2/apps/app-1/switchboards/sb-1") + + +# ── error propagation: list_all (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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get.side_effect = error_cls("error") + + client = SwitchboardApiClient(https) + + with pytest.raises(error_cls): + list(client.list_all("app-1")) + + +# ── error propagation: create (post) ────────────────────────────────── + + +@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_switchboard", return_value={}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = SwitchboardApiClient(https) + + with pytest.raises(error_cls): + client.create("app-1", mocker.Mock()) + + +# ── error propagation: delete ────────────────────────────────────────── + + +@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/sc" + https.delete.side_effect = error_cls("error") + + client = SwitchboardApiClient(https) + + with pytest.raises(error_cls): + client.delete("app-1", "sb-1") diff --git a/tests/unit/conversations/test_switchboard_action.py b/tests/unit/conversations/test_switchboard_action.py new file mode 100644 index 0000000..e58d35c --- /dev/null +++ b/tests/unit/conversations/test_switchboard_action.py @@ -0,0 +1,127 @@ +import pytest + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.infrastructure.api_clients.conversations.switchboard_action_api_client import SwitchboardActionApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.switchboard_action_api_client" + + +# ── accept_control ────────────────────────────────────────────────────── + + +def test_accept_control_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {} + + client = SwitchboardActionApiClient(https) + client.accept_control("app-1", "conv-1") + + https.post.assert_called_with( + "/v2/apps/app-1/conversations/conv-1/acceptControl", + {}, + ) + + +def test_accept_control_with_metadata(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {} + + client = SwitchboardActionApiClient(https) + client.accept_control("app-1", "conv-1", metadata={"key": "val"}) + + https.post.assert_called_with( + "/v2/apps/app-1/conversations/conv-1/acceptControl", + {"metadata": {"key": "val"}}, + ) + + +# ── offer_control ────────────────────────────────────────────────────── + + +def test_offer_control_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_payload_offer_control", return_value={"integrationId": "int-1"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {} + + client = SwitchboardActionApiClient(https) + client.offer_control("app-1", "conv-1", mocker.Mock()) + + https.post.assert_called_with( + "/v2/apps/app-1/conversations/conv-1/offerControl", + {"integrationId": "int-1"}, + ) + + +# ── pass_control ─────────────────────────────────────────────────────── + + +def test_pass_control_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_payload_pass_control", return_value={"integrationId": "int-2"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {} + + client = SwitchboardActionApiClient(https) + client.pass_control("app-1", "conv-1", mocker.Mock()) + + https.post.assert_called_with( + "/v2/apps/app-1/conversations/conv-1/passControl", + {"integrationId": "int-2"}, + ) + + +# ── release_control ──────────────────────────────────────────────────── + + +def test_release_control_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {} + + client = SwitchboardActionApiClient(https) + client.release_control("app-1", "conv-1") + + https.post.assert_called_with( + "/v2/apps/app-1/conversations/conv-1/releaseControl", + {}, + ) + + +def test_release_control_with_metadata(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {} + + client = SwitchboardActionApiClient(https) + client.release_control("app-1", "conv-1", metadata={"key": "val"}) + + https.post.assert_called_with( + "/v2/apps/app-1/conversations/conv-1/releaseControl", + {"metadata": {"key": "val"}}, + ) + + +# ── error propagation: accept_control (post) ────────────────────────── + + +@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_accept_control_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = SwitchboardActionApiClient(https) + + with pytest.raises(error_cls): + client.accept_control("app-1", "conv-1") diff --git a/tests/unit/conversations/test_switchboard_integration.py b/tests/unit/conversations/test_switchboard_integration.py new file mode 100644 index 0000000..a9185bc --- /dev/null +++ b/tests/unit/conversations/test_switchboard_integration.py @@ -0,0 +1,158 @@ +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.conversations.switchboard import SwitchboardIntegration +from libzapi.infrastructure.api_clients.conversations.switchboard_integration_api_client import ( + SwitchboardIntegrationApiClient, +) + +MODULE = "libzapi.infrastructure.api_clients.conversations.switchboard_integration_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(SwitchboardIntegration, id=just("sbi-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "switchboard_integration:sbi-1" + + +# ── list_all ──────────────────────────────────────────────────────────── + + +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/sc" + https.get_raw.return_value = {"switchboardIntegrations": [{}]} + + client = SwitchboardIntegrationApiClient(https) + list(client.list_all("app-1", "sb-1")) + + https.get_raw.assert_called_with( + "https://example.zendesk.com/sc/v2/apps/app-1/switchboards/sb-1/switchboardIntegrations?page[size]=100" + ) + + +# ── create ────────────────────────────────────────────────────────────── + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create_switchboard_integration", return_value={"name": "bot"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {"switchboardIntegration": {}} + + client = SwitchboardIntegrationApiClient(https) + client.create("app-1", "sb-1", mocker.Mock()) + + https.post.assert_called_with( + "/v2/apps/app-1/switchboards/sb-1/switchboardIntegrations", + {"name": "bot"}, + ) + + +# ── update ────────────────────────────────────────────────────────────── + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update_switchboard_integration", return_value={"name": "updated"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.patch.return_value = {"switchboardIntegration": {}} + + client = SwitchboardIntegrationApiClient(https) + client.update("app-1", "sb-1", "sbi-1", mocker.Mock()) + + https.patch.assert_called_with( + "/v2/apps/app-1/switchboards/sb-1/switchboardIntegrations/sbi-1", + {"name": "updated"}, + ) + + +# ── delete ────────────────────────────────────────────────────────────── + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = SwitchboardIntegrationApiClient(https) + client.delete("app-1", "sb-1", "sbi-1") + + https.delete.assert_called_with("/v2/apps/app-1/switchboards/sb-1/switchboardIntegrations/sbi-1") + + +# ── error propagation: list_all (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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.side_effect = error_cls("error") + + client = SwitchboardIntegrationApiClient(https) + + with pytest.raises(error_cls): + list(client.list_all("app-1", "sb-1")) + + +# ── error propagation: create (post) ────────────────────────────────── + + +@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_switchboard_integration", return_value={}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = SwitchboardIntegrationApiClient(https) + + with pytest.raises(error_cls): + client.create("app-1", "sb-1", mocker.Mock()) + + +# ── error propagation: delete ────────────────────────────────────────── + + +@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/sc" + https.delete.side_effect = error_cls("error") + + client = SwitchboardIntegrationApiClient(https) + + with pytest.raises(error_cls): + client.delete("app-1", "sb-1", "sbi-1") diff --git a/tests/unit/conversations/test_user.py b/tests/unit/conversations/test_user.py new file mode 100644 index 0000000..22c1c3e --- /dev/null +++ b/tests/unit/conversations/test_user.py @@ -0,0 +1,215 @@ +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.conversations.user import User +from libzapi.infrastructure.api_clients.conversations.user_api_client import UserApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.user_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(User, id=just("usr-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "sunco_user:usr-1" + + +# ── list_by_email ────────────────────────────────────────────────────── + + +def test_list_by_email_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.return_value = {"users": [{}]} + + client = UserApiClient(https) + list(client.list_by_email("app-1", "test@example.com")) + + https.get_raw.assert_called_with( + "https://example.zendesk.com/sc/v2/apps/app-1/users?filter[identities.email]=test@example.com&page[size]=100" + ) + + +# ── get ───────────────────────────────────────────────────────────────── + + +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/sc" + https.get.return_value = {"user": {}} + + client = UserApiClient(https) + client.get("app-1", "usr-1") + + https.get.assert_called_with("/v2/apps/app-1/users/usr-1") + + +# ── create ────────────────────────────────────────────────────────────── + + +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={"externalId": "ext-1"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {"user": {}} + + client = UserApiClient(https) + client.create("app-1", mocker.Mock()) + + https.post.assert_called_with("/v2/apps/app-1/users", {"externalId": "ext-1"}) + + +# ── update ────────────────────────────────────────────────────────────── + + +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={"profile": {"givenName": "John"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.patch.return_value = {"user": {}} + + client = UserApiClient(https) + client.update("app-1", "usr-1", mocker.Mock()) + + https.patch.assert_called_with("/v2/apps/app-1/users/usr-1", {"profile": {"givenName": "John"}}) + + +# ── delete ────────────────────────────────────────────────────────────── + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = UserApiClient(https) + client.delete("app-1", "usr-1") + + https.delete.assert_called_with("/v2/apps/app-1/users/usr-1") + + +# ── delete_personal_info ──────────────────────────────────────────────── + + +def test_delete_personal_info_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = UserApiClient(https) + client.delete_personal_info("app-1", "usr-1") + + https.delete.assert_called_with("/v2/apps/app-1/users/usr-1/personalinformation") + + +# ── sync ──────────────────────────────────────────────────────────────── + + +def test_sync_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {} + + client = UserApiClient(https) + client.sync("app-1", "zd-123") + + https.post.assert_called_with("/v2/apps/app-1/users/zd-123/sync", {}) + + +# ── error propagation: list_all (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_list_by_email_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.side_effect = error_cls("error") + + client = UserApiClient(https) + + with pytest.raises(error_cls): + list(client.list_by_email("app-1", "test@example.com")) + + +# ── error propagation: 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_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get.side_effect = error_cls("error") + + client = UserApiClient(https) + + with pytest.raises(error_cls): + client.get("app-1", "usr-1") + + +# ── error propagation: create (post) ────────────────────────────────── + + +@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={}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = UserApiClient(https) + + with pytest.raises(error_cls): + client.create("app-1", mocker.Mock()) + + +# ── error propagation: delete ────────────────────────────────────────── + + +@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/sc" + https.delete.side_effect = error_cls("error") + + client = UserApiClient(https) + + with pytest.raises(error_cls): + client.delete("app-1", "usr-1") diff --git a/tests/unit/conversations/test_webhook.py b/tests/unit/conversations/test_webhook.py new file mode 100644 index 0000000..7c0724f --- /dev/null +++ b/tests/unit/conversations/test_webhook.py @@ -0,0 +1,194 @@ +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.conversations.webhook import Webhook +from libzapi.infrastructure.api_clients.conversations.webhook_api_client import WebhookApiClient + +MODULE = "libzapi.infrastructure.api_clients.conversations.webhook_api_client" + +# ── Hypothesis ────────────────────────────────────────────────────────── + +strategy = builds(Webhook, id=just("wh-1")) + + +@given(strategy) +def test_logical_key(model): + assert model.logical_key.as_str() == "sunco_webhook:wh-1" + + +# ── list_all ──────────────────────────────────────────────────────────── + + +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/sc" + https.get_raw.return_value = {"webhooks": [{}]} + + client = WebhookApiClient(https) + list(client.list_all("app-1", "int-1")) + + https.get_raw.assert_called_with( + "https://example.zendesk.com/sc/v2/apps/app-1/integrations/int-1/webhooks?page[size]=100" + ) + + +# ── get ───────────────────────────────────────────────────────────────── + + +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/sc" + https.get.return_value = {"webhook": {}} + + client = WebhookApiClient(https) + client.get("app-1", "int-1", "wh-1") + + https.get.assert_called_with("/v2/apps/app-1/integrations/int-1/webhooks/wh-1") + + +# ── create ────────────────────────────────────────────────────────────── + + +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={"target": "https://example.com"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.return_value = {"webhook": {}} + + client = WebhookApiClient(https) + client.create("app-1", "int-1", mocker.Mock()) + + https.post.assert_called_with( + "/v2/apps/app-1/integrations/int-1/webhooks", + {"target": "https://example.com"}, + ) + + +# ── update ────────────────────────────────────────────────────────────── + + +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={"target": "https://updated.com"}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.patch.return_value = {"webhook": {}} + + client = WebhookApiClient(https) + client.update("app-1", "int-1", "wh-1", mocker.Mock()) + + https.patch.assert_called_with( + "/v2/apps/app-1/integrations/int-1/webhooks/wh-1", + {"target": "https://updated.com"}, + ) + + +# ── delete ────────────────────────────────────────────────────────────── + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + + client = WebhookApiClient(https) + client.delete("app-1", "int-1", "wh-1") + + https.delete.assert_called_with("/v2/apps/app-1/integrations/int-1/webhooks/wh-1") + + +# ── error propagation: list_all (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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get_raw.side_effect = error_cls("error") + + client = WebhookApiClient(https) + + with pytest.raises(error_cls): + list(client.list_all("app-1", "int-1")) + + +# ── error propagation: 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_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.get.side_effect = error_cls("error") + + client = WebhookApiClient(https) + + with pytest.raises(error_cls): + client.get("app-1", "int-1", "wh-1") + + +# ── error propagation: create (post) ────────────────────────────────── + + +@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={}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com/sc" + https.post.side_effect = error_cls("error") + + client = WebhookApiClient(https) + + with pytest.raises(error_cls): + client.create("app-1", "int-1", mocker.Mock()) + + +# ── error propagation: delete ────────────────────────────────────────── + + +@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/sc" + https.delete.side_effect = error_cls("error") + + client = WebhookApiClient(https) + + with pytest.raises(error_cls): + client.delete("app-1", "int-1", "wh-1") diff --git a/tests/unit/infrastructure/http/test_client.py b/tests/unit/infrastructure/http/test_client.py index c5e67e7..826cb3e 100644 --- a/tests/unit/infrastructure/http/test_client.py +++ b/tests/unit/infrastructure/http/test_client.py @@ -72,7 +72,9 @@ def test_get_returns_json(mocker): result = client.get("/api/v2/tickets/1.json") - client.session.get.assert_called_once_with("https://example.zendesk.com/api/v2/tickets/1.json", timeout=30.0) + client.session.get.assert_called_once_with( + "https://example.zendesk.com/api/v2/tickets/1.json", params=None, timeout=30.0 + ) assert result == {"ticket": {"id": 1}} 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" },