From d1a92730acabfef411839386e12920219ecb8534 Mon Sep 17 00:00:00 2001 From: Leandro Meili Date: Tue, 21 Apr 2026 19:22:12 -0300 Subject: [PATCH] feat(ticketing): add ticket fields CUD, reorder, option helpers Refs #79. - Introduce CreateTicketFieldCmd / UpdateTicketFieldCmd / TicketFieldOptionCmd - Expand mapper to cover every Zendesk ticket_field payload field while preserving false booleans and skipping unset optionals - Refactor client to use Cmd dataclasses, add reorder and option endpoints (list, get, upsert, delete) - Migrate service to **fields kwargs ergonomics; expose reorder and option helpers - Unit + integration coverage at 100% across 302 stmts Co-Authored-By: Claude Opus 4.7 (1M context) --- .../commands/ticketing/ticket_field_cmds.py | 59 ++++++ .../ticketing/ticket_fields_service.py | 50 ++++- .../ticketing/ticket_field_api_client.py | 70 +++++-- .../mappers/ticketing/ticket_field_mapper.py | 77 +++++-- .../ticketing/test_ticket_field.py | 73 ++++++- .../ticketing/test_ticket_field_client.py | 191 ++++++++++++++++++ .../ticketing/test_ticket_field_mapper.py | 189 +++++++++++++++++ .../ticketing/test_ticket_field_service.py | 186 +++++++++++++++++ 8 files changed, 858 insertions(+), 37 deletions(-) create mode 100644 libzapi/application/commands/ticketing/ticket_field_cmds.py create mode 100644 tests/unit/ticketing/test_ticket_field_client.py create mode 100644 tests/unit/ticketing/test_ticket_field_mapper.py create mode 100644 tests/unit/ticketing/test_ticket_field_service.py diff --git a/libzapi/application/commands/ticketing/ticket_field_cmds.py b/libzapi/application/commands/ticketing/ticket_field_cmds.py new file mode 100644 index 0000000..ac54eea --- /dev/null +++ b/libzapi/application/commands/ticketing/ticket_field_cmds.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, TypeAlias + + +@dataclass(frozen=True, slots=True) +class CreateTicketFieldCmd: + title: str + type: str + description: str | None = None + active: bool | None = None + required: bool | None = None + collapsed_for_agents: bool | None = None + regexp_for_validation: str | None = None + title_in_portal: str | None = None + visible_in_portal: bool | None = None + editable_in_portal: bool | None = None + required_in_portal: bool | None = None + agent_can_edit: bool | None = None + tag: str | None = None + position: int | None = None + custom_field_options: Iterable[dict[str, Any]] | None = None + sub_type_id: int | None = None + relationship_target_type: str | None = None + relationship_filter: dict[str, Any] | None = None + agent_description: str | None = None + + +@dataclass(frozen=True, slots=True) +class UpdateTicketFieldCmd: + title: str | None = None + description: str | None = None + active: bool | None = None + required: bool | None = None + collapsed_for_agents: bool | None = None + regexp_for_validation: str | None = None + title_in_portal: str | None = None + visible_in_portal: bool | None = None + editable_in_portal: bool | None = None + required_in_portal: bool | None = None + agent_can_edit: bool | None = None + tag: str | None = None + position: int | None = None + custom_field_options: Iterable[dict[str, Any]] | None = None + sub_type_id: int | None = None + relationship_target_type: str | None = None + relationship_filter: dict[str, Any] | None = None + agent_description: str | None = None + + +@dataclass(frozen=True, slots=True) +class TicketFieldOptionCmd: + name: str + value: str + id: int | None = None + + +TicketFieldCmd: TypeAlias = CreateTicketFieldCmd | UpdateTicketFieldCmd diff --git a/libzapi/application/services/ticketing/ticket_fields_service.py b/libzapi/application/services/ticketing/ticket_fields_service.py index 56fc63b..5970141 100644 --- a/libzapi/application/services/ticketing/ticket_fields_service.py +++ b/libzapi/application/services/ticketing/ticket_fields_service.py @@ -1,6 +1,16 @@ -from typing import Iterable +from __future__ import annotations + +from typing import Any, Iterable + +from libzapi.application.commands.ticketing.ticket_field_cmds import ( + CreateTicketFieldCmd, + TicketFieldOptionCmd, + UpdateTicketFieldCmd, +) from libzapi.domain.models.ticketing.ticket_field import TicketField -from libzapi.infrastructure.api_clients.ticketing.ticket_field_api_client import TicketFieldApiClient +from libzapi.infrastructure.api_clients.ticketing.ticket_field_api_client import ( + TicketFieldApiClient, +) class TicketFieldsService: @@ -13,13 +23,35 @@ def list_all(self) -> Iterable[TicketField]: return self._client.list() def get_by_id(self, field_id: int) -> TicketField: - return self._client.get(field_id) + return self._client.get(field_id=field_id) + + def create(self, **fields) -> TicketField: + return self._client.create(entity=CreateTicketFieldCmd(**fields)) + + def update(self, field_id: int, **fields) -> TicketField: + return self._client.update( + field_id=field_id, entity=UpdateTicketFieldCmd(**fields) + ) + + def delete(self, field_id: int) -> None: + self._client.delete(field_id=field_id) + + def reorder(self, field_ids: Iterable[int]) -> None: + self._client.reorder(field_ids=field_ids) + + def list_options(self, field_id: int) -> Iterable[dict[str, Any]]: + return self._client.list_options(field_id=field_id) - def create_field(self, entity: TicketField) -> TicketField: - return self._client.create(entity) + def get_option(self, field_id: int, option_id: int) -> dict[str, Any]: + return self._client.get_option(field_id=field_id, option_id=option_id) - def update_field(self, field_id: int, entity: TicketField) -> TicketField: - return self._client.update(field_id, entity) + def upsert_option( + self, field_id: int, name: str, value: str, id: int | None = None + ) -> dict[str, Any]: + return self._client.upsert_option( + field_id=field_id, + option=TicketFieldOptionCmd(name=name, value=value, id=id), + ) - def delete_field(self, field_id: int) -> None: - self._client.delete(field_id) + def delete_option(self, field_id: int, option_id: int) -> None: + self._client.delete_option(field_id=field_id, option_id=option_id) diff --git a/libzapi/infrastructure/api_clients/ticketing/ticket_field_api_client.py b/libzapi/infrastructure/api_clients/ticketing/ticket_field_api_client.py index fa27efb..94ebe36 100644 --- a/libzapi/infrastructure/api_clients/ticketing/ticket_field_api_client.py +++ b/libzapi/infrastructure/api_clients/ticketing/ticket_field_api_client.py @@ -1,10 +1,21 @@ from __future__ import annotations -from typing import Iterable + +from typing import Any, Iterable, Iterator + +from libzapi.application.commands.ticketing.ticket_field_cmds import ( + CreateTicketFieldCmd, + TicketFieldOptionCmd, + UpdateTicketFieldCmd, +) +from libzapi.domain.models.ticketing.ticket_field import TicketField from libzapi.infrastructure.http.client import HttpClient from libzapi.infrastructure.http.pagination import yield_items -from libzapi.infrastructure.mappers.ticketing.ticket_field_mapper import to_payload +from libzapi.infrastructure.mappers.ticketing.ticket_field_mapper import ( + option_to_payload, + to_payload_create, + to_payload_update, +) from libzapi.infrastructure.serialization.parse import to_domain -from libzapi.domain.models.ticketing.ticket_field import TicketField class TicketFieldApiClient: @@ -13,28 +24,61 @@ class TicketFieldApiClient: def __init__(self, http: HttpClient) -> None: self._http = http - def list(self) -> Iterable[TicketField]: + def list(self) -> Iterator[TicketField]: for obj in yield_items( get_json=self._http.get, - first_path="/api/v2/ticket_fields.json", + first_path="/api/v2/ticket_fields", base_url=self._http.base_url, items_key="ticket_fields", ): yield to_domain(data=obj, cls=TicketField) def get(self, field_id: int) -> TicketField: - data = self._http.get(f"/api/v2/ticket_fields/{field_id}.json") + data = self._http.get(f"/api/v2/ticket_fields/{int(field_id)}") return to_domain(data=data["ticket_field"], cls=TicketField) - def create(self, entity: TicketField) -> TicketField: - payload = to_payload(entity) - data = self._http.post("/api/v2/ticket_fields.json", payload) + def create(self, entity: CreateTicketFieldCmd) -> TicketField: + payload = to_payload_create(entity) + data = self._http.post("/api/v2/ticket_fields", payload) return to_domain(data=data["ticket_field"], cls=TicketField) - def update(self, field_id: int, entity: TicketField) -> TicketField: - payload = to_payload(entity) - data = self._http.put(f"/api/v2/ticket_fields/{field_id}.json", payload) + def update(self, field_id: int, entity: UpdateTicketFieldCmd) -> TicketField: + payload = to_payload_update(entity) + data = self._http.put(f"/api/v2/ticket_fields/{int(field_id)}", payload) return to_domain(data=data["ticket_field"], cls=TicketField) def delete(self, field_id: int) -> None: - self._http.delete(f"/api/v2/ticket_fields/{field_id}.json") + self._http.delete(f"/api/v2/ticket_fields/{int(field_id)}") + + def reorder(self, field_ids: Iterable[int]) -> None: + payload = {"ticket_field_ids": [int(i) for i in field_ids]} + self._http.put("/api/v2/ticket_fields/reorder", payload) + + def list_options(self, field_id: int) -> Iterator[dict[str, Any]]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"/api/v2/ticket_fields/{int(field_id)}/options", + base_url=self._http.base_url, + items_key="custom_field_options", + ): + yield obj + + def get_option(self, field_id: int, option_id: int) -> dict[str, Any]: + data = self._http.get( + f"/api/v2/ticket_fields/{int(field_id)}/options/{int(option_id)}" + ) + return data["custom_field_option"] + + def upsert_option( + self, field_id: int, option: TicketFieldOptionCmd + ) -> dict[str, Any]: + payload = option_to_payload(option) + data = self._http.post( + f"/api/v2/ticket_fields/{int(field_id)}/options", payload + ) + return data["custom_field_option"] + + def delete_option(self, field_id: int, option_id: int) -> None: + self._http.delete( + f"/api/v2/ticket_fields/{int(field_id)}/options/{int(option_id)}" + ) diff --git a/libzapi/infrastructure/mappers/ticketing/ticket_field_mapper.py b/libzapi/infrastructure/mappers/ticketing/ticket_field_mapper.py index f593d3c..12b6c79 100644 --- a/libzapi/infrastructure/mappers/ticketing/ticket_field_mapper.py +++ b/libzapi/infrastructure/mappers/ticketing/ticket_field_mapper.py @@ -1,12 +1,65 @@ -from libzapi.domain.models.ticketing.ticket_field import TicketField - - -def to_payload(entity: TicketField) -> dict: - """Convert domain model back to Zendesk's JSON shape.""" - return { - "ticket_field": { - "title": entity.title, - "type": entity.type, - "required": entity.required, - } - } +from __future__ import annotations + +from libzapi.application.commands.ticketing.ticket_field_cmds import ( + CreateTicketFieldCmd, + TicketFieldOptionCmd, + UpdateTicketFieldCmd, +) + + +def _add_optionals(body: dict, cmd: CreateTicketFieldCmd | UpdateTicketFieldCmd) -> None: + if cmd.description is not None: + body["description"] = cmd.description + if cmd.active is not None: + body["active"] = cmd.active + if cmd.required is not None: + body["required"] = cmd.required + if cmd.collapsed_for_agents is not None: + body["collapsed_for_agents"] = cmd.collapsed_for_agents + if cmd.regexp_for_validation is not None: + body["regexp_for_validation"] = cmd.regexp_for_validation + if cmd.title_in_portal is not None: + body["title_in_portal"] = cmd.title_in_portal + if cmd.visible_in_portal is not None: + body["visible_in_portal"] = cmd.visible_in_portal + if cmd.editable_in_portal is not None: + body["editable_in_portal"] = cmd.editable_in_portal + if cmd.required_in_portal is not None: + body["required_in_portal"] = cmd.required_in_portal + if cmd.agent_can_edit is not None: + body["agent_can_edit"] = cmd.agent_can_edit + if cmd.tag is not None: + body["tag"] = cmd.tag + if cmd.position is not None: + body["position"] = cmd.position + if cmd.custom_field_options is not None: + body["custom_field_options"] = list(cmd.custom_field_options) + if cmd.sub_type_id is not None: + body["sub_type_id"] = cmd.sub_type_id + if cmd.relationship_target_type is not None: + body["relationship_target_type"] = cmd.relationship_target_type + if cmd.relationship_filter is not None: + body["relationship_filter"] = cmd.relationship_filter + if cmd.agent_description is not None: + body["agent_description"] = cmd.agent_description + + +def to_payload_create(cmd: CreateTicketFieldCmd) -> dict: + body: dict = {"title": cmd.title, "type": cmd.type} + _add_optionals(body, cmd) + return {"ticket_field": body} + + +def to_payload_update(cmd: UpdateTicketFieldCmd) -> dict: + body: dict = {} + if cmd.title is not None: + body["title"] = cmd.title + _add_optionals(body, cmd) + return {"ticket_field": body} + + +def option_to_payload(cmd: TicketFieldOptionCmd) -> dict: + body: dict = {"name": cmd.name, "value": cmd.value} + if cmd.id is not None: + body["id"] = cmd.id + return {"custom_field_option": body} diff --git a/tests/integration/ticketing/test_ticket_field.py b/tests/integration/ticketing/test_ticket_field.py index 5dbb3b7..9444293 100644 --- a/tests/integration/ticketing/test_ticket_field.py +++ b/tests/integration/ticketing/test_ticket_field.py @@ -1,6 +1,73 @@ +import itertools +import uuid + from libzapi import Ticketing -def test_list_ticket_forms(ticketing: Ticketing): - itens = list(ticketing.ticket_fields.list_all()) - assert len(itens) > 0, "Expected at least one group from the live API" +def _unique() -> str: + return uuid.uuid4().hex[:10] + + +def _create_field(ticketing: Ticketing, **overrides): + suffix = _unique() + defaults = dict( + title=f"libzapi field {suffix}", + type="text", + ) + defaults.update(overrides) + return ticketing.ticket_fields.create(**defaults) + + +def test_list_and_get_ticket_field(ticketing: Ticketing): + fields = list(itertools.islice(ticketing.ticket_fields.list_all(), 20)) + assert len(fields) > 0 + field = ticketing.ticket_fields.get_by_id(fields[0].id) + assert field.title == fields[0].title + + +def test_create_update_delete_field(ticketing: Ticketing): + field = _create_field(ticketing, description="created by libzapi") + assert field.id > 0 + try: + updated = ticketing.ticket_fields.update( + field.id, description="updated by libzapi", active=False + ) + assert updated.description == "updated by libzapi" + assert updated.active is False + finally: + ticketing.ticket_fields.delete(field.id) + + +def test_dropdown_options_lifecycle(ticketing: Ticketing): + field = _create_field( + ticketing, + type="tagger", + custom_field_options=[ + {"name": "Alpha", "value": f"alpha_{_unique()}"}, + ], + ) + try: + options = list(ticketing.ticket_fields.list_options(field.id)) + assert len(options) >= 1 + + created = ticketing.ticket_fields.upsert_option( + field_id=field.id, name="Beta", value=f"beta_{_unique()}" + ) + assert created["id"] + + fetched = ticketing.ticket_fields.get_option( + field_id=field.id, option_id=created["id"] + ) + assert fetched["id"] == created["id"] + + ticketing.ticket_fields.delete_option( + field_id=field.id, option_id=created["id"] + ) + finally: + ticketing.ticket_fields.delete(field.id) + + +def test_reorder_does_not_raise(ticketing: Ticketing): + fields = list(itertools.islice(ticketing.ticket_fields.list_all(), 5)) + ids = [f.id for f in fields] + ticketing.ticket_fields.reorder(ids) diff --git a/tests/unit/ticketing/test_ticket_field_client.py b/tests/unit/ticketing/test_ticket_field_client.py new file mode 100644 index 0000000..60152c5 --- /dev/null +++ b/tests/unit/ticketing/test_ticket_field_client.py @@ -0,0 +1,191 @@ +import pytest + +from libzapi.application.commands.ticketing.ticket_field_cmds import ( + CreateTicketFieldCmd, + TicketFieldOptionCmd, + UpdateTicketFieldCmd, +) +from libzapi.domain.errors import ( + NotFound, + RateLimited, + Unauthorized, + UnprocessableEntity, +) +from libzapi.infrastructure.api_clients.ticketing import TicketFieldApiClient + + +@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_field_api_client.to_domain", + side_effect=lambda data, cls: {**(data or {})}, + ) + + +# --------------------------------------------------------------------------- +# Listing / pagination +# --------------------------------------------------------------------------- + + +def test_list_yields_items(http, domain): + http.get.return_value = { + "ticket_fields": [{"id": 1}, {"id": 2}], + "meta": {"has_more": False}, + "links": {"next": None}, + } + client = TicketFieldApiClient(http) + result = list(client.list()) + http.get.assert_called_with("/api/v2/ticket_fields") + assert len(result) == 2 + assert result[0]["id"] == 1 + + +def test_get_returns_domain(http, domain): + http.get.return_value = {"ticket_field": {"id": 5}} + client = TicketFieldApiClient(http) + result = client.get(field_id=5) + http.get.assert_called_with("/api/v2/ticket_fields/5") + assert result["id"] == 5 + + +# --------------------------------------------------------------------------- +# create / update / delete +# --------------------------------------------------------------------------- + + +def test_create_posts_payload(http, domain): + http.post.return_value = {"ticket_field": {"id": 1, "title": "Order"}} + client = TicketFieldApiClient(http) + result = client.create( + CreateTicketFieldCmd(title="Order", type="text", required=True) + ) + http.post.assert_called_with( + "/api/v2/ticket_fields", + {"ticket_field": {"title": "Order", "type": "text", "required": True}}, + ) + assert result["title"] == "Order" + + +def test_update_puts_payload(http, domain): + http.put.return_value = {"ticket_field": {"id": 1, "active": False}} + client = TicketFieldApiClient(http) + client.update(field_id=1, entity=UpdateTicketFieldCmd(active=False)) + http.put.assert_called_with( + "/api/v2/ticket_fields/1", {"ticket_field": {"active": False}} + ) + + +def test_delete_calls_delete(http): + client = TicketFieldApiClient(http) + client.delete(field_id=7) + http.delete.assert_called_with("/api/v2/ticket_fields/7") + + +# --------------------------------------------------------------------------- +# reorder +# --------------------------------------------------------------------------- + + +def test_reorder_puts_ids(http): + client = TicketFieldApiClient(http) + client.reorder(field_ids=[3, 1, 2]) + http.put.assert_called_with( + "/api/v2/ticket_fields/reorder", {"ticket_field_ids": [3, 1, 2]} + ) + + +def test_reorder_converts_iterable(http): + client = TicketFieldApiClient(http) + client.reorder(field_ids=iter([3, 1])) + http.put.assert_called_with( + "/api/v2/ticket_fields/reorder", {"ticket_field_ids": [3, 1]} + ) + + +# --------------------------------------------------------------------------- +# options +# --------------------------------------------------------------------------- + + +def test_list_options_yields_items(http): + http.get.return_value = { + "custom_field_options": [{"id": 1, "name": "A"}, {"id": 2, "name": "B"}], + "meta": {"has_more": False}, + "links": {"next": None}, + } + client = TicketFieldApiClient(http) + result = list(client.list_options(field_id=5)) + http.get.assert_called_with("/api/v2/ticket_fields/5/options") + assert len(result) == 2 + assert result[0]["name"] == "A" + + +def test_get_option_returns_item(http): + http.get.return_value = {"custom_field_option": {"id": 7, "name": "A"}} + client = TicketFieldApiClient(http) + result = client.get_option(field_id=5, option_id=7) + http.get.assert_called_with("/api/v2/ticket_fields/5/options/7") + assert result == {"id": 7, "name": "A"} + + +def test_upsert_option_posts_payload(http): + http.post.return_value = {"custom_field_option": {"id": 9, "name": "A"}} + client = TicketFieldApiClient(http) + result = client.upsert_option( + field_id=5, option=TicketFieldOptionCmd(name="A", value="a") + ) + http.post.assert_called_with( + "/api/v2/ticket_fields/5/options", + {"custom_field_option": {"name": "A", "value": "a"}}, + ) + assert result["id"] == 9 + + +def test_upsert_option_with_id_posts_payload(http): + http.post.return_value = {"custom_field_option": {"id": 9, "name": "A"}} + client = TicketFieldApiClient(http) + client.upsert_option( + field_id=5, option=TicketFieldOptionCmd(name="A", value="a", id=9) + ) + http.post.assert_called_with( + "/api/v2/ticket_fields/5/options", + {"custom_field_option": {"name": "A", "value": "a", "id": 9}}, + ) + + +def test_delete_option_calls_delete(http): + client = TicketFieldApiClient(http) + client.delete_option(field_id=5, option_id=7) + http.delete.assert_called_with("/api/v2/ticket_fields/5/options/7") + + +# --------------------------------------------------------------------------- +# Error propagation +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "error_cls", + [ + pytest.param(Unauthorized, id="401"), + pytest.param(NotFound, id="404"), + pytest.param(UnprocessableEntity, id="422"), + pytest.param(RateLimited, id="429"), + ], +) +def test_ticket_field_api_client_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.side_effect = error_cls("error") + + client = TicketFieldApiClient(https) + + with pytest.raises(error_cls): + list(client.list()) diff --git a/tests/unit/ticketing/test_ticket_field_mapper.py b/tests/unit/ticketing/test_ticket_field_mapper.py new file mode 100644 index 0000000..f8afb44 --- /dev/null +++ b/tests/unit/ticketing/test_ticket_field_mapper.py @@ -0,0 +1,189 @@ +from libzapi.application.commands.ticketing.ticket_field_cmds import ( + CreateTicketFieldCmd, + TicketFieldOptionCmd, + UpdateTicketFieldCmd, +) +from libzapi.infrastructure.mappers.ticketing.ticket_field_mapper import ( + option_to_payload, + to_payload_create, + to_payload_update, +) + + +# --------------------------------------------------------------------------- +# to_payload_create +# --------------------------------------------------------------------------- + + +def test_create_minimal_payload_only_includes_required(): + payload = to_payload_create(CreateTicketFieldCmd(title="Order", type="text")) + assert payload == {"ticket_field": {"title": "Order", "type": "text"}} + + +def test_create_includes_all_optional_fields(): + cmd = CreateTicketFieldCmd( + title="Order", + type="tagger", + description="desc", + active=True, + required=True, + collapsed_for_agents=True, + regexp_for_validation=r"^\d+$", + title_in_portal="Order #", + visible_in_portal=True, + editable_in_portal=True, + required_in_portal=True, + agent_can_edit=True, + tag="order", + position=3, + custom_field_options=[{"name": "A", "value": "a"}], + sub_type_id=2, + relationship_target_type="zen:user", + relationship_filter={"all": []}, + agent_description="internal", + ) + + body = to_payload_create(cmd)["ticket_field"] + + assert body["title"] == "Order" + assert body["type"] == "tagger" + assert body["description"] == "desc" + assert body["active"] is True + assert body["required"] is True + assert body["collapsed_for_agents"] is True + assert body["regexp_for_validation"] == r"^\d+$" + assert body["title_in_portal"] == "Order #" + assert body["visible_in_portal"] is True + assert body["editable_in_portal"] is True + assert body["required_in_portal"] is True + assert body["agent_can_edit"] is True + assert body["tag"] == "order" + assert body["position"] == 3 + assert body["custom_field_options"] == [{"name": "A", "value": "a"}] + assert body["sub_type_id"] == 2 + assert body["relationship_target_type"] == "zen:user" + assert body["relationship_filter"] == {"all": []} + assert body["agent_description"] == "internal" + + +def test_create_preserves_false_booleans(): + body = to_payload_create( + CreateTicketFieldCmd( + title="t", + type="text", + active=False, + required=False, + collapsed_for_agents=False, + visible_in_portal=False, + editable_in_portal=False, + required_in_portal=False, + agent_can_edit=False, + ) + )["ticket_field"] + assert body["active"] is False + assert body["required"] is False + assert body["collapsed_for_agents"] is False + assert body["visible_in_portal"] is False + assert body["editable_in_portal"] is False + assert body["required_in_portal"] is False + assert body["agent_can_edit"] is False + + +def test_create_skips_none_optional_fields(): + body = to_payload_create( + CreateTicketFieldCmd(title="t", type="text") + )["ticket_field"] + assert set(body.keys()) == {"title", "type"} + + +def test_create_converts_options_iterable_to_list(): + opts = [{"name": "A", "value": "a"}] + body = to_payload_create( + CreateTicketFieldCmd( + title="t", type="tagger", custom_field_options=iter(opts) + ) + )["ticket_field"] + assert body["custom_field_options"] == opts + + +# --------------------------------------------------------------------------- +# to_payload_update +# --------------------------------------------------------------------------- + + +def test_update_empty_cmd_returns_empty_patch(): + assert to_payload_update(UpdateTicketFieldCmd()) == {"ticket_field": {}} + + +def test_update_includes_all_fields(): + cmd = UpdateTicketFieldCmd( + title="New", + description="d", + active=True, + required=True, + collapsed_for_agents=True, + regexp_for_validation=r"\d+", + title_in_portal="Foo", + visible_in_portal=True, + editable_in_portal=True, + required_in_portal=True, + agent_can_edit=True, + tag="x", + position=1, + custom_field_options=[{"name": "A", "value": "a"}], + sub_type_id=9, + relationship_target_type="zen:user", + relationship_filter={"all": []}, + agent_description="hi", + ) + body = to_payload_update(cmd)["ticket_field"] + assert body == { + "title": "New", + "description": "d", + "active": True, + "required": True, + "collapsed_for_agents": True, + "regexp_for_validation": r"\d+", + "title_in_portal": "Foo", + "visible_in_portal": True, + "editable_in_portal": True, + "required_in_portal": True, + "agent_can_edit": True, + "tag": "x", + "position": 1, + "custom_field_options": [{"name": "A", "value": "a"}], + "sub_type_id": 9, + "relationship_target_type": "zen:user", + "relationship_filter": {"all": []}, + "agent_description": "hi", + } + + +def test_update_preserves_false_booleans(): + body = to_payload_update(UpdateTicketFieldCmd(active=False))["ticket_field"] + assert body == {"active": False} + + +def test_update_converts_options_iterable_to_list(): + opts = [{"name": "A", "value": "a"}] + body = to_payload_update( + UpdateTicketFieldCmd(custom_field_options=iter(opts)) + )["ticket_field"] + assert body["custom_field_options"] == opts + + +# --------------------------------------------------------------------------- +# option_to_payload +# --------------------------------------------------------------------------- + + +def test_option_to_payload_without_id(): + payload = option_to_payload(TicketFieldOptionCmd(name="A", value="a")) + assert payload == {"custom_field_option": {"name": "A", "value": "a"}} + + +def test_option_to_payload_with_id(): + payload = option_to_payload(TicketFieldOptionCmd(name="A", value="a", id=42)) + assert payload == { + "custom_field_option": {"name": "A", "value": "a", "id": 42} + } diff --git a/tests/unit/ticketing/test_ticket_field_service.py b/tests/unit/ticketing/test_ticket_field_service.py new file mode 100644 index 0000000..7f05df4 --- /dev/null +++ b/tests/unit/ticketing/test_ticket_field_service.py @@ -0,0 +1,186 @@ +import pytest +from unittest.mock import Mock, sentinel + +from libzapi.application.commands.ticketing.ticket_field_cmds import ( + CreateTicketFieldCmd, + TicketFieldOptionCmd, + UpdateTicketFieldCmd, +) +from libzapi.application.services.ticketing.ticket_fields_service import ( + TicketFieldsService, +) +from libzapi.domain.errors import ( + NotFound, + RateLimited, + Unauthorized, + UnprocessableEntity, +) + + +def _make_service(client=None): + client = client or Mock() + return TicketFieldsService(client), client + + +# --------------------------------------------------------------------------- +# Delegation-only methods +# --------------------------------------------------------------------------- + + +class TestDelegation: + def test_list_all_delegates(self): + service, client = _make_service() + client.list.return_value = sentinel.fields + assert service.list_all() is sentinel.fields + client.list.assert_called_once_with() + + def test_get_by_id_delegates(self): + service, client = _make_service() + client.get.return_value = sentinel.field + assert service.get_by_id(5) is sentinel.field + client.get.assert_called_once_with(field_id=5) + + def test_delete_delegates(self): + service, client = _make_service() + service.delete(5) + client.delete.assert_called_once_with(field_id=5) + + def test_reorder_delegates(self): + service, client = _make_service() + service.reorder([3, 1, 2]) + client.reorder.assert_called_once_with(field_ids=[3, 1, 2]) + + def test_list_options_delegates(self): + service, client = _make_service() + client.list_options.return_value = sentinel.options + assert service.list_options(5) is sentinel.options + client.list_options.assert_called_once_with(field_id=5) + + def test_get_option_delegates(self): + service, client = _make_service() + client.get_option.return_value = sentinel.option + assert service.get_option(field_id=5, option_id=7) is sentinel.option + client.get_option.assert_called_once_with(field_id=5, option_id=7) + + def test_delete_option_delegates(self): + service, client = _make_service() + service.delete_option(field_id=5, option_id=7) + client.delete_option.assert_called_once_with(field_id=5, option_id=7) + + +# --------------------------------------------------------------------------- +# create / update +# --------------------------------------------------------------------------- + + +class TestCreate: + def test_builds_create_cmd_and_delegates(self): + service, client = _make_service() + client.create.return_value = sentinel.field + + result = service.create(title="Order", type="text") + + client.create.assert_called_once() + cmd = client.create.call_args.kwargs["entity"] + assert isinstance(cmd, CreateTicketFieldCmd) + assert cmd.title == "Order" + assert cmd.type == "text" + assert result is sentinel.field + + def test_passes_all_optional_fields(self): + service, client = _make_service() + service.create( + title="Order", + type="tagger", + active=False, + required=True, + description="d", + custom_field_options=[{"name": "A", "value": "a"}], + ) + cmd = client.create.call_args.kwargs["entity"] + assert cmd.active is False + assert cmd.required is True + assert cmd.description == "d" + assert cmd.custom_field_options == [{"name": "A", "value": "a"}] + + +class TestUpdate: + def test_builds_update_cmd_and_delegates(self): + service, client = _make_service() + client.update.return_value = sentinel.field + + result = service.update(7, description="updated", active=False) + + client.update.assert_called_once() + assert client.update.call_args.kwargs["field_id"] == 7 + cmd = client.update.call_args.kwargs["entity"] + assert isinstance(cmd, UpdateTicketFieldCmd) + assert cmd.description == "updated" + assert cmd.active is False + assert result is sentinel.field + + def test_empty_fields_yields_blank_cmd(self): + service, client = _make_service() + service.update(1) + cmd = client.update.call_args.kwargs["entity"] + assert cmd.title is None + assert cmd.active is None + + +# --------------------------------------------------------------------------- +# upsert_option +# --------------------------------------------------------------------------- + + +class TestUpsertOption: + def test_builds_option_cmd_and_delegates(self): + service, client = _make_service() + client.upsert_option.return_value = sentinel.option + + result = service.upsert_option(field_id=5, name="A", value="a") + + assert result is sentinel.option + assert client.upsert_option.call_args.kwargs["field_id"] == 5 + cmd = client.upsert_option.call_args.kwargs["option"] + assert isinstance(cmd, TicketFieldOptionCmd) + assert cmd.name == "A" + assert cmd.value == "a" + assert cmd.id is None + + def test_passes_id(self): + service, client = _make_service() + service.upsert_option(field_id=5, name="A", value="a", id=9) + cmd = client.upsert_option.call_args.kwargs["option"] + assert cmd.id == 9 + + +# --------------------------------------------------------------------------- +# Error propagation +# --------------------------------------------------------------------------- + + +class TestErrorPropagation: + @pytest.mark.parametrize( + "error_cls", [Unauthorized, NotFound, UnprocessableEntity, RateLimited] + ) + def test_create_propagates_client_error(self, error_cls): + service, client = _make_service() + client.create.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.create(title="t", type="text") + + @pytest.mark.parametrize( + "error_cls", [Unauthorized, NotFound, UnprocessableEntity, RateLimited] + ) + def test_update_propagates_client_error(self, error_cls): + service, client = _make_service() + client.update.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.update(1) + + @pytest.mark.parametrize("error_cls", [Unauthorized, NotFound]) + def test_list_all_propagates_client_error(self, error_cls): + service, client = _make_service() + client.list.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.list_all()