From 3cc453ac09020688bf4916e98abd95f262218f74 Mon Sep 17 00:00:00 2001 From: Leandro Meili Date: Tue, 21 Apr 2026 21:57:17 -0300 Subject: [PATCH] feat(ticketing): add dynamic content items and variants Add DynamicContentItem + DynamicContentVariant domain models, item and variant create/update commands, mapper, API client (list/get/create/update/delete for items; list/get/create/update/delete for variants), and DynamicContentService wired into the Ticketing facade. 100% unit coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ticketing/dynamic_content_cmds.py | 38 +++++ .../services/ticketing/__init__.py | 2 + .../ticketing/dynamic_content_service.py | 100 +++++++++++++ .../models/ticketing/dynamic_content.py | 39 +++++ .../api_clients/ticketing/__init__.py | 4 + .../ticketing/dynamic_content_api_client.py | 102 +++++++++++++ .../ticketing/dynamic_content_mapper.py | 63 ++++++++ .../ticketing/test_dynamic_content.py | 29 ++++ tests/unit/ticketing/test_dynamic_content.py | 140 ++++++++++++++++++ .../ticketing/test_dynamic_content_mapper.py | 107 +++++++++++++ .../ticketing/test_dynamic_content_service.py | 118 +++++++++++++++ 11 files changed, 742 insertions(+) create mode 100644 libzapi/application/commands/ticketing/dynamic_content_cmds.py create mode 100644 libzapi/application/services/ticketing/dynamic_content_service.py create mode 100644 libzapi/domain/models/ticketing/dynamic_content.py create mode 100644 libzapi/infrastructure/api_clients/ticketing/dynamic_content_api_client.py create mode 100644 libzapi/infrastructure/mappers/ticketing/dynamic_content_mapper.py create mode 100644 tests/integration/ticketing/test_dynamic_content.py create mode 100644 tests/unit/ticketing/test_dynamic_content.py create mode 100644 tests/unit/ticketing/test_dynamic_content_mapper.py create mode 100644 tests/unit/ticketing/test_dynamic_content_service.py diff --git a/libzapi/application/commands/ticketing/dynamic_content_cmds.py b/libzapi/application/commands/ticketing/dynamic_content_cmds.py new file mode 100644 index 0000000..9fa1dfd --- /dev/null +++ b/libzapi/application/commands/ticketing/dynamic_content_cmds.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass(frozen=True, slots=True) +class DynamicContentVariantInputCmd: + content: str + locale_id: int + default: Optional[bool] = None + active: Optional[bool] = None + + +@dataclass(frozen=True, slots=True) +class CreateDynamicContentItemCmd: + name: str + default_locale_id: int + variants: List[DynamicContentVariantInputCmd] = field(default_factory=list) + + +@dataclass(frozen=True, slots=True) +class UpdateDynamicContentItemCmd: + name: Optional[str] = None + + +@dataclass(frozen=True, slots=True) +class CreateDynamicContentVariantCmd: + content: str + locale_id: int + default: Optional[bool] = None + active: Optional[bool] = None + + +@dataclass(frozen=True, slots=True) +class UpdateDynamicContentVariantCmd: + content: Optional[str] = None + locale_id: Optional[int] = None + default: Optional[bool] = None + active: Optional[bool] = None diff --git a/libzapi/application/services/ticketing/__init__.py b/libzapi/application/services/ticketing/__init__.py index e540027..9da62b2 100644 --- a/libzapi/application/services/ticketing/__init__.py +++ b/libzapi/application/services/ticketing/__init__.py @@ -7,6 +7,7 @@ from libzapi.application.services.ticketing.custom_ticket_statuses_service import ( CustomTicketStatusesService, ) +from libzapi.application.services.ticketing.dynamic_content_service import DynamicContentService from libzapi.application.services.ticketing.email_notifications_service import EmailNotificationService from libzapi.application.services.ticketing.groups_service import GroupsService from libzapi.application.services.ticketing.group_memberships_service import ( @@ -79,6 +80,7 @@ def __init__( self.custom_ticket_statuses = CustomTicketStatusesService( api.CustomTicketStatusApiClient(http) ) + self.dynamic_content = DynamicContentService(api.DynamicContentApiClient(http)) self.email_notifications = EmailNotificationService(api.EmailNotificationApiClient(http)) self.groups = GroupsService(api.GroupApiClient(http)) self.group_memberships = GroupMembershipsService(api.GroupMembershipApiClient(http)) diff --git a/libzapi/application/services/ticketing/dynamic_content_service.py b/libzapi/application/services/ticketing/dynamic_content_service.py new file mode 100644 index 0000000..5acfa59 --- /dev/null +++ b/libzapi/application/services/ticketing/dynamic_content_service.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Iterator, List, Optional + +from libzapi.application.commands.ticketing.dynamic_content_cmds import ( + CreateDynamicContentItemCmd, + CreateDynamicContentVariantCmd, + DynamicContentVariantInputCmd, + UpdateDynamicContentItemCmd, + UpdateDynamicContentVariantCmd, +) +from libzapi.domain.models.ticketing.dynamic_content import ( + DynamicContentItem, + DynamicContentVariant, +) +from libzapi.infrastructure.api_clients.ticketing.dynamic_content_api_client import ( + DynamicContentApiClient, +) + + +class DynamicContentService: + def __init__(self, client: DynamicContentApiClient) -> None: + self._client = client + + def list_items(self) -> Iterator[DynamicContentItem]: + return self._client.list_items() + + def get_item(self, item_id: int) -> DynamicContentItem: + return self._client.get_item(item_id=item_id) + + def create_item( + self, + *, + name: str, + default_locale_id: int, + variants: Optional[List[DynamicContentVariantInputCmd]] = None, + ) -> DynamicContentItem: + cmd = CreateDynamicContentItemCmd( + name=name, + default_locale_id=default_locale_id, + variants=list(variants) if variants else [], + ) + return self._client.create_item(entity=cmd) + + def update_item( + self, item_id: int, *, name: Optional[str] = None + ) -> DynamicContentItem: + cmd = UpdateDynamicContentItemCmd(name=name) + return self._client.update_item(item_id=item_id, entity=cmd) + + def delete_item(self, item_id: int) -> None: + self._client.delete_item(item_id=item_id) + + def list_variants(self, item_id: int) -> Iterator[DynamicContentVariant]: + return self._client.list_variants(item_id=item_id) + + def get_variant( + self, item_id: int, variant_id: int + ) -> DynamicContentVariant: + return self._client.get_variant(item_id=item_id, variant_id=variant_id) + + def create_variant( + self, + item_id: int, + *, + content: str, + locale_id: int, + default: Optional[bool] = None, + active: Optional[bool] = None, + ) -> DynamicContentVariant: + cmd = CreateDynamicContentVariantCmd( + content=content, + locale_id=locale_id, + default=default, + active=active, + ) + return self._client.create_variant(item_id=item_id, entity=cmd) + + def update_variant( + self, + item_id: int, + variant_id: int, + *, + content: Optional[str] = None, + locale_id: Optional[int] = None, + default: Optional[bool] = None, + active: Optional[bool] = None, + ) -> DynamicContentVariant: + cmd = UpdateDynamicContentVariantCmd( + content=content, + locale_id=locale_id, + default=default, + active=active, + ) + return self._client.update_variant( + item_id=item_id, variant_id=variant_id, entity=cmd + ) + + def delete_variant(self, item_id: int, variant_id: int) -> None: + self._client.delete_variant(item_id=item_id, variant_id=variant_id) diff --git a/libzapi/domain/models/ticketing/dynamic_content.py b/libzapi/domain/models/ticketing/dynamic_content.py new file mode 100644 index 0000000..a3af4e5 --- /dev/null +++ b/libzapi/domain/models/ticketing/dynamic_content.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Optional + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class DynamicContentVariant: + id: int + content: str + locale_id: int + default: bool = False + active: bool = True + outdated: bool = False + url: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("dynamic_content_variant", f"variant_id_{self.id}") + + +@dataclass(frozen=True, slots=True) +class DynamicContentItem: + id: int + name: str + placeholder: str + default_locale_id: int + url: Optional[str] = None + outdated: bool = False + variants: List[DynamicContentVariant] = field(default_factory=list) + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("dynamic_content_item", f"item_id_{self.id}") diff --git a/libzapi/infrastructure/api_clients/ticketing/__init__.py b/libzapi/infrastructure/api_clients/ticketing/__init__.py index 46c70b3..3caed91 100644 --- a/libzapi/infrastructure/api_clients/ticketing/__init__.py +++ b/libzapi/infrastructure/api_clients/ticketing/__init__.py @@ -2,6 +2,9 @@ from libzapi.infrastructure.api_clients.ticketing.attachment_api_client import AttachmentApiClient from libzapi.infrastructure.api_clients.ticketing.automation_api_client import AutomationApiClient from libzapi.infrastructure.api_clients.ticketing.brand_api_client import BrandApiClient +from libzapi.infrastructure.api_clients.ticketing.dynamic_content_api_client import ( + DynamicContentApiClient, +) from libzapi.infrastructure.api_clients.ticketing.brand_agent_api_client import BrandAgentApiClient from libzapi.infrastructure.api_clients.ticketing.custom_ticket_status_api_client import ( CustomTicketStatusApiClient, @@ -61,6 +64,7 @@ "BrandApiClient", "BrandAgentApiClient", "CustomTicketStatusApiClient", + "DynamicContentApiClient", "EmailNotificationApiClient", "GroupApiClient", "GroupMembershipApiClient", diff --git a/libzapi/infrastructure/api_clients/ticketing/dynamic_content_api_client.py b/libzapi/infrastructure/api_clients/ticketing/dynamic_content_api_client.py new file mode 100644 index 0000000..89b2bcd --- /dev/null +++ b/libzapi/infrastructure/api_clients/ticketing/dynamic_content_api_client.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.ticketing.dynamic_content_cmds import ( + CreateDynamicContentItemCmd, + CreateDynamicContentVariantCmd, + UpdateDynamicContentItemCmd, + UpdateDynamicContentVariantCmd, +) +from libzapi.domain.models.ticketing.dynamic_content import ( + DynamicContentItem, + DynamicContentVariant, +) +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.http.pagination import yield_items +from libzapi.infrastructure.mappers.ticketing.dynamic_content_mapper import ( + to_payload_create_item, + to_payload_create_variant, + to_payload_update_item, + to_payload_update_variant, +) +from libzapi.infrastructure.serialization.parse import to_domain + +_ITEMS = "/api/v2/dynamic_content/items" + + +class DynamicContentApiClient: + """HTTP adapter for Zendesk Dynamic Content.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_items(self) -> Iterator[DynamicContentItem]: + for obj in yield_items( + get_json=self._http.get, + first_path=_ITEMS, + base_url=self._http.base_url, + items_key="items", + ): + yield to_domain(data=obj, cls=DynamicContentItem) + + def get_item(self, item_id: int) -> DynamicContentItem: + data = self._http.get(f"{_ITEMS}/{int(item_id)}") + return to_domain(data=data["item"], cls=DynamicContentItem) + + def create_item( + self, entity: CreateDynamicContentItemCmd + ) -> DynamicContentItem: + data = self._http.post(_ITEMS, to_payload_create_item(entity)) + return to_domain(data=data["item"], cls=DynamicContentItem) + + def update_item( + self, item_id: int, entity: UpdateDynamicContentItemCmd + ) -> DynamicContentItem: + data = self._http.put( + f"{_ITEMS}/{int(item_id)}", to_payload_update_item(entity) + ) + return to_domain(data=data["item"], cls=DynamicContentItem) + + def delete_item(self, item_id: int) -> None: + self._http.delete(f"{_ITEMS}/{int(item_id)}") + + def list_variants(self, item_id: int) -> Iterator[DynamicContentVariant]: + path = f"{_ITEMS}/{int(item_id)}/variants" + for obj in yield_items( + get_json=self._http.get, + first_path=path, + base_url=self._http.base_url, + items_key="variants", + ): + yield to_domain(data=obj, cls=DynamicContentVariant) + + def get_variant(self, item_id: int, variant_id: int) -> DynamicContentVariant: + data = self._http.get( + f"{_ITEMS}/{int(item_id)}/variants/{int(variant_id)}" + ) + return to_domain(data=data["variant"], cls=DynamicContentVariant) + + def create_variant( + self, item_id: int, entity: CreateDynamicContentVariantCmd + ) -> DynamicContentVariant: + data = self._http.post( + f"{_ITEMS}/{int(item_id)}/variants", + to_payload_create_variant(entity), + ) + return to_domain(data=data["variant"], cls=DynamicContentVariant) + + def update_variant( + self, + item_id: int, + variant_id: int, + entity: UpdateDynamicContentVariantCmd, + ) -> DynamicContentVariant: + data = self._http.put( + f"{_ITEMS}/{int(item_id)}/variants/{int(variant_id)}", + to_payload_update_variant(entity), + ) + return to_domain(data=data["variant"], cls=DynamicContentVariant) + + def delete_variant(self, item_id: int, variant_id: int) -> None: + self._http.delete(f"{_ITEMS}/{int(item_id)}/variants/{int(variant_id)}") diff --git a/libzapi/infrastructure/mappers/ticketing/dynamic_content_mapper.py b/libzapi/infrastructure/mappers/ticketing/dynamic_content_mapper.py new file mode 100644 index 0000000..48d80c0 --- /dev/null +++ b/libzapi/infrastructure/mappers/ticketing/dynamic_content_mapper.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from libzapi.application.commands.ticketing.dynamic_content_cmds import ( + CreateDynamicContentItemCmd, + CreateDynamicContentVariantCmd, + DynamicContentVariantInputCmd, + UpdateDynamicContentItemCmd, + UpdateDynamicContentVariantCmd, +) + + +def _variant_input_to_payload(variant: DynamicContentVariantInputCmd) -> dict: + payload: dict = { + "content": variant.content, + "locale_id": int(variant.locale_id), + } + if variant.default is not None: + payload["default"] = variant.default + if variant.active is not None: + payload["active"] = variant.active + return payload + + +def to_payload_create_item(cmd: CreateDynamicContentItemCmd) -> dict: + return { + "item": { + "name": cmd.name, + "default_locale_id": int(cmd.default_locale_id), + "variants": [_variant_input_to_payload(v) for v in cmd.variants], + } + } + + +def to_payload_update_item(cmd: UpdateDynamicContentItemCmd) -> dict: + inner: dict = {} + if cmd.name is not None: + inner["name"] = cmd.name + return {"item": inner} + + +def to_payload_create_variant(cmd: CreateDynamicContentVariantCmd) -> dict: + inner: dict = { + "content": cmd.content, + "locale_id": int(cmd.locale_id), + } + if cmd.default is not None: + inner["default"] = cmd.default + if cmd.active is not None: + inner["active"] = cmd.active + return {"variant": inner} + + +def to_payload_update_variant(cmd: UpdateDynamicContentVariantCmd) -> dict: + inner: dict = {} + if cmd.content is not None: + inner["content"] = cmd.content + if cmd.locale_id is not None: + inner["locale_id"] = int(cmd.locale_id) + if cmd.default is not None: + inner["default"] = cmd.default + if cmd.active is not None: + inner["active"] = cmd.active + return {"variant": inner} diff --git a/tests/integration/ticketing/test_dynamic_content.py b/tests/integration/ticketing/test_dynamic_content.py new file mode 100644 index 0000000..cb6b261 --- /dev/null +++ b/tests/integration/ticketing/test_dynamic_content.py @@ -0,0 +1,29 @@ +import itertools + +import pytest + +from libzapi import Ticketing + + +def test_list_items(ticketing: Ticketing): + items = list(itertools.islice(ticketing.dynamic_content.list_items(), 20)) + assert isinstance(items, list) + + +def test_get_unknown_raises(ticketing: Ticketing): + from libzapi.domain.errors import NotFound + + with pytest.raises(NotFound): + ticketing.dynamic_content.get_item(999999999) + + +def test_list_variants_for_known_item(ticketing: Ticketing): + items = list(itertools.islice(ticketing.dynamic_content.list_items(), 1)) + if not items: + pytest.skip("No dynamic content items on this tenant.") + variants = list( + itertools.islice( + ticketing.dynamic_content.list_variants(items[0].id), 20 + ) + ) + assert isinstance(variants, list) diff --git a/tests/unit/ticketing/test_dynamic_content.py b/tests/unit/ticketing/test_dynamic_content.py new file mode 100644 index 0000000..2e11fca --- /dev/null +++ b/tests/unit/ticketing/test_dynamic_content.py @@ -0,0 +1,140 @@ +import pytest + +from libzapi.application.commands.ticketing.dynamic_content_cmds import ( + CreateDynamicContentItemCmd, + CreateDynamicContentVariantCmd, + UpdateDynamicContentItemCmd, + UpdateDynamicContentVariantCmd, +) +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.infrastructure.api_clients.ticketing import DynamicContentApiClient + + +@pytest.fixture +def http(mocker): + m = mocker.Mock() + m.base_url = "https://example.zendesk.com" + return m + + +@pytest.fixture +def domain(mocker): + return mocker.patch( + "libzapi.infrastructure.api_clients.ticketing.dynamic_content_api_client.to_domain", + side_effect=lambda data, cls: {"_cls": cls.__name__, **(data or {})}, + ) + + +class TestItems: + def test_list_items_yields(self, http, domain): + http.get.return_value = { + "items": [{"id": 1}, {"id": 2}], + "meta": {"has_more": False}, + "links": {"next": None}, + } + client = DynamicContentApiClient(http) + items = list(client.list_items()) + assert len(items) == 2 + assert all(i["_cls"] == "DynamicContentItem" for i in items) + http.get.assert_called_with("/api/v2/dynamic_content/items") + + def test_get_item(self, http, domain): + http.get.return_value = {"item": {"id": 7}} + client = DynamicContentApiClient(http) + result = client.get_item(7) + assert result["_cls"] == "DynamicContentItem" + http.get.assert_called_with("/api/v2/dynamic_content/items/7") + + def test_create_item(self, http, domain): + http.post.return_value = {"item": {"id": 1}} + client = DynamicContentApiClient(http) + client.create_item( + CreateDynamicContentItemCmd(name="greet", default_locale_id=1) + ) + http.post.assert_called_with( + "/api/v2/dynamic_content/items", + {"item": {"name": "greet", "default_locale_id": 1, "variants": []}}, + ) + + def test_update_item(self, http, domain): + http.put.return_value = {"item": {"id": 7}} + client = DynamicContentApiClient(http) + client.update_item(7, UpdateDynamicContentItemCmd(name="x")) + http.put.assert_called_with( + "/api/v2/dynamic_content/items/7", {"item": {"name": "x"}} + ) + + def test_delete_item(self, http): + client = DynamicContentApiClient(http) + client.delete_item(7) + http.delete.assert_called_with("/api/v2/dynamic_content/items/7") + + +class TestVariants: + def test_list_variants(self, http, domain): + http.get.return_value = { + "variants": [{"id": 1}], + "meta": {"has_more": False}, + "links": {"next": None}, + } + client = DynamicContentApiClient(http) + items = list(client.list_variants(7)) + assert len(items) == 1 + assert items[0]["_cls"] == "DynamicContentVariant" + http.get.assert_called_with("/api/v2/dynamic_content/items/7/variants") + + def test_get_variant(self, http, domain): + http.get.return_value = {"variant": {"id": 9}} + client = DynamicContentApiClient(http) + result = client.get_variant(7, 9) + assert result["_cls"] == "DynamicContentVariant" + http.get.assert_called_with("/api/v2/dynamic_content/items/7/variants/9") + + def test_create_variant(self, http, domain): + http.post.return_value = {"variant": {"id": 9}} + client = DynamicContentApiClient(http) + client.create_variant( + 7, CreateDynamicContentVariantCmd(content="Hi", locale_id=1) + ) + http.post.assert_called_with( + "/api/v2/dynamic_content/items/7/variants", + {"variant": {"content": "Hi", "locale_id": 1}}, + ) + + def test_update_variant(self, http, domain): + http.put.return_value = {"variant": {"id": 9}} + client = DynamicContentApiClient(http) + client.update_variant( + 7, 9, UpdateDynamicContentVariantCmd(content="new") + ) + http.put.assert_called_with( + "/api/v2/dynamic_content/items/7/variants/9", + {"variant": {"content": "new"}}, + ) + + def test_delete_variant(self, http): + client = DynamicContentApiClient(http) + client.delete_variant(7, 9) + http.delete.assert_called_with("/api/v2/dynamic_content/items/7/variants/9") + + +@pytest.mark.parametrize( + "error_cls", [Unauthorized, NotFound, UnprocessableEntity, RateLimited] +) +def test_raises_on_http_error(error_cls, http): + http.get.side_effect = error_cls("error") + client = DynamicContentApiClient(http) + with pytest.raises(error_cls): + list(client.list_items()) + + +def test_logical_keys(): + from libzapi.domain.models.ticketing.dynamic_content import ( + DynamicContentItem, + DynamicContentVariant, + ) + + item = DynamicContentItem(id=1, name="x", placeholder="{{x}}", default_locale_id=1) + assert item.logical_key.as_str() == "dynamic_content_item:item_id_1" + variant = DynamicContentVariant(id=9, content="Hi", locale_id=1) + assert variant.logical_key.as_str() == "dynamic_content_variant:variant_id_9" diff --git a/tests/unit/ticketing/test_dynamic_content_mapper.py b/tests/unit/ticketing/test_dynamic_content_mapper.py new file mode 100644 index 0000000..0212a3a --- /dev/null +++ b/tests/unit/ticketing/test_dynamic_content_mapper.py @@ -0,0 +1,107 @@ +from libzapi.application.commands.ticketing.dynamic_content_cmds import ( + CreateDynamicContentItemCmd, + CreateDynamicContentVariantCmd, + DynamicContentVariantInputCmd, + UpdateDynamicContentItemCmd, + UpdateDynamicContentVariantCmd, +) +from libzapi.infrastructure.mappers.ticketing.dynamic_content_mapper import ( + to_payload_create_item, + to_payload_create_variant, + to_payload_update_item, + to_payload_update_variant, +) + + +def test_create_item_minimal(): + payload = to_payload_create_item( + CreateDynamicContentItemCmd(name="greeting", default_locale_id=1) + ) + assert payload == { + "item": {"name": "greeting", "default_locale_id": 1, "variants": []} + } + + +def test_create_item_with_variants(): + payload = to_payload_create_item( + CreateDynamicContentItemCmd( + name="greeting", + default_locale_id=1, + variants=[ + DynamicContentVariantInputCmd( + content="Hello", locale_id=1, default=True, active=True + ), + DynamicContentVariantInputCmd(content="Olá", locale_id=77), + ], + ) + ) + assert payload == { + "item": { + "name": "greeting", + "default_locale_id": 1, + "variants": [ + { + "content": "Hello", + "locale_id": 1, + "default": True, + "active": True, + }, + {"content": "Olá", "locale_id": 77}, + ], + } + } + + +def test_update_item_empty(): + assert to_payload_update_item(UpdateDynamicContentItemCmd()) == {"item": {}} + + +def test_update_item_with_name(): + assert to_payload_update_item(UpdateDynamicContentItemCmd(name="x")) == { + "item": {"name": "x"} + } + + +def test_create_variant_minimal(): + payload = to_payload_create_variant( + CreateDynamicContentVariantCmd(content="Hi", locale_id=1) + ) + assert payload == {"variant": {"content": "Hi", "locale_id": 1}} + + +def test_create_variant_full(): + payload = to_payload_create_variant( + CreateDynamicContentVariantCmd( + content="Hi", locale_id=1, default=False, active=True + ) + ) + assert payload == { + "variant": { + "content": "Hi", + "locale_id": 1, + "default": False, + "active": True, + } + } + + +def test_update_variant_empty(): + assert to_payload_update_variant(UpdateDynamicContentVariantCmd()) == { + "variant": {} + } + + +def test_update_variant_all_fields(): + payload = to_payload_update_variant( + UpdateDynamicContentVariantCmd( + content="x", locale_id=1, default=True, active=False + ) + ) + assert payload == { + "variant": { + "content": "x", + "locale_id": 1, + "default": True, + "active": False, + } + } diff --git a/tests/unit/ticketing/test_dynamic_content_service.py b/tests/unit/ticketing/test_dynamic_content_service.py new file mode 100644 index 0000000..7e71654 --- /dev/null +++ b/tests/unit/ticketing/test_dynamic_content_service.py @@ -0,0 +1,118 @@ +import pytest +from unittest.mock import Mock, sentinel + +from libzapi.application.commands.ticketing.dynamic_content_cmds import ( + CreateDynamicContentItemCmd, + CreateDynamicContentVariantCmd, + DynamicContentVariantInputCmd, + UpdateDynamicContentItemCmd, + UpdateDynamicContentVariantCmd, +) +from libzapi.application.services.ticketing.dynamic_content_service import ( + DynamicContentService, +) +from libzapi.domain.errors import NotFound, Unauthorized + + +def _make_service(client=None): + client = client or Mock() + return DynamicContentService(client), client + + +class TestItemDelegation: + def test_list_items_delegates(self): + service, client = _make_service() + client.list_items.return_value = sentinel.items + assert service.list_items() is sentinel.items + client.list_items.assert_called_once_with() + + def test_get_item_delegates(self): + service, client = _make_service() + service.get_item(7) + client.get_item.assert_called_once_with(item_id=7) + + def test_delete_item_delegates(self): + service, client = _make_service() + service.delete_item(7) + client.delete_item.assert_called_once_with(item_id=7) + + +class TestItemCreate: + def test_minimal(self): + service, client = _make_service() + service.create_item(name="x", default_locale_id=1) + cmd = client.create_item.call_args.kwargs["entity"] + assert isinstance(cmd, CreateDynamicContentItemCmd) + assert cmd.name == "x" + assert cmd.default_locale_id == 1 + assert cmd.variants == [] + + def test_with_variants(self): + service, client = _make_service() + variants = [DynamicContentVariantInputCmd(content="Hi", locale_id=1)] + service.create_item(name="x", default_locale_id=1, variants=variants) + cmd = client.create_item.call_args.kwargs["entity"] + assert len(cmd.variants) == 1 + + +class TestItemUpdate: + def test_update_item(self): + service, client = _make_service() + service.update_item(7, name="new") + call = client.update_item.call_args + assert call.kwargs["item_id"] == 7 + cmd = call.kwargs["entity"] + assert isinstance(cmd, UpdateDynamicContentItemCmd) + assert cmd.name == "new" + + +class TestVariantDelegation: + def test_list_variants_delegates(self): + service, client = _make_service() + service.list_variants(7) + client.list_variants.assert_called_once_with(item_id=7) + + def test_get_variant_delegates(self): + service, client = _make_service() + service.get_variant(7, 9) + client.get_variant.assert_called_once_with(item_id=7, variant_id=9) + + def test_delete_variant_delegates(self): + service, client = _make_service() + service.delete_variant(7, 9) + client.delete_variant.assert_called_once_with(item_id=7, variant_id=9) + + +class TestVariantCreate: + def test_builds_cmd(self): + service, client = _make_service() + service.create_variant(7, content="Hi", locale_id=1, default=True) + call = client.create_variant.call_args + assert call.kwargs["item_id"] == 7 + cmd = call.kwargs["entity"] + assert isinstance(cmd, CreateDynamicContentVariantCmd) + assert cmd.content == "Hi" + assert cmd.locale_id == 1 + assert cmd.default is True + + +class TestVariantUpdate: + def test_builds_cmd(self): + service, client = _make_service() + service.update_variant(7, 9, content="x", active=False) + call = client.update_variant.call_args + assert call.kwargs["item_id"] == 7 + assert call.kwargs["variant_id"] == 9 + cmd = call.kwargs["entity"] + assert isinstance(cmd, UpdateDynamicContentVariantCmd) + assert cmd.content == "x" + assert cmd.active is False + + +class TestErrorPropagation: + @pytest.mark.parametrize("error_cls", [Unauthorized, NotFound]) + def test_list_items_propagates_error(self, error_cls): + service, client = _make_service() + client.list_items.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.list_items()