diff --git a/e2e_config.test.json b/e2e_config.test.json index fb5acc9a..3494720f 100644 --- a/e2e_config.test.json +++ b/e2e_config.test.json @@ -68,6 +68,7 @@ "notifications.batch.id": "MST-3638-2460-4825", "notifications.category.id": "NTC-6157-0397", "notifications.contact.id": "CTT-0001-9158", + "notifications.directory.id": "DIR-9614-2985", "notifications.message.id": "MSG-0000-6215-1019-0139", "notifications.subscriber.id": "NTS-0829-7123-7123", "integration.extension.id": "EXT-6587-4477", diff --git a/mpt_api_client/resources/notifications/directories.py b/mpt_api_client/resources/notifications/directories.py new file mode 100644 index 00000000..d788c452 --- /dev/null +++ b/mpt_api_client/resources/notifications/directories.py @@ -0,0 +1,53 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncGetMixin, + CollectionMixin, + GetMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel + + +class Directory(Model): + """Notifications Directory resource. + + Attributes: + account: Reference to the account owning the directory. + origin: Reference to the account the directory originates from. + type: Directory type. + contacts: References to the contacts belonging to the directory. + audit: Audit information (created, updated events). + """ + + account: BaseModel | None + origin: BaseModel | None + type: str | None + contacts: list[BaseModel] | None + audit: BaseModel | None + + +class DirectoriesServiceConfig: + """Notifications Directories service configuration.""" + + _endpoint = "/public/v1/notifications/directories" + _model_class = Directory + _collection_key = "data" + + +class DirectoriesService( + GetMixin[Directory], + CollectionMixin[Directory], + Service[Directory], + DirectoriesServiceConfig, +): + """Notifications Directories service.""" + + +class AsyncDirectoriesService( + AsyncGetMixin[Directory], + AsyncCollectionMixin[Directory], + AsyncService[Directory], + DirectoriesServiceConfig, +): + """Async Notifications Directories service.""" diff --git a/mpt_api_client/resources/notifications/footers.py b/mpt_api_client/resources/notifications/footers.py new file mode 100644 index 00000000..333f739f --- /dev/null +++ b/mpt_api_client/resources/notifications/footers.py @@ -0,0 +1,53 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncManagedResourceMixin, + CollectionMixin, + ManagedResourceMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel + + +class Footer(Model): + """Notifications Footer resource. + + Attributes: + language_code: Language code of the footer. + content: Content of the footer. + is_default: Whether this footer is the default one. + status: Footer status. + audit: Audit information (created, updated events). + """ + + language_code: str | None + content: str | None + is_default: bool | None + status: str | None + audit: BaseModel | None + + +class FootersServiceConfig: + """Notifications Footers service configuration.""" + + _endpoint = "/public/v1/notifications/footers" + _model_class = Footer + _collection_key = "data" + + +class FootersService( + ManagedResourceMixin[Footer], + CollectionMixin[Footer], + Service[Footer], + FootersServiceConfig, +): + """Notifications Footers service.""" + + +class AsyncFootersService( + AsyncManagedResourceMixin[Footer], + AsyncCollectionMixin[Footer], + AsyncService[Footer], + FootersServiceConfig, +): + """Async Notifications Footers service.""" diff --git a/mpt_api_client/resources/notifications/notifications.py b/mpt_api_client/resources/notifications/notifications.py index 1ffae9f5..3dcacb22 100644 --- a/mpt_api_client/resources/notifications/notifications.py +++ b/mpt_api_client/resources/notifications/notifications.py @@ -6,11 +6,20 @@ CategoriesService, ) from mpt_api_client.resources.notifications.contacts import AsyncContactsService, ContactsService +from mpt_api_client.resources.notifications.directories import ( + AsyncDirectoriesService, + DirectoriesService, +) +from mpt_api_client.resources.notifications.footers import AsyncFootersService, FootersService from mpt_api_client.resources.notifications.messages import AsyncMessagesService, MessagesService from mpt_api_client.resources.notifications.subscribers import ( AsyncSubscribersService, SubscribersService, ) +from mpt_api_client.resources.notifications.webhooks import ( + AsyncWebhooksService, + WebhooksService, +) class Notifications: @@ -51,6 +60,16 @@ def contacts(self) -> ContactsService: """Contacts service.""" return ContactsService(http_client=self.http_client) + @property + def directories(self) -> DirectoriesService: + """Directories service.""" + return DirectoriesService(http_client=self.http_client) + + @property + def footers(self) -> FootersService: + """Footers service.""" + return FootersService(http_client=self.http_client) + @property def messages(self) -> MessagesService: """Messages service.""" @@ -61,6 +80,11 @@ def subscribers(self) -> SubscribersService: """Subscriptions service.""" return SubscribersService(http_client=self.http_client) + @property + def webhooks(self) -> WebhooksService: + """Webhooks service.""" + return WebhooksService(http_client=self.http_client) + class AsyncNotifications: """Notifications MPT API Module.""" @@ -100,6 +124,16 @@ def contacts(self) -> AsyncContactsService: """Async Contacts service.""" return AsyncContactsService(http_client=self.http_client) + @property + def directories(self) -> AsyncDirectoriesService: + """Async Directories service.""" + return AsyncDirectoriesService(http_client=self.http_client) + + @property + def footers(self) -> AsyncFootersService: + """Async Footers service.""" + return AsyncFootersService(http_client=self.http_client) + @property def messages(self) -> AsyncMessagesService: """Async Messages service.""" @@ -109,3 +143,8 @@ def messages(self) -> AsyncMessagesService: def subscribers(self) -> AsyncSubscribersService: """Subscriptions service.""" return AsyncSubscribersService(http_client=self.http_client) + + @property + def webhooks(self) -> AsyncWebhooksService: + """Async Webhooks service.""" + return AsyncWebhooksService(http_client=self.http_client) diff --git a/mpt_api_client/resources/notifications/webhooks.py b/mpt_api_client/resources/notifications/webhooks.py new file mode 100644 index 00000000..423af8fe --- /dev/null +++ b/mpt_api_client/resources/notifications/webhooks.py @@ -0,0 +1,81 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncDisableMixin, + AsyncEnableMixin, + AsyncManagedResourceMixin, + CollectionMixin, + DisableMixin, + EnableMixin, + ManagedResourceMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel + + +class Webhook(Model): + """Notifications Webhook resource. + + Attributes: + name: Webhook name. + url: Endpoint the webhook calls. + description: Webhook description. + status: Webhook status. + type: Webhook type. + secret: Shared secret used to sign webhook calls. + statistics: Call statistics of the webhook. + object_type: Type of the object the webhook is bound to. + account: Reference to the account owning the webhook. + object: Reference to the object the webhook is bound to. + criteria: Key/value criteria narrowing when the webhook fires. + last_success: Last successful call. + last_failure: Last failed call. + last_call: Last call regardless of its outcome. + audit: Audit information (created, updated events). + """ + + name: str | None + url: str | None + description: str | None + status: str | None + type: str | None + secret: str | None + statistics: BaseModel | None + object_type: str | None + account: BaseModel | None + object: BaseModel | None + criteria: list[BaseModel] | None + last_success: BaseModel | None + last_failure: BaseModel | None + last_call: BaseModel | None + audit: BaseModel | None + + +class WebhooksServiceConfig: + """Notifications Webhooks service configuration.""" + + _endpoint = "/public/v1/notifications/webhooks" + _model_class = Webhook + _collection_key = "data" + + +class WebhooksService( + EnableMixin[Webhook], + DisableMixin[Webhook], + ManagedResourceMixin[Webhook], + CollectionMixin[Webhook], + Service[Webhook], + WebhooksServiceConfig, +): + """Notifications Webhooks service.""" + + +class AsyncWebhooksService( + AsyncEnableMixin[Webhook], + AsyncDisableMixin[Webhook], + AsyncManagedResourceMixin[Webhook], + AsyncCollectionMixin[Webhook], + AsyncService[Webhook], + WebhooksServiceConfig, +): + """Async Notifications Webhooks service.""" diff --git a/pyproject.toml b/pyproject.toml index 15858ace..5bd44deb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,8 @@ per-file-ignores = [ "mpt_api_client/resources/commerce/*.py: WPS235 WPS215", "mpt_api_client/resources/exchange/*.py: WPS235 WPS215", "mpt_api_client/resources/integration/*.py: WPS204 WPS214 WPS215 WPS235", + "mpt_api_client/resources/notifications/footers.py: WPS110 WPS215", + "mpt_api_client/resources/notifications/notifications.py: WPS214", "mpt_api_client/resources/helpdesk/*.py: WPS204 WPS215 WPS214", "mpt_api_client/resources/program/*.py: WPS204 WPS215 WPS235", "mpt_api_client/rql/query_builder.py: WPS110 WPS115 WPS210 WPS214", diff --git a/tests/e2e/helper.py b/tests/e2e/helper.py index 367a015b..61973c04 100644 --- a/tests/e2e/helper.py +++ b/tests/e2e/helper.py @@ -58,6 +58,62 @@ def create_fixture_resource_and_delete(service, resource_data, upload_file=None) _delete_resource(service, resource) +async def _async_create_first_free(service, candidates, is_taken): + remaining = list(candidates) + while remaining: + candidate = remaining.pop(0) + try: + return await service.create(candidate) + except MPTAPIError as error: + if not is_taken(error): + raise + raise RuntimeError(f"Every candidate payload is already taken: {candidates}") + + +def _create_first_free(service, candidates, is_taken): + for candidate in candidates: + try: + return service.create(candidate) + except MPTAPIError as error: + if not is_taken(error): + raise + raise RuntimeError(f"Every candidate payload is already taken: {candidates}") + + +@asynccontextmanager +async def async_create_first_free_fixture_and_delete(service, candidates, is_taken): + """Create the first candidate the platform accepts, then delete it on teardown. + + ``candidates`` are payloads that differ only in a server-enforced uniqueness key and + ``is_taken(error)`` tells whether a rejected create means that key is already in use. + The create call itself is the claim, so two runs that race for the same key cannot both + succeed and the loser moves on to the next candidate instead of failing. + """ + resource = await _async_create_first_free(service, candidates, is_taken) + + try: + yield resource + finally: + await _delete_async_resource(service, resource) + + +@contextmanager +def create_first_free_fixture_and_delete(service, candidates, is_taken): + """Create the first candidate the platform accepts, then delete it on teardown. + + ``candidates`` are payloads that differ only in a server-enforced uniqueness key and + ``is_taken(error)`` tells whether a rejected create means that key is already in use. + The create call itself is the claim, so two runs that race for the same key cannot both + succeed and the loser moves on to the next candidate instead of failing. + """ + resource = _create_first_free(service, candidates, is_taken) + + try: + yield resource + finally: + _delete_resource(service, resource) + + @asynccontextmanager async def async_create_fixture_resource_and_finalize(service, resource_data, finalize, logger): """Create a resource, then transition it to a terminal state on teardown. diff --git a/tests/e2e/notifications/directories/__init__.py b/tests/e2e/notifications/directories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/notifications/directories/conftest.py b/tests/e2e/notifications/directories/conftest.py new file mode 100644 index 00000000..3aeb0e83 --- /dev/null +++ b/tests/e2e/notifications/directories/conftest.py @@ -0,0 +1,16 @@ +import pytest + + +@pytest.fixture +def directories_service(mpt_ops): + return mpt_ops.notifications.directories + + +@pytest.fixture +def async_directories_service(async_mpt_ops): + return async_mpt_ops.notifications.directories + + +@pytest.fixture(scope="session") +def directory_id(e2e_config): + return e2e_config["notifications.directory.id"] diff --git a/tests/e2e/notifications/directories/test_async_directories.py b/tests/e2e/notifications/directories/test_async_directories.py new file mode 100644 index 00000000..966c7f75 --- /dev/null +++ b/tests/e2e/notifications/directories/test_async_directories.py @@ -0,0 +1,25 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from tests.e2e.helper import assert_async_service_filter_with_iterate + +pytestmark = [pytest.mark.flaky] + + +async def test_get_directory(async_directories_service, directory_id): + result = await async_directories_service.get(directory_id) + + assert result.id == directory_id + + +async def test_get_directory_not_found(async_directories_service): + bogus_id = "DIR-0000-0000" + + with pytest.raises(MPTAPIError, match=r"404 Not Found"): + await async_directories_service.get(bogus_id) + + +async def test_filter_directories(async_directories_service, directory_id): + await assert_async_service_filter_with_iterate( + async_directories_service, directory_id, None + ) # act diff --git a/tests/e2e/notifications/directories/test_sync_directories.py b/tests/e2e/notifications/directories/test_sync_directories.py new file mode 100644 index 00000000..49f6bcc3 --- /dev/null +++ b/tests/e2e/notifications/directories/test_sync_directories.py @@ -0,0 +1,23 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from tests.e2e.helper import assert_service_filter_with_iterate + +pytestmark = [pytest.mark.flaky] + + +def test_get_directory(directories_service, directory_id): + result = directories_service.get(directory_id) + + assert result.id == directory_id + + +def test_get_directory_not_found(directories_service): + bogus_id = "DIR-0000-0000" + + with pytest.raises(MPTAPIError, match=r"404 Not Found"): + directories_service.get(bogus_id) + + +def test_filter_directories(directories_service, directory_id): + assert_service_filter_with_iterate(directories_service, directory_id, None) # act diff --git a/tests/e2e/notifications/footers/__init__.py b/tests/e2e/notifications/footers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/notifications/footers/conftest.py b/tests/e2e/notifications/footers/conftest.py new file mode 100644 index 00000000..23bf65ca --- /dev/null +++ b/tests/e2e/notifications/footers/conftest.py @@ -0,0 +1,52 @@ +import pytest + +from tests.e2e.helper import ( + async_create_first_free_fixture_and_delete, + create_first_free_fixture_and_delete, +) + +# The platform allows one active footer per language and per account and only accepts the +# cultures it ships with. The create call is the claim on a language: a run tries these +# supported codes in order and moves on when the platform reports the language as taken, so +# concurrent runs sharing the operations account cannot both win the same code. +FOOTER_LANGUAGE_CODES = ("hu-HU", "nb-NO", "pt-PT", "ja-JP", "nl-NL", "it-IT", "fr-FR", "cs-CZ") + + +def _is_language_taken(error): + return "already exists" in str(error) + + +@pytest.fixture +def footers_service(mpt_ops): + return mpt_ops.notifications.footers + + +@pytest.fixture +def async_footers_service(async_mpt_ops): + return async_mpt_ops.notifications.footers + + +@pytest.fixture +def footer_data(short_uuid): + return {"content": f"e2e footer - please delete {short_uuid}"} + + +@pytest.fixture +def footer_candidates(footer_data): + return [{**footer_data, "languageCode": code} for code in FOOTER_LANGUAGE_CODES] + + +@pytest.fixture +def created_footer(footers_service, footer_candidates): + with create_first_free_fixture_and_delete( + footers_service, footer_candidates, _is_language_taken + ) as footer: + yield footer + + +@pytest.fixture +async def async_created_footer(async_footers_service, footer_candidates): + async with async_create_first_free_fixture_and_delete( + async_footers_service, footer_candidates, _is_language_taken + ) as footer: + yield footer diff --git a/tests/e2e/notifications/footers/test_async_footers.py b/tests/e2e/notifications/footers/test_async_footers.py new file mode 100644 index 00000000..d5617797 --- /dev/null +++ b/tests/e2e/notifications/footers/test_async_footers.py @@ -0,0 +1,57 @@ +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_footer(async_created_footer, footer_data): + result = async_created_footer.content + + assert result == footer_data["content"] + + +async def test_get_footer(async_footers_service, async_created_footer): + result = await async_footers_service.get(async_created_footer.id) + + assert result.id == async_created_footer.id + + +async def test_get_footer_not_found(async_footers_service): + bogus_id = "FLV-0000-0000" + + with pytest.raises(MPTAPIError, match=r"404 Not Found"): + await async_footers_service.get(bogus_id) + + +async def test_update_footer(async_footers_service, async_created_footer, short_uuid): + new_content = f"e2e footer updated {short_uuid}" + + await assert_async_update_resource( # act + async_footers_service, async_created_footer.id, "content", new_content + ) + + +async def test_delete_footer(async_footers_service, async_created_footer): + await async_footers_service.delete(async_created_footer.id) + + result = await async_footers_service.get(async_created_footer.id) + + assert result.status == "Deleted" + + +async def test_delete_footer_not_found(async_footers_service): + bogus_id = "FLV-0000-0000" + + with pytest.raises(MPTAPIError): + await async_footers_service.delete(bogus_id) + + +async def test_filter_footers(async_footers_service, async_created_footer): + await assert_async_service_filter_with_iterate( # act + async_footers_service, async_created_footer.id, None + ) diff --git a/tests/e2e/notifications/footers/test_sync_footers.py b/tests/e2e/notifications/footers/test_sync_footers.py new file mode 100644 index 00000000..5707c70f --- /dev/null +++ b/tests/e2e/notifications/footers/test_sync_footers.py @@ -0,0 +1,50 @@ +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_footer(created_footer, footer_data): + result = created_footer.content + + assert result == footer_data["content"] + + +def test_get_footer(footers_service, created_footer): + result = footers_service.get(created_footer.id) + + assert result.id == created_footer.id + + +def test_get_footer_not_found(footers_service): + bogus_id = "FLV-0000-0000" + + with pytest.raises(MPTAPIError, match=r"404 Not Found"): + footers_service.get(bogus_id) + + +def test_update_footer(footers_service, created_footer, short_uuid): + new_content = f"e2e footer updated {short_uuid}" + + assert_update_resource(footers_service, created_footer.id, "content", new_content) # act + + +def test_delete_footer(footers_service, created_footer): + footers_service.delete(created_footer.id) + + result = footers_service.get(created_footer.id) + + assert result.status == "Deleted" + + +def test_delete_footer_not_found(footers_service): + bogus_id = "FLV-0000-0000" + + with pytest.raises(MPTAPIError): + footers_service.delete(bogus_id) + + +def test_filter_footers(footers_service, created_footer): + assert_service_filter_with_iterate(footers_service, created_footer.id, None) # act diff --git a/tests/e2e/notifications/webhooks/__init__.py b/tests/e2e/notifications/webhooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/notifications/webhooks/conftest.py b/tests/e2e/notifications/webhooks/conftest.py new file mode 100644 index 00000000..9caff41f --- /dev/null +++ b/tests/e2e/notifications/webhooks/conftest.py @@ -0,0 +1,65 @@ +from http import HTTPStatus + +import pytest + +from tests.e2e.helper import ( + async_create_first_free_fixture_and_delete, + create_first_free_fixture_and_delete, +) + +# The platform allows one webhook per type and bound object. The create call is the claim on +# a type: a run tries these order-validation types in order and moves on when the platform +# answers 409 Conflict, so concurrent runs sharing the seeded product cannot both win the +# same type. +WEBHOOK_TYPES = ( + "ValidatePurchaseOrderDraft", + "ValidateChangeOrderDraft", + "ValidateTerminateOrder", + "ValidatePurchaseOrderQuerying", + "ValidateConfigurationOrderDraft", +) + + +def _is_type_taken(error): + return error.status_code == HTTPStatus.CONFLICT + + +@pytest.fixture +def webhooks_service(mpt_vendor): + return mpt_vendor.notifications.webhooks + + +@pytest.fixture +def async_webhooks_service(async_mpt_vendor): + return async_mpt_vendor.notifications.webhooks + + +@pytest.fixture +def webhook_data(product_id, short_uuid, uuid_str): + return { + "url": f"https://example.com/e2e/{short_uuid}", + "description": f"e2e webhook - please delete {short_uuid}", + "criteria": {"product.id": product_id}, + "secret": f"e2e-webhook-secret-{uuid_str}", + } + + +@pytest.fixture +def webhook_candidates(webhook_data): + return [{**webhook_data, "type": webhook_type} for webhook_type in WEBHOOK_TYPES] + + +@pytest.fixture +def created_webhook(webhooks_service, webhook_candidates): + with create_first_free_fixture_and_delete( + webhooks_service, webhook_candidates, _is_type_taken + ) as webhook: + yield webhook + + +@pytest.fixture +async def async_created_webhook(async_webhooks_service, webhook_candidates): + async with async_create_first_free_fixture_and_delete( + async_webhooks_service, webhook_candidates, _is_type_taken + ) as webhook: + yield webhook diff --git a/tests/e2e/notifications/webhooks/test_async_webhooks.py b/tests/e2e/notifications/webhooks/test_async_webhooks.py new file mode 100644 index 00000000..f042373b --- /dev/null +++ b/tests/e2e/notifications/webhooks/test_async_webhooks.py @@ -0,0 +1,67 @@ +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_webhook(async_created_webhook, webhook_data): + result = async_created_webhook.url + + assert result == webhook_data["url"] + + +async def test_get_webhook(async_webhooks_service, async_created_webhook): + result = await async_webhooks_service.get(async_created_webhook.id) + + assert result.id == async_created_webhook.id + + +async def test_get_webhook_not_found(async_webhooks_service): + bogus_id = "WBH-0000-0000" + + with pytest.raises(MPTAPIError, match=r"404 Not Found"): + await async_webhooks_service.get(bogus_id) + + +async def test_update_webhook(async_webhooks_service, async_created_webhook, short_uuid): + new_description = f"e2e webhook updated {short_uuid}" + + await assert_async_update_resource( # act + async_webhooks_service, async_created_webhook.id, "description", new_description + ) + + +async def test_disable_webhook(async_webhooks_service, async_created_webhook): + result = await async_webhooks_service.disable(async_created_webhook.id) + + assert result.status == "Disabled" + + +async def test_enable_webhook(async_webhooks_service, async_created_webhook): + await async_webhooks_service.disable(async_created_webhook.id) + + result = await async_webhooks_service.enable(async_created_webhook.id) + + assert result.status == "Enabled" + + +async def test_delete_webhook(async_webhooks_service, async_created_webhook): + await async_webhooks_service.delete(async_created_webhook.id) # act + + +async def test_delete_webhook_not_found(async_webhooks_service): + bogus_id = "WBH-0000-0000" + + with pytest.raises(MPTAPIError, match=r"404 Not Found"): + await async_webhooks_service.delete(bogus_id) + + +async def test_filter_webhooks(async_webhooks_service, async_created_webhook): + await assert_async_service_filter_with_iterate( # act + async_webhooks_service, async_created_webhook.id, None + ) diff --git a/tests/e2e/notifications/webhooks/test_sync_webhooks.py b/tests/e2e/notifications/webhooks/test_sync_webhooks.py new file mode 100644 index 00000000..d1117782 --- /dev/null +++ b/tests/e2e/notifications/webhooks/test_sync_webhooks.py @@ -0,0 +1,62 @@ +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_webhook(created_webhook, webhook_data): + result = created_webhook.url + + assert result == webhook_data["url"] + + +def test_get_webhook(webhooks_service, created_webhook): + result = webhooks_service.get(created_webhook.id) + + assert result.id == created_webhook.id + + +def test_get_webhook_not_found(webhooks_service): + bogus_id = "WBH-0000-0000" + + with pytest.raises(MPTAPIError, match=r"404 Not Found"): + webhooks_service.get(bogus_id) + + +def test_update_webhook(webhooks_service, created_webhook, short_uuid): + new_description = f"e2e webhook updated {short_uuid}" + + assert_update_resource( # act + webhooks_service, created_webhook.id, "description", new_description + ) + + +def test_disable_webhook(webhooks_service, created_webhook): + result = webhooks_service.disable(created_webhook.id) + + assert result.status == "Disabled" + + +def test_enable_webhook(webhooks_service, created_webhook): + webhooks_service.disable(created_webhook.id) + + result = webhooks_service.enable(created_webhook.id) + + assert result.status == "Enabled" + + +def test_delete_webhook(webhooks_service, created_webhook): + webhooks_service.delete(created_webhook.id) # act + + +def test_delete_webhook_not_found(webhooks_service): + bogus_id = "WBH-0000-0000" + + with pytest.raises(MPTAPIError, match=r"404 Not Found"): + webhooks_service.delete(bogus_id) + + +def test_filter_webhooks(webhooks_service, created_webhook): + assert_service_filter_with_iterate(webhooks_service, created_webhook.id, None) # act diff --git a/tests/unit/resources/notifications/test_directories.py b/tests/unit/resources/notifications/test_directories.py new file mode 100644 index 00000000..d4cbc30a --- /dev/null +++ b/tests/unit/resources/notifications/test_directories.py @@ -0,0 +1,90 @@ +import httpx +import pytest +import respx + +from mpt_api_client.resources.notifications.directories import ( + AsyncDirectoriesService, + DirectoriesService, +) + + +@pytest.fixture +def directories_service(http_client): + return DirectoriesService(http_client=http_client) + + +@pytest.fixture +def async_directories_service(async_http_client): + return AsyncDirectoriesService(http_client=async_http_client) + + +@pytest.fixture +def directory_data(): + return { + "id": "NDR-1234", + "type": "Vendor", + "account": {"id": "ACC-1234", "name": "Account"}, + "origin": {"id": "ACC-5678", "name": "Origin account"}, + "contacts": [{"id": "CON-1234", "email": "contact@example.com"}], + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +def test_endpoint(directories_service): + result = directories_service.build_path() + + assert result == "/public/v1/notifications/directories" + + +def test_async_endpoint(async_directories_service): + result = async_directories_service.build_path() + + assert result == "/public/v1/notifications/directories" + + +@pytest.mark.parametrize("method", ["get", "iterate", "fetch_page"]) +def test_mixins_present(directories_service, method): + result = hasattr(directories_service, method) + + assert result is True + + +@pytest.mark.parametrize("method", ["get", "iterate", "fetch_page"]) +def test_async_mixins_present(async_directories_service, method): + result = hasattr(async_directories_service, method) + + assert result is True + + +@pytest.mark.parametrize("method", ["create", "update", "delete"]) +def test_write_mixins_absent(directories_service, method): + result = hasattr(directories_service, method) + + assert result is False + + +@pytest.mark.parametrize("method", ["create", "update", "delete"]) +def test_async_write_mixins_absent(async_directories_service, method): + result = hasattr(async_directories_service, method) + + assert result is False + + +def test_get_directory(directories_service, directory_data): + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/notifications/directories/NDR-1234" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=directory_data, + ) + ) + + result = directories_service.get("NDR-1234") + + assert mock_route.call_count == 1 + assert result.to_dict() == directory_data + assert result.account.id == "ACC-1234" + assert result.contacts[0].id == "CON-1234" diff --git a/tests/unit/resources/notifications/test_footers.py b/tests/unit/resources/notifications/test_footers.py new file mode 100644 index 00000000..1dc190a4 --- /dev/null +++ b/tests/unit/resources/notifications/test_footers.py @@ -0,0 +1,95 @@ +import httpx +import pytest +import respx + +from mpt_api_client.resources.notifications.footers import ( + AsyncFootersService, + FootersService, +) + + +@pytest.fixture +def footers_service(http_client): + return FootersService(http_client=http_client) + + +@pytest.fixture +def async_footers_service(async_http_client): + return AsyncFootersService(http_client=async_http_client) + + +@pytest.fixture +def footer_data(): + return { + "id": "NFT-1234", + "languageCode": "en-US", + "content": "Footer content", + "isDefault": True, + "status": "Active", + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +def test_endpoint(footers_service): + result = footers_service.build_path() + + assert result == "/public/v1/notifications/footers" + + +def test_async_endpoint(async_footers_service): + result = async_footers_service.build_path() + + assert result == "/public/v1/notifications/footers" + + +@pytest.mark.parametrize("method", ["get", "create", "update", "delete", "iterate", "fetch_page"]) +def test_mixins_present(footers_service, method): + result = hasattr(footers_service, method) + + assert result is True + + +@pytest.mark.parametrize("method", ["get", "create", "update", "delete", "iterate", "fetch_page"]) +def test_async_mixins_present(async_footers_service, method): + result = hasattr(async_footers_service, method) + + assert result is True + + +def test_get_footer(footers_service, footer_data): + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/notifications/footers/NFT-1234" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=footer_data, + ) + ) + + result = footers_service.get("NFT-1234") + + assert mock_route.call_count == 1 + assert result.id == "NFT-1234" + assert result.language_code == "en-US" + assert result.is_default is True + assert result.status == "Active" + + +async def test_async_get_footer(async_footers_service, footer_data): + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/notifications/footers/NFT-1234" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=footer_data, + ) + ) + + result = await async_footers_service.get("NFT-1234") + + assert mock_route.call_count == 1 + assert result.to_dict() == footer_data diff --git a/tests/unit/resources/notifications/test_notifications.py b/tests/unit/resources/notifications/test_notifications.py index cdf3270b..994eb8f6 100644 --- a/tests/unit/resources/notifications/test_notifications.py +++ b/tests/unit/resources/notifications/test_notifications.py @@ -8,11 +8,20 @@ CategoriesService, ) from mpt_api_client.resources.notifications.contacts import AsyncContactsService, ContactsService +from mpt_api_client.resources.notifications.directories import ( + AsyncDirectoriesService, + DirectoriesService, +) +from mpt_api_client.resources.notifications.footers import AsyncFootersService, FootersService from mpt_api_client.resources.notifications.messages import AsyncMessagesService, MessagesService from mpt_api_client.resources.notifications.subscribers import ( AsyncSubscribersService, SubscribersService, ) +from mpt_api_client.resources.notifications.webhooks import ( + AsyncWebhooksService, + WebhooksService, +) def test_notifications_init(http_client): @@ -37,6 +46,9 @@ def test_async_notifications_init(async_http_client): ("messages", MessagesService), ("batches", BatchesService), ("subscribers", SubscribersService), + ("directories", DirectoriesService), + ("footers", FootersService), + ("webhooks", WebhooksService), ], ) def test_notifications_properties(http_client, attr_name, expected): @@ -55,6 +67,9 @@ def test_notifications_properties(http_client, attr_name, expected): ("messages", AsyncMessagesService), ("batches", AsyncBatchesService), ("subscribers", AsyncSubscribersService), + ("directories", AsyncDirectoriesService), + ("footers", AsyncFootersService), + ("webhooks", AsyncWebhooksService), ], ) def test_async_notifications_properties(http_client, attr_name, expected): diff --git a/tests/unit/resources/notifications/test_webhooks.py b/tests/unit/resources/notifications/test_webhooks.py new file mode 100644 index 00000000..18fc28e1 --- /dev/null +++ b/tests/unit/resources/notifications/test_webhooks.py @@ -0,0 +1,129 @@ +import httpx +import pytest +import respx + +from mpt_api_client.resources.notifications.webhooks import ( + AsyncWebhooksService, + WebhooksService, +) + + +@pytest.fixture +def webhooks_service(http_client): + return WebhooksService(http_client=http_client) + + +@pytest.fixture +def async_webhooks_service(async_http_client): + return AsyncWebhooksService(http_client=async_http_client) + + +@pytest.fixture +def webhook_data(): + return { + "id": "WH-1234", + "name": "Order validation", + "url": "https://example.com/hook", + "description": "Validates draft orders", + "status": "Enabled", + "type": "ValidatePurchaseOrderDraft", + "secret": "s3cret", + "objectType": "Order", + "account": {"id": "ACC-1234", "name": "Account"}, + "object": {"id": "PRD-1234", "name": "Product"}, + "criteria": [{"key": "product.id", "value": "PRD-1234"}], + "statistics": {"total": 10, "failed": 1}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +def test_endpoint(webhooks_service): + result = webhooks_service.build_path() + + assert result == "/public/v1/notifications/webhooks" + + +def test_async_endpoint(async_webhooks_service): + result = async_webhooks_service.build_path() + + assert result == "/public/v1/notifications/webhooks" + + +@pytest.mark.parametrize( + "method", + ["get", "create", "update", "delete", "enable", "disable", "iterate", "fetch_page"], +) +def test_mixins_present(webhooks_service, method): + result = hasattr(webhooks_service, method) + + assert result is True + + +@pytest.mark.parametrize( + "method", + ["get", "create", "update", "delete", "enable", "disable", "iterate", "fetch_page"], +) +def test_async_mixins_present(async_webhooks_service, method): + result = hasattr(async_webhooks_service, method) + + assert result is True + + +def test_get_webhook(webhooks_service, webhook_data): + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/notifications/webhooks/WH-1234" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=webhook_data, + ) + ) + + result = webhooks_service.get("WH-1234") + + assert mock_route.call_count == 1 + assert result.to_dict() == webhook_data + assert result.object_type == "Order" + assert result.object.id == "PRD-1234" + + +@pytest.mark.parametrize("action", ["enable", "disable"]) +def test_webhook_state_actions(webhooks_service, action): + response_expected_data = {"id": "WH-1234", "status": "Enabled"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/notifications/webhooks/WH-1234/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = getattr(webhooks_service, action)("WH-1234") + + assert mock_route.call_count == 1 + assert result.to_dict() == response_expected_data + + +@pytest.mark.parametrize("action", ["enable", "disable"]) +async def test_async_webhook_state_actions(async_webhooks_service, action): + response_expected_data = {"id": "WH-1234", "status": "Enabled"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/notifications/webhooks/WH-1234/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = await getattr(async_webhooks_service, action)("WH-1234") + + assert mock_route.call_count == 1 + assert result.to_dict() == response_expected_data