From 6b5a6a43356d576f348d2f78728cca1afa2ed4dc Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Fri, 21 Aug 2026 16:09:45 +0100 Subject: [PATCH] feat(notifications): add templates and template variants services The /public/v1/notifications/templates collection endpoint and its nested /public/v1/notifications/templates/{templateId}/variants collection had no service class, so they were unreachable from MPTClient and AsyncMPTClient. Add sync and async services for both. Templates is registered as a property on the Notifications group. Variants is a nested collection, so it is reached through a variants(template_id) method on the templates service that forwards the parent id as an endpoint param, following the catalog product terms pattern, rather than being registered as a group-level property. Mixins follow what the OpenAPI spec documents: both endpoints are fully managed collections with POST {id}/activate and POST {id}/disable actions. The spec has no deactivate action, so ActivatableMixin does not fit; disable comes from DisableMixin and activate is defined on the services, as in helpdesk queues. E2E coverage exercises both services against the live TEST environment through the operations client on the seeded notifications category. The platform only accepts a Manual template without criteria, activates a template only once it has a default variant, promotes the first activated variant to default, and refuses to disable or delete that default variant. The fixtures build that chain, and the disable test for variants therefore works on a second active variant. Deleting a template soft-deletes it and its variants, which the delete tests assert and which lets the variant fixtures leave cleanup to the template teardown. No pyproject.toml change is needed: the WPS214 per-file-ignores entry for mpt_api_client/resources/notifications/*.py was added in MPT-24265. The POST /templates/{templateId}/variants/_/preview action is outside the scope of this subtask and is not implemented. Streaming support is tracked separately in MPT-24241. MPT-24266 Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Fable 5.1 --- .../resources/notifications/notifications.py | 14 ++ .../notifications/template_variants.py | 73 +++++++++ .../resources/notifications/templates.py | 117 ++++++++++++++ tests/e2e/notifications/templates/__init__.py | 0 tests/e2e/notifications/templates/conftest.py | 92 +++++++++++ .../templates/test_async_templates.py | 84 ++++++++++ .../templates/test_sync_templates.py | 76 +++++++++ .../templates/variants/__init__.py | 0 .../templates/variants/conftest.py | 10 ++ .../variants/test_async_template_variants.py | 98 ++++++++++++ .../variants/test_sync_template_variants.py | 81 ++++++++++ .../notifications/test_notifications.py | 6 + .../notifications/test_template_variants.py | 125 +++++++++++++++ .../resources/notifications/test_templates.py | 150 ++++++++++++++++++ 14 files changed, 926 insertions(+) create mode 100644 mpt_api_client/resources/notifications/template_variants.py create mode 100644 mpt_api_client/resources/notifications/templates.py create mode 100644 tests/e2e/notifications/templates/__init__.py create mode 100644 tests/e2e/notifications/templates/conftest.py create mode 100644 tests/e2e/notifications/templates/test_async_templates.py create mode 100644 tests/e2e/notifications/templates/test_sync_templates.py create mode 100644 tests/e2e/notifications/templates/variants/__init__.py create mode 100644 tests/e2e/notifications/templates/variants/conftest.py create mode 100644 tests/e2e/notifications/templates/variants/test_async_template_variants.py create mode 100644 tests/e2e/notifications/templates/variants/test_sync_template_variants.py create mode 100644 tests/unit/resources/notifications/test_template_variants.py create mode 100644 tests/unit/resources/notifications/test_templates.py diff --git a/mpt_api_client/resources/notifications/notifications.py b/mpt_api_client/resources/notifications/notifications.py index 3dcacb22..34c7b78f 100644 --- a/mpt_api_client/resources/notifications/notifications.py +++ b/mpt_api_client/resources/notifications/notifications.py @@ -16,6 +16,10 @@ AsyncSubscribersService, SubscribersService, ) +from mpt_api_client.resources.notifications.templates import ( + AsyncTemplatesService, + TemplatesService, +) from mpt_api_client.resources.notifications.webhooks import ( AsyncWebhooksService, WebhooksService, @@ -80,6 +84,11 @@ def subscribers(self) -> SubscribersService: """Subscriptions service.""" return SubscribersService(http_client=self.http_client) + @property + def templates(self) -> TemplatesService: + """Templates service.""" + return TemplatesService(http_client=self.http_client) + @property def webhooks(self) -> WebhooksService: """Webhooks service.""" @@ -144,6 +153,11 @@ def subscribers(self) -> AsyncSubscribersService: """Subscriptions service.""" return AsyncSubscribersService(http_client=self.http_client) + @property + def templates(self) -> AsyncTemplatesService: + """Async Templates service.""" + return AsyncTemplatesService(http_client=self.http_client) + @property def webhooks(self) -> AsyncWebhooksService: """Async Webhooks service.""" diff --git a/mpt_api_client/resources/notifications/template_variants.py b/mpt_api_client/resources/notifications/template_variants.py new file mode 100644 index 00000000..753d1489 --- /dev/null +++ b/mpt_api_client/resources/notifications/template_variants.py @@ -0,0 +1,73 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncDisableMixin, + AsyncManagedResourceMixin, + CollectionMixin, + DisableMixin, + ManagedResourceMixin, +) +from mpt_api_client.models import Model, ResourceData +from mpt_api_client.models.model import BaseModel + + +class TemplateVariant(Model): + """Notifications Template Variant resource. + + Attributes: + body: Body content of the variant. + default: Whether this variant is the default one of its template. + language_code: Language code of the variant. + template: Reference to the parent template. + status: Variant status. + subject: Subject of the messages created from this variant. + audit: Audit information (created, updated events). + """ + + body: str | None + default: bool | None + language_code: str | None + template: BaseModel | None + status: str | None + subject: str | None + audit: BaseModel | None + + +class TemplateVariantsServiceConfig: + """Notifications Template Variants service configuration.""" + + _endpoint = "/public/v1/notifications/templates/{template_id}/variants" + _model_class = TemplateVariant + _collection_key = "data" + + +class TemplateVariantsService( + DisableMixin[TemplateVariant], + ManagedResourceMixin[TemplateVariant], + CollectionMixin[TemplateVariant], + Service[TemplateVariant], + TemplateVariantsServiceConfig, +): + """Notifications Template Variants service.""" + + def activate( + self, resource_id: str, resource_data: ResourceData | None = None + ) -> TemplateVariant: + """Switch template variant to active state.""" + return self._resource(resource_id).post("activate", json=resource_data) + + +class AsyncTemplateVariantsService( + AsyncDisableMixin[TemplateVariant], + AsyncManagedResourceMixin[TemplateVariant], + AsyncCollectionMixin[TemplateVariant], + AsyncService[TemplateVariant], + TemplateVariantsServiceConfig, +): + """Async Notifications Template Variants service.""" + + async def activate( + self, resource_id: str, resource_data: ResourceData | None = None + ) -> TemplateVariant: + """Switch template variant to active state.""" + return await self._resource(resource_id).post("activate", json=resource_data) diff --git a/mpt_api_client/resources/notifications/templates.py b/mpt_api_client/resources/notifications/templates.py new file mode 100644 index 00000000..9315faa7 --- /dev/null +++ b/mpt_api_client/resources/notifications/templates.py @@ -0,0 +1,117 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncDisableMixin, + AsyncManagedResourceMixin, + CollectionMixin, + DisableMixin, + ManagedResourceMixin, +) +from mpt_api_client.models import Model, ResourceData +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.notifications.template_variants import ( + AsyncTemplateVariantsService, + TemplateVariantsService, +) + + +class Template(Model): + """Notifications Template resource. + + Attributes: + name: Template name. + category: Reference to the notification category of the template. + criteria: Criteria triggering the template automatically. + default_variant: Reference to the default variant of the template. + description: Template description. + last_used: Timestamp of the last time the template was used. + owner: Reference to the account owning the template. + schedule: Reference to the schedule triggering the template. + statistics: Usage statistics of the template. + status: Template status. + type: How the template is triggered (Event, Scheduled or Manual). + variants: References to the language-specific variants of the template. + external_id: External identifier of the template. + audit: Audit information (created, updated events). + """ + + name: str | None + category: BaseModel | None + criteria: BaseModel | None + default_variant: BaseModel | None + description: str | None + last_used: str | None + owner: BaseModel | None + schedule: BaseModel | None + statistics: BaseModel | None + status: str | None + type: str | None + variants: list[BaseModel] | None + external_id: str | None + audit: BaseModel | None + + +class TemplatesServiceConfig: + """Notifications Templates service configuration.""" + + _endpoint = "/public/v1/notifications/templates" + _model_class = Template + _collection_key = "data" + + +class TemplatesService( + DisableMixin[Template], + ManagedResourceMixin[Template], + CollectionMixin[Template], + Service[Template], + TemplatesServiceConfig, +): + """Notifications Templates service.""" + + def activate(self, resource_id: str, resource_data: ResourceData | None = None) -> Template: + """Switch template to active state.""" + return self._resource(resource_id).post("activate", json=resource_data) + + def variants(self, template_id: str) -> TemplateVariantsService: + """Access template variants service. + + Args: + template_id: Template ID. + + Returns: + TemplateVariantsService + """ + return TemplateVariantsService( + http_client=self.http_client, + endpoint_params={"template_id": template_id}, + ) + + +class AsyncTemplatesService( + AsyncDisableMixin[Template], + AsyncManagedResourceMixin[Template], + AsyncCollectionMixin[Template], + AsyncService[Template], + TemplatesServiceConfig, +): + """Async Notifications Templates service.""" + + async def activate( + self, resource_id: str, resource_data: ResourceData | None = None + ) -> Template: + """Switch template to active state.""" + return await self._resource(resource_id).post("activate", json=resource_data) + + def variants(self, template_id: str) -> AsyncTemplateVariantsService: + """Access async template variants service. + + Args: + template_id: Template ID. + + Returns: + AsyncTemplateVariantsService + """ + return AsyncTemplateVariantsService( + http_client=self.http_client, + endpoint_params={"template_id": template_id}, + ) diff --git a/tests/e2e/notifications/templates/__init__.py b/tests/e2e/notifications/templates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/notifications/templates/conftest.py b/tests/e2e/notifications/templates/conftest.py new file mode 100644 index 00000000..9965bf09 --- /dev/null +++ b/tests/e2e/notifications/templates/conftest.py @@ -0,0 +1,92 @@ +import pytest + +from tests.e2e.helper import ( + async_create_fixture_resource_and_delete, + create_fixture_resource_and_delete, +) + +# A template can only be activated once it has a default variant, and the first variant +# activated on a template becomes its default. Deleting a template soft-deletes its variants, +# so the variant fixtures only create and leave the cleanup to the template teardown. + + +@pytest.fixture +def templates_service(mpt_ops): + return mpt_ops.notifications.templates + + +@pytest.fixture +def async_templates_service(async_mpt_ops): + return async_mpt_ops.notifications.templates + + +@pytest.fixture +def template_data(short_uuid, category_id): + return { + "name": f"e2e template {short_uuid}", + "description": f"e2e template - please delete {short_uuid}", + "type": "Manual", + "category": {"id": category_id}, + } + + +@pytest.fixture +def template_variant_data(short_uuid): + return { + "languageCode": "en-US", + "subject": f"e2e template variant {short_uuid}", + "body": f"e2e template variant body {short_uuid}", + } + + +@pytest.fixture +def created_template(templates_service, template_data): + with create_fixture_resource_and_delete(templates_service, template_data) as template: + yield template + + +@pytest.fixture +async def async_created_template(async_templates_service, template_data): + async with async_create_fixture_resource_and_delete( + async_templates_service, template_data + ) as template: + yield template + + +@pytest.fixture +def template_variants_service(templates_service, created_template): + return templates_service.variants(created_template.id) + + +@pytest.fixture +def async_template_variants_service(async_templates_service, async_created_template): + return async_templates_service.variants(async_created_template.id) + + +@pytest.fixture +def created_template_variant(template_variants_service, template_variant_data): + return template_variants_service.create(template_variant_data) + + +@pytest.fixture +async def async_created_template_variant(async_template_variants_service, template_variant_data): + return await async_template_variants_service.create(template_variant_data) + + +@pytest.fixture +def active_template( + templates_service, template_variants_service, created_template, created_template_variant +): + template_variants_service.activate(created_template_variant.id) + return templates_service.activate(created_template.id) + + +@pytest.fixture +async def async_active_template( + async_templates_service, + async_template_variants_service, + async_created_template, + async_created_template_variant, +): + await async_template_variants_service.activate(async_created_template_variant.id) + return await async_templates_service.activate(async_created_template.id) diff --git a/tests/e2e/notifications/templates/test_async_templates.py b/tests/e2e/notifications/templates/test_async_templates.py new file mode 100644 index 00000000..23d90cd8 --- /dev/null +++ b/tests/e2e/notifications/templates/test_async_templates.py @@ -0,0 +1,84 @@ +from http import HTTPStatus + +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from tests.e2e.helper import ( + assert_async_service_filter_with_iterate, + assert_async_update_resource, +) + +pytestmark = [pytest.mark.flaky] + + +def test_create_template(async_created_template, template_data): + result = async_created_template.name + + assert result == template_data["name"] + + +async def test_get_template(async_templates_service, async_created_template): + result = await async_templates_service.get(async_created_template.id) + + assert result.id == async_created_template.id + + +async def test_get_template_not_found(async_templates_service): + bogus_id = "NTM-0000-0000" + + with pytest.raises(MPTAPIError) as error: + await async_templates_service.get(bogus_id) + + assert error.value.status_code == HTTPStatus.NOT_FOUND + + +async def test_update_template(async_templates_service, async_created_template, short_uuid): + new_description = f"e2e template updated {short_uuid}" + + await assert_async_update_resource( # act + async_templates_service, async_created_template.id, "description", new_description + ) + + +async def test_activate_template( + async_templates_service, + async_template_variants_service, + async_created_template, + async_created_template_variant, +): + await async_template_variants_service.activate(async_created_template_variant.id) + + result = await async_templates_service.activate(async_created_template.id) + + assert result.status == "Active" + assert result.default_variant.id == async_created_template_variant.id + + +async def test_disable_template(async_templates_service, async_active_template): + result = await async_templates_service.disable(async_active_template.id) + + assert result.status == "Disabled" + + +async def test_delete_template(async_templates_service, async_created_template): + await async_templates_service.delete(async_created_template.id) + + result = await async_templates_service.get(async_created_template.id) + + assert result.status == "Deleted" + + +async def test_delete_template_not_found(async_templates_service): + # The platform answers a delete on an unknown id with 400, not 404. + bogus_id = "NTM-0000-0000" + + with pytest.raises(MPTAPIError) as error: + await async_templates_service.delete(bogus_id) + + assert error.value.status_code == HTTPStatus.BAD_REQUEST + + +async def test_filter_templates(async_templates_service, async_created_template): + await assert_async_service_filter_with_iterate( # act + async_templates_service, async_created_template.id, None + ) diff --git a/tests/e2e/notifications/templates/test_sync_templates.py b/tests/e2e/notifications/templates/test_sync_templates.py new file mode 100644 index 00000000..78ea7b68 --- /dev/null +++ b/tests/e2e/notifications/templates/test_sync_templates.py @@ -0,0 +1,76 @@ +from http import HTTPStatus + +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from tests.e2e.helper import assert_service_filter_with_iterate, assert_update_resource + +pytestmark = [pytest.mark.flaky] + + +def test_create_template(created_template, template_data): + result = created_template.name + + assert result == template_data["name"] + + +def test_get_template(templates_service, created_template): + result = templates_service.get(created_template.id) + + assert result.id == created_template.id + + +def test_get_template_not_found(templates_service): + bogus_id = "NTM-0000-0000" + + with pytest.raises(MPTAPIError) as error: + templates_service.get(bogus_id) + + assert error.value.status_code == HTTPStatus.NOT_FOUND + + +def test_update_template(templates_service, created_template, short_uuid): + new_description = f"e2e template updated {short_uuid}" + + assert_update_resource( # act + templates_service, created_template.id, "description", new_description + ) + + +def test_activate_template( + templates_service, template_variants_service, created_template, created_template_variant +): + template_variants_service.activate(created_template_variant.id) + + result = templates_service.activate(created_template.id) + + assert result.status == "Active" + assert result.default_variant.id == created_template_variant.id + + +def test_disable_template(templates_service, active_template): + result = templates_service.disable(active_template.id) + + assert result.status == "Disabled" + + +def test_delete_template(templates_service, created_template): + templates_service.delete(created_template.id) + + result = templates_service.get(created_template.id) + + assert result.status == "Deleted" + + +def test_delete_template_not_found(templates_service): + # The platform answers a delete on an unknown id with 400, not 404. + bogus_id = "NTM-0000-0000" + + with pytest.raises(MPTAPIError) as error: + templates_service.delete(bogus_id) + + assert error.value.status_code == HTTPStatus.BAD_REQUEST + + +def test_filter_templates(templates_service, created_template): + assert_service_filter_with_iterate(templates_service, created_template.id, None) # act diff --git a/tests/e2e/notifications/templates/variants/__init__.py b/tests/e2e/notifications/templates/variants/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/notifications/templates/variants/conftest.py b/tests/e2e/notifications/templates/variants/conftest.py new file mode 100644 index 00000000..2d004568 --- /dev/null +++ b/tests/e2e/notifications/templates/variants/conftest.py @@ -0,0 +1,10 @@ +import pytest + + +@pytest.fixture +def secondary_template_variant_data(short_uuid): + return { + "languageCode": "de-DE", + "subject": f"e2e secondary template variant {short_uuid}", + "body": f"e2e secondary template variant body {short_uuid}", + } diff --git a/tests/e2e/notifications/templates/variants/test_async_template_variants.py b/tests/e2e/notifications/templates/variants/test_async_template_variants.py new file mode 100644 index 00000000..dab9eed8 --- /dev/null +++ b/tests/e2e/notifications/templates/variants/test_async_template_variants.py @@ -0,0 +1,98 @@ +from http import HTTPStatus + +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from tests.e2e.helper import ( + assert_async_service_filter_with_iterate, + assert_async_update_resource, +) + +pytestmark = [pytest.mark.flaky] + + +def test_create_template_variant(async_created_template_variant, template_variant_data): + result = async_created_template_variant.subject + + assert result == template_variant_data["subject"] + + +async def test_get_template_variant( + async_template_variants_service, async_created_template_variant +): + result = await async_template_variants_service.get(async_created_template_variant.id) + + assert result.id == async_created_template_variant.id + + +async def test_get_template_variant_not_found(async_template_variants_service): + bogus_id = "NTL-0000-0000-0000" + + with pytest.raises(MPTAPIError) as error: + await async_template_variants_service.get(bogus_id) + + assert error.value.status_code == HTTPStatus.NOT_FOUND + + +async def test_update_template_variant( + async_template_variants_service, async_created_template_variant, short_uuid +): + new_subject = f"e2e template variant updated {short_uuid}" + + await assert_async_update_resource( # act + async_template_variants_service, async_created_template_variant.id, "subject", new_subject + ) + + +async def test_activate_template_variant( + async_template_variants_service, async_created_template_variant +): + result = await async_template_variants_service.activate(async_created_template_variant.id) + + assert result.status == "Active" + assert result.default + + +async def test_disable_template_variant( + async_template_variants_service, + async_created_template_variant, + secondary_template_variant_data, +): + # The default variant cannot be disabled, so the test disables a second active variant. + await async_template_variants_service.activate(async_created_template_variant.id) + secondary_variant = await async_template_variants_service.create( + secondary_template_variant_data + ) + await async_template_variants_service.activate(secondary_variant.id) + + result = await async_template_variants_service.disable(secondary_variant.id) + + assert result.status == "Disabled" + + +async def test_delete_template_variant( + async_template_variants_service, async_created_template_variant +): + await async_template_variants_service.delete(async_created_template_variant.id) + + result = await async_template_variants_service.get(async_created_template_variant.id) + + assert result.status == "Deleted" + + +async def test_delete_template_variant_not_found(async_template_variants_service): + # The platform answers a delete on an unknown id with 400, not 404. + bogus_id = "NTL-0000-0000-0000" + + with pytest.raises(MPTAPIError) as error: + await async_template_variants_service.delete(bogus_id) + + assert error.value.status_code == HTTPStatus.BAD_REQUEST + + +async def test_filter_template_variants( + async_template_variants_service, async_created_template_variant +): + await assert_async_service_filter_with_iterate( # act + async_template_variants_service, async_created_template_variant.id, None + ) diff --git a/tests/e2e/notifications/templates/variants/test_sync_template_variants.py b/tests/e2e/notifications/templates/variants/test_sync_template_variants.py new file mode 100644 index 00000000..8a6eb9b8 --- /dev/null +++ b/tests/e2e/notifications/templates/variants/test_sync_template_variants.py @@ -0,0 +1,81 @@ +from http import HTTPStatus + +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from tests.e2e.helper import assert_service_filter_with_iterate, assert_update_resource + +pytestmark = [pytest.mark.flaky] + + +def test_create_template_variant(created_template_variant, template_variant_data): + result = created_template_variant.subject + + assert result == template_variant_data["subject"] + + +def test_get_template_variant(template_variants_service, created_template_variant): + result = template_variants_service.get(created_template_variant.id) + + assert result.id == created_template_variant.id + + +def test_get_template_variant_not_found(template_variants_service): + bogus_id = "NTL-0000-0000-0000" + + with pytest.raises(MPTAPIError) as error: + template_variants_service.get(bogus_id) + + assert error.value.status_code == HTTPStatus.NOT_FOUND + + +def test_update_template_variant(template_variants_service, created_template_variant, short_uuid): + new_subject = f"e2e template variant updated {short_uuid}" + + assert_update_resource( # act + template_variants_service, created_template_variant.id, "subject", new_subject + ) + + +def test_activate_template_variant(template_variants_service, created_template_variant): + result = template_variants_service.activate(created_template_variant.id) + + assert result.status == "Active" + assert result.default + + +def test_disable_template_variant( + template_variants_service, created_template_variant, secondary_template_variant_data +): + # The default variant cannot be disabled, so the test disables a second active variant. + template_variants_service.activate(created_template_variant.id) + secondary_variant = template_variants_service.create(secondary_template_variant_data) + template_variants_service.activate(secondary_variant.id) + + result = template_variants_service.disable(secondary_variant.id) + + assert result.status == "Disabled" + + +def test_delete_template_variant(template_variants_service, created_template_variant): + template_variants_service.delete(created_template_variant.id) + + result = template_variants_service.get(created_template_variant.id) + + assert result.status == "Deleted" + + +def test_delete_template_variant_not_found(template_variants_service): + # The platform answers a delete on an unknown id with 400, not 404. + bogus_id = "NTL-0000-0000-0000" + + with pytest.raises(MPTAPIError) as error: + template_variants_service.delete(bogus_id) + + assert error.value.status_code == HTTPStatus.BAD_REQUEST + + +def test_filter_template_variants(template_variants_service, created_template_variant): + assert_service_filter_with_iterate( # act + template_variants_service, created_template_variant.id, None + ) diff --git a/tests/unit/resources/notifications/test_notifications.py b/tests/unit/resources/notifications/test_notifications.py index 994eb8f6..921d1f36 100644 --- a/tests/unit/resources/notifications/test_notifications.py +++ b/tests/unit/resources/notifications/test_notifications.py @@ -18,6 +18,10 @@ AsyncSubscribersService, SubscribersService, ) +from mpt_api_client.resources.notifications.templates import ( + AsyncTemplatesService, + TemplatesService, +) from mpt_api_client.resources.notifications.webhooks import ( AsyncWebhooksService, WebhooksService, @@ -48,6 +52,7 @@ def test_async_notifications_init(async_http_client): ("subscribers", SubscribersService), ("directories", DirectoriesService), ("footers", FootersService), + ("templates", TemplatesService), ("webhooks", WebhooksService), ], ) @@ -69,6 +74,7 @@ def test_notifications_properties(http_client, attr_name, expected): ("subscribers", AsyncSubscribersService), ("directories", AsyncDirectoriesService), ("footers", AsyncFootersService), + ("templates", AsyncTemplatesService), ("webhooks", AsyncWebhooksService), ], ) diff --git a/tests/unit/resources/notifications/test_template_variants.py b/tests/unit/resources/notifications/test_template_variants.py new file mode 100644 index 00000000..62756d2d --- /dev/null +++ b/tests/unit/resources/notifications/test_template_variants.py @@ -0,0 +1,125 @@ +import httpx +import pytest +import respx + +from mpt_api_client.resources.notifications.template_variants import ( + AsyncTemplateVariantsService, + TemplateVariantsService, +) + +TEMPLATE_ID = "NTL-1234" +VARIANTS_PATH = f"/public/v1/notifications/templates/{TEMPLATE_ID}/variants" + + +@pytest.fixture +def template_variants_service(http_client): + return TemplateVariantsService( + http_client=http_client, endpoint_params={"template_id": TEMPLATE_ID} + ) + + +@pytest.fixture +def async_template_variants_service(async_http_client): + return AsyncTemplateVariantsService( + http_client=async_http_client, endpoint_params={"template_id": TEMPLATE_ID} + ) + + +@pytest.fixture +def template_variant_data(): + return { + "id": "NTV-1234", + "body": "Hello", + "default": True, + "languageCode": "en-US", + "subject": "Order confirmed", + "status": "Active", + "template": {"id": TEMPLATE_ID, "name": "Order confirmation"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +def test_endpoint_contains_template_id(template_variants_service): + result = template_variants_service.build_path() + + assert result == VARIANTS_PATH + + +def test_async_endpoint_contains_template_id(async_template_variants_service): + result = async_template_variants_service.build_path() + + assert result == VARIANTS_PATH + + +@pytest.mark.parametrize( + "method", + ["get", "create", "update", "delete", "activate", "disable", "iterate", "fetch_page"], +) +def test_mixins_present(template_variants_service, method): + result = hasattr(template_variants_service, method) + + assert result is True + + +@pytest.mark.parametrize( + "method", + ["get", "create", "update", "delete", "activate", "disable", "iterate", "fetch_page"], +) +def test_async_mixins_present(async_template_variants_service, method): + result = hasattr(async_template_variants_service, method) + + assert result is True + + +def test_get_template_variant(template_variants_service, template_variant_data): + with respx.mock: + mock_route = respx.get(f"https://api.example.com{VARIANTS_PATH}/NTV-1234").mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=template_variant_data, + ) + ) + + result = template_variants_service.get("NTV-1234") + + assert mock_route.call_count == 1 + assert result.to_dict() == template_variant_data + assert result.language_code == "en-US" + assert result.template.id == TEMPLATE_ID + + +@pytest.mark.parametrize("action", ["activate", "disable"]) +def test_template_variant_state_actions(template_variants_service, action): + response_expected_data = {"id": "NTV-1234", "status": "Active"} + with respx.mock: + mock_route = respx.post(f"https://api.example.com{VARIANTS_PATH}/NTV-1234/{action}").mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = getattr(template_variants_service, action)("NTV-1234") + + assert mock_route.call_count == 1 + assert result.to_dict() == response_expected_data + + +@pytest.mark.parametrize("action", ["activate", "disable"]) +async def test_async_template_variant_state_actions(async_template_variants_service, action): + response_expected_data = {"id": "NTV-1234", "status": "Active"} + with respx.mock: + mock_route = respx.post(f"https://api.example.com{VARIANTS_PATH}/NTV-1234/{action}").mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = await getattr(async_template_variants_service, action)("NTV-1234") + + assert mock_route.call_count == 1 + assert result.to_dict() == response_expected_data diff --git a/tests/unit/resources/notifications/test_templates.py b/tests/unit/resources/notifications/test_templates.py new file mode 100644 index 00000000..eb23b476 --- /dev/null +++ b/tests/unit/resources/notifications/test_templates.py @@ -0,0 +1,150 @@ +import httpx +import pytest +import respx + +from mpt_api_client.resources.notifications.template_variants import ( + AsyncTemplateVariantsService, + TemplateVariantsService, +) +from mpt_api_client.resources.notifications.templates import ( + AsyncTemplatesService, + TemplatesService, +) + + +@pytest.fixture +def templates_service(http_client): + return TemplatesService(http_client=http_client) + + +@pytest.fixture +def async_templates_service(async_http_client): + return AsyncTemplatesService(http_client=async_http_client) + + +@pytest.fixture +def template_data(): + return { + "id": "NTL-1234", + "name": "Order confirmation", + "description": "Sent when an order is completed", + "status": "Active", + "type": "Event", + "externalId": "order-confirmation", + "lastUsed": "2024-01-01T00:00:00Z", + "category": {"id": "NCA-1234", "name": "Orders"}, + "defaultVariant": {"id": "NTV-1234", "languageCode": "en-US"}, + "owner": {"id": "ACC-1234", "name": "Account"}, + "variants": [{"id": "NTV-1234", "languageCode": "en-US"}], + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +def test_endpoint(templates_service): + result = templates_service.build_path() + + assert result == "/public/v1/notifications/templates" + + +def test_async_endpoint(async_templates_service): + result = async_templates_service.build_path() + + assert result == "/public/v1/notifications/templates" + + +@pytest.mark.parametrize( + "method", + ["get", "create", "update", "delete", "activate", "disable", "iterate", "fetch_page"], +) +def test_mixins_present(templates_service, method): + result = hasattr(templates_service, method) + + assert result is True + + +@pytest.mark.parametrize( + "method", + ["get", "create", "update", "delete", "activate", "disable", "iterate", "fetch_page"], +) +def test_async_mixins_present(async_templates_service, method): + result = hasattr(async_templates_service, method) + + assert result is True + + +def test_get_template(templates_service, template_data): + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/notifications/templates/NTL-1234" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=template_data, + ) + ) + + result = templates_service.get("NTL-1234") + + assert mock_route.call_count == 1 + assert result.to_dict() == template_data + assert result.external_id == "order-confirmation" + assert result.default_variant.id == "NTV-1234" + + +@pytest.mark.parametrize("action", ["activate", "disable"]) +def test_template_state_actions(templates_service, action): + response_expected_data = {"id": "NTL-1234", "status": "Active"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/notifications/templates/NTL-1234/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = getattr(templates_service, action)("NTL-1234") + + assert mock_route.call_count == 1 + assert result.to_dict() == response_expected_data + + +@pytest.mark.parametrize("action", ["activate", "disable"]) +async def test_async_template_state_actions(async_templates_service, action): + response_expected_data = {"id": "NTL-1234", "status": "Active"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/notifications/templates/NTL-1234/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = await getattr(async_templates_service, action)("NTL-1234") + + assert mock_route.call_count == 1 + assert result.to_dict() == response_expected_data + + +def test_variants_accessor(templates_service): + result = templates_service.variants("NTL-1234") + + assert isinstance(result, TemplateVariantsService) + assert result.http_client is templates_service.http_client + assert result.endpoint_params == {"template_id": "NTL-1234"} + assert result.build_path() == "/public/v1/notifications/templates/NTL-1234/variants" + + +def test_async_variants_accessor(async_templates_service): + result = async_templates_service.variants("NTL-1234") + + assert isinstance(result, AsyncTemplateVariantsService) + assert result.http_client is async_templates_service.http_client + assert result.endpoint_params == {"template_id": "NTL-1234"} + assert result.build_path() == "/public/v1/notifications/templates/NTL-1234/variants"