Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions libzapi/application/commands/ticketing/ticket_skip_cmds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class CreateTicketSkipCmd:
reason: str
2 changes: 2 additions & 0 deletions libzapi/application/services/ticketing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
30 changes: 30 additions & 0 deletions libzapi/application/services/ticketing/ticket_skips_service.py
Original file line number Diff line number Diff line change
@@ -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)
)
20 changes: 20 additions & 0 deletions libzapi/domain/models/ticketing/ticket_skip.py
Original file line number Diff line number Diff line change
@@ -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}")
2 changes: 2 additions & 0 deletions libzapi/infrastructure/api_clients/ticketing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -88,6 +89,7 @@
"TicketFormApiClient",
"TicketMetricApiClient",
"TicketMetricEventApiClient",
"TicketSkipApiClient",
"TicketTriggerApiClient",
"TicketTriggerCategoryApiClient",
"UserApiClient",
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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}}
34 changes: 34 additions & 0 deletions tests/integration/ticketing/test_ticket_skips.py
Original file line number Diff line number Diff line change
@@ -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)
91 changes: 91 additions & 0 deletions tests/unit/ticketing/test_ticket_skip.py
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 12 additions & 0 deletions tests/unit/ticketing/test_ticket_skip_mapper.py
Original file line number Diff line number Diff line change
@@ -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": ""}}
62 changes: 62 additions & 0 deletions tests/unit/ticketing/test_ticket_skips_service.py
Original file line number Diff line number Diff line change
@@ -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")
Loading