From d1480ba0e6157be6d032e1dbad4476783bf05d3b Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Fri, 21 Aug 2026 16:04:42 +0100 Subject: [PATCH 1/2] feat(notifications): add directories, footers and webhooks services The /public/v1/notifications/directories, /public/v1/notifications/footers and /public/v1/notifications/webhooks collection endpoints had no service class, so they were unreachable from MPTClient and AsyncMPTClient. Add sync and async services for all three and register them as properties on the Notifications group. Mixins follow what the OpenAPI spec documents per endpoint: directories are read-only (GET collection and GET by id), footers are fully managed, and webhooks are managed with enable/disable actions. Adding any service to Notifications pushes the group class past wemake-python-styleguide's max_methods default of 7, which it was sitting exactly on, so mpt_api_client/resources/notifications/*.py needs a per-file-ignores entry for WPS214, as every other group at 8+ methods already has. The entry also repeats WPS215 (previously inherited from the broader mpt_api_client/resources/* pattern, which a more specific entry replaces rather than extends) and adds WPS110 for the API-mandated Footer.content field name. Streaming support for these endpoints is out of scope and tracked separately in MPT-24241. MPT-24265 Co-Authored-By: Claude Opus 5 --- .../resources/notifications/directories.py | 53 +++++++ .../resources/notifications/footers.py | 53 +++++++ .../resources/notifications/notifications.py | 39 ++++++ .../resources/notifications/webhooks.py | 81 +++++++++++ pyproject.toml | 2 + .../notifications/test_directories.py | 90 ++++++++++++ .../resources/notifications/test_footers.py | 95 +++++++++++++ .../notifications/test_notifications.py | 15 ++ .../resources/notifications/test_webhooks.py | 129 ++++++++++++++++++ 9 files changed, 557 insertions(+) create mode 100644 mpt_api_client/resources/notifications/directories.py create mode 100644 mpt_api_client/resources/notifications/footers.py create mode 100644 mpt_api_client/resources/notifications/webhooks.py create mode 100644 tests/unit/resources/notifications/test_directories.py create mode 100644 tests/unit/resources/notifications/test_footers.py create mode 100644 tests/unit/resources/notifications/test_webhooks.py 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/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 From cd32da0808da32f60958e130d7b974cd9daf2ade Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Thu, 17 Sep 2026 09:51:34 +0100 Subject: [PATCH 2/2] test(e2e): add notifications directories, footers and webhooks e2e coverage Cover the mixin surface each new service exposes against the live TEST environment. Directories are read-only, so the tests cover get, 404 on a bogus id and the filtered iterate on a stable vendor-account directory registered as notifications.directory.id. Footers get the managed lifecycle through the operations client and webhooks the managed lifecycle plus enable and disable through the vendor client on the seeded product. Both platforms enforce a uniqueness key on create: one active footer per language and account, one webhook per type and bound object. The fixtures therefore treat the create call as the claim on that key: they try a list of supported candidates in order and move on when the platform reports the key as taken, so concurrent runs sharing the operations account or the seeded product cannot both win the same value. Footers soft-delete, which the delete test asserts. Co-Authored-By: Claude Fable 5.1 --- e2e_config.test.json | 1 + tests/e2e/helper.py | 56 ++++++++++++++++ .../e2e/notifications/directories/__init__.py | 0 .../e2e/notifications/directories/conftest.py | 16 +++++ .../directories/test_async_directories.py | 25 +++++++ .../directories/test_sync_directories.py | 23 +++++++ tests/e2e/notifications/footers/__init__.py | 0 tests/e2e/notifications/footers/conftest.py | 52 ++++++++++++++ .../footers/test_async_footers.py | 57 ++++++++++++++++ .../footers/test_sync_footers.py | 50 ++++++++++++++ tests/e2e/notifications/webhooks/__init__.py | 0 tests/e2e/notifications/webhooks/conftest.py | 65 ++++++++++++++++++ .../webhooks/test_async_webhooks.py | 67 +++++++++++++++++++ .../webhooks/test_sync_webhooks.py | 62 +++++++++++++++++ 14 files changed, 474 insertions(+) create mode 100644 tests/e2e/notifications/directories/__init__.py create mode 100644 tests/e2e/notifications/directories/conftest.py create mode 100644 tests/e2e/notifications/directories/test_async_directories.py create mode 100644 tests/e2e/notifications/directories/test_sync_directories.py create mode 100644 tests/e2e/notifications/footers/__init__.py create mode 100644 tests/e2e/notifications/footers/conftest.py create mode 100644 tests/e2e/notifications/footers/test_async_footers.py create mode 100644 tests/e2e/notifications/footers/test_sync_footers.py create mode 100644 tests/e2e/notifications/webhooks/__init__.py create mode 100644 tests/e2e/notifications/webhooks/conftest.py create mode 100644 tests/e2e/notifications/webhooks/test_async_webhooks.py create mode 100644 tests/e2e/notifications/webhooks/test_sync_webhooks.py 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/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