From e2e101be93924b181920dd3032f5e473015dce58 Mon Sep 17 00:00:00 2001 From: Leandro Meili Date: Sat, 21 Mar 2026 23:30:15 -0300 Subject: [PATCH] [feature]: Complete Custom Data API with full CRUD and new resources Replace all NotImplementedError stubs with real implementations for Custom Objects, Fields, and Records. Add new resources: Record Events, Object Triggers, Record Attachments, Permission Policies, and Access Rules. Records now support search, count, upsert, bulk jobs, and incremental export. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commands/custom_data/__init__.py | 0 .../custom_data/custom_object_cmds.py | 22 +++ .../custom_data/custom_object_field_cmds.py | 27 +++ .../custom_data/custom_object_record_cmds.py | 26 +++ .../custom_data/object_trigger_cmds.py | 26 +++ .../commands/custom_data/permission_cmds.py | 27 +++ .../services/custom_data/__init__.py | 14 +- .../custom_data/access_rules_service.py | 30 +++ .../custom_object_fields_service.py | 20 ++ .../custom_data/custom_object_records.py | 66 +++++++ .../custom_data/custom_objects_service.py | 12 ++ .../custom_data/object_triggers_service.py | 51 +++++ .../permission_policies_service.py | 20 ++ .../custom_data/record_attachments_service.py | 30 +++ .../custom_data/record_events_service.py | 12 ++ .../domain/models/custom_data/access_rule.py | 16 ++ .../models/custom_data/object_trigger.py | 24 +++ .../models/custom_data/permission_policy.py | 21 ++ .../models/custom_data/record_attachment.py | 20 ++ .../domain/models/custom_data/record_event.py | 25 +++ .../api_clients/custom_data/__init__.py | 10 + .../api_clients/custom_data/access_rule.py | 41 ++++ .../api_clients/custom_data/custom_object.py | 26 ++- .../custom_data/custom_object_field.py | 29 ++- .../custom_data/custom_object_record.py | 111 ++++++++++- .../api_clients/custom_data/object_trigger.py | 86 ++++++++ .../custom_data/permission_policy.py | 30 +++ .../custom_data/record_attachment.py | 38 ++++ .../api_clients/custom_data/record_event.py | 47 +++++ .../mappers/custom_data/__init__.py | 0 .../mappers/custom_data/access_rule_mapper.py | 14 ++ .../custom_data/custom_object_field_mapper.py | 39 ++++ .../custom_data/custom_object_mapper.py | 23 +++ .../custom_object_record_mapper.py | 28 +++ .../custom_data/object_trigger_mapper.py | 27 +++ .../custom_data/permission_policy_mapper.py | 16 ++ .../custom_data/test_access_rules.py | 21 ++ .../custom_data/test_custom_object.py | 24 +++ .../custom_data/test_custom_object_fields.py | 25 ++- .../custom_data/test_custom_object_records.py | 29 +++ .../custom_data/test_object_triggers.py | 26 +++ .../custom_data/test_permission_policies.py | 24 +++ .../custom_data/test_record_attachments.py | 28 +++ .../custom_data/test_record_events.py | 14 ++ tests/unit/custom_data/test_access_rule.py | 128 ++++++++++++ tests/unit/custom_data/test_custom_object.py | 84 ++++++++ .../custom_data/test_custom_object_field.py | 79 ++++++++ .../custom_data/test_custom_object_record.py | 186 ++++++++++++++++++ tests/unit/custom_data/test_object_trigger.py | 165 ++++++++++++++++ .../custom_data/test_permission_policy.py | 67 +++++++ .../custom_data/test_record_attachment.py | 89 +++++++++ tests/unit/custom_data/test_record_event.py | 43 ++++ uv.lock | 2 +- 53 files changed, 2059 insertions(+), 29 deletions(-) create mode 100644 libzapi/application/commands/custom_data/__init__.py create mode 100644 libzapi/application/commands/custom_data/custom_object_cmds.py create mode 100644 libzapi/application/commands/custom_data/custom_object_field_cmds.py create mode 100644 libzapi/application/commands/custom_data/custom_object_record_cmds.py create mode 100644 libzapi/application/commands/custom_data/object_trigger_cmds.py create mode 100644 libzapi/application/commands/custom_data/permission_cmds.py create mode 100644 libzapi/application/services/custom_data/access_rules_service.py create mode 100644 libzapi/application/services/custom_data/object_triggers_service.py create mode 100644 libzapi/application/services/custom_data/permission_policies_service.py create mode 100644 libzapi/application/services/custom_data/record_attachments_service.py create mode 100644 libzapi/application/services/custom_data/record_events_service.py create mode 100644 libzapi/domain/models/custom_data/access_rule.py create mode 100644 libzapi/domain/models/custom_data/object_trigger.py create mode 100644 libzapi/domain/models/custom_data/permission_policy.py create mode 100644 libzapi/domain/models/custom_data/record_attachment.py create mode 100644 libzapi/domain/models/custom_data/record_event.py create mode 100644 libzapi/infrastructure/api_clients/custom_data/access_rule.py create mode 100644 libzapi/infrastructure/api_clients/custom_data/object_trigger.py create mode 100644 libzapi/infrastructure/api_clients/custom_data/permission_policy.py create mode 100644 libzapi/infrastructure/api_clients/custom_data/record_attachment.py create mode 100644 libzapi/infrastructure/api_clients/custom_data/record_event.py create mode 100644 libzapi/infrastructure/mappers/custom_data/__init__.py create mode 100644 libzapi/infrastructure/mappers/custom_data/access_rule_mapper.py create mode 100644 libzapi/infrastructure/mappers/custom_data/custom_object_field_mapper.py create mode 100644 libzapi/infrastructure/mappers/custom_data/custom_object_mapper.py create mode 100644 libzapi/infrastructure/mappers/custom_data/custom_object_record_mapper.py create mode 100644 libzapi/infrastructure/mappers/custom_data/object_trigger_mapper.py create mode 100644 libzapi/infrastructure/mappers/custom_data/permission_policy_mapper.py create mode 100644 tests/integration/custom_data/test_access_rules.py create mode 100644 tests/integration/custom_data/test_object_triggers.py create mode 100644 tests/integration/custom_data/test_permission_policies.py create mode 100644 tests/integration/custom_data/test_record_attachments.py create mode 100644 tests/integration/custom_data/test_record_events.py create mode 100644 tests/unit/custom_data/test_access_rule.py create mode 100644 tests/unit/custom_data/test_object_trigger.py create mode 100644 tests/unit/custom_data/test_permission_policy.py create mode 100644 tests/unit/custom_data/test_record_attachment.py create mode 100644 tests/unit/custom_data/test_record_event.py diff --git a/libzapi/application/commands/custom_data/__init__.py b/libzapi/application/commands/custom_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libzapi/application/commands/custom_data/custom_object_cmds.py b/libzapi/application/commands/custom_data/custom_object_cmds.py new file mode 100644 index 0000000..12e6e43 --- /dev/null +++ b/libzapi/application/commands/custom_data/custom_object_cmds.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateCustomObjectCmd: + key: str + title: str + title_pluralized: str + include_in_list_view: bool = False + description: str = "" + allows_photos: bool = False + allows_attachments: bool = False + + +@dataclass(frozen=True, slots=True) +class UpdateCustomObjectCmd: + title: str | None = None + title_pluralized: str | None = None + description: str | None = None + include_in_list_view: bool | None = None + allows_photos: bool | None = None + allows_attachments: bool | None = None diff --git a/libzapi/application/commands/custom_data/custom_object_field_cmds.py b/libzapi/application/commands/custom_data/custom_object_field_cmds.py new file mode 100644 index 0000000..a995ef4 --- /dev/null +++ b/libzapi/application/commands/custom_data/custom_object_field_cmds.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CreateCustomObjectFieldCmd: + type: str + key: str + title: str + description: str = "" + active: bool = True + position: int = 0 + regexp_for_validation: str | None = None + custom_field_options: list[dict] | None = None + relationship_target_type: str | None = None + relationship_filter: dict | None = None + tag: str | None = None + + +@dataclass(frozen=True, slots=True) +class UpdateCustomObjectFieldCmd: + title: str | None = None + description: str | None = None + active: bool | None = None + position: int | None = None + custom_field_options: list[dict] | None = None diff --git a/libzapi/application/commands/custom_data/custom_object_record_cmds.py b/libzapi/application/commands/custom_data/custom_object_record_cmds.py new file mode 100644 index 0000000..ba85c48 --- /dev/null +++ b/libzapi/application/commands/custom_data/custom_object_record_cmds.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class CreateCustomObjectRecordCmd: + name: str + custom_object_fields: dict[str, str] = field(default_factory=dict) + external_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class UpdateCustomObjectRecordCmd: + custom_object_fields: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class BulkJobCmd: + action: str + items: list[dict] = field(default_factory=list) + + +@dataclass(frozen=True, slots=True) +class FilteredSearchCmd: + filter: dict = field(default_factory=dict) diff --git a/libzapi/application/commands/custom_data/object_trigger_cmds.py b/libzapi/application/commands/custom_data/object_trigger_cmds.py new file mode 100644 index 0000000..8950f53 --- /dev/null +++ b/libzapi/application/commands/custom_data/object_trigger_cmds.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class CreateObjectTriggerCmd: + title: str + conditions: dict + actions: list[dict] + active: bool = True + description: str = "" + + +@dataclass(frozen=True, slots=True) +class UpdateObjectTriggerCmd: + title: str | None = None + conditions: dict | None = None + actions: list[dict] | None = None + active: bool | None = None + description: str | None = None + + +@dataclass(frozen=True, slots=True) +class UpdateManyTriggersCmd: + triggers: list[dict] = field(default_factory=list) diff --git a/libzapi/application/commands/custom_data/permission_cmds.py b/libzapi/application/commands/custom_data/permission_cmds.py new file mode 100644 index 0000000..10ac4e9 --- /dev/null +++ b/libzapi/application/commands/custom_data/permission_cmds.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class UpdatePermissionPolicyCmd: + create: str | None = None + read: str | None = None + update: str | None = None + delete: str | None = None + create_rule_id: str | None = None + read_rule_id: str | None = None + update_rule_id: str | None = None + delete_rule_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class CreateAccessRuleCmd: + name: str + conditions: dict = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class UpdateAccessRuleCmd: + name: str | None = None + conditions: dict | None = None diff --git a/libzapi/application/services/custom_data/__init__.py b/libzapi/application/services/custom_data/__init__.py index 421a310..02b93eb 100644 --- a/libzapi/application/services/custom_data/__init__.py +++ b/libzapi/application/services/custom_data/__init__.py @@ -1,7 +1,12 @@ -from libzapi.application.services.custom_data.custom_objects_service import CustomObjectsService +from libzapi.application.services.custom_data.access_rules_service import AccessRulesService from libzapi.application.services.custom_data.custom_object_fields_service import CustomObjectFieldsService from libzapi.application.services.custom_data.custom_object_records import CustomObjectRecordsService -from libzapi.infrastructure.http.auth import oauth_headers, api_token_headers +from libzapi.application.services.custom_data.custom_objects_service import CustomObjectsService +from libzapi.application.services.custom_data.object_triggers_service import ObjectTriggersService +from libzapi.application.services.custom_data.permission_policies_service import PermissionPoliciesService +from libzapi.application.services.custom_data.record_attachments_service import RecordAttachmentsService +from libzapi.application.services.custom_data.record_events_service import RecordEventsService +from libzapi.infrastructure.http.auth import api_token_headers, oauth_headers from libzapi.infrastructure.http.client import HttpClient import libzapi.infrastructure.api_clients.custom_data as api @@ -23,3 +28,8 @@ def __init__( self.custom_objects = CustomObjectsService(api.CustomObjectApiClient(http)) self.custom_object_fields = CustomObjectFieldsService(api.CustomObjectFieldApiClient(http)) self.custom_object_records = CustomObjectRecordsService(api.CustomObjectRecordApiClient(http)) + self.record_events = RecordEventsService(api.RecordEventApiClient(http)) + self.object_triggers = ObjectTriggersService(api.ObjectTriggerApiClient(http)) + self.record_attachments = RecordAttachmentsService(api.RecordAttachmentApiClient(http)) + self.permission_policies = PermissionPoliciesService(api.PermissionPolicyApiClient(http)) + self.access_rules = AccessRulesService(api.AccessRuleApiClient(http)) diff --git a/libzapi/application/services/custom_data/access_rules_service.py b/libzapi/application/services/custom_data/access_rules_service.py new file mode 100644 index 0000000..c8e8753 --- /dev/null +++ b/libzapi/application/services/custom_data/access_rules_service.py @@ -0,0 +1,30 @@ +from typing import Iterator + +from libzapi.application.commands.custom_data.permission_cmds import CreateAccessRuleCmd, UpdateAccessRuleCmd +from libzapi.domain.models.custom_data.access_rule import AccessRule +from libzapi.infrastructure.api_clients.custom_data import AccessRuleApiClient + + +class AccessRulesService: + def __init__(self, client: AccessRuleApiClient) -> None: + self._client = client + + def list_all(self, custom_object_key: str) -> Iterator[AccessRule]: + return self._client.list_all(custom_object_key=custom_object_key) + + def get(self, custom_object_key: str, rule_id: str) -> AccessRule: + return self._client.get(custom_object_key=custom_object_key, rule_id=rule_id) + + def create(self, custom_object_key: str, name: str, conditions: dict | None = None) -> AccessRule: + cmd = CreateAccessRuleCmd(name=name, conditions=conditions or {}) + return self._client.create(custom_object_key=custom_object_key, cmd=cmd) + + def update(self, custom_object_key: str, rule_id: str, **kwargs) -> AccessRule: + cmd = UpdateAccessRuleCmd(**kwargs) + return self._client.update(custom_object_key=custom_object_key, rule_id=rule_id, cmd=cmd) + + def delete(self, custom_object_key: str, rule_id: str) -> None: + self._client.delete(custom_object_key=custom_object_key, rule_id=rule_id) + + def definitions(self, custom_object_key: str) -> dict: + return self._client.definitions(custom_object_key=custom_object_key) diff --git a/libzapi/application/services/custom_data/custom_object_fields_service.py b/libzapi/application/services/custom_data/custom_object_fields_service.py index 362839b..870881e 100644 --- a/libzapi/application/services/custom_data/custom_object_fields_service.py +++ b/libzapi/application/services/custom_data/custom_object_fields_service.py @@ -1,5 +1,11 @@ +from __future__ import annotations + from typing import Iterator +from libzapi.application.commands.custom_data.custom_object_field_cmds import ( + CreateCustomObjectFieldCmd, + UpdateCustomObjectFieldCmd, +) from libzapi.domain.models.custom_data.custom_object_field import CustomObjectField from libzapi.infrastructure.api_clients.custom_data import CustomObjectFieldApiClient @@ -15,3 +21,17 @@ def list_all(self, custom_object_key: str) -> Iterator[CustomObjectField]: def get(self, custom_object_key: str, custom_object_field_id: int) -> CustomObjectField: return self._client.get(custom_object_key=custom_object_key, custom_object_field_id=custom_object_field_id) + + def create(self, custom_object_key: str, type: str, key: str, title: str, **kwargs) -> CustomObjectField: + cmd = CreateCustomObjectFieldCmd(type=type, key=key, title=title, **kwargs) + return self._client.create(custom_object_key=custom_object_key, cmd=cmd) + + def update(self, custom_object_key: str, field_id: int, **kwargs) -> CustomObjectField: + cmd = UpdateCustomObjectFieldCmd(**kwargs) + return self._client.update(custom_object_key=custom_object_key, field_id=field_id, cmd=cmd) + + def delete(self, custom_object_key: str, field_id: int) -> None: + self._client.delete(custom_object_key=custom_object_key, field_id=field_id) + + def reorder(self, custom_object_key: str, field_ids: list[int]) -> None: + self._client.reorder(custom_object_key=custom_object_key, field_ids=field_ids) diff --git a/libzapi/application/services/custom_data/custom_object_records.py b/libzapi/application/services/custom_data/custom_object_records.py index e0b44cd..852fa40 100644 --- a/libzapi/application/services/custom_data/custom_object_records.py +++ b/libzapi/application/services/custom_data/custom_object_records.py @@ -1,7 +1,17 @@ +from __future__ import annotations + from typing import Iterator, Optional, Iterable +from libzapi.application.commands.custom_data.custom_object_record_cmds import ( + BulkJobCmd, + CreateCustomObjectRecordCmd, + FilteredSearchCmd, + UpdateCustomObjectRecordCmd, +) from libzapi.domain.models.custom_data.custom_object_record import CustomObjectRecord +from libzapi.domain.shared_objects.count_snapshot import CountSnapshot from libzapi.domain.shared_objects.custom_object_limit import CustomObjectLimit +from libzapi.domain.shared_objects.job_status import JobStatus from libzapi.infrastructure.api_clients.custom_data import CustomObjectRecordApiClient from libzapi.infrastructure.api_clients.custom_data.custom_object_record import SortOrder, SortType @@ -26,5 +36,61 @@ def list_all( def get(self, custom_object_key: str, custom_object_record_id: str) -> CustomObjectRecord: return self._client.get(custom_object_key=custom_object_key, custom_object_record_id=custom_object_record_id) + def create( + self, custom_object_key: str, name: str, custom_object_fields: dict[str, str] | None = None, **kwargs + ) -> CustomObjectRecord: + cmd = CreateCustomObjectRecordCmd(name=name, custom_object_fields=custom_object_fields or {}, **kwargs) + return self._client.create(custom_object_key=custom_object_key, cmd=cmd) + + def update( + self, custom_object_key: str, record_id: str, custom_object_fields: dict[str, str] + ) -> CustomObjectRecord: + cmd = UpdateCustomObjectRecordCmd(custom_object_fields=custom_object_fields) + return self._client.update(custom_object_key=custom_object_key, record_id=record_id, cmd=cmd) + + def upsert( + self, + custom_object_key: str, + name: str, + custom_object_fields: dict[str, str] | None = None, + *, + external_id: str | None = None, + upsert_by_name: str | None = None, + **kwargs, + ) -> CustomObjectRecord: + cmd = CreateCustomObjectRecordCmd(name=name, custom_object_fields=custom_object_fields or {}, **kwargs) + return self._client.upsert( + custom_object_key=custom_object_key, cmd=cmd, external_id=external_id, name=upsert_by_name + ) + + def delete(self, custom_object_key: str, record_id: str) -> None: + self._client.delete(custom_object_key=custom_object_key, record_id=record_id) + + def delete_by_external_id(self, custom_object_key: str, external_id: str) -> None: + self._client.delete_by_external_id(custom_object_key=custom_object_key, external_id=external_id) + + def delete_by_name(self, custom_object_key: str, name: str) -> None: + self._client.delete_by_name(custom_object_key=custom_object_key, name=name) + + def count(self, custom_object_key: str) -> CountSnapshot: + return self._client.count(custom_object_key=custom_object_key) + + def search(self, custom_object_key: str, query: str, sort: str | None = None) -> Iterator[CustomObjectRecord]: + return self._client.search(custom_object_key=custom_object_key, query=query, sort=sort) + + def filtered_search(self, custom_object_key: str, filter: dict) -> Iterator[CustomObjectRecord]: + cmd = FilteredSearchCmd(filter=filter) + return self._client.filtered_search(custom_object_key=custom_object_key, cmd=cmd) + + def autocomplete(self, custom_object_key: str, name: str) -> Iterator[CustomObjectRecord]: + return self._client.autocomplete(custom_object_key=custom_object_key, name=name) + + def bulk_job(self, custom_object_key: str, action: str, items: list[dict]) -> JobStatus: + cmd = BulkJobCmd(action=action, items=items) + return self._client.bulk_job(custom_object_key=custom_object_key, cmd=cmd) + + def incremental_export(self, custom_object_key: str, start_time: int) -> Iterator[CustomObjectRecord]: + return self._client.incremental_export(custom_object_key=custom_object_key, start_time=start_time) + def limit(self) -> CustomObjectLimit: return self._client.limit() diff --git a/libzapi/application/services/custom_data/custom_objects_service.py b/libzapi/application/services/custom_data/custom_objects_service.py index 34c63e6..05c2794 100644 --- a/libzapi/application/services/custom_data/custom_objects_service.py +++ b/libzapi/application/services/custom_data/custom_objects_service.py @@ -1,5 +1,6 @@ from typing import Iterator +from libzapi.application.commands.custom_data.custom_object_cmds import CreateCustomObjectCmd, UpdateCustomObjectCmd from libzapi.domain.models.custom_data.custom_object import CustomObject from libzapi.domain.shared_objects.custom_object_limit import CustomObjectLimit from libzapi.infrastructure.api_clients.custom_data import CustomObjectApiClient @@ -17,5 +18,16 @@ def list_all(self) -> Iterator[CustomObject]: def get(self, custom_object_id: str) -> CustomObject: return self._client.get(custom_object_id=custom_object_id) + def create(self, key: str, title: str, title_pluralized: str, **kwargs) -> CustomObject: + cmd = CreateCustomObjectCmd(key=key, title=title, title_pluralized=title_pluralized, **kwargs) + return self._client.create(cmd=cmd) + + def update(self, custom_object_key: str, **kwargs) -> CustomObject: + cmd = UpdateCustomObjectCmd(**kwargs) + return self._client.update(custom_object_key=custom_object_key, cmd=cmd) + + def delete(self, custom_object_key: str) -> None: + self._client.delete(custom_object_key=custom_object_key) + def limit(self) -> CustomObjectLimit: return self._client.limit() diff --git a/libzapi/application/services/custom_data/object_triggers_service.py b/libzapi/application/services/custom_data/object_triggers_service.py new file mode 100644 index 0000000..dbb92d7 --- /dev/null +++ b/libzapi/application/services/custom_data/object_triggers_service.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.custom_data.object_trigger_cmds import ( + CreateObjectTriggerCmd, + UpdateManyTriggersCmd, + UpdateObjectTriggerCmd, +) +from libzapi.domain.models.custom_data.object_trigger import ObjectTrigger +from libzapi.infrastructure.api_clients.custom_data import ObjectTriggerApiClient + + +class ObjectTriggersService: + def __init__(self, client: ObjectTriggerApiClient) -> None: + self._client = client + + def list_all(self, custom_object_key: str) -> Iterator[ObjectTrigger]: + return self._client.list_all(custom_object_key=custom_object_key) + + def list_active(self, custom_object_key: str) -> Iterator[ObjectTrigger]: + return self._client.list_active(custom_object_key=custom_object_key) + + def search(self, custom_object_key: str, query: str) -> Iterator[ObjectTrigger]: + return self._client.search(custom_object_key=custom_object_key, query=query) + + def get(self, custom_object_key: str, trigger_id: int) -> ObjectTrigger: + return self._client.get(custom_object_key=custom_object_key, trigger_id=trigger_id) + + def definitions(self, custom_object_key: str) -> dict: + return self._client.definitions(custom_object_key=custom_object_key) + + def create( + self, custom_object_key: str, title: str, conditions: dict, actions: list[dict], **kwargs + ) -> ObjectTrigger: + cmd = CreateObjectTriggerCmd(title=title, conditions=conditions, actions=actions, **kwargs) + return self._client.create(custom_object_key=custom_object_key, cmd=cmd) + + def update(self, custom_object_key: str, trigger_id: int, **kwargs) -> ObjectTrigger: + cmd = UpdateObjectTriggerCmd(**kwargs) + return self._client.update(custom_object_key=custom_object_key, trigger_id=trigger_id, cmd=cmd) + + def update_many(self, custom_object_key: str, triggers: list[dict]) -> list[ObjectTrigger]: + cmd = UpdateManyTriggersCmd(triggers=triggers) + return self._client.update_many(custom_object_key=custom_object_key, cmd=cmd) + + def delete(self, custom_object_key: str, trigger_id: int) -> None: + self._client.delete(custom_object_key=custom_object_key, trigger_id=trigger_id) + + def delete_many(self, custom_object_key: str, ids: list[int]) -> None: + self._client.delete_many(custom_object_key=custom_object_key, ids=ids) diff --git a/libzapi/application/services/custom_data/permission_policies_service.py b/libzapi/application/services/custom_data/permission_policies_service.py new file mode 100644 index 0000000..fb4d85c --- /dev/null +++ b/libzapi/application/services/custom_data/permission_policies_service.py @@ -0,0 +1,20 @@ +from typing import Iterator + +from libzapi.application.commands.custom_data.permission_cmds import UpdatePermissionPolicyCmd +from libzapi.domain.models.custom_data.permission_policy import PermissionPolicy +from libzapi.infrastructure.api_clients.custom_data import PermissionPolicyApiClient + + +class PermissionPoliciesService: + def __init__(self, client: PermissionPolicyApiClient) -> None: + self._client = client + + def list_all(self, custom_object_key: str) -> Iterator[PermissionPolicy]: + return self._client.list_all(custom_object_key=custom_object_key) + + def get(self, custom_object_key: str, policy_id: str) -> PermissionPolicy: + return self._client.get(custom_object_key=custom_object_key, policy_id=policy_id) + + def update(self, custom_object_key: str, policy_id: str, **kwargs) -> PermissionPolicy: + cmd = UpdatePermissionPolicyCmd(**kwargs) + return self._client.update(custom_object_key=custom_object_key, policy_id=policy_id, cmd=cmd) diff --git a/libzapi/application/services/custom_data/record_attachments_service.py b/libzapi/application/services/custom_data/record_attachments_service.py new file mode 100644 index 0000000..a9f41cb --- /dev/null +++ b/libzapi/application/services/custom_data/record_attachments_service.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.domain.models.custom_data.record_attachment import RecordAttachment +from libzapi.infrastructure.api_clients.custom_data import RecordAttachmentApiClient + + +class RecordAttachmentsService: + def __init__(self, client: RecordAttachmentApiClient) -> None: + self._client = client + + def list_all(self, custom_object_key: str, record_id: str) -> Iterator[RecordAttachment]: + return self._client.list_all(custom_object_key=custom_object_key, record_id=record_id) + + def create(self, custom_object_key: str, record_id: str, file: tuple) -> RecordAttachment: + return self._client.create(custom_object_key=custom_object_key, record_id=record_id, file=file) + + def update(self, custom_object_key: str, record_id: str, attachment_id: str, **kwargs) -> RecordAttachment: + return self._client.update( + custom_object_key=custom_object_key, record_id=record_id, attachment_id=attachment_id, payload=kwargs + ) + + def delete(self, custom_object_key: str, record_id: str, attachment_id: str) -> None: + self._client.delete(custom_object_key=custom_object_key, record_id=record_id, attachment_id=attachment_id) + + def download_url(self, custom_object_key: str, record_id: str, attachment_id: str) -> str: + return self._client.download_url( + custom_object_key=custom_object_key, record_id=record_id, attachment_id=attachment_id + ) diff --git a/libzapi/application/services/custom_data/record_events_service.py b/libzapi/application/services/custom_data/record_events_service.py new file mode 100644 index 0000000..c008221 --- /dev/null +++ b/libzapi/application/services/custom_data/record_events_service.py @@ -0,0 +1,12 @@ +from typing import Iterator + +from libzapi.domain.models.custom_data.record_event import RecordEvent +from libzapi.infrastructure.api_clients.custom_data import RecordEventApiClient + + +class RecordEventsService: + def __init__(self, client: RecordEventApiClient) -> None: + self._client = client + + def list_all(self, custom_object_key: str, record_id: str, page_size: int = 100) -> Iterator[RecordEvent]: + return self._client.list_all(custom_object_key=custom_object_key, record_id=record_id, page_size=page_size) diff --git a/libzapi/domain/models/custom_data/access_rule.py b/libzapi/domain/models/custom_data/access_rule.py new file mode 100644 index 0000000..fe155ac --- /dev/null +++ b/libzapi/domain/models/custom_data/access_rule.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass, field + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class AccessRule: + id: str + name: str = "" + conditions: dict = field(default_factory=dict) + created_at: str = "" + updated_at: str = "" + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("access_rule", self.id) diff --git a/libzapi/domain/models/custom_data/object_trigger.py b/libzapi/domain/models/custom_data/object_trigger.py new file mode 100644 index 0000000..fb6cfc9 --- /dev/null +++ b/libzapi/domain/models/custom_data/object_trigger.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field +from datetime import datetime + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class ObjectTrigger: + id: int + title: str + active: bool = True + position: int | None = None + conditions: dict = field(default_factory=dict) + actions: list[dict] = field(default_factory=list) + description: str = "" + raw_title: str = "" + default: bool = False + url: str = "" + created_at: datetime | None = None + updated_at: datetime | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("object_trigger", str(self.id)) diff --git a/libzapi/domain/models/custom_data/permission_policy.py b/libzapi/domain/models/custom_data/permission_policy.py new file mode 100644 index 0000000..12ad39a --- /dev/null +++ b/libzapi/domain/models/custom_data/permission_policy.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class PermissionPolicy: + id: str + role: str = "" + create: str = "" + read: str = "" + update: str = "" + delete: str = "" + create_rule_id: str | None = None + read_rule_id: str | None = None + update_rule_id: str | None = None + delete_rule_id: str | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("permission_policy", self.id) diff --git a/libzapi/domain/models/custom_data/record_attachment.py b/libzapi/domain/models/custom_data/record_attachment.py new file mode 100644 index 0000000..cbb0af0 --- /dev/null +++ b/libzapi/domain/models/custom_data/record_attachment.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from datetime import datetime + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class RecordAttachment: + id: str + file_name: str = "" + content_url: str = "" + content_type: str = "" + size: int = 0 + created_at: datetime | None = None + updated_at: datetime | None = None + malware_access_override: bool = False + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("record_attachment", self.id) diff --git a/libzapi/domain/models/custom_data/record_event.py b/libzapi/domain/models/custom_data/record_event.py new file mode 100644 index 0000000..4f2bcc5 --- /dev/null +++ b/libzapi/domain/models/custom_data/record_event.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from datetime import datetime + +from libzapi.domain.shared_objects.logical_key import LogicalKey + + +@dataclass(frozen=True, slots=True) +class RecordEventActor: + user_id: int | None = None + + +@dataclass(frozen=True, slots=True) +class RecordEvent: + id: str + type: str + source: str = "" + description: str = "" + actor: RecordEventActor | None = None + created_at: datetime | None = None + received_at: datetime | None = None + properties: dict | None = None + + @property + def logical_key(self) -> LogicalKey: + return LogicalKey("record_event", self.id) diff --git a/libzapi/infrastructure/api_clients/custom_data/__init__.py b/libzapi/infrastructure/api_clients/custom_data/__init__.py index b6f5a12..a1fb097 100644 --- a/libzapi/infrastructure/api_clients/custom_data/__init__.py +++ b/libzapi/infrastructure/api_clients/custom_data/__init__.py @@ -1,10 +1,20 @@ +from libzapi.infrastructure.api_clients.custom_data.access_rule import AccessRuleApiClient from libzapi.infrastructure.api_clients.custom_data.custom_object import CustomObjectApiClient from libzapi.infrastructure.api_clients.custom_data.custom_object_field import CustomObjectFieldApiClient from libzapi.infrastructure.api_clients.custom_data.custom_object_record import CustomObjectRecordApiClient +from libzapi.infrastructure.api_clients.custom_data.object_trigger import ObjectTriggerApiClient +from libzapi.infrastructure.api_clients.custom_data.permission_policy import PermissionPolicyApiClient +from libzapi.infrastructure.api_clients.custom_data.record_attachment import RecordAttachmentApiClient +from libzapi.infrastructure.api_clients.custom_data.record_event import RecordEventApiClient __all__ = [ + "AccessRuleApiClient", "CustomObjectApiClient", "CustomObjectFieldApiClient", "CustomObjectRecordApiClient", + "ObjectTriggerApiClient", + "PermissionPolicyApiClient", + "RecordAttachmentApiClient", + "RecordEventApiClient", ] diff --git a/libzapi/infrastructure/api_clients/custom_data/access_rule.py b/libzapi/infrastructure/api_clients/custom_data/access_rule.py new file mode 100644 index 0000000..de1678b --- /dev/null +++ b/libzapi/infrastructure/api_clients/custom_data/access_rule.py @@ -0,0 +1,41 @@ +from typing import Iterator + +from libzapi.application.commands.custom_data.permission_cmds import CreateAccessRuleCmd, UpdateAccessRuleCmd +from libzapi.domain.models.custom_data.access_rule import AccessRule +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.custom_data.access_rule_mapper import to_payload_create, to_payload_update +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/custom_objects" + + +class AccessRuleApiClient: + """HTTP adapter for Zendesk Custom Object Access Rules.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, custom_object_key: str) -> Iterator[AccessRule]: + data = self._http.get(f"{_BASE}/{custom_object_key}/access_rules") + for obj in data.get("access_rules", []): + yield to_domain(data=obj, cls=AccessRule) + + def get(self, custom_object_key: str, rule_id: str) -> AccessRule: + data = self._http.get(f"{_BASE}/{custom_object_key}/access_rules/{rule_id}") + return to_domain(data=data["access_rule"], cls=AccessRule) + + def create(self, custom_object_key: str, cmd: CreateAccessRuleCmd) -> AccessRule: + payload = to_payload_create(cmd) + data = self._http.post(f"{_BASE}/{custom_object_key}/access_rules", json=payload) + return to_domain(data=data["access_rule"], cls=AccessRule) + + def update(self, custom_object_key: str, rule_id: str, cmd: UpdateAccessRuleCmd) -> AccessRule: + payload = to_payload_update(cmd) + data = self._http.patch(f"{_BASE}/{custom_object_key}/access_rules/{rule_id}", json=payload) + return to_domain(data=data["access_rule"], cls=AccessRule) + + def delete(self, custom_object_key: str, rule_id: str) -> None: + self._http.delete(f"{_BASE}/{custom_object_key}/access_rules/{rule_id}") + + def definitions(self, custom_object_key: str) -> dict: + return self._http.get(f"{_BASE}/{custom_object_key}/access_rules/definitions") diff --git a/libzapi/infrastructure/api_clients/custom_data/custom_object.py b/libzapi/infrastructure/api_clients/custom_data/custom_object.py index e99e7c4..74f6649 100644 --- a/libzapi/infrastructure/api_clients/custom_data/custom_object.py +++ b/libzapi/infrastructure/api_clients/custom_data/custom_object.py @@ -1,11 +1,15 @@ from typing import Iterator +from libzapi.application.commands.custom_data.custom_object_cmds import CreateCustomObjectCmd, UpdateCustomObjectCmd from libzapi.domain.models.custom_data.custom_object import CustomObject from libzapi.domain.shared_objects.custom_object_limit import CustomObjectLimit from libzapi.infrastructure.http.client import HttpClient from libzapi.infrastructure.http.pagination import yield_items +from libzapi.infrastructure.mappers.custom_data.custom_object_mapper import to_payload_create, to_payload_update from libzapi.infrastructure.serialization.parse import to_domain +_BASE = "/api/v2/custom_objects" + class CustomObjectApiClient: """HTTP adapter for Zendesk Custom Objects""" @@ -16,25 +20,29 @@ def __init__(self, http: HttpClient) -> None: def list_all(self) -> Iterator[CustomObject]: for obj in yield_items( get_json=self._http.get, - first_path="/api/v2/custom_objects", + first_path=_BASE, base_url=self._http.base_url, items_key="custom_objects", ): yield to_domain(data=obj, cls=CustomObject) def get(self, custom_object_id: str) -> CustomObject: - data = self._http.get(f"/api/v2/custom_objects/{custom_object_id}") + data = self._http.get(f"{_BASE}/{custom_object_id}") return to_domain(data=data["custom_object"], cls=CustomObject) - def create(self, payload: dict) -> CustomObject: - raise NotImplementedError + def create(self, cmd: CreateCustomObjectCmd) -> CustomObject: + payload = to_payload_create(cmd) + data = self._http.post(_BASE, json=payload) + return to_domain(data=data["custom_object"], cls=CustomObject) - def update(self, custom_object_id: str, data: dict) -> CustomObject: - raise NotImplementedError + def update(self, custom_object_key: str, cmd: UpdateCustomObjectCmd) -> CustomObject: + payload = to_payload_update(cmd) + data = self._http.patch(f"{_BASE}/{custom_object_key}", json=payload) + return to_domain(data=data["custom_object"], cls=CustomObject) - def delete(self, custom_object_id: str) -> CustomObject: - raise NotImplementedError + def delete(self, custom_object_key: str) -> None: + self._http.delete(f"{_BASE}/{custom_object_key}") def limit(self) -> CustomObjectLimit: - data = self._http.get("/api/v2/custom_objects/limits/object_limit") + data = self._http.get(f"{_BASE}/limits/object_limit") return to_domain(data=data, cls=CustomObjectLimit) diff --git a/libzapi/infrastructure/api_clients/custom_data/custom_object_field.py b/libzapi/infrastructure/api_clients/custom_data/custom_object_field.py index b3c03d3..c9f9892 100644 --- a/libzapi/infrastructure/api_clients/custom_data/custom_object_field.py +++ b/libzapi/infrastructure/api_clients/custom_data/custom_object_field.py @@ -1,8 +1,17 @@ from typing import Iterator +from libzapi.application.commands.custom_data.custom_object_field_cmds import ( + CreateCustomObjectFieldCmd, + UpdateCustomObjectFieldCmd, +) from libzapi.domain.models.custom_data.custom_object_field import CustomObjectField from libzapi.infrastructure.http.client import HttpClient from libzapi.infrastructure.http.pagination import yield_items +from libzapi.infrastructure.mappers.custom_data.custom_object_field_mapper import ( + to_payload_create, + to_payload_reorder, + to_payload_update, +) from libzapi.infrastructure.serialization.parse import to_domain @@ -25,11 +34,19 @@ def get(self, custom_object_key: str, custom_object_field_id: int) -> CustomObje data = self._http.get(f"/api/v2/custom_objects/{custom_object_key}/fields/{custom_object_field_id}") return to_domain(data=data["custom_object_field"], cls=CustomObjectField) - def create(self, payload: dict) -> CustomObjectField: - raise NotImplementedError + def create(self, custom_object_key: str, cmd: CreateCustomObjectFieldCmd) -> CustomObjectField: + payload = to_payload_create(cmd) + data = self._http.post(f"/api/v2/custom_objects/{custom_object_key}/fields", json=payload) + return to_domain(data=data["custom_object_field"], cls=CustomObjectField) + + def update(self, custom_object_key: str, field_id: int, cmd: UpdateCustomObjectFieldCmd) -> CustomObjectField: + payload = to_payload_update(cmd) + data = self._http.patch(f"/api/v2/custom_objects/{custom_object_key}/fields/{field_id}", json=payload) + return to_domain(data=data["custom_object_field"], cls=CustomObjectField) - def update(self, custom_object_id: str, data: dict) -> CustomObjectField: - raise NotImplementedError + def delete(self, custom_object_key: str, field_id: int) -> None: + self._http.delete(f"/api/v2/custom_objects/{custom_object_key}/fields/{field_id}") - def delete(self, custom_object_id: str) -> CustomObjectField: - raise NotImplementedError + def reorder(self, custom_object_key: str, field_ids: list[int]) -> None: + payload = to_payload_reorder(field_ids) + self._http.put(f"/api/v2/custom_objects/{custom_object_key}/fields/reorder", json=payload) diff --git a/libzapi/infrastructure/api_clients/custom_data/custom_object_record.py b/libzapi/infrastructure/api_clients/custom_data/custom_object_record.py index c878faf..7786ab5 100644 --- a/libzapi/infrastructure/api_clients/custom_data/custom_object_record.py +++ b/libzapi/infrastructure/api_clients/custom_data/custom_object_record.py @@ -1,9 +1,23 @@ from typing import Iterator, TypeAlias, Literal, Iterable +from libzapi.application.commands.custom_data.custom_object_record_cmds import ( + BulkJobCmd, + CreateCustomObjectRecordCmd, + FilteredSearchCmd, + UpdateCustomObjectRecordCmd, +) from libzapi.domain.models.custom_data.custom_object_record import CustomObjectRecord +from libzapi.domain.shared_objects.count_snapshot import CountSnapshot from libzapi.domain.shared_objects.custom_object_limit import CustomObjectLimit +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.custom_data.custom_object_record_mapper import ( + to_payload_bulk_job, + to_payload_create, + to_payload_filtered_search, + to_payload_update, +) from libzapi.infrastructure.serialization.parse import to_domain _ALLOWED_SORT_TYPES = {"id", "updated_at"} @@ -12,6 +26,8 @@ SortType: TypeAlias = Literal["id", "updated_at"] SortOrder: TypeAlias = Literal["asc", "desc"] +_BASE = "/api/v2/custom_objects" + class CustomObjectRecordApiClient: """HTTP adapter for Zendesk Custom Objects Records API.""" @@ -33,27 +49,104 @@ def list_all( ) for obj in yield_items( get_json=self._http.get, - first_path=f"/api/v2/custom_objects/{custom_object_key}/records?{query}", + first_path=f"{_BASE}/{custom_object_key}/records?{query}", base_url=self._http.base_url, items_key="custom_object_records", ): yield to_domain(data=obj, cls=CustomObjectRecord) def get(self, custom_object_key: str, custom_object_record_id: str) -> CustomObjectRecord: - data = self._http.get(f"/api/v2/custom_objects/{custom_object_key}/records/{custom_object_record_id}") + data = self._http.get(f"{_BASE}/{custom_object_key}/records/{custom_object_record_id}") + return to_domain(data=data["custom_object_record"], cls=CustomObjectRecord) + + def create(self, custom_object_key: str, cmd: CreateCustomObjectRecordCmd) -> CustomObjectRecord: + payload = to_payload_create(cmd) + data = self._http.post(f"{_BASE}/{custom_object_key}/records", json=payload) + return to_domain(data=data["custom_object_record"], cls=CustomObjectRecord) + + def update(self, custom_object_key: str, record_id: str, cmd: UpdateCustomObjectRecordCmd) -> CustomObjectRecord: + payload = to_payload_update(cmd) + data = self._http.patch(f"{_BASE}/{custom_object_key}/records/{record_id}", json=payload) + return to_domain(data=data["custom_object_record"], cls=CustomObjectRecord) + + def upsert( + self, + custom_object_key: str, + cmd: CreateCustomObjectRecordCmd, + *, + external_id: str | None = None, + name: str | None = None, + ) -> CustomObjectRecord: + payload = to_payload_create(cmd) + path = f"{_BASE}/{custom_object_key}/records" + if external_id: + path = f"{path}?external_id={external_id}" + elif name: + path = f"{path}?name={name}" + data = self._http.patch(path, json=payload) return to_domain(data=data["custom_object_record"], cls=CustomObjectRecord) - def create(self, payload: dict) -> CustomObjectRecord: - raise NotImplementedError + def delete(self, custom_object_key: str, record_id: str) -> None: + self._http.delete(f"{_BASE}/{custom_object_key}/records/{record_id}") + + def delete_by_external_id(self, custom_object_key: str, external_id: str) -> None: + self._http.delete(f"{_BASE}/{custom_object_key}/records?external_id={external_id}") + + def delete_by_name(self, custom_object_key: str, name: str) -> None: + self._http.delete(f"{_BASE}/{custom_object_key}/records?name={name}") - def update(self, custom_object_id: str, data: dict) -> CustomObjectRecord: - raise NotImplementedError + def count(self, custom_object_key: str) -> CountSnapshot: + data = self._http.get(f"{_BASE}/{custom_object_key}/records/count") + return to_domain(data=data["count"], cls=CountSnapshot) + + def search(self, custom_object_key: str, query: str, sort: str | None = None) -> Iterator[CustomObjectRecord]: + path = f"{_BASE}/{custom_object_key}/records/search?query={query}" + if sort: + path = f"{path}&sort={sort}" + for obj in yield_items( + get_json=self._http.get, + first_path=path, + base_url=self._http.base_url, + items_key="custom_object_records", + ): + yield to_domain(data=obj, cls=CustomObjectRecord) + + def filtered_search(self, custom_object_key: str, cmd: FilteredSearchCmd) -> Iterator[CustomObjectRecord]: + payload = to_payload_filtered_search(cmd) + data = self._http.post(f"{_BASE}/{custom_object_key}/records/search", json=payload) + for obj in data.get("custom_object_records", []): + yield to_domain(data=obj, cls=CustomObjectRecord) + + def autocomplete(self, custom_object_key: str, name: str) -> Iterator[CustomObjectRecord]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"{_BASE}/{custom_object_key}/records/autocomplete?name={name}", + base_url=self._http.base_url, + items_key="custom_object_records", + ): + yield to_domain(data=obj, cls=CustomObjectRecord) - def delete(self, custom_object_id: str) -> CustomObjectRecord: - raise NotImplementedError + def bulk_job(self, custom_object_key: str, cmd: BulkJobCmd) -> JobStatus: + payload = to_payload_bulk_job(cmd) + data = self._http.post(f"{_BASE}/{custom_object_key}/jobs", json=payload) + return to_domain(data=data["job_status"], cls=JobStatus) + + def incremental_export(self, custom_object_key: str, start_time: int) -> Iterator[CustomObjectRecord]: + path = f"/api/v2/incremental/custom_objects/{custom_object_key}/cursor?start_time={int(start_time)}" + while path: + data = self._http.get(path) + for obj in data.get("custom_object_records", []): + yield to_domain(data=obj, cls=CustomObjectRecord) + if data.get("end_of_stream"): + break + after_url = data.get("links", {}).get("next") + if after_url and isinstance(after_url, str): + path = after_url.replace(self._http.base_url, "") if after_url.startswith("https://") else after_url + else: + path = None def limit(self) -> CustomObjectLimit: - data = self._http.get("/api/v2/custom_objects/limits/record_limit") + data = self._http.get(f"{_BASE}/limits/record_limit") return to_domain(data=data, cls=CustomObjectLimit) diff --git a/libzapi/infrastructure/api_clients/custom_data/object_trigger.py b/libzapi/infrastructure/api_clients/custom_data/object_trigger.py new file mode 100644 index 0000000..d8882f4 --- /dev/null +++ b/libzapi/infrastructure/api_clients/custom_data/object_trigger.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.application.commands.custom_data.object_trigger_cmds import ( + CreateObjectTriggerCmd, + UpdateManyTriggersCmd, + UpdateObjectTriggerCmd, +) +from libzapi.domain.models.custom_data.object_trigger import ObjectTrigger +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.http.pagination import yield_items +from libzapi.infrastructure.mappers.custom_data.object_trigger_mapper import ( + to_payload_create, + to_payload_update, + to_payload_update_many, +) +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/custom_objects" + + +class ObjectTriggerApiClient: + """HTTP adapter for Zendesk Object Triggers.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def _triggers_path(self, key: str) -> str: + return f"{_BASE}/{key}/triggers" + + def list_all(self, custom_object_key: str) -> Iterator[ObjectTrigger]: + for obj in yield_items( + get_json=self._http.get, + first_path=self._triggers_path(custom_object_key), + base_url=self._http.base_url, + items_key="triggers", + ): + yield to_domain(data=obj, cls=ObjectTrigger) + + def list_active(self, custom_object_key: str) -> Iterator[ObjectTrigger]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"{self._triggers_path(custom_object_key)}/active", + base_url=self._http.base_url, + items_key="triggers", + ): + yield to_domain(data=obj, cls=ObjectTrigger) + + def search(self, custom_object_key: str, query: str) -> Iterator[ObjectTrigger]: + for obj in yield_items( + get_json=self._http.get, + first_path=f"{self._triggers_path(custom_object_key)}/search?query={query}", + base_url=self._http.base_url, + items_key="triggers", + ): + yield to_domain(data=obj, cls=ObjectTrigger) + + def get(self, custom_object_key: str, trigger_id: int) -> ObjectTrigger: + data = self._http.get(f"{self._triggers_path(custom_object_key)}/{int(trigger_id)}") + return to_domain(data=data["trigger"], cls=ObjectTrigger) + + def definitions(self, custom_object_key: str) -> dict: + return self._http.get(f"{self._triggers_path(custom_object_key)}/definitions") + + def create(self, custom_object_key: str, cmd: CreateObjectTriggerCmd) -> ObjectTrigger: + payload = to_payload_create(cmd) + data = self._http.post(self._triggers_path(custom_object_key), json=payload) + return to_domain(data=data["trigger"], cls=ObjectTrigger) + + def update(self, custom_object_key: str, trigger_id: int, cmd: UpdateObjectTriggerCmd) -> ObjectTrigger: + payload = to_payload_update(cmd) + data = self._http.put(f"{self._triggers_path(custom_object_key)}/{int(trigger_id)}", json=payload) + return to_domain(data=data["trigger"], cls=ObjectTrigger) + + def update_many(self, custom_object_key: str, cmd: UpdateManyTriggersCmd) -> list[ObjectTrigger]: + payload = to_payload_update_many(cmd) + data = self._http.put(f"{self._triggers_path(custom_object_key)}/update_many", json=payload) + return [to_domain(data=t, cls=ObjectTrigger) for t in data.get("triggers", [])] + + def delete(self, custom_object_key: str, trigger_id: int) -> None: + self._http.delete(f"{self._triggers_path(custom_object_key)}/{int(trigger_id)}") + + def delete_many(self, custom_object_key: str, ids: list[int]) -> None: + ids_str = ",".join(str(i) for i in ids) + self._http.delete(f"{self._triggers_path(custom_object_key)}/destroy_many?ids={ids_str}") diff --git a/libzapi/infrastructure/api_clients/custom_data/permission_policy.py b/libzapi/infrastructure/api_clients/custom_data/permission_policy.py new file mode 100644 index 0000000..a66c993 --- /dev/null +++ b/libzapi/infrastructure/api_clients/custom_data/permission_policy.py @@ -0,0 +1,30 @@ +from typing import Iterator + +from libzapi.application.commands.custom_data.permission_cmds import UpdatePermissionPolicyCmd +from libzapi.domain.models.custom_data.permission_policy import PermissionPolicy +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.mappers.custom_data.permission_policy_mapper import to_payload_update +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/custom_objects" + + +class PermissionPolicyApiClient: + """HTTP adapter for Zendesk Custom Object Permission Policies.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, custom_object_key: str) -> Iterator[PermissionPolicy]: + data = self._http.get(f"{_BASE}/{custom_object_key}/permission_policies") + for obj in data.get("permission_policies", []): + yield to_domain(data=obj, cls=PermissionPolicy) + + def get(self, custom_object_key: str, policy_id: str) -> PermissionPolicy: + data = self._http.get(f"{_BASE}/{custom_object_key}/permission_policies/{policy_id}") + return to_domain(data=data["permission_policy"], cls=PermissionPolicy) + + def update(self, custom_object_key: str, policy_id: str, cmd: UpdatePermissionPolicyCmd) -> PermissionPolicy: + payload = to_payload_update(cmd) + data = self._http.patch(f"{_BASE}/{custom_object_key}/permission_policies/{policy_id}", json=payload) + return to_domain(data=data["permission_policy"], cls=PermissionPolicy) diff --git a/libzapi/infrastructure/api_clients/custom_data/record_attachment.py b/libzapi/infrastructure/api_clients/custom_data/record_attachment.py new file mode 100644 index 0000000..11fe7b5 --- /dev/null +++ b/libzapi/infrastructure/api_clients/custom_data/record_attachment.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.domain.models.custom_data.record_attachment import RecordAttachment +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/custom_objects" + + +class RecordAttachmentApiClient: + """HTTP adapter for Zendesk Custom Object Record Attachments.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def _path(self, key: str, record_id: str) -> str: + return f"{_BASE}/{key}/records/{record_id}/attachments" + + def list_all(self, custom_object_key: str, record_id: str) -> Iterator[RecordAttachment]: + data = self._http.get(self._path(custom_object_key, record_id)) + for obj in data.get("attachments", []): + yield to_domain(data=obj, cls=RecordAttachment) + + def create(self, custom_object_key: str, record_id: str, file: tuple) -> RecordAttachment: + data = self._http.post_multipart(self._path(custom_object_key, record_id), files={"file": file}) + return to_domain(data=data["attachment"], cls=RecordAttachment) + + def update(self, custom_object_key: str, record_id: str, attachment_id: str, payload: dict) -> RecordAttachment: + data = self._http.put(f"{self._path(custom_object_key, record_id)}/{attachment_id}", json=payload) + return to_domain(data=data["attachment"], cls=RecordAttachment) + + def delete(self, custom_object_key: str, record_id: str, attachment_id: str) -> None: + self._http.delete(f"{self._path(custom_object_key, record_id)}/{attachment_id}") + + def download_url(self, custom_object_key: str, record_id: str, attachment_id: str) -> str: + return f"{self._http.base_url}{self._path(custom_object_key, record_id)}/{attachment_id}/download" diff --git a/libzapi/infrastructure/api_clients/custom_data/record_event.py b/libzapi/infrastructure/api_clients/custom_data/record_event.py new file mode 100644 index 0000000..d97b19a --- /dev/null +++ b/libzapi/infrastructure/api_clients/custom_data/record_event.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Iterator + +from libzapi.domain.models.custom_data.record_event import RecordEvent +from libzapi.infrastructure.http.client import HttpClient +from libzapi.infrastructure.serialization.parse import to_domain + +_BASE = "/api/v2/custom_objects" + + +def _extract_next(data: dict, base_url: str) -> str | None: + """Extract cursor pagination link from Record Events response. + + This API returns ``links`` as a list (e.g. ``[{"next": "..."}]``) + instead of the standard dict format, so the generic pagination + utility cannot be used here. + """ + links = data.get("links") + if isinstance(links, list): + for link in links: + if isinstance(link, dict) and link.get("next"): + nxt = link["next"] + return nxt.replace(base_url, "") if nxt.startswith("https://") else nxt + return None + if isinstance(links, dict) and links.get("next"): + nxt = links["next"] + return nxt.replace(base_url, "") if nxt.startswith("https://") else nxt + return None + + +class RecordEventApiClient: + """HTTP adapter for Zendesk Custom Object Record Events.""" + + def __init__(self, http: HttpClient) -> None: + self._http = http + + def list_all(self, custom_object_key: str, record_id: str, page_size: int = 100) -> Iterator[RecordEvent]: + path: str | None = f"{_BASE}/{custom_object_key}/records/{record_id}/events?page[size]={page_size}" + while path: + data = self._http.get(path) + for obj in data.get("events", []): + yield to_domain(data=obj, cls=RecordEvent) + meta = data.get("meta") or {} + if not meta.get("has_more"): + break + path = _extract_next(data, self._http.base_url) diff --git a/libzapi/infrastructure/mappers/custom_data/__init__.py b/libzapi/infrastructure/mappers/custom_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libzapi/infrastructure/mappers/custom_data/access_rule_mapper.py b/libzapi/infrastructure/mappers/custom_data/access_rule_mapper.py new file mode 100644 index 0000000..c2a1762 --- /dev/null +++ b/libzapi/infrastructure/mappers/custom_data/access_rule_mapper.py @@ -0,0 +1,14 @@ +from libzapi.application.commands.custom_data.permission_cmds import CreateAccessRuleCmd, UpdateAccessRuleCmd + + +def to_payload_create(cmd: CreateAccessRuleCmd) -> dict: + payload: dict = {"name": cmd.name} + if cmd.conditions: + payload["conditions"] = cmd.conditions + return {"access_rule": payload} + + +def to_payload_update(cmd: UpdateAccessRuleCmd) -> dict: + fields = ("name", "conditions") + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"access_rule": patch} diff --git a/libzapi/infrastructure/mappers/custom_data/custom_object_field_mapper.py b/libzapi/infrastructure/mappers/custom_data/custom_object_field_mapper.py new file mode 100644 index 0000000..c0bd7f9 --- /dev/null +++ b/libzapi/infrastructure/mappers/custom_data/custom_object_field_mapper.py @@ -0,0 +1,39 @@ +from libzapi.application.commands.custom_data.custom_object_field_cmds import ( + CreateCustomObjectFieldCmd, + UpdateCustomObjectFieldCmd, +) + + +def to_payload_create(cmd: CreateCustomObjectFieldCmd) -> dict: + payload: dict = { + "type": cmd.type, + "key": cmd.key, + "title": cmd.title, + } + if cmd.description: + payload["description"] = cmd.description + if cmd.active is not True: + payload["active"] = cmd.active + if cmd.position: + payload["position"] = cmd.position + if cmd.regexp_for_validation is not None: + payload["regexp_for_validation"] = cmd.regexp_for_validation + if cmd.custom_field_options is not None: + payload["custom_field_options"] = cmd.custom_field_options + if cmd.relationship_target_type is not None: + payload["relationship_target_type"] = cmd.relationship_target_type + if cmd.relationship_filter is not None: + payload["relationship_filter"] = cmd.relationship_filter + if cmd.tag is not None: + payload["tag"] = cmd.tag + return {"custom_object_field": payload} + + +def to_payload_update(cmd: UpdateCustomObjectFieldCmd) -> dict: + fields = ("title", "description", "active", "position", "custom_field_options") + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"custom_object_field": patch} + + +def to_payload_reorder(field_ids: list[int]) -> dict: + return {"field_ids": field_ids} diff --git a/libzapi/infrastructure/mappers/custom_data/custom_object_mapper.py b/libzapi/infrastructure/mappers/custom_data/custom_object_mapper.py new file mode 100644 index 0000000..2f84a23 --- /dev/null +++ b/libzapi/infrastructure/mappers/custom_data/custom_object_mapper.py @@ -0,0 +1,23 @@ +from libzapi.application.commands.custom_data.custom_object_cmds import CreateCustomObjectCmd, UpdateCustomObjectCmd + + +def to_payload_create(cmd: CreateCustomObjectCmd) -> dict: + payload: dict = { + "key": cmd.key, + "title": cmd.title, + "title_pluralized": cmd.title_pluralized, + "include_in_list_view": cmd.include_in_list_view, + } + if cmd.description: + payload["description"] = cmd.description + if cmd.allows_photos: + payload["allows_photos"] = cmd.allows_photos + if cmd.allows_attachments: + payload["allows_attachments"] = cmd.allows_attachments + return {"custom_object": payload} + + +def to_payload_update(cmd: UpdateCustomObjectCmd) -> dict: + fields = ("title", "title_pluralized", "description", "include_in_list_view", "allows_photos", "allows_attachments") + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"custom_object": patch} diff --git a/libzapi/infrastructure/mappers/custom_data/custom_object_record_mapper.py b/libzapi/infrastructure/mappers/custom_data/custom_object_record_mapper.py new file mode 100644 index 0000000..71ed0b3 --- /dev/null +++ b/libzapi/infrastructure/mappers/custom_data/custom_object_record_mapper.py @@ -0,0 +1,28 @@ +from libzapi.application.commands.custom_data.custom_object_record_cmds import ( + BulkJobCmd, + CreateCustomObjectRecordCmd, + FilteredSearchCmd, + UpdateCustomObjectRecordCmd, +) + + +def to_payload_create(cmd: CreateCustomObjectRecordCmd) -> dict: + payload: dict = { + "name": cmd.name, + "custom_object_fields": cmd.custom_object_fields, + } + if cmd.external_id is not None: + payload["external_id"] = cmd.external_id + return {"custom_object_record": payload} + + +def to_payload_update(cmd: UpdateCustomObjectRecordCmd) -> dict: + return {"custom_object_record": {"custom_object_fields": cmd.custom_object_fields}} + + +def to_payload_bulk_job(cmd: BulkJobCmd) -> dict: + return {"job": {"action": cmd.action, "items": cmd.items}} + + +def to_payload_filtered_search(cmd: FilteredSearchCmd) -> dict: + return {"filter": cmd.filter} diff --git a/libzapi/infrastructure/mappers/custom_data/object_trigger_mapper.py b/libzapi/infrastructure/mappers/custom_data/object_trigger_mapper.py new file mode 100644 index 0000000..e0a01c8 --- /dev/null +++ b/libzapi/infrastructure/mappers/custom_data/object_trigger_mapper.py @@ -0,0 +1,27 @@ +from libzapi.application.commands.custom_data.object_trigger_cmds import ( + CreateObjectTriggerCmd, + UpdateManyTriggersCmd, + UpdateObjectTriggerCmd, +) + + +def to_payload_create(cmd: CreateObjectTriggerCmd) -> dict: + payload: dict = { + "title": cmd.title, + "conditions": cmd.conditions, + "actions": cmd.actions, + "active": cmd.active, + } + if cmd.description: + payload["description"] = cmd.description + return {"trigger": payload} + + +def to_payload_update(cmd: UpdateObjectTriggerCmd) -> dict: + fields = ("title", "conditions", "actions", "active", "description") + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"trigger": patch} + + +def to_payload_update_many(cmd: UpdateManyTriggersCmd) -> dict: + return {"triggers": cmd.triggers} diff --git a/libzapi/infrastructure/mappers/custom_data/permission_policy_mapper.py b/libzapi/infrastructure/mappers/custom_data/permission_policy_mapper.py new file mode 100644 index 0000000..5c46f8c --- /dev/null +++ b/libzapi/infrastructure/mappers/custom_data/permission_policy_mapper.py @@ -0,0 +1,16 @@ +from libzapi.application.commands.custom_data.permission_cmds import UpdatePermissionPolicyCmd + + +def to_payload_update(cmd: UpdatePermissionPolicyCmd) -> dict: + fields = ( + "create", + "read", + "update", + "delete", + "create_rule_id", + "read_rule_id", + "update_rule_id", + "delete_rule_id", + ) + patch = {f: getattr(cmd, f) for f in fields if getattr(cmd, f) is not None} + return {"permission_policy": patch} diff --git a/tests/integration/custom_data/test_access_rules.py b/tests/integration/custom_data/test_access_rules.py new file mode 100644 index 0000000..9c0ad2b --- /dev/null +++ b/tests/integration/custom_data/test_access_rules.py @@ -0,0 +1,21 @@ +import pytest + +from libzapi import CustomData + + +@pytest.fixture() +def custom_object_key(custom_data: CustomData) -> str: + objects = list(custom_data.custom_objects.list_all()) + if not objects: + pytest.skip("No custom objects found in the live API") + return objects[0].key + + +def test_list_access_rules(custom_data: CustomData, custom_object_key: str): + rules = list(custom_data.access_rules.list_all(custom_object_key)) + assert isinstance(rules, list) + + +def test_definitions(custom_data: CustomData, custom_object_key: str): + defs = custom_data.access_rules.definitions(custom_object_key) + assert isinstance(defs, dict) diff --git a/tests/integration/custom_data/test_custom_object.py b/tests/integration/custom_data/test_custom_object.py index 8a55240..35d9080 100644 --- a/tests/integration/custom_data/test_custom_object.py +++ b/tests/integration/custom_data/test_custom_object.py @@ -1,3 +1,5 @@ +import uuid + from libzapi import CustomData @@ -10,3 +12,25 @@ def test_limit_objects(custom_data: CustomData): limit = custom_data.custom_objects.limit() assert limit.limit > 0, "Expected limit to be greater than 0" assert limit.count >= 0, "Expected count to be non-negative" + + +def test_create_update_delete_custom_object(custom_data: CustomData): + uid = str(uuid.uuid4())[:8] + key = f"test_{uid}" + + obj = custom_data.custom_objects.create( + key=key, + title=f"Test Object {uid}", + title_pluralized=f"Test Objects {uid}", + include_in_list_view=False, + ) + assert obj.key == key + + try: + updated = custom_data.custom_objects.update(key, title=f"Updated {uid}") + assert updated.title == f"Updated {uid}" + + fetched = custom_data.custom_objects.get(key) + assert fetched.key == key + finally: + custom_data.custom_objects.delete(key) diff --git a/tests/integration/custom_data/test_custom_object_fields.py b/tests/integration/custom_data/test_custom_object_fields.py index 4b28e67..f3faadc 100644 --- a/tests/integration/custom_data/test_custom_object_fields.py +++ b/tests/integration/custom_data/test_custom_object_fields.py @@ -3,9 +3,30 @@ from libzapi import CustomData -def test_list_objects_and_get(custom_data: CustomData): +@pytest.fixture() +def custom_object_key(custom_data: CustomData) -> str: objects = list(custom_data.custom_objects.list_all()) if not objects: pytest.skip("No custom objects found in the live API") - items = list(custom_data.custom_object_fields.list_all(objects[0].key)) + return objects[0].key + + +def test_list_objects_and_get(custom_data: CustomData, custom_object_key: str): + items = list(custom_data.custom_object_fields.list_all(custom_object_key)) assert len(items) > 0, "Expected at least 1 custom object fields" + + +def test_create_and_delete_field(custom_data: CustomData, custom_object_key: str): + field = custom_data.custom_object_fields.create( + custom_object_key=custom_object_key, + type="text", + key="integration_test_field", + title="Integration Test Field", + ) + assert field.key == "integration_test_field" + + try: + fetched = custom_data.custom_object_fields.get(custom_object_key, field.id) + assert fetched.id == field.id + finally: + custom_data.custom_object_fields.delete(custom_object_key, field.id) diff --git a/tests/integration/custom_data/test_custom_object_records.py b/tests/integration/custom_data/test_custom_object_records.py index cfd7382..80d71fe 100644 --- a/tests/integration/custom_data/test_custom_object_records.py +++ b/tests/integration/custom_data/test_custom_object_records.py @@ -28,3 +28,32 @@ def test_list_objects_and_get_with_sort_and_pagination(custom_data: CustomData, def test_list_records_limit(custom_data: CustomData): limit = custom_data.custom_object_records.limit() assert limit.limit > 0, "Expected record limit to be greater than 0" + + +def test_count_records(custom_data: CustomData, custom_object_key: str): + count = custom_data.custom_object_records.count(custom_object_key) + assert count.value >= 0 + + +def test_search_records(custom_data: CustomData, custom_object_key: str): + results = list(custom_data.custom_object_records.search(custom_object_key, query="*")) + assert isinstance(results, list) + + +def test_autocomplete_records(custom_data: CustomData, custom_object_key: str): + results = list(custom_data.custom_object_records.autocomplete(custom_object_key, name="a")) + assert isinstance(results, list) + + +def test_create_update_delete_record(custom_data: CustomData, custom_object_key: str): + record = custom_data.custom_object_records.create( + custom_object_key=custom_object_key, + name="Integration Test Record", + ) + assert record.id is not None + + try: + fetched = custom_data.custom_object_records.get(custom_object_key, record.id) + assert fetched.id == record.id + finally: + custom_data.custom_object_records.delete(custom_object_key, record.id) diff --git a/tests/integration/custom_data/test_object_triggers.py b/tests/integration/custom_data/test_object_triggers.py new file mode 100644 index 0000000..ea87f39 --- /dev/null +++ b/tests/integration/custom_data/test_object_triggers.py @@ -0,0 +1,26 @@ +import pytest + +from libzapi import CustomData + + +@pytest.fixture() +def custom_object_key(custom_data: CustomData) -> str: + objects = list(custom_data.custom_objects.list_all()) + if not objects: + pytest.skip("No custom objects found in the live API") + return objects[0].key + + +def test_list_triggers(custom_data: CustomData, custom_object_key: str): + triggers = list(custom_data.object_triggers.list_all(custom_object_key)) + assert isinstance(triggers, list) + + +def test_list_active_triggers(custom_data: CustomData, custom_object_key: str): + triggers = list(custom_data.object_triggers.list_active(custom_object_key)) + assert isinstance(triggers, list) + + +def test_definitions(custom_data: CustomData, custom_object_key: str): + defs = custom_data.object_triggers.definitions(custom_object_key) + assert isinstance(defs, dict) diff --git a/tests/integration/custom_data/test_permission_policies.py b/tests/integration/custom_data/test_permission_policies.py new file mode 100644 index 0000000..a81eeef --- /dev/null +++ b/tests/integration/custom_data/test_permission_policies.py @@ -0,0 +1,24 @@ +import pytest + +from libzapi import CustomData + + +@pytest.fixture() +def custom_object_key(custom_data: CustomData) -> str: + objects = list(custom_data.custom_objects.list_all()) + if not objects: + pytest.skip("No custom objects found in the live API") + return objects[0].key + + +def test_list_permission_policies(custom_data: CustomData, custom_object_key: str): + policies = list(custom_data.permission_policies.list_all(custom_object_key)) + assert isinstance(policies, list) + + +def test_list_and_get_policy(custom_data: CustomData, custom_object_key: str): + policies = list(custom_data.permission_policies.list_all(custom_object_key)) + if not policies: + pytest.skip("No permission policies found") + policy = custom_data.permission_policies.get(custom_object_key, policies[0].id) + assert policy.id == policies[0].id diff --git a/tests/integration/custom_data/test_record_attachments.py b/tests/integration/custom_data/test_record_attachments.py new file mode 100644 index 0000000..04dd53b --- /dev/null +++ b/tests/integration/custom_data/test_record_attachments.py @@ -0,0 +1,28 @@ +import pytest + +from libzapi import CustomData + + +@pytest.fixture() +def custom_object_key_and_record(custom_data: CustomData) -> tuple[str, str]: + objects = list(custom_data.custom_objects.list_all()) + if not objects: + pytest.skip("No custom objects found") + # Find an object with allows_attachments enabled + for obj in objects: + if obj.allows_attachments: + records = list(custom_data.custom_object_records.list_all(obj.key)) + if records: + return obj.key, records[0].id + pytest.skip("No custom objects with attachments enabled and records found") + + +def test_list_attachments(custom_data: CustomData, custom_object_key_and_record: tuple[str, str]): + key, record_id = custom_object_key_and_record + attachments = list(custom_data.record_attachments.list_all(key, record_id)) + assert isinstance(attachments, list) + + +def test_download_url(custom_data: CustomData): + url = custom_data.record_attachments.download_url("obj", "rec", "att") + assert "/attachments/att/download" in url diff --git a/tests/integration/custom_data/test_record_events.py b/tests/integration/custom_data/test_record_events.py new file mode 100644 index 0000000..f603d9f --- /dev/null +++ b/tests/integration/custom_data/test_record_events.py @@ -0,0 +1,14 @@ +import pytest + +from libzapi import CustomData + + +def test_list_record_events(custom_data: CustomData): + objects = list(custom_data.custom_objects.list_all()) + if not objects: + pytest.skip("No custom objects found") + records = list(custom_data.custom_object_records.list_all(objects[0].key)) + if not records: + pytest.skip("No records found") + events = list(custom_data.record_events.list_all(objects[0].key, records[0].id, page_size=10)) + assert isinstance(events, list) diff --git a/tests/unit/custom_data/test_access_rule.py b/tests/unit/custom_data/test_access_rule.py new file mode 100644 index 0000000..2df2805 --- /dev/null +++ b/tests/unit/custom_data/test_access_rule.py @@ -0,0 +1,128 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.custom_data.access_rule import AccessRule +from libzapi.infrastructure.api_clients.custom_data.access_rule import AccessRuleApiClient + +MODULE = "libzapi.infrastructure.api_clients.custom_data.access_rule" + +strategy = builds(AccessRule, id=just("rule-1")) + + +@given(strategy) +def test_logical_key(model: AccessRule): + assert model.logical_key.as_str() == "access_rule:rule-1" + + +def test_list_all_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"access_rules": [{}]} + client = AccessRuleApiClient(https) + list(client.list_all("car")) + https.get.assert_called_with("/api/v2/custom_objects/car/access_rules") + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"access_rule": {}} + client = AccessRuleApiClient(https) + client.get("car", "rule-1") + https.get.assert_called_with("/api/v2/custom_objects/car/access_rules/rule-1") + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"access_rule": {"name": "R"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"access_rule": {}} + client = AccessRuleApiClient(https) + client.create("car", mocker.Mock()) + https.post.assert_called_with("/api/v2/custom_objects/car/access_rules", json={"access_rule": {"name": "R"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"access_rule": {"name": "U"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.patch.return_value = {"access_rule": {}} + client = AccessRuleApiClient(https) + client.update("car", "rule-1", mocker.Mock()) + https.patch.assert_called_with( + "/api/v2/custom_objects/car/access_rules/rule-1", json={"access_rule": {"name": "U"}} + ) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = AccessRuleApiClient(https) + client.delete("car", "rule-1") + https.delete.assert_called_with("/api/v2/custom_objects/car/access_rules/rule-1") + + +def test_definitions_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"definitions": {}} + client = AccessRuleApiClient(https) + client.definitions("car") + https.get.assert_called_with("/api/v2/custom_objects/car/access_rules/definitions") + + +@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_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.get.side_effect = error_cls("error") + client = AccessRuleApiClient(https) + with pytest.raises(error_cls): + client.get("car", "rule-1") + + +@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_create_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.post.side_effect = error_cls("error") + client = AccessRuleApiClient(https) + with pytest.raises(error_cls): + client.create("car", mocker.Mock()) + + +@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_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.delete.side_effect = error_cls("error") + client = AccessRuleApiClient(https) + with pytest.raises(error_cls): + client.delete("car", "rule-1") diff --git a/tests/unit/custom_data/test_custom_object.py b/tests/unit/custom_data/test_custom_object.py index 7d5cb9a..ab63eba 100644 --- a/tests/unit/custom_data/test_custom_object.py +++ b/tests/unit/custom_data/test_custom_object.py @@ -116,3 +116,87 @@ def test_custom_object_api_client_limit_raises_on_http_error(error_cls, mocker): with pytest.raises(error_cls): client.limit() + + +MODULE = "libzapi.infrastructure.api_clients.custom_data.custom_object" + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"custom_object": {"key": "car"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"custom_object": {}} + client = CustomObjectApiClient(https) + client.create(mocker.Mock()) + https.post.assert_called_with("/api/v2/custom_objects", json={"custom_object": {"key": "car"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"custom_object": {"title": "Car"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.patch.return_value = {"custom_object": {}} + client = CustomObjectApiClient(https) + client.update("car", mocker.Mock()) + https.patch.assert_called_with("/api/v2/custom_objects/car", json={"custom_object": {"title": "Car"}}) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = CustomObjectApiClient(https) + client.delete("car") + https.delete.assert_called_with("/api/v2/custom_objects/car") + + +@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_create_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.post.side_effect = error_cls("error") + client = CustomObjectApiClient(https) + with pytest.raises(error_cls): + client.create(mocker.Mock()) + + +@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_update_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.patch.side_effect = error_cls("error") + client = CustomObjectApiClient(https) + with pytest.raises(error_cls): + client.update("car", mocker.Mock()) + + +@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_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.delete.side_effect = error_cls("error") + client = CustomObjectApiClient(https) + with pytest.raises(error_cls): + client.delete("car") diff --git a/tests/unit/custom_data/test_custom_object_field.py b/tests/unit/custom_data/test_custom_object_field.py index ab4fe4e..d51e822 100644 --- a/tests/unit/custom_data/test_custom_object_field.py +++ b/tests/unit/custom_data/test_custom_object_field.py @@ -85,3 +85,82 @@ def test_custom_object_field_api_client_get_raises_on_http_error(error_cls, mock with pytest.raises(error_cls): client.get("car", 123) + + +MODULE = "libzapi.infrastructure.api_clients.custom_data.custom_object_field" + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"custom_object_field": {"key": "make"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"custom_object_field": {}} + client = CustomObjectFieldApiClient(https) + client.create("car", mocker.Mock()) + https.post.assert_called_with("/api/v2/custom_objects/car/fields", json={"custom_object_field": {"key": "make"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"custom_object_field": {"title": "Make"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.patch.return_value = {"custom_object_field": {}} + client = CustomObjectFieldApiClient(https) + client.update("car", 123, mocker.Mock()) + https.patch.assert_called_with( + "/api/v2/custom_objects/car/fields/123", json={"custom_object_field": {"title": "Make"}} + ) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = CustomObjectFieldApiClient(https) + client.delete("car", 123) + https.delete.assert_called_with("/api/v2/custom_objects/car/fields/123") + + +def test_reorder_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_payload_reorder", return_value={"field_ids": [3, 1, 2]}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {} + client = CustomObjectFieldApiClient(https) + client.reorder("car", [3, 1, 2]) + https.put.assert_called_with("/api/v2/custom_objects/car/fields/reorder", json={"field_ids": [3, 1, 2]}) + + +@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_create_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.post.side_effect = error_cls("error") + client = CustomObjectFieldApiClient(https) + with pytest.raises(error_cls): + client.create("car", mocker.Mock()) + + +@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_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.delete.side_effect = error_cls("error") + client = CustomObjectFieldApiClient(https) + with pytest.raises(error_cls): + client.delete("car", 123) diff --git a/tests/unit/custom_data/test_custom_object_record.py b/tests/unit/custom_data/test_custom_object_record.py index e0a6a84..3e20437 100644 --- a/tests/unit/custom_data/test_custom_object_record.py +++ b/tests/unit/custom_data/test_custom_object_record.py @@ -217,3 +217,189 @@ def test_limit_raises_on_http_error(error_cls, mocker): with pytest.raises(error_cls): client.limit() + + +MODULE = "libzapi.infrastructure.api_clients.custom_data.custom_object_record" + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"custom_object_record": {"name": "rec"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"custom_object_record": {}} + client = CustomObjectRecordApiClient(https) + client.create("car", mocker.Mock()) + https.post.assert_called_with("/api/v2/custom_objects/car/records", json={"custom_object_record": {"name": "rec"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"custom_object_record": {"custom_object_fields": {}}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.patch.return_value = {"custom_object_record": {}} + client = CustomObjectRecordApiClient(https) + client.update("car", "rec-1", mocker.Mock()) + https.patch.assert_called_with( + "/api/v2/custom_objects/car/records/rec-1", + json={"custom_object_record": {"custom_object_fields": {}}}, + ) + + +def test_upsert_by_external_id(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"custom_object_record": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.patch.return_value = {"custom_object_record": {}} + client = CustomObjectRecordApiClient(https) + client.upsert("car", mocker.Mock(), external_id="ext-1") + https.patch.assert_called_with( + "/api/v2/custom_objects/car/records?external_id=ext-1", + json={"custom_object_record": {}}, + ) + + +def test_upsert_by_name(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"custom_object_record": {}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.patch.return_value = {"custom_object_record": {}} + client = CustomObjectRecordApiClient(https) + client.upsert("car", mocker.Mock(), name="My Record") + https.patch.assert_called_with( + "/api/v2/custom_objects/car/records?name=My Record", + json={"custom_object_record": {}}, + ) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = CustomObjectRecordApiClient(https) + client.delete("car", "rec-1") + https.delete.assert_called_with("/api/v2/custom_objects/car/records/rec-1") + + +def test_delete_by_external_id(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = CustomObjectRecordApiClient(https) + client.delete_by_external_id("car", "ext-1") + https.delete.assert_called_with("/api/v2/custom_objects/car/records?external_id=ext-1") + + +def test_delete_by_name(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = CustomObjectRecordApiClient(https) + client.delete_by_name("car", "My Record") + https.delete.assert_called_with("/api/v2/custom_objects/car/records?name=My Record") + + +def test_count_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"count": {"refreshed_at": "2024-01-01", "value": 42}} + client = CustomObjectRecordApiClient(https) + client.count("car") + https.get.assert_called_with("/api/v2/custom_objects/car/records/count") + + +def test_search_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"custom_object_records": [{}]} + client = CustomObjectRecordApiClient(https) + list(client.search("car", "query")) + https.get.assert_called_with("/api/v2/custom_objects/car/records/search?query=query") + + +def test_search_with_sort(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"custom_object_records": [{}]} + client = CustomObjectRecordApiClient(https) + list(client.search("car", "query", sort="-name")) + https.get.assert_called_with("/api/v2/custom_objects/car/records/search?query=query&sort=-name") + + +def test_filtered_search_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_filtered_search", return_value={"filter": {"field": "value"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"custom_object_records": [{}]} + client = CustomObjectRecordApiClient(https) + list(client.filtered_search("car", mocker.Mock())) + https.post.assert_called_with("/api/v2/custom_objects/car/records/search", json={"filter": {"field": "value"}}) + + +def test_autocomplete_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"custom_object_records": [{}]} + client = CustomObjectRecordApiClient(https) + list(client.autocomplete("car", "test")) + https.get.assert_called_with("/api/v2/custom_objects/car/records/autocomplete?name=test") + + +def test_bulk_job_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_bulk_job", return_value={"job": {"action": "create", "items": []}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"job_status": {}} + client = CustomObjectRecordApiClient(https) + client.bulk_job("car", mocker.Mock()) + https.post.assert_called_with("/api/v2/custom_objects/car/jobs", json={"job": {"action": "create", "items": []}}) + + +def test_incremental_export_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"custom_object_records": [{}], "end_of_stream": True} + client = CustomObjectRecordApiClient(https) + list(client.incremental_export("car", 1706820299)) + https.get.assert_called_with("/api/v2/incremental/custom_objects/car/cursor?start_time=1706820299") + + +@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_create_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.post.side_effect = error_cls("error") + client = CustomObjectRecordApiClient(https) + with pytest.raises(error_cls): + client.create("car", mocker.Mock()) + + +@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_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.delete.side_effect = error_cls("error") + client = CustomObjectRecordApiClient(https) + with pytest.raises(error_cls): + client.delete("car", "rec-1") diff --git a/tests/unit/custom_data/test_object_trigger.py b/tests/unit/custom_data/test_object_trigger.py new file mode 100644 index 0000000..e5e3f50 --- /dev/null +++ b/tests/unit/custom_data/test_object_trigger.py @@ -0,0 +1,165 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.custom_data.object_trigger import ObjectTrigger +from libzapi.infrastructure.api_clients.custom_data.object_trigger import ObjectTriggerApiClient + +MODULE = "libzapi.infrastructure.api_clients.custom_data.object_trigger" + +strategy = builds(ObjectTrigger, id=just(42), title=just("Test")) + + +@given(strategy) +def test_logical_key(model: ObjectTrigger): + assert model.logical_key.as_str() == "object_trigger:42" + + +def test_list_all_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"triggers": [{}]} + client = ObjectTriggerApiClient(https) + list(client.list_all("car")) + https.get.assert_called_with("/api/v2/custom_objects/car/triggers") + + +def test_list_active_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"triggers": [{}]} + client = ObjectTriggerApiClient(https) + list(client.list_active("car")) + https.get.assert_called_with("/api/v2/custom_objects/car/triggers/active") + + +def test_search_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"triggers": [{}]} + client = ObjectTriggerApiClient(https) + list(client.search("car", "test")) + https.get.assert_called_with("/api/v2/custom_objects/car/triggers/search?query=test") + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"trigger": {}} + client = ObjectTriggerApiClient(https) + client.get("car", 42) + https.get.assert_called_with("/api/v2/custom_objects/car/triggers/42") + + +def test_definitions_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"definitions": {}} + client = ObjectTriggerApiClient(https) + client.definitions("car") + https.get.assert_called_with("/api/v2/custom_objects/car/triggers/definitions") + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_create", return_value={"trigger": {"title": "T"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post.return_value = {"trigger": {}} + client = ObjectTriggerApiClient(https) + client.create("car", mocker.Mock()) + https.post.assert_called_with("/api/v2/custom_objects/car/triggers", json={"trigger": {"title": "T"}}) + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"trigger": {"title": "U"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {"trigger": {}} + client = ObjectTriggerApiClient(https) + client.update("car", 42, mocker.Mock()) + https.put.assert_called_with("/api/v2/custom_objects/car/triggers/42", json={"trigger": {"title": "U"}}) + + +def test_update_many_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update_many", return_value={"triggers": [{"id": 1}]}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.put.return_value = {"triggers": [{}]} + client = ObjectTriggerApiClient(https) + client.update_many("car", mocker.Mock()) + https.put.assert_called_with("/api/v2/custom_objects/car/triggers/update_many", json={"triggers": [{"id": 1}]}) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = ObjectTriggerApiClient(https) + client.delete("car", 42) + https.delete.assert_called_with("/api/v2/custom_objects/car/triggers/42") + + +def test_delete_many_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = ObjectTriggerApiClient(https) + client.delete_many("car", [1, 2, 3]) + https.delete.assert_called_with("/api/v2/custom_objects/car/triggers/destroy_many?ids=1,2,3") + + +@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_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.get.side_effect = error_cls("error") + client = ObjectTriggerApiClient(https) + with pytest.raises(error_cls): + client.get("car", 42) + + +@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_create_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.post.side_effect = error_cls("error") + client = ObjectTriggerApiClient(https) + with pytest.raises(error_cls): + client.create("car", mocker.Mock()) + + +@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_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.delete.side_effect = error_cls("error") + client = ObjectTriggerApiClient(https) + with pytest.raises(error_cls): + client.delete("car", 42) diff --git a/tests/unit/custom_data/test_permission_policy.py b/tests/unit/custom_data/test_permission_policy.py new file mode 100644 index 0000000..a8d2d11 --- /dev/null +++ b/tests/unit/custom_data/test_permission_policy.py @@ -0,0 +1,67 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.custom_data.permission_policy import PermissionPolicy +from libzapi.infrastructure.api_clients.custom_data.permission_policy import PermissionPolicyApiClient + +MODULE = "libzapi.infrastructure.api_clients.custom_data.permission_policy" + +strategy = builds(PermissionPolicy, id=just("policy-1")) + + +@given(strategy) +def test_logical_key(model: PermissionPolicy): + assert model.logical_key.as_str() == "permission_policy:policy-1" + + +def test_list_all_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"permission_policies": [{}]} + client = PermissionPolicyApiClient(https) + list(client.list_all("car")) + https.get.assert_called_with("/api/v2/custom_objects/car/permission_policies") + + +def test_get_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"permission_policy": {}} + client = PermissionPolicyApiClient(https) + client.get("car", "custom-role-123") + https.get.assert_called_with("/api/v2/custom_objects/car/permission_policies/custom-role-123") + + +def test_update_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + mocker.patch(f"{MODULE}.to_payload_update", return_value={"permission_policy": {"read": "all"}}) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.patch.return_value = {"permission_policy": {}} + client = PermissionPolicyApiClient(https) + client.update("car", "custom-role-123", mocker.Mock()) + https.patch.assert_called_with( + "/api/v2/custom_objects/car/permission_policies/custom-role-123", + json={"permission_policy": {"read": "all"}}, + ) + + +@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_get_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.get.side_effect = error_cls("error") + client = PermissionPolicyApiClient(https) + with pytest.raises(error_cls): + client.get("car", "p-1") diff --git a/tests/unit/custom_data/test_record_attachment.py b/tests/unit/custom_data/test_record_attachment.py new file mode 100644 index 0000000..8ea9808 --- /dev/null +++ b/tests/unit/custom_data/test_record_attachment.py @@ -0,0 +1,89 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.custom_data.record_attachment import RecordAttachment +from libzapi.infrastructure.api_clients.custom_data.record_attachment import RecordAttachmentApiClient + +MODULE = "libzapi.infrastructure.api_clients.custom_data.record_attachment" + +strategy = builds(RecordAttachment, id=just("att-1")) + + +@given(strategy) +def test_logical_key(model: RecordAttachment): + assert model.logical_key.as_str() == "record_attachment:att-1" + + +def test_list_all_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"attachments": [{}]} + client = RecordAttachmentApiClient(https) + list(client.list_all("car", "rec-1")) + https.get.assert_called_with("/api/v2/custom_objects/car/records/rec-1/attachments") + + +def test_create_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.post_multipart.return_value = {"attachment": {}} + client = RecordAttachmentApiClient(https) + client.create("car", "rec-1", ("file.txt", b"data", "text/plain")) + https.post_multipart.assert_called_with( + "/api/v2/custom_objects/car/records/rec-1/attachments", + files={"file": ("file.txt", b"data", "text/plain")}, + ) + + +def test_delete_calls_correct_path(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = RecordAttachmentApiClient(https) + client.delete("car", "rec-1", "att-1") + https.delete.assert_called_with("/api/v2/custom_objects/car/records/rec-1/attachments/att-1") + + +def test_download_url(mocker): + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + client = RecordAttachmentApiClient(https) + url = client.download_url("car", "rec-1", "att-1") + assert url == "https://example.zendesk.com/api/v2/custom_objects/car/records/rec-1/attachments/att-1/download" + + +@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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.get.side_effect = error_cls("error") + client = RecordAttachmentApiClient(https) + with pytest.raises(error_cls): + list(client.list_all("car", "rec-1")) + + +@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_delete_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.delete.side_effect = error_cls("error") + client = RecordAttachmentApiClient(https) + with pytest.raises(error_cls): + client.delete("car", "rec-1", "att-1") diff --git a/tests/unit/custom_data/test_record_event.py b/tests/unit/custom_data/test_record_event.py new file mode 100644 index 0000000..735a618 --- /dev/null +++ b/tests/unit/custom_data/test_record_event.py @@ -0,0 +1,43 @@ +import pytest +from hypothesis import given +from hypothesis.strategies import builds, just + +from libzapi.domain.errors import NotFound, RateLimited, Unauthorized, UnprocessableEntity +from libzapi.domain.models.custom_data.record_event import RecordEvent +from libzapi.infrastructure.api_clients.custom_data.record_event import RecordEventApiClient + +MODULE = "libzapi.infrastructure.api_clients.custom_data.record_event" + +strategy = builds(RecordEvent, id=just("evt-1"), type=just("record_created")) + + +@given(strategy) +def test_logical_key(model: RecordEvent): + assert model.logical_key.as_str() == "record_event:evt-1" + + +def test_list_all_calls_correct_path(mocker): + mocker.patch(f"{MODULE}.to_domain", return_value=mocker.Mock()) + https = mocker.Mock() + https.base_url = "https://example.zendesk.com" + https.get.return_value = {"events": [{}], "meta": {"has_more": False}, "links": [{"next": ""}]} + client = RecordEventApiClient(https) + list(client.list_all("car", "rec-1", page_size=50)) + https.get.assert_called_with("/api/v2/custom_objects/car/records/rec-1/events?page[size]=50") + + +@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_list_all_raises_on_http_error(error_cls, mocker): + https = mocker.Mock() + https.get.side_effect = error_cls("error") + client = RecordEventApiClient(https) + with pytest.raises(error_cls): + list(client.list_all("car", "rec-1")) diff --git a/uv.lock b/uv.lock index fabd18e..38a070d 100644 --- a/uv.lock +++ b/uv.lock @@ -586,7 +586,7 @@ wheels = [ [[package]] name = "libzapi" -version = "0.6.4" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "cattrs" },