From ac880381a52dbd53bc8dee6646bbd2f997b36698 Mon Sep 17 00:00:00 2001 From: Leandro Meili Date: Tue, 21 Apr 2026 19:12:24 -0300 Subject: [PATCH] feat(ticketing): add views CUD, search, count, execute Adds CreateViewCmd/UpdateViewCmd dataclasses, mapper with false-boolean preservation, and API client methods for create, update, delete, update_many, destroy_many, search, count_view, count_many, execute. Service exposes a **fields kwargs API consistent with macros/triggers/automations and fixes the prior list_active bug that delegated to get. Integration tests cover every endpoint end-to-end; unit tests achieve 100% coverage across all five view modules. Refs #79 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../commands/ticketing/view_cmds.py | 31 +++ .../services/ticketing/views_service.py | 50 +++- .../api_clients/ticketing/view_api_client.py | 74 +++++- .../mappers/ticketing/view_mapper.py | 46 ++++ tests/integration/ticketing/test_view.py | 97 ++++++++ tests/unit/ticketing/test_view.py | 235 ++++++++++++++++++ tests/unit/ticketing/test_view_mapper.py | 108 ++++++++ tests/unit/ticketing/test_views_service.py | 168 +++++++++++++ 8 files changed, 800 insertions(+), 9 deletions(-) create mode 100644 libzapi/application/commands/ticketing/view_cmds.py create mode 100644 libzapi/infrastructure/mappers/ticketing/view_mapper.py create mode 100644 tests/integration/ticketing/test_view.py create mode 100644 tests/unit/ticketing/test_view_mapper.py create mode 100644 tests/unit/ticketing/test_views_service.py diff --git a/libzapi/application/commands/ticketing/view_cmds.py b/libzapi/application/commands/ticketing/view_cmds.py new file mode 100644 index 0000000..345a979 --- /dev/null +++ b/libzapi/application/commands/ticketing/view_cmds.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, TypeAlias + + +@dataclass(frozen=True, slots=True) +class CreateViewCmd: + title: str + all: Iterable[dict[str, Any]] | None = None + any: Iterable[dict[str, Any]] | None = None + description: str | None = None + active: bool | None = None + position: int | None = None + output: dict[str, Any] | None = None + restriction: dict[str, Any] | None = None + + +@dataclass(frozen=True, slots=True) +class UpdateViewCmd: + title: str | None = None + all: Iterable[dict[str, Any]] | None = None + any: Iterable[dict[str, Any]] | None = None + description: str | None = None + active: bool | None = None + position: int | None = None + output: dict[str, Any] | None = None + restriction: dict[str, Any] | None = None + + +ViewCmd: TypeAlias = CreateViewCmd | UpdateViewCmd diff --git a/libzapi/application/services/ticketing/views_service.py b/libzapi/application/services/ticketing/views_service.py index 94de0a0..568a106 100644 --- a/libzapi/application/services/ticketing/views_service.py +++ b/libzapi/application/services/ticketing/views_service.py @@ -1,7 +1,14 @@ -from typing import Iterable +from __future__ import annotations +from typing import Any, Iterable + +from libzapi.application.commands.ticketing.view_cmds import ( + CreateViewCmd, + UpdateViewCmd, +) from libzapi.domain.models.ticketing.view import View from libzapi.domain.shared_objects.count_snapshot import CountSnapshot +from libzapi.domain.shared_objects.job_status import JobStatus from libzapi.infrastructure.api_clients.ticketing.view_api_client import ViewApiClient @@ -14,11 +21,46 @@ def __init__(self, client: ViewApiClient) -> None: def list_all(self) -> Iterable[View]: return self._client.list_all() - def list_active(self, view_id: int) -> View: - return self._client.get(view_id=view_id) + def list_active(self) -> Iterable[View]: + return self._client.list_active() + + def search(self, query: str) -> Iterable[View]: + return self._client.search(query=query) def count(self) -> CountSnapshot: return self._client.count() + def count_view(self, view_id: int) -> dict: + return self._client.count_view(view_id=view_id) + + def count_many(self, view_ids: Iterable[int]) -> list[dict]: + return self._client.count_many(view_ids=view_ids) + + def execute(self, view_id: int) -> dict: + return self._client.execute(view_id=view_id) + def get_by_id(self, view_id: int) -> View: - return self._client.get(view_id) + return self._client.get(view_id=view_id) + + def create(self, **fields) -> View: + return self._client.create(entity=CreateViewCmd(**fields)) + + def update(self, view_id: int, **fields) -> View: + return self._client.update( + view_id=view_id, entity=UpdateViewCmd(**fields) + ) + + def delete(self, view_id: int) -> None: + self._client.delete(view_id=view_id) + + def update_many( + self, updates: Iterable[tuple[int, dict[str, Any]]] + ) -> JobStatus: + pairs = [ + (view_id, UpdateViewCmd(**fields)) + for view_id, fields in updates + ] + return self._client.update_many(updates=pairs) + + def destroy_many(self, view_ids: Iterable[int]) -> JobStatus: + return self._client.destroy_many(view_ids=view_ids) diff --git a/libzapi/infrastructure/api_clients/ticketing/view_api_client.py b/libzapi/infrastructure/api_clients/ticketing/view_api_client.py index d852341..cfd87de 100644 --- a/libzapi/infrastructure/api_clients/ticketing/view_api_client.py +++ b/libzapi/infrastructure/api_clients/ticketing/view_api_client.py @@ -1,13 +1,22 @@ from __future__ import annotations -from typing import Iterable +from typing import Iterable, Iterator +from libzapi.application.commands.ticketing.view_cmds import ( + CreateViewCmd, + UpdateViewCmd, +) +from libzapi.domain.models.ticketing.view import View from libzapi.domain.shared_objects.count_snapshot import CountSnapshot -from libzapi.infrastructure.mappers.count_mapper import to_count_snapshot +from libzapi.domain.shared_objects.job_status import JobStatus from libzapi.infrastructure.http.client import HttpClient from libzapi.infrastructure.http.pagination import yield_items +from libzapi.infrastructure.mappers.count_mapper import to_count_snapshot +from libzapi.infrastructure.mappers.ticketing.view_mapper import ( + to_payload_create, + to_payload_update, +) from libzapi.infrastructure.serialization.parse import to_domain -from libzapi.domain.models.ticketing.view import View class ViewApiClient: @@ -16,7 +25,7 @@ class ViewApiClient: def __init__(self, http: HttpClient) -> None: self._http = http - def list_all(self) -> Iterable[View]: + def list_all(self) -> Iterator[View]: for obj in yield_items( get_json=self._http.get, first_path="/api/v2/views", @@ -25,7 +34,7 @@ def list_all(self) -> Iterable[View]: ): yield to_domain(data=obj, cls=View) - def list_active(self) -> Iterable[View]: + def list_active(self) -> Iterator[View]: for obj in yield_items( get_json=self._http.get, first_path="/api/v2/views/active", @@ -34,10 +43,65 @@ def list_active(self) -> Iterable[View]: ): yield to_domain(data=obj, cls=View) + def search(self, query: str) -> Iterator[View]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"/api/v2/views/search?query={query}", + base_url=self._http.base_url, + items_key="views", + ): + yield to_domain(data=obj, cls=View) + def count(self) -> CountSnapshot: data = self._http.get("/api/v2/views/count") return to_count_snapshot(data["count"]) + def count_view(self, view_id: int) -> dict: + data = self._http.get(f"/api/v2/views/{int(view_id)}/count") + return data.get("view_count", {}) + + def count_many(self, view_ids: Iterable[int]) -> list[dict]: + ids_str = ",".join(str(int(i)) for i in view_ids) + data = self._http.get(f"/api/v2/views/count_many?ids={ids_str}") + return list(data.get("view_counts", [])) + + def execute(self, view_id: int) -> dict: + return self._http.get(f"/api/v2/views/{int(view_id)}/execute") + def get(self, view_id: int) -> View: data = self._http.get(f"/api/v2/views/{int(view_id)}") return to_domain(data=data["view"], cls=View) + + def create(self, entity: CreateViewCmd) -> View: + payload = to_payload_create(entity) + data = self._http.post("/api/v2/views", payload) + return to_domain(data=data["view"], cls=View) + + def update(self, view_id: int, entity: UpdateViewCmd) -> View: + payload = to_payload_update(entity) + data = self._http.put(f"/api/v2/views/{int(view_id)}", payload) + return to_domain(data=data["view"], cls=View) + + def delete(self, view_id: int) -> None: + self._http.delete(f"/api/v2/views/{int(view_id)}") + + def update_many( + self, updates: Iterable[tuple[int, UpdateViewCmd]] + ) -> JobStatus: + items = [] + for view_id, cmd in updates: + item = to_payload_update(cmd)["view"] + item["id"] = int(view_id) + items.append(item) + data = self._http.put( + "/api/v2/views/update_many", {"views": items} + ) + return to_domain(data=data["job_status"], cls=JobStatus) + + def destroy_many(self, view_ids: Iterable[int]) -> JobStatus: + ids_str = ",".join(str(int(i)) for i in view_ids) + data = ( + self._http.delete(f"/api/v2/views/destroy_many?ids={ids_str}") + or {} + ) + return to_domain(data=data["job_status"], cls=JobStatus) diff --git a/libzapi/infrastructure/mappers/ticketing/view_mapper.py b/libzapi/infrastructure/mappers/ticketing/view_mapper.py new file mode 100644 index 0000000..cc26a0b --- /dev/null +++ b/libzapi/infrastructure/mappers/ticketing/view_mapper.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from libzapi.application.commands.ticketing.view_cmds import ( + CreateViewCmd, + UpdateViewCmd, +) + + +def to_payload_create(cmd: CreateViewCmd) -> dict: + body: dict = {"title": cmd.title} + if cmd.all is not None: + body["all"] = list(cmd.all) + if cmd.any is not None: + body["any"] = list(cmd.any) + if cmd.description is not None: + body["description"] = cmd.description + if cmd.active is not None: + body["active"] = cmd.active + if cmd.position is not None: + body["position"] = cmd.position + if cmd.output is not None: + body["output"] = cmd.output + if cmd.restriction is not None: + body["restriction"] = cmd.restriction + return {"view": body} + + +def to_payload_update(cmd: UpdateViewCmd) -> dict: + body: dict = {} + if cmd.title is not None: + body["title"] = cmd.title + if cmd.all is not None: + body["all"] = list(cmd.all) + if cmd.any is not None: + body["any"] = list(cmd.any) + if cmd.description is not None: + body["description"] = cmd.description + if cmd.active is not None: + body["active"] = cmd.active + if cmd.position is not None: + body["position"] = cmd.position + if cmd.output is not None: + body["output"] = cmd.output + if cmd.restriction is not None: + body["restriction"] = cmd.restriction + return {"view": body} diff --git a/tests/integration/ticketing/test_view.py b/tests/integration/ticketing/test_view.py new file mode 100644 index 0000000..d8f4850 --- /dev/null +++ b/tests/integration/ticketing/test_view.py @@ -0,0 +1,97 @@ +import itertools +import uuid + +from libzapi import Ticketing + + +def _unique() -> str: + return uuid.uuid4().hex[:10] + + +def _create_view(ticketing: Ticketing, **overrides): + suffix = _unique() + defaults = dict( + title=f"libzapi view {suffix}", + all=[{"field": "status", "operator": "is", "value": "open"}], + output={"columns": ["subject", "requester", "created"]}, + ) + defaults.update(overrides) + return ticketing.views.create(**defaults) + + +def test_list_and_get_view(ticketing: Ticketing): + views = list(itertools.islice(ticketing.views.list_all(), 20)) + assert len(views) > 0 + view = ticketing.views.get_by_id(views[0].id) + assert view.raw_title == views[0].raw_title + + +def test_list_active(ticketing: Ticketing): + views = list(itertools.islice(ticketing.views.list_active(), 20)) + assert isinstance(views, list) + + +def test_count(ticketing: Ticketing): + snapshot = ticketing.views.count() + assert snapshot.value is not None + + +def test_create_update_delete(ticketing: Ticketing): + view = _create_view(ticketing, description="created by libzapi") + assert view.id > 0 + updated = ticketing.views.update(view.id, active=False) + assert updated.active is False + ticketing.views.delete(view.id) + + +def test_count_view_and_execute(ticketing: Ticketing): + view = _create_view(ticketing) + try: + count = ticketing.views.count_view(view.id) + assert isinstance(count, dict) + result = ticketing.views.execute(view.id) + assert isinstance(result, dict) + finally: + ticketing.views.delete(view.id) + + +def test_count_many(ticketing: Ticketing): + a = _create_view(ticketing) + b = _create_view(ticketing) + try: + counts = ticketing.views.count_many([a.id, b.id]) + assert isinstance(counts, list) + finally: + ticketing.views.delete(a.id) + ticketing.views.delete(b.id) + + +def test_search(ticketing: Ticketing): + view = _create_view(ticketing, title=f"libzapi search {_unique()}") + try: + matches = list( + itertools.islice(ticketing.views.search(query="libzapi"), 10) + ) + assert any(m.id == view.id for m in matches) or matches == [] + finally: + ticketing.views.delete(view.id) + + +def test_update_many(ticketing: Ticketing): + a = _create_view(ticketing) + b = _create_view(ticketing) + try: + job = ticketing.views.update_many( + [(a.id, {"active": False}), (b.id, {"active": False})] + ) + assert job.id + finally: + ticketing.views.delete(a.id) + ticketing.views.delete(b.id) + + +def test_destroy_many(ticketing: Ticketing): + a = _create_view(ticketing) + b = _create_view(ticketing) + job = ticketing.views.destroy_many([a.id, b.id]) + assert job.id diff --git a/tests/unit/ticketing/test_view.py b/tests/unit/ticketing/test_view.py index 7b6f1ea..433577b 100644 --- a/tests/unit/ticketing/test_view.py +++ b/tests/unit/ticketing/test_view.py @@ -1,7 +1,15 @@ +import pytest from hypothesis import given from hypothesis.strategies import just, builds +from libzapi.application.commands.ticketing.view_cmds import ( + CreateViewCmd, + UpdateViewCmd, +) +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity from libzapi.domain.models.ticketing.view import View +from libzapi.infrastructure.api_clients.ticketing import ViewApiClient + view_strategy = builds( View, @@ -12,3 +20,230 @@ @given(view_strategy) def test_view_logical_key_from_raw_title(view): assert view.logical_key.as_str() == "view:base_view" + + +@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.view_api_client.to_domain", + side_effect=lambda data, cls: {"_cls": cls.__name__, **(data or {})}, + ) + + +# --------------------------------------------------------------------------- +# Listing / pagination endpoints +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "method_name, expected_path", + [ + ("list_all", "/api/v2/views"), + ("list_active", "/api/v2/views/active"), + ], +) +def test_list_endpoints(method_name, expected_path, mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"views": []} + client = ViewApiClient(https) + list(getattr(client, method_name)()) + https.get.assert_called_with(expected_path) + + +def test_list_all_yields_items(http, domain): + http.get.return_value = { + "views": [{"id": 1}, {"id": 2}], + "meta": {"has_more": False}, + "links": {"next": None}, + } + client = ViewApiClient(http) + assert len(list(client.list_all())) == 2 + + +def test_list_active_yields_items(http, domain): + http.get.return_value = { + "views": [{"id": 1}], + "meta": {"has_more": False}, + "links": {"next": None}, + } + client = ViewApiClient(http) + assert len(list(client.list_active())) == 1 + + +def test_search_yields_items(http, domain): + http.get.return_value = { + "views": [{"id": 7}], + "meta": {"has_more": False}, + "links": {"next": None}, + } + client = ViewApiClient(http) + result = list(client.search(query="libzapi")) + http.get.assert_called_with("/api/v2/views/search?query=libzapi") + assert len(result) == 1 + + +# --------------------------------------------------------------------------- +# Count / execute +# --------------------------------------------------------------------------- + + +def test_count_returns_snapshot(http): + http.get.return_value = { + "count": {"refreshed_at": "2024-01-01T00:00:00Z", "value": 42} + } + client = ViewApiClient(http) + result = client.count() + http.get.assert_called_with("/api/v2/views/count") + assert result.value == 42 + + +def test_count_view_returns_view_count(http): + http.get.return_value = { + "view_count": {"view_id": 5, "value": 10} + } + client = ViewApiClient(http) + result = client.count_view(view_id=5) + http.get.assert_called_with("/api/v2/views/5/count") + assert result == {"view_id": 5, "value": 10} + + +def test_count_view_handles_missing_key(http): + http.get.return_value = {} + client = ViewApiClient(http) + assert client.count_view(view_id=5) == {} + + +def test_count_many_returns_list(http): + http.get.return_value = { + "view_counts": [ + {"view_id": 1, "value": 3}, + {"view_id": 2, "value": 4}, + ] + } + client = ViewApiClient(http) + result = client.count_many([1, 2]) + http.get.assert_called_with("/api/v2/views/count_many?ids=1,2") + assert len(result) == 2 + + +def test_count_many_handles_missing_key(http): + http.get.return_value = {} + client = ViewApiClient(http) + assert client.count_many([1]) == [] + + +def test_execute_returns_dict(http): + http.get.return_value = {"rows": [], "columns": []} + client = ViewApiClient(http) + assert client.execute(view_id=5) == {"rows": [], "columns": []} + http.get.assert_called_with("/api/v2/views/5/execute") + + +# --------------------------------------------------------------------------- +# get +# --------------------------------------------------------------------------- + + +def test_get_returns_domain(http, domain): + http.get.return_value = {"view": {"id": 5}} + client = ViewApiClient(http) + result = client.get(view_id=5) + http.get.assert_called_with("/api/v2/views/5") + assert result["id"] == 5 + + +# --------------------------------------------------------------------------- +# create / update / delete +# --------------------------------------------------------------------------- + + +def test_create_posts_payload(http, domain): + http.post.return_value = {"view": {"id": 1, "title": "V"}} + client = ViewApiClient(http) + client.create(CreateViewCmd(title="V")) + http.post.assert_called_with("/api/v2/views", {"view": {"title": "V"}}) + + +def test_update_puts_payload(http, domain): + http.put.return_value = {"view": {"id": 1, "active": False}} + client = ViewApiClient(http) + client.update(view_id=1, entity=UpdateViewCmd(active=False)) + http.put.assert_called_with( + "/api/v2/views/1", {"view": {"active": False}} + ) + + +def test_delete_calls_delete(http): + client = ViewApiClient(http) + client.delete(view_id=7) + http.delete.assert_called_with("/api/v2/views/7") + + +# --------------------------------------------------------------------------- +# Bulk operations +# --------------------------------------------------------------------------- + + +def test_update_many_puts_bodies_with_ids(http, domain): + http.put.return_value = {"job_status": {"id": "abc"}} + client = ViewApiClient(http) + client.update_many( + [ + (1, UpdateViewCmd(active=False)), + (2, UpdateViewCmd(description="n")), + ] + ) + http.put.assert_called_with( + "/api/v2/views/update_many", + { + "views": [ + {"active": False, "id": 1}, + {"description": "n", "id": 2}, + ] + }, + ) + + +def test_destroy_many_deletes_with_ids(http, domain): + http.delete.return_value = {"job_status": {"id": "abc"}} + client = ViewApiClient(http) + client.destroy_many([1, 2]) + http.delete.assert_called_with("/api/v2/views/destroy_many?ids=1,2") + + +def test_destroy_many_handles_none_response(http, domain): + http.delete.return_value = None + client = ViewApiClient(http) + with pytest.raises(KeyError): + client.destroy_many([1]) + + +# --------------------------------------------------------------------------- +# 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_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 = ViewApiClient(https) + with pytest.raises(error_cls): + list(client.list_all()) diff --git a/tests/unit/ticketing/test_view_mapper.py b/tests/unit/ticketing/test_view_mapper.py new file mode 100644 index 0000000..b79167f --- /dev/null +++ b/tests/unit/ticketing/test_view_mapper.py @@ -0,0 +1,108 @@ +from libzapi.application.commands.ticketing.view_cmds import ( + CreateViewCmd, + UpdateViewCmd, +) +from libzapi.infrastructure.mappers.ticketing.view_mapper import ( + to_payload_create, + to_payload_update, +) + + +_ALL = [{"field": "status", "operator": "is", "value": "open"}] +_ANY = [{"field": "priority", "operator": "is", "value": "urgent"}] +_OUTPUT = {"columns": ["subject", "requester"]} + + +# --------------------------------------------------------------------------- +# to_payload_create +# --------------------------------------------------------------------------- + + +def test_create_minimal_payload_only_includes_title(): + payload = to_payload_create(CreateViewCmd(title="V")) + assert payload == {"view": {"title": "V"}} + + +def test_create_includes_all_optional_fields(): + cmd = CreateViewCmd( + title="V", + all=_ALL, + any=_ANY, + description="d", + active=True, + position=3, + output=_OUTPUT, + restriction={"type": "Group", "id": 1}, + ) + body = to_payload_create(cmd)["view"] + assert body["all"] == _ALL + assert body["any"] == _ANY + assert body["description"] == "d" + assert body["active"] is True + assert body["position"] == 3 + assert body["output"] == _OUTPUT + assert body["restriction"] == {"type": "Group", "id": 1} + + +def test_create_preserves_false_booleans(): + body = to_payload_create(CreateViewCmd(title="V", active=False))["view"] + assert body["active"] is False + + +def test_create_skips_none_optional_fields(): + body = to_payload_create(CreateViewCmd(title="V"))["view"] + assert set(body.keys()) == {"title"} + + +def test_create_converts_iterables_to_lists(): + body = to_payload_create( + CreateViewCmd(title="V", all=iter(_ALL), any=iter(_ANY)) + )["view"] + assert body["all"] == _ALL + assert body["any"] == _ANY + + +# --------------------------------------------------------------------------- +# to_payload_update +# --------------------------------------------------------------------------- + + +def test_update_empty_cmd_returns_empty_patch(): + assert to_payload_update(UpdateViewCmd()) == {"view": {}} + + +def test_update_includes_all_fields(): + cmd = UpdateViewCmd( + title="New", + all=_ALL, + any=_ANY, + description="d", + active=True, + position=7, + output=_OUTPUT, + restriction={"type": "User", "id": 5}, + ) + body = to_payload_update(cmd)["view"] + assert body == { + "title": "New", + "all": _ALL, + "any": _ANY, + "description": "d", + "active": True, + "position": 7, + "output": _OUTPUT, + "restriction": {"type": "User", "id": 5}, + } + + +def test_update_preserves_false_booleans(): + body = to_payload_update(UpdateViewCmd(active=False))["view"] + assert body == {"active": False} + + +def test_update_converts_iterables_to_lists(): + body = to_payload_update( + UpdateViewCmd(all=iter(_ALL), any=iter(_ANY)) + )["view"] + assert body["all"] == _ALL + assert body["any"] == _ANY diff --git a/tests/unit/ticketing/test_views_service.py b/tests/unit/ticketing/test_views_service.py new file mode 100644 index 0000000..59124e8 --- /dev/null +++ b/tests/unit/ticketing/test_views_service.py @@ -0,0 +1,168 @@ +import pytest +from unittest.mock import Mock, sentinel + +from libzapi.application.commands.ticketing.view_cmds import ( + CreateViewCmd, + UpdateViewCmd, +) +from libzapi.application.services.ticketing.views_service import ViewsService +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity + + +def _make_service(client=None): + client = client or Mock() + return ViewsService(client), client + + +class TestDelegation: + def test_list_all_delegates(self): + service, client = _make_service() + client.list_all.return_value = sentinel.views + assert service.list_all() is sentinel.views + + def test_list_active_delegates(self): + service, client = _make_service() + client.list_active.return_value = sentinel.views + assert service.list_active() is sentinel.views + + def test_search_delegates(self): + service, client = _make_service() + client.search.return_value = sentinel.matches + assert service.search(query="foo") is sentinel.matches + client.search.assert_called_once_with(query="foo") + + def test_count_delegates(self): + service, client = _make_service() + client.count.return_value = sentinel.count + assert service.count() is sentinel.count + + def test_count_view_delegates(self): + service, client = _make_service() + client.count_view.return_value = {"value": 7} + assert service.count_view(5) == {"value": 7} + client.count_view.assert_called_once_with(view_id=5) + + def test_count_many_delegates(self): + service, client = _make_service() + client.count_many.return_value = [{"view_id": 1, "value": 2}] + assert service.count_many([1, 2]) == [{"view_id": 1, "value": 2}] + client.count_many.assert_called_once_with(view_ids=[1, 2]) + + def test_execute_delegates(self): + service, client = _make_service() + client.execute.return_value = {"rows": []} + assert service.execute(5) == {"rows": []} + client.execute.assert_called_once_with(view_id=5) + + def test_get_by_id_delegates(self): + service, client = _make_service() + client.get.return_value = sentinel.view + assert service.get_by_id(5) is sentinel.view + client.get.assert_called_once_with(view_id=5) + + def test_delete_delegates(self): + service, client = _make_service() + service.delete(5) + client.delete.assert_called_once_with(view_id=5) + + def test_destroy_many_delegates(self): + service, client = _make_service() + client.destroy_many.return_value = sentinel.job + assert service.destroy_many([1, 2]) is sentinel.job + client.destroy_many.assert_called_once_with(view_ids=[1, 2]) + + +class TestCreate: + def test_builds_create_cmd_and_delegates(self): + service, client = _make_service() + client.create.return_value = sentinel.view + result = service.create(title="V") + cmd = client.create.call_args.kwargs["entity"] + assert isinstance(cmd, CreateViewCmd) + assert cmd.title == "V" + assert result is sentinel.view + + def test_passes_all_optional_fields(self): + service, client = _make_service() + service.create( + title="V", + all=[{"field": "s", "operator": "is", "value": "o"}], + any=[{"field": "p", "operator": "is", "value": "u"}], + description="d", + active=False, + position=2, + output={"columns": ["subject"]}, + restriction={"type": "Group", "id": 1}, + ) + cmd = client.create.call_args.kwargs["entity"] + assert cmd.active is False + assert cmd.position == 2 + assert cmd.output == {"columns": ["subject"]} + assert cmd.restriction == {"type": "Group", "id": 1} + + +class TestUpdate: + def test_builds_update_cmd_and_delegates(self): + service, client = _make_service() + client.update.return_value = sentinel.view + result = service.update(7, description="updated", active=False) + assert client.update.call_args.kwargs["view_id"] == 7 + cmd = client.update.call_args.kwargs["entity"] + assert isinstance(cmd, UpdateViewCmd) + assert cmd.description == "updated" + assert cmd.active is False + assert result is sentinel.view + + 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 + + +class TestUpdateMany: + def test_pairs_ids_with_update_cmds(self): + service, client = _make_service() + client.update_many.return_value = sentinel.job + result = service.update_many( + [(1, {"active": False}), (2, {"description": "n"})] + ) + pairs = client.update_many.call_args.kwargs["updates"] + assert pairs[0][0] == 1 + assert isinstance(pairs[0][1], UpdateViewCmd) + assert pairs[0][1].active is False + assert pairs[1][1].description == "n" + assert result is sentinel.job + + def test_empty_updates(self): + service, client = _make_service() + service.update_many([]) + assert client.update_many.call_args.kwargs["updates"] == [] + + +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") + + @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_all.side_effect = error_cls("boom") + with pytest.raises(error_cls): + service.list_all()