diff --git a/libzapi/application/commands/ticketing/ticket_skip_cmds.py b/libzapi/application/commands/ticketing/ticket_skip_cmds.py new file mode 100644 index 0000000..a4d83d5 --- /dev/null +++ b/libzapi/application/commands/ticketing/ticket_skip_cmds.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateTicketSkipCmd: + reason: str diff --git a/libzapi/application/services/ticketing/__init__.py b/libzapi/application/services/ticketing/__init__.py index e540027..693650d 100644 --- a/libzapi/application/services/ticketing/__init__.py +++ b/libzapi/application/services/ticketing/__init__.py @@ -45,6 +45,7 @@ from libzapi.application.services.ticketing.ticket_fields_service import TicketFieldsService from libzapi.application.services.ticketing.ticket_forms_service import TicketFormsService from libzapi.application.services.ticketing.ticket_metrics_service import TicketMetricsService +from libzapi.application.services.ticketing.ticket_skips_service import TicketSkipsService from libzapi.application.services.ticketing.ticket_trigger_categories_service import TicketTriggerCategoriesService from libzapi.application.services.ticketing.ticket_trigger_service import TicketTriggerService from libzapi.application.services.ticketing.users_service import UsersService @@ -114,6 +115,7 @@ def __init__( self.ticket_forms = TicketFormsService(api.TicketFormApiClient(http)) self.ticket_metrics = TicketMetricsService(api.TicketMetricApiClient(http)) self.ticket_metric_events = api.TicketMetricEventApiClient(http) + self.ticket_skips = TicketSkipsService(api.TicketSkipApiClient(http)) self.ticket_triggers = TicketTriggerService(api.TicketTriggerApiClient(http)) self.ticket_trigger_categories = TicketTriggerCategoriesService(api.TicketTriggerCategoryApiClient(http)) self.users = UsersService(api.UserApiClient(http)) diff --git a/libzapi/application/services/ticketing/ticket_skips_service.py b/libzapi/application/services/ticketing/ticket_skips_service.py new file mode 100644 index 0000000..b270c3d --- /dev/null +++ b/libzapi/application/services/ticketing/ticket_skips_service.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.application.commands.ticketing.ticket_skip_cmds import CreateTicketSkipCmd +from libzapi.domain.models.ticketing.ticket_skip import TicketSkip +from libzapi.infrastructure.api_clients.ticketing.ticket_skip_api_client import ( + TicketSkipApiClient, +) + + +class TicketSkipsService: + """High-level service for Zendesk Ticket Skips.""" + + def __init__(self, client: TicketSkipApiClient) -> None: + self._client = client + + def list_all(self) -> Iterable[TicketSkip]: + return self._client.list() + + def list_by_user(self, user_id: int) -> Iterable[TicketSkip]: + return self._client.list_by_user(user_id=user_id) + + def list_by_ticket(self, ticket_id: int) -> Iterable[TicketSkip]: + return self._client.list_by_ticket(ticket_id=ticket_id) + + def create(self, ticket_id: int, reason: str) -> TicketSkip: + return self._client.create( + ticket_id=ticket_id, entity=CreateTicketSkipCmd(reason=reason) + ) diff --git a/libzapi/domain/models/ticketing/ticket_skip.py b/libzapi/domain/models/ticketing/ticket_skip.py new file mode 100644 index 0000000..3d41662 --- /dev/null +++ b/libzapi/domain/models/ticketing/ticket_skip.py @@ -0,0 +1,20 @@ +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 TicketSkip: + id: int + user_id: int + ticket_id: int + reason: str + created_at: datetime + updated_at: datetime + ticket: Optional[dict] = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("ticket_skip", f"skip_id_{self.id}") diff --git a/libzapi/infrastructure/api_clients/ticketing/__init__.py b/libzapi/infrastructure/api_clients/ticketing/__init__.py index 46c70b3..d52148b 100644 --- a/libzapi/infrastructure/api_clients/ticketing/__init__.py +++ b/libzapi/infrastructure/api_clients/ticketing/__init__.py @@ -43,6 +43,7 @@ from libzapi.infrastructure.api_clients.ticketing.ticket_metric_api_client import TicketMetricApiClient from libzapi.infrastructure.api_clients.ticketing.ticket_metric_event_api_client import TicketMetricEventApiClient from libzapi.infrastructure.api_clients.ticketing.ticket_activity_api_client import TicketActivityApiClient +from libzapi.infrastructure.api_clients.ticketing.ticket_skip_api_client import TicketSkipApiClient from libzapi.infrastructure.api_clients.ticketing.ticket_trigger_api_client import TicketTriggerApiClient from libzapi.infrastructure.api_clients.ticketing.ticket_trigger_category_api_client import ( TicketTriggerCategoryApiClient, @@ -88,6 +89,7 @@ "TicketFormApiClient", "TicketMetricApiClient", "TicketMetricEventApiClient", + "TicketSkipApiClient", "TicketTriggerApiClient", "TicketTriggerCategoryApiClient", "UserApiClient", diff --git a/libzapi/infrastructure/api_clients/ticketing/ticket_skip_api_client.py b/libzapi/infrastructure/api_clients/ticketing/ticket_skip_api_client.py new file mode 100644 index 0000000..2b4927d --- /dev/null +++ b/libzapi/infrastructure/api_clients/ticketing/ticket_skip_api_client.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.ticketing.ticket_skip_cmds import CreateTicketSkipCmd +from libzapi.domain.models.ticketing.ticket_skip import TicketSkip +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.http.pagination import yield_items +from libzapi.infrastructure.mappers.ticketing.ticket_skip_mapper import to_payload_create +from libzapi.infrastructure.serialization.parse import to_domain + + +class TicketSkipApiClient: + """HTTP adapter for Zendesk Ticket Skips.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list(self) -> Iterator[TicketSkip]: + for obj in yield_items( + get_json=self._http.get, + first_path="/api/v2/skips", + base_url=self._http.base_url, + items_key="skips", + ): + yield to_domain(data=obj, cls=TicketSkip) + + def list_by_user(self, user_id: int) -> Iterator[TicketSkip]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"/api/v2/users/{int(user_id)}/skips", + base_url=self._http.base_url, + items_key="skips", + ): + yield to_domain(data=obj, cls=TicketSkip) + + def list_by_ticket(self, ticket_id: int) -> Iterator[TicketSkip]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"/api/v2/tickets/{int(ticket_id)}/skips", + base_url=self._http.base_url, + items_key="skips", + ): + yield to_domain(data=obj, cls=TicketSkip) + + def create(self, ticket_id: int, entity: CreateTicketSkipCmd) -> TicketSkip: + payload = to_payload_create(entity) + data = self._http.post( + f"/api/v2/tickets/{int(ticket_id)}/skips", payload + ) + return to_domain(data=data["skip"], cls=TicketSkip) diff --git a/libzapi/infrastructure/mappers/ticketing/ticket_skip_mapper.py b/libzapi/infrastructure/mappers/ticketing/ticket_skip_mapper.py new file mode 100644 index 0000000..74be1e5 --- /dev/null +++ b/libzapi/infrastructure/mappers/ticketing/ticket_skip_mapper.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from libzapi.application.commands.ticketing.ticket_skip_cmds import CreateTicketSkipCmd + + +def to_payload_create(cmd: CreateTicketSkipCmd) -> dict: + return {"skip": {"reason": cmd.reason}} diff --git a/tests/integration/ticketing/test_ticket_skips.py b/tests/integration/ticketing/test_ticket_skips.py new file mode 100644 index 0000000..b38d956 --- /dev/null +++ b/tests/integration/ticketing/test_ticket_skips.py @@ -0,0 +1,34 @@ +import itertools + +from libzapi import Ticketing + + +def test_list_all(ticketing: Ticketing): + items = list(itertools.islice(ticketing.ticket_skips.list_all(), 20)) + assert isinstance(items, list) + + +def test_list_by_user_returns_iterable(ticketing: Ticketing): + skips = list(itertools.islice(ticketing.ticket_skips.list_all(), 1)) + if not skips: + import pytest + + pytest.skip("No ticket skips on this tenant.") + user_id = skips[0].user_id + items = list( + itertools.islice(ticketing.ticket_skips.list_by_user(user_id), 20) + ) + assert isinstance(items, list) + + +def test_list_by_ticket_returns_iterable(ticketing: Ticketing): + skips = list(itertools.islice(ticketing.ticket_skips.list_all(), 1)) + if not skips: + import pytest + + pytest.skip("No ticket skips on this tenant.") + ticket_id = skips[0].ticket_id + items = list( + itertools.islice(ticketing.ticket_skips.list_by_ticket(ticket_id), 20) + ) + assert isinstance(items, list) diff --git a/tests/unit/ticketing/test_ticket_skip.py b/tests/unit/ticketing/test_ticket_skip.py new file mode 100644 index 0000000..1a837ab --- /dev/null +++ b/tests/unit/ticketing/test_ticket_skip.py @@ -0,0 +1,91 @@ +import pytest + +from libzapi.application.commands.ticketing.ticket_skip_cmds import CreateTicketSkipCmd +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.infrastructure.api_clients.ticketing import TicketSkipApiClient + + +@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.ticket_skip_api_client.to_domain", + side_effect=lambda data, cls: {"_cls": cls.__name__, **(data or {})}, + ) + + +@pytest.mark.parametrize( + "method, args, path", + [ + ("list", [], "/api/v2/skips"), + ("list_by_user", [42], "/api/v2/users/42/skips"), + ("list_by_ticket", [7], "/api/v2/tickets/7/skips"), + ], +) +def test_list_endpoints_hit_expected_path(method, args, path, http, domain): + http.get.return_value = {"skips": [{"id": 1}]} + client = TicketSkipApiClient(http) + items = list(getattr(client, method)(*args)) + assert len(items) == 1 + assert items[0]["_cls"] == "TicketSkip" + http.get.assert_called_with(path) + + +def test_list_yields_items(http, domain): + http.get.return_value = { + "skips": [{"id": 1}, {"id": 2}], + "meta": {"has_more": False}, + "links": {"next": None}, + } + client = TicketSkipApiClient(http) + items = list(client.list()) + assert len(items) == 2 + assert all(i["_cls"] == "TicketSkip" for i in items) + + +def test_list_by_user_coerces_user_id(http, domain): + http.get.return_value = {"skips": []} + client = TicketSkipApiClient(http) + list(client.list_by_user(user_id="5")) # type: ignore[arg-type] + http.get.assert_called_with("/api/v2/users/5/skips") + + +def test_create_posts_payload(http, domain): + http.post.return_value = {"skip": {"id": 1}} + client = TicketSkipApiClient(http) + client.create(ticket_id=42, entity=CreateTicketSkipCmd(reason="not my area")) + http.post.assert_called_with( + "/api/v2/tickets/42/skips", {"skip": {"reason": "not my area"}} + ) + + +@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 = TicketSkipApiClient(http) + with pytest.raises(error_cls): + list(client.list()) + + +def test_ticket_skip_logical_key(): + from datetime import datetime + + from libzapi.domain.models.ticketing.ticket_skip import TicketSkip + + skip = TicketSkip( + id=9, + user_id=42, + ticket_id=7, + reason="not mine", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + assert skip.logical_key.as_str() == "ticket_skip:skip_id_9" diff --git a/tests/unit/ticketing/test_ticket_skip_mapper.py b/tests/unit/ticketing/test_ticket_skip_mapper.py new file mode 100644 index 0000000..7478364 --- /dev/null +++ b/tests/unit/ticketing/test_ticket_skip_mapper.py @@ -0,0 +1,12 @@ +from libzapi.application.commands.ticketing.ticket_skip_cmds import CreateTicketSkipCmd +from libzapi.infrastructure.mappers.ticketing.ticket_skip_mapper import to_payload_create + + +def test_create_payload(): + payload = to_payload_create(CreateTicketSkipCmd(reason="not my area")) + assert payload == {"skip": {"reason": "not my area"}} + + +def test_create_empty_reason(): + payload = to_payload_create(CreateTicketSkipCmd(reason="")) + assert payload == {"skip": {"reason": ""}} diff --git a/tests/unit/ticketing/test_ticket_skips_service.py b/tests/unit/ticketing/test_ticket_skips_service.py new file mode 100644 index 0000000..9a0195c --- /dev/null +++ b/tests/unit/ticketing/test_ticket_skips_service.py @@ -0,0 +1,62 @@ +import pytest +from unittest.mock import Mock, sentinel + +from libzapi.application.commands.ticketing.ticket_skip_cmds import CreateTicketSkipCmd +from libzapi.application.services.ticketing.ticket_skips_service import ( + TicketSkipsService, +) +from libzapi.domain.errors import NotFound, Unauthorized + + +def _make_service(client=None): + client = client or Mock() + return TicketSkipsService(client), client + + +class TestDelegation: + def test_list_all_delegates(self): + service, client = _make_service() + client.list.return_value = sentinel.skips + assert service.list_all() is sentinel.skips + client.list.assert_called_once_with() + + def test_list_by_user_delegates(self): + service, client = _make_service() + service.list_by_user(42) + client.list_by_user.assert_called_once_with(user_id=42) + + def test_list_by_ticket_delegates(self): + service, client = _make_service() + service.list_by_ticket(7) + client.list_by_ticket.assert_called_once_with(ticket_id=7) + + +class TestCreate: + def test_builds_cmd_and_delegates(self): + service, client = _make_service() + client.create.return_value = sentinel.skip + + result = service.create(ticket_id=42, reason="not my area") + + call = client.create.call_args + assert call.kwargs["ticket_id"] == 42 + cmd = call.kwargs["entity"] + assert isinstance(cmd, CreateTicketSkipCmd) + assert cmd.reason == "not my area" + assert result is sentinel.skip + + +class TestErrorPropagation: + @pytest.mark.parametrize("error_cls", [Unauthorized, NotFound]) + def test_list_propagates_error(self, error_cls): + service, client = _make_service() + client.list.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.list_all() + + @pytest.mark.parametrize("error_cls", [Unauthorized, NotFound]) + def test_create_propagates_error(self, error_cls): + service, client = _make_service() + client.create.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.create(ticket_id=1, reason="x")