diff --git a/libzapi/application/services/ticketing/__init__.py b/libzapi/application/services/ticketing/__init__.py index e540027..32d955b 100644 --- a/libzapi/application/services/ticketing/__init__.py +++ b/libzapi/application/services/ticketing/__init__.py @@ -12,6 +12,9 @@ from libzapi.application.services.ticketing.group_memberships_service import ( GroupMembershipsService, ) +from libzapi.application.services.ticketing.incremental_exports_service import ( + IncrementalExportsService, +) from libzapi.application.services.ticketing.job_statuses_service import JobStatusesService from libzapi.application.services.ticketing.locales_service import LocalesService from libzapi.application.services.ticketing.macro_service import MacroService @@ -82,6 +85,9 @@ def __init__( self.email_notifications = EmailNotificationService(api.EmailNotificationApiClient(http)) self.groups = GroupsService(api.GroupApiClient(http)) self.group_memberships = GroupMembershipsService(api.GroupMembershipApiClient(http)) + self.incremental_exports = IncrementalExportsService( + api.IncrementalExportApiClient(http) + ) self.job_statuses = JobStatusesService(api.JobStatusApiClient(http)) self.locales = LocalesService(api.LocaleApiClient(http)) self.macros = MacroService(api.MacroApiClient(http)) diff --git a/libzapi/application/services/ticketing/incremental_exports_service.py b/libzapi/application/services/ticketing/incremental_exports_service.py new file mode 100644 index 0000000..e2f432f --- /dev/null +++ b/libzapi/application/services/ticketing/incremental_exports_service.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Iterable + +from libzapi.domain.models.ticketing.organization import Organization +from libzapi.domain.models.ticketing.ticket import Ticket +from libzapi.domain.models.ticketing.user import User +from libzapi.infrastructure.api_clients.ticketing.incremental_export_api_client import ( + IncrementalExportApiClient, +) + + +class IncrementalExportsService: + """High-level service for Zendesk Ticketing Incremental Exports.""" + + def __init__(self, client: IncrementalExportApiClient) -> None: + self._client = client + + def tickets(self, start_time: int) -> Iterable[Ticket]: + return self._client.tickets(start_time=start_time) + + def tickets_cursor(self, start_time: int) -> Iterable[Ticket]: + return self._client.tickets_cursor(start_time=start_time) + + def ticket_events(self, start_time: int) -> Iterable[dict]: + return self._client.ticket_events(start_time=start_time) + + def users(self, start_time: int) -> Iterable[User]: + return self._client.users(start_time=start_time) + + def users_cursor(self, start_time: int) -> Iterable[User]: + return self._client.users_cursor(start_time=start_time) + + def organizations(self, start_time: int) -> Iterable[Organization]: + return self._client.organizations(start_time=start_time) + + def sample(self, resource: str, start_time: int) -> dict: + return self._client.sample(resource=resource, start_time=start_time) diff --git a/libzapi/infrastructure/api_clients/ticketing/__init__.py b/libzapi/infrastructure/api_clients/ticketing/__init__.py index 46c70b3..64efca7 100644 --- a/libzapi/infrastructure/api_clients/ticketing/__init__.py +++ b/libzapi/infrastructure/api_clients/ticketing/__init__.py @@ -11,6 +11,9 @@ from libzapi.infrastructure.api_clients.ticketing.group_membership_api_client import ( GroupMembershipApiClient, ) +from libzapi.infrastructure.api_clients.ticketing.incremental_export_api_client import ( + IncrementalExportApiClient, +) from libzapi.infrastructure.api_clients.ticketing.job_status_api_client import JobStatusApiClient from libzapi.infrastructure.api_clients.ticketing.locale_api_client import LocaleApiClient from libzapi.infrastructure.api_clients.ticketing.macro_api_client import MacroApiClient @@ -64,6 +67,7 @@ "EmailNotificationApiClient", "GroupApiClient", "GroupMembershipApiClient", + "IncrementalExportApiClient", "JobStatusApiClient", "LocaleApiClient", "MacroApiClient", diff --git a/libzapi/infrastructure/api_clients/ticketing/incremental_export_api_client.py b/libzapi/infrastructure/api_clients/ticketing/incremental_export_api_client.py new file mode 100644 index 0000000..9ca0bdf --- /dev/null +++ b/libzapi/infrastructure/api_clients/ticketing/incremental_export_api_client.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.domain.models.ticketing.organization import Organization +from libzapi.domain.models.ticketing.ticket import Ticket +from libzapi.domain.models.ticketing.user import User +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.http.pagination import yield_items +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/incremental" + + +class IncrementalExportApiClient: + """HTTP adapter for Zendesk Ticketing Incremental Export endpoints. + + Time-based endpoints follow ``next_page`` links, cursor-based endpoints + follow ``after_url`` + ``end_of_stream`` semantics. + """ + + def __init__(self, http: HttpClient) -> None: + self._http = http + + # ----------------------------------------------------------------- + # Time-based exports + # ----------------------------------------------------------------- + + def tickets(self, start_time: int) -> Iterator[Ticket]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"{_BASE}/tickets?start_time={int(start_time)}", + base_url=self._http.base_url, + items_key="tickets", + ): + yield to_domain(data=obj, cls=Ticket) + + def ticket_events(self, start_time: int) -> Iterator[dict]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"{_BASE}/ticket_events?start_time={int(start_time)}", + base_url=self._http.base_url, + items_key="ticket_events", + ): + yield obj + + def users(self, start_time: int) -> Iterator[User]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"{_BASE}/users?start_time={int(start_time)}", + base_url=self._http.base_url, + items_key="users", + ): + yield to_domain(data=obj, cls=User) + + def organizations(self, start_time: int) -> Iterator[Organization]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"{_BASE}/organizations?start_time={int(start_time)}", + base_url=self._http.base_url, + items_key="organizations", + ): + yield to_domain(data=obj, cls=Organization) + + # ----------------------------------------------------------------- + # Cursor-based exports + # ----------------------------------------------------------------- + + def tickets_cursor(self, start_time: int) -> Iterator[Ticket]: + yield from self._iter_cursor( + first_path=f"{_BASE}/tickets/cursor?start_time={int(start_time)}", + items_key="tickets", + cls=Ticket, + ) + + def users_cursor(self, start_time: int) -> Iterator[User]: + yield from self._iter_cursor( + first_path=f"{_BASE}/users/cursor?start_time={int(start_time)}", + items_key="users", + cls=User, + ) + + def _iter_cursor(self, first_path: str, items_key: str, cls): + path: str | None = first_path + while path: + page = self._http.get(path) + for obj in page.get(items_key, []) or []: + yield to_domain(data=obj, cls=cls) + if page.get("end_of_stream"): + return + after_url = page.get("after_url") + if not after_url: + return + path = ( + after_url.replace(self._http.base_url, "") + if isinstance(after_url, str) and after_url.startswith("https://") + else after_url + ) + + # ----------------------------------------------------------------- + # Sample endpoint (one page, no pagination) + # ----------------------------------------------------------------- + + def sample(self, resource: str, start_time: int) -> dict: + allowed = {"tickets", "users", "organizations", "ticket_events"} + if resource not in allowed: + raise ValueError(f"Unknown incremental sample resource: {resource}") + return self._http.get( + f"{_BASE}/{resource}/sample?start_time={int(start_time)}" + ) diff --git a/tests/integration/ticketing/test_incremental_exports.py b/tests/integration/ticketing/test_incremental_exports.py new file mode 100644 index 0000000..de19446 --- /dev/null +++ b/tests/integration/ticketing/test_incremental_exports.py @@ -0,0 +1,54 @@ +import itertools + +from libzapi import Ticketing + +_EPOCH = 0 + + +def test_tickets(ticketing: Ticketing): + items = list( + itertools.islice(ticketing.incremental_exports.tickets(start_time=_EPOCH), 10) + ) + assert isinstance(items, list) + + +def test_tickets_cursor(ticketing: Ticketing): + items = list( + itertools.islice( + ticketing.incremental_exports.tickets_cursor(start_time=_EPOCH), 10 + ) + ) + assert isinstance(items, list) + + +def test_ticket_events(ticketing: Ticketing): + items = list( + itertools.islice( + ticketing.incremental_exports.ticket_events(start_time=_EPOCH), 10 + ) + ) + assert isinstance(items, list) + + +def test_users(ticketing: Ticketing): + items = list( + itertools.islice(ticketing.incremental_exports.users(start_time=_EPOCH), 10) + ) + assert isinstance(items, list) + + +def test_organizations(ticketing: Ticketing): + items = list( + itertools.islice( + ticketing.incremental_exports.organizations(start_time=_EPOCH), 10 + ) + ) + assert isinstance(items, list) + + +def test_sample(ticketing: Ticketing): + payload = ticketing.incremental_exports.sample( + resource="tickets", start_time=_EPOCH + ) + assert isinstance(payload, dict) + assert "tickets" in payload or "count" in payload diff --git a/tests/unit/ticketing/test_incremental_export.py b/tests/unit/ticketing/test_incremental_export.py new file mode 100644 index 0000000..232bee2 --- /dev/null +++ b/tests/unit/ticketing/test_incremental_export.py @@ -0,0 +1,206 @@ +import pytest + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized +from libzapi.infrastructure.api_clients.ticketing import IncrementalExportApiClient + + +@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.incremental_export_api_client.to_domain", + side_effect=lambda data, cls: {"_cls": cls.__name__, **(data or {})}, + ) + + +# --------------------------------------------------------------------------- +# Time-based: tickets / users / organizations / ticket_events +# --------------------------------------------------------------------------- + + +def test_tickets_hits_expected_path(http, domain): + http.get.return_value = {"tickets": []} + client = IncrementalExportApiClient(http) + list(client.tickets(start_time=123)) + http.get.assert_called_with("/api/v2/incremental/tickets?start_time=123") + + +def test_tickets_yields_items(http, domain): + http.get.return_value = { + "tickets": [{"id": 1}, {"id": 2}], + "next_page": None, + "end_time": 500, + } + client = IncrementalExportApiClient(http) + items = list(client.tickets(start_time=100)) + assert len(items) == 2 + assert all(i["_cls"] == "Ticket" for i in items) + + +def test_ticket_events_returns_raw_dicts(http, domain): + http.get.return_value = { + "ticket_events": [{"id": 1, "child_events": []}] + } + client = IncrementalExportApiClient(http) + items = list(client.ticket_events(start_time=100)) + assert items == [{"id": 1, "child_events": []}] + http.get.assert_called_with( + "/api/v2/incremental/ticket_events?start_time=100" + ) + + +def test_users_hits_expected_path(http, domain): + http.get.return_value = {"users": [{"id": 1}]} + client = IncrementalExportApiClient(http) + items = list(client.users(start_time=500)) + assert len(items) == 1 + assert items[0]["_cls"] == "User" + http.get.assert_called_with("/api/v2/incremental/users?start_time=500") + + +def test_organizations_hits_expected_path(http, domain): + http.get.return_value = {"organizations": [{"id": 1}]} + client = IncrementalExportApiClient(http) + items = list(client.organizations(start_time=500)) + assert len(items) == 1 + assert items[0]["_cls"] == "Organization" + http.get.assert_called_with( + "/api/v2/incremental/organizations?start_time=500" + ) + + +def test_start_time_coerced_to_int(http, domain): + http.get.return_value = {"tickets": []} + client = IncrementalExportApiClient(http) + list(client.tickets(start_time="42")) # type: ignore[arg-type] + http.get.assert_called_with("/api/v2/incremental/tickets?start_time=42") + + +# --------------------------------------------------------------------------- +# Cursor-based: tickets_cursor / users_cursor +# --------------------------------------------------------------------------- + + +def test_tickets_cursor_stops_on_end_of_stream(http, domain): + http.get.return_value = { + "tickets": [{"id": 1}], + "end_of_stream": True, + "after_url": "https://example.zendesk.com/api/v2/incremental/tickets/cursor?cursor=abc", + } + client = IncrementalExportApiClient(http) + items = list(client.tickets_cursor(start_time=100)) + assert len(items) == 1 + http.get.assert_called_once_with( + "/api/v2/incremental/tickets/cursor?start_time=100" + ) + + +def test_tickets_cursor_follows_after_url_https(http, domain): + http.get.side_effect = [ + { + "tickets": [{"id": 1}], + "after_url": "https://example.zendesk.com/api/v2/incremental/tickets/cursor?cursor=abc", + "end_of_stream": False, + }, + { + "tickets": [{"id": 2}], + "after_url": None, + "end_of_stream": True, + }, + ] + client = IncrementalExportApiClient(http) + items = list(client.tickets_cursor(start_time=100)) + assert len(items) == 2 + assert http.get.call_count == 2 + assert http.get.call_args_list[1].args == ( + "/api/v2/incremental/tickets/cursor?cursor=abc", + ) + + +def test_cursor_stops_when_after_url_missing(http, domain): + http.get.return_value = {"tickets": [{"id": 1}], "end_of_stream": False} + client = IncrementalExportApiClient(http) + items = list(client.tickets_cursor(start_time=100)) + assert len(items) == 1 + + +def test_users_cursor_hits_expected_path(http, domain): + http.get.return_value = {"users": [], "end_of_stream": True} + client = IncrementalExportApiClient(http) + list(client.users_cursor(start_time=42)) + http.get.assert_called_once_with( + "/api/v2/incremental/users/cursor?start_time=42" + ) + + +def test_cursor_handles_relative_after_url(http, domain): + http.get.side_effect = [ + { + "tickets": [{"id": 1}], + "after_url": "/api/v2/incremental/tickets/cursor?cursor=xyz", + "end_of_stream": False, + }, + {"tickets": [], "end_of_stream": True}, + ] + client = IncrementalExportApiClient(http) + list(client.tickets_cursor(start_time=100)) + assert http.get.call_args_list[1].args == ( + "/api/v2/incremental/tickets/cursor?cursor=xyz", + ) + + +def test_cursor_skips_when_items_missing(http, domain): + http.get.return_value = {"end_of_stream": True} + client = IncrementalExportApiClient(http) + items = list(client.tickets_cursor(start_time=100)) + assert items == [] + + +# --------------------------------------------------------------------------- +# Sample +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "resource", ["tickets", "users", "organizations", "ticket_events"] +) +def test_sample_hits_expected_path(resource, http): + http.get.return_value = {"count": 1} + client = IncrementalExportApiClient(http) + assert client.sample(resource=resource, start_time=99) == {"count": 1} + http.get.assert_called_with( + f"/api/v2/incremental/{resource}/sample?start_time=99" + ) + + +def test_sample_raises_on_unknown_resource(http): + client = IncrementalExportApiClient(http) + with pytest.raises(ValueError): + client.sample(resource="articles", start_time=100) + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("error_cls", [Unauthorized, NotFound, RateLimited]) +def test_tickets_raises_on_http_error(error_cls, http): + http.get.side_effect = error_cls("error") + client = IncrementalExportApiClient(http) + with pytest.raises(error_cls): + list(client.tickets(start_time=100)) + + +@pytest.mark.parametrize("error_cls", [Unauthorized, NotFound, RateLimited]) +def test_cursor_raises_on_http_error(error_cls, http): + http.get.side_effect = error_cls("error") + client = IncrementalExportApiClient(http) + with pytest.raises(error_cls): + list(client.tickets_cursor(start_time=100)) diff --git a/tests/unit/ticketing/test_incremental_exports_service.py b/tests/unit/ticketing/test_incremental_exports_service.py new file mode 100644 index 0000000..791b479 --- /dev/null +++ b/tests/unit/ticketing/test_incremental_exports_service.py @@ -0,0 +1,50 @@ +import pytest +from unittest.mock import Mock, sentinel + +from libzapi.application.services.ticketing.incremental_exports_service import ( + IncrementalExportsService, +) +from libzapi.domain.errors import NotFound, Unauthorized + + +def _make_service(client=None): + client = client or Mock() + return IncrementalExportsService(client), client + + +class TestDelegation: + @pytest.mark.parametrize( + "method, client_method", + [ + ("tickets", "tickets"), + ("tickets_cursor", "tickets_cursor"), + ("ticket_events", "ticket_events"), + ("users", "users"), + ("users_cursor", "users_cursor"), + ("organizations", "organizations"), + ], + ) + def test_method_delegates(self, method, client_method): + service, client = _make_service() + getattr(client, client_method).return_value = sentinel.result + result = getattr(service, method)(start_time=100) + getattr(client, client_method).assert_called_once_with(start_time=100) + assert result is sentinel.result + + +class TestSample: + def test_delegates(self): + service, client = _make_service() + client.sample.return_value = sentinel.sample + result = service.sample(resource="tickets", start_time=200) + client.sample.assert_called_once_with(resource="tickets", start_time=200) + assert result is sentinel.sample + + +class TestErrorPropagation: + @pytest.mark.parametrize("error_cls", [Unauthorized, NotFound]) + def test_tickets_propagates_error(self, error_cls): + service, client = _make_service() + client.tickets.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.tickets(start_time=1)