diff --git a/libzapi/application/commands/ticketing/bookmark_cmds.py b/libzapi/application/commands/ticketing/bookmark_cmds.py new file mode 100644 index 0000000..9da9a19 --- /dev/null +++ b/libzapi/application/commands/ticketing/bookmark_cmds.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateBookmarkCmd: + ticket_id: int diff --git a/libzapi/application/services/ticketing/__init__.py b/libzapi/application/services/ticketing/__init__.py index 2b7b229..a334c5a 100644 --- a/libzapi/application/services/ticketing/__init__.py +++ b/libzapi/application/services/ticketing/__init__.py @@ -3,6 +3,7 @@ from libzapi.application.services.ticketing.attachments_service import AttachmentsService from libzapi.application.services.ticketing.automations_service import AutomationsService from libzapi.application.services.ticketing.brand_agents_service import BrandAgentsService +from libzapi.application.services.ticketing.bookmarks_service import BookmarksService from libzapi.application.services.ticketing.brands_service import BrandsService from libzapi.application.services.ticketing.custom_ticket_statuses_service import ( CustomTicketStatusesService, @@ -81,6 +82,7 @@ def __init__( self.automations = AutomationsService(api.AutomationApiClient(http)) self.brands = BrandsService(api.BrandApiClient(http)) self.brand_agents = BrandAgentsService(api.BrandAgentApiClient(http)) + self.bookmarks = BookmarksService(api.BookmarkApiClient(http)) self.custom_ticket_statuses = CustomTicketStatusesService( api.CustomTicketStatusApiClient(http) ) diff --git a/libzapi/application/services/ticketing/bookmarks_service.py b/libzapi/application/services/ticketing/bookmarks_service.py new file mode 100644 index 0000000..3f38c0c --- /dev/null +++ b/libzapi/application/services/ticketing/bookmarks_service.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.application.commands.ticketing.bookmark_cmds import CreateBookmarkCmd +from libzapi.domain.models.ticketing.bookmark import Bookmark +from libzapi.infrastructure.api_clients.ticketing.bookmark_api_client import ( + BookmarkApiClient, +) + + +class BookmarksService: + def __init__(self, client: BookmarkApiClient) -> None: + self._client = client + + def list_all(self) -> Iterable[Bookmark]: + return self._client.list() + + def create(self, ticket_id: int) -> Bookmark: + return self._client.create(entity=CreateBookmarkCmd(ticket_id=ticket_id)) + + def delete(self, bookmark_id: int) -> None: + self._client.delete(bookmark_id=bookmark_id) diff --git a/libzapi/domain/models/ticketing/bookmark.py b/libzapi/domain/models/ticketing/bookmark.py new file mode 100644 index 0000000..b147260 --- /dev/null +++ b/libzapi/domain/models/ticketing/bookmark.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class Bookmark: + id: int + url: Optional[str] = None + ticket: Optional[dict] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("bookmark", f"bookmark_id_{self.id}") diff --git a/libzapi/infrastructure/api_clients/ticketing/__init__.py b/libzapi/infrastructure/api_clients/ticketing/__init__.py index 60325ba..80d4ca4 100644 --- a/libzapi/infrastructure/api_clients/ticketing/__init__.py +++ b/libzapi/infrastructure/api_clients/ticketing/__init__.py @@ -2,6 +2,7 @@ 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.bookmark_api_client import BookmarkApiClient from libzapi.infrastructure.api_clients.ticketing.dynamic_content_api_client import ( DynamicContentApiClient, ) @@ -65,6 +66,7 @@ "AccountSettingsApiClient", "AttachmentApiClient", "AutomationApiClient", + "BookmarkApiClient", "BrandApiClient", "BrandAgentApiClient", "CustomTicketStatusApiClient", diff --git a/libzapi/infrastructure/api_clients/ticketing/bookmark_api_client.py b/libzapi/infrastructure/api_clients/ticketing/bookmark_api_client.py new file mode 100644 index 0000000..3da66d7 --- /dev/null +++ b/libzapi/infrastructure/api_clients/ticketing/bookmark_api_client.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.ticketing.bookmark_cmds import CreateBookmarkCmd +from libzapi.domain.models.ticketing.bookmark import Bookmark +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.http.pagination import yield_items +from libzapi.infrastructure.mappers.ticketing.bookmark_mapper import to_payload_create +from libzapi.infrastructure.serialization.parse import to_domain + + +class BookmarkApiClient: + """HTTP adapter for Zendesk Bookmarks (current user's bookmarks).""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list(self) -> Iterator[Bookmark]: + for obj in yield_items( + get_json=self._http.get, + first_path="/api/v2/bookmarks", + base_url=self._http.base_url, + items_key="bookmarks", + ): + yield to_domain(data=obj, cls=Bookmark) + + def create(self, entity: CreateBookmarkCmd) -> Bookmark: + data = self._http.post("/api/v2/bookmarks", to_payload_create(entity)) + return to_domain(data=data["bookmark"], cls=Bookmark) + + def delete(self, bookmark_id: int) -> None: + self._http.delete(f"/api/v2/bookmarks/{int(bookmark_id)}") diff --git a/libzapi/infrastructure/mappers/ticketing/bookmark_mapper.py b/libzapi/infrastructure/mappers/ticketing/bookmark_mapper.py new file mode 100644 index 0000000..74730e6 --- /dev/null +++ b/libzapi/infrastructure/mappers/ticketing/bookmark_mapper.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from libzapi.application.commands.ticketing.bookmark_cmds import CreateBookmarkCmd + + +def to_payload_create(cmd: CreateBookmarkCmd) -> dict: + return {"bookmark": {"ticket_id": int(cmd.ticket_id)}} diff --git a/tests/integration/ticketing/test_bookmarks.py b/tests/integration/ticketing/test_bookmarks.py new file mode 100644 index 0000000..ced2b63 --- /dev/null +++ b/tests/integration/ticketing/test_bookmarks.py @@ -0,0 +1,8 @@ +import itertools + +from libzapi import Ticketing + + +def test_list_all(ticketing: Ticketing): + items = list(itertools.islice(ticketing.bookmarks.list_all(), 20)) + assert isinstance(items, list) diff --git a/tests/unit/ticketing/test_bookmark.py b/tests/unit/ticketing/test_bookmark.py new file mode 100644 index 0000000..354cbaf --- /dev/null +++ b/tests/unit/ticketing/test_bookmark.py @@ -0,0 +1,62 @@ +import pytest + +from libzapi.application.commands.ticketing.bookmark_cmds import CreateBookmarkCmd +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized +from libzapi.infrastructure.api_clients.ticketing import BookmarkApiClient + + +@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.bookmark_api_client.to_domain", + side_effect=lambda data, cls: {"_cls": cls.__name__, **(data or {})}, + ) + + +def test_list_yields_items(http, domain): + http.get.return_value = { + "bookmarks": [{"id": 1}, {"id": 2}], + "meta": {"has_more": False}, + "links": {"next": None}, + } + client = BookmarkApiClient(http) + items = list(client.list()) + assert len(items) == 2 + assert all(i["_cls"] == "Bookmark" for i in items) + http.get.assert_called_with("/api/v2/bookmarks") + + +def test_create_posts_payload(http, domain): + http.post.return_value = {"bookmark": {"id": 1}} + client = BookmarkApiClient(http) + result = client.create(CreateBookmarkCmd(ticket_id=42)) + assert result["_cls"] == "Bookmark" + http.post.assert_called_with("/api/v2/bookmarks", {"bookmark": {"ticket_id": 42}}) + + +def test_delete(http): + client = BookmarkApiClient(http) + client.delete(5) + http.delete.assert_called_with("/api/v2/bookmarks/5") + + +@pytest.mark.parametrize("error_cls", [Unauthorized, NotFound, RateLimited]) +def test_raises_on_http_error(error_cls, http): + http.get.side_effect = error_cls("error") + client = BookmarkApiClient(http) + with pytest.raises(error_cls): + list(client.list()) + + +def test_bookmark_logical_key(): + from libzapi.domain.models.ticketing.bookmark import Bookmark + + bookmark = Bookmark(id=7) + assert bookmark.logical_key.as_str() == "bookmark:bookmark_id_7" diff --git a/tests/unit/ticketing/test_bookmark_mapper.py b/tests/unit/ticketing/test_bookmark_mapper.py new file mode 100644 index 0000000..f8910f1 --- /dev/null +++ b/tests/unit/ticketing/test_bookmark_mapper.py @@ -0,0 +1,14 @@ +from libzapi.application.commands.ticketing.bookmark_cmds import CreateBookmarkCmd +from libzapi.infrastructure.mappers.ticketing.bookmark_mapper import to_payload_create + + +def test_create_payload(): + assert to_payload_create(CreateBookmarkCmd(ticket_id=42)) == { + "bookmark": {"ticket_id": 42} + } + + +def test_create_coerces_ticket_id(): + assert to_payload_create(CreateBookmarkCmd(ticket_id="7")) == { # type: ignore[arg-type] + "bookmark": {"ticket_id": 7} + } diff --git a/tests/unit/ticketing/test_bookmarks_service.py b/tests/unit/ticketing/test_bookmarks_service.py new file mode 100644 index 0000000..8673cdd --- /dev/null +++ b/tests/unit/ticketing/test_bookmarks_service.py @@ -0,0 +1,40 @@ +import pytest +from unittest.mock import Mock, sentinel + +from libzapi.application.commands.ticketing.bookmark_cmds import CreateBookmarkCmd +from libzapi.application.services.ticketing.bookmarks_service import BookmarksService +from libzapi.domain.errors import Unauthorized + + +def _make_service(client=None): + client = client or Mock() + return BookmarksService(client), client + + +class TestDelegation: + def test_list_all_delegates(self): + service, client = _make_service() + client.list.return_value = sentinel.items + assert service.list_all() is sentinel.items + client.list.assert_called_once_with() + + def test_create_builds_cmd(self): + service, client = _make_service() + client.create.return_value = sentinel.bookmark + result = service.create(42) + cmd = client.create.call_args.kwargs["entity"] + assert isinstance(cmd, CreateBookmarkCmd) + assert cmd.ticket_id == 42 + assert result is sentinel.bookmark + + def test_delete_delegates(self): + service, client = _make_service() + service.delete(5) + client.delete.assert_called_once_with(bookmark_id=5) + + +def test_propagates_unauthorized(): + service, client = _make_service() + client.list.side_effect = Unauthorized("x") + with pytest.raises(Unauthorized): + service.list_all()