diff --git a/e2e_config.test.json b/e2e_config.test.json index d0c4127..692db17 100644 --- a/e2e_config.test.json +++ b/e2e_config.test.json @@ -34,5 +34,8 @@ "commerce.product.listing.id": "LST-5489-0806", "commerce.product.template.id": "TPL-1767-7355-0002", "commerce.user.id": "USR-4303-2348", + "commerce.subscription.agreement.id": "AGR-2473-3299-1721", + "commerce.subscription.id": "SUB-3678-1831-2188", + "commerce.subscription.product.item.id": "ITM-1767-7355-0001", "notifications.message.id": "MSG-0000-6215-1019-0139" } diff --git a/mpt_api_client/resources/commerce/mixins.py b/mpt_api_client/resources/commerce/mixins.py new file mode 100644 index 0000000..856ca76 --- /dev/null +++ b/mpt_api_client/resources/commerce/mixins.py @@ -0,0 +1,33 @@ +from mpt_api_client.models.model import ResourceData + + +class TerminateMixin[Model]: + """Terminate resource mixin.""" + + def terminate(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Terminate resource. + + Args: + resource_id: Resource ID + resource_data: Resource data + + Returns: + Terminated resource. + """ + return self._resource_action(resource_id, "POST", "terminate", json=resource_data) # type: ignore[attr-defined, no-any-return] + + +class AsyncTerminateMixin[Model]: + """Asynchronous terminate resource mixin.""" + + async def terminate(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Terminate resource. + + Args: + resource_id: Resource ID + resource_data: Resource data + + Returns: + Terminated resource. + """ + return await self._resource_action(resource_id, "POST", "terminate", json=resource_data) # type: ignore[attr-defined, no-any-return] diff --git a/mpt_api_client/resources/commerce/subscriptions.py b/mpt_api_client/resources/commerce/subscriptions.py index ca5e46e..1df4180 100644 --- a/mpt_api_client/resources/commerce/subscriptions.py +++ b/mpt_api_client/resources/commerce/subscriptions.py @@ -5,14 +5,15 @@ from mpt_api_client.http.mixins import ( AsyncCollectionMixin, AsyncCreateMixin, - AsyncDeleteMixin, AsyncGetMixin, + AsyncUpdateMixin, CollectionMixin, CreateMixin, - DeleteMixin, GetMixin, + UpdateMixin, ) -from mpt_api_client.models import Model, ResourceData +from mpt_api_client.models import Model +from mpt_api_client.resources.commerce.mixins import AsyncTerminateMixin, TerminateMixin class Subscription(Model): @@ -29,9 +30,10 @@ class SubscriptionsServiceConfig: class SubscriptionsService( # noqa: WPS215 CreateMixin[Subscription], - DeleteMixin, + UpdateMixin[Subscription], GetMixin[Subscription], CollectionMixin[Subscription], + TerminateMixin[Subscription], Service[Subscription], SubscriptionsServiceConfig, ): @@ -49,24 +51,13 @@ def render(self, resource_id: str) -> str: response = self._resource_do_request(resource_id, "GET", "render") return response.text - def terminate(self, resource_id: str, resource_data: ResourceData) -> Subscription: - """Terminate subscription. - - Args: - resource_id: Order resource ID - resource_data: Order resource data - - Returns: - Subscription template text in markdown format. - """ - return self._resource_action(resource_id, "POST", "terminate", json=resource_data) - class AsyncSubscriptionsService( # noqa: WPS215 AsyncCreateMixin[Subscription], - AsyncDeleteMixin, + AsyncUpdateMixin[Subscription], AsyncGetMixin[Subscription], AsyncCollectionMixin[Subscription], + AsyncTerminateMixin[Subscription], AsyncService[Subscription], SubscriptionsServiceConfig, ): @@ -83,15 +74,3 @@ async def render(self, resource_id: str) -> str: """ response = await self._resource_do_request(resource_id, "GET", "render") return response.text - - async def terminate(self, resource_id: str, resource_data: ResourceData) -> Subscription: - """Terminate subscription. - - Args: - resource_id: Order resource ID - resource_data: Order resource data - - Returns: - Subscription template text in markdown format. - """ - return await self._resource_action(resource_id, "POST", "terminate", json=resource_data) diff --git a/pyproject.toml b/pyproject.toml index bdb1bd6..334caf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,9 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:DeprecationWarning", ] rp_project = "mpt-api-python-client" +markers = [ + "flaky: mark test as flaky (may fail intermittently)", +] [tool.coverage.run] branch = true @@ -120,14 +123,16 @@ per-file-ignores = [ "mpt_api_client/resources/catalog/*.py: WPS110 WPS214 WPS215 WPS235", "mpt_api_client/resources/catalog/mixins.py: WPS110 WPS202 WPS214 WPS215 WPS235", "mpt_api_client/resources/catalog/products.py: WPS204 WPS214 WPS215 WPS235", + "mpt_api_client/resources/commerce/*.py: WPS235 WPS215", "mpt_api_client/rql/query_builder.py: WPS110 WPS115 WPS210 WPS214", "tests/e2e/accounts/*.py: WPS430 WPS202", "tests/e2e/catalog/*.py: WPS202 WPS421", "tests/e2e/catalog/items/*.py: WPS110 WPS202", - "tests/e2e/commerce/*.py: WPS204 WPS453", + "tests/e2e/commerce/*.py: WPS202 WPS204 WPS453", "tests/e2e/commerce/agreement/*.py: WPS202", "tests/e2e/commerce/agreement/attachment/*.py: WPS202", "tests/e2e/commerce/order/*.py: WPS202 WPS204", + "tests/e2e/commerce/subscription/*.py: WPS202", "tests/unit/http/test_async_service.py: WPS204 WPS202", "tests/unit/http/test_service.py: WPS204 WPS202", "tests/unit/http/test_mixins.py: WPS204 WPS202 WPS210", diff --git a/tests/e2e/commerce/conftest.py b/tests/e2e/commerce/conftest.py index df69632..1c44c9f 100644 --- a/tests/e2e/commerce/conftest.py +++ b/tests/e2e/commerce/conftest.py @@ -29,3 +29,13 @@ def commerce_product_template_id(e2e_config): @pytest.fixture def commerce_user_id(e2e_config): return e2e_config["commerce.user.id"] + + +@pytest.fixture +def subscription_item_id(e2e_config): + return e2e_config["commerce.subscription.product.item.id"] + + +@pytest.fixture +def subscription_agreement_id(e2e_config): + return e2e_config["commerce.subscription.agreement.id"] diff --git a/tests/e2e/commerce/subscription/conftest.py b/tests/e2e/commerce/subscription/conftest.py new file mode 100644 index 0000000..9128934 --- /dev/null +++ b/tests/e2e/commerce/subscription/conftest.py @@ -0,0 +1,41 @@ +import pytest +from freezegun import freeze_time + + +@pytest.fixture +def subscription_id(e2e_config): + return e2e_config["commerce.subscription.id"] + + +@pytest.fixture +def invalid_subscription_id(): + return "SUB-0000-0000-0000" + + +@pytest.fixture +def subscription_factory(subscription_agreement_id, subscription_item_id): + @freeze_time("2025-11-14T09:00:00.000Z") + def factory( + name: str = "E2E Created Subscription", + external_vendor_id: str = "ext-vendor-id", + quantity: int = 1, + ): + return { + "name": name, + "startDate": "2025-11-03T09:00:00.000Z", + "commitmentDate": "2026-11-02T09:00:00.000Z", + "autoRenew": True, + "agreement": {"id": subscription_agreement_id}, + "externalIds": {"vendor": external_vendor_id}, + "template": None, + "lines": [ + { + "item": {"id": subscription_item_id}, + "quantity": quantity, + "price": {"unitPP": 10}, + } + ], + "parameters": {"fulfillment": []}, + } + + return factory diff --git a/tests/e2e/commerce/subscription/test_async_subscription.py b/tests/e2e/commerce/subscription/test_async_subscription.py new file mode 100644 index 0000000..e75d597 --- /dev/null +++ b/tests/e2e/commerce/subscription/test_async_subscription.py @@ -0,0 +1,82 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from mpt_api_client.rql.query_builder import RQLQuery + +pytestmark = [pytest.mark.flaky] + + +@pytest.fixture +async def created_subscription(async_mpt_vendor, subscription_factory): + subscription_data = subscription_factory() + + subscription = await async_mpt_vendor.commerce.subscriptions.create(subscription_data) + + yield subscription + + try: + await async_mpt_vendor.commerce.subscriptions.terminate(subscription.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to terminate subscription: {getattr(error, 'title', str(error))}") # noqa: WPS421 + + +async def test_get_subscription_by_id(async_mpt_vendor, subscription_id): + result = await async_mpt_vendor.commerce.subscriptions.get(subscription_id) + + assert result is not None + + +async def test_list_subscriptions(async_mpt_vendor): + limit = 10 + + result = await async_mpt_vendor.commerce.subscriptions.fetch_page(limit=limit) + + assert result is not None + + +async def test_get_subscription_by_id_not_found(async_mpt_vendor, invalid_subscription_id): + with pytest.raises(MPTAPIError, match="404 Not Found"): + await async_mpt_vendor.commerce.subscriptions.get(invalid_subscription_id) + + +async def test_filter_subscriptions(async_mpt_vendor, subscription_id): + select_fields = ["-externalIds"] + filtered_subscriptions = ( + async_mpt_vendor.commerce.subscriptions.filter(RQLQuery(id=subscription_id)) + .filter(RQLQuery(name="E2E Seeded Subscription")) + .select(*select_fields) + ) + + result = [subscription async for subscription in filtered_subscriptions.iterate()] + + assert len(result) == 1 + + +def test_create_subscription(created_subscription): + result = created_subscription + + assert result is not None + + +async def test_update_subscription(async_mpt_vendor, created_subscription): + updated_subscription_data = { + "name": "E2E Updated Subscription", + } + + result = await async_mpt_vendor.commerce.subscriptions.update( + created_subscription.id, updated_subscription_data + ) + + assert result is not None + + +async def test_terminate_subscription(async_mpt_vendor, created_subscription): + result = await async_mpt_vendor.commerce.subscriptions.terminate(created_subscription.id) + + assert result is not None + + +async def test_render_subscription(async_mpt_vendor, created_subscription): + result = await async_mpt_vendor.commerce.subscriptions.render(created_subscription.id) + + assert result is not None diff --git a/tests/e2e/commerce/subscription/test_sync_subscription.py b/tests/e2e/commerce/subscription/test_sync_subscription.py new file mode 100644 index 0000000..114ce76 --- /dev/null +++ b/tests/e2e/commerce/subscription/test_sync_subscription.py @@ -0,0 +1,82 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from mpt_api_client.rql.query_builder import RQLQuery + +pytestmark = [pytest.mark.flaky] + + +@pytest.fixture +def created_subscription(mpt_vendor, subscription_factory): + subscription_data = subscription_factory() + + subscription = mpt_vendor.commerce.subscriptions.create(subscription_data) + + yield subscription + + try: + mpt_vendor.commerce.subscriptions.terminate(subscription.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to terminate subscription: {getattr(error, 'title', str(error))}") # noqa: WPS421 + + +def test_get_subscription_by_id(mpt_vendor, subscription_id): + result = mpt_vendor.commerce.subscriptions.get(subscription_id) + + assert result is not None + + +def test_list_subscriptions(mpt_vendor): + limit = 10 + + result = mpt_vendor.commerce.subscriptions.fetch_page(limit=limit) + + assert result is not None + + +def test_get_subscription_by_id_not_found(mpt_vendor, invalid_subscription_id): + with pytest.raises(MPTAPIError, match="404 Not Found"): + mpt_vendor.commerce.subscriptions.get(invalid_subscription_id) + + +def test_filter_subscriptions(mpt_vendor, subscription_id): + select_fields = ["-externalIds"] + filtered_subscriptions = ( + mpt_vendor.commerce.subscriptions.filter(RQLQuery(id=subscription_id)) + .filter(RQLQuery(name="E2E Seeded Subscription")) + .select(*select_fields) + ) + + result = list(filtered_subscriptions.iterate()) + + assert len(result) == 1 + + +def test_create_subscription(created_subscription): + result = created_subscription + + assert result is not None + + +def test_update_subscription(mpt_vendor, created_subscription): + updated_subscription_data = { + "name": "E2E Updated Subscription", + } + + result = mpt_vendor.commerce.subscriptions.update( + created_subscription.id, updated_subscription_data + ) + + assert result is not None + + +def test_terminate_subscription(mpt_vendor, created_subscription): + result = mpt_vendor.commerce.subscriptions.terminate(created_subscription.id) + + assert result is not None + + +def test_render_subscription(mpt_vendor, created_subscription): + result = mpt_vendor.commerce.subscriptions.render(created_subscription.id) + + assert result is not None diff --git a/tests/unit/resources/commerce/test_mixins.py b/tests/unit/resources/commerce/test_mixins.py new file mode 100644 index 0000000..23f5212 --- /dev/null +++ b/tests/unit/resources/commerce/test_mixins.py @@ -0,0 +1,98 @@ +import httpx +import pytest +import respx + +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.resources.commerce.mixins import ( + AsyncTerminateMixin, + TerminateMixin, +) +from tests.unit.conftest import DummyModel + + +class DummyTerminateService( + TerminateMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "public/v1/dummy/terminate" + _model_class = DummyModel + + +class AsyncDummyTerminateService( + AsyncTerminateMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "public/v1/dummy/terminate" + _model_class = DummyModel + + +@pytest.fixture +def dummy_terminate_service(http_client): + return DummyTerminateService(http_client=http_client) + + +@pytest.fixture +def async_dummy_terminate_service(async_http_client): + return AsyncDummyTerminateService(http_client=async_http_client) + + +def test_terminate_with_data(dummy_terminate_service): + dummy_expected = {"id": "DUMMY-123", "status": "Terminated", "name": "Terminated DUMMY-123"} + with respx.mock: + respx.post("https://api.example.com/public/v1/dummy/terminate/DUMMY-123/terminate").mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=dummy_expected, + ) + ) + + result = dummy_terminate_service.terminate("DUMMY-123", {"name": "Terminated DUMMY-123"}) + + assert result is not None + + +def test_terminate(dummy_terminate_service): + dummy_expected = {"id": "DUMMY-124", "status": "Terminated", "name": "Terminated DUMMY-124"} + with respx.mock: + respx.post("https://api.example.com/public/v1/dummy/terminate/DUMMY-124/terminate").mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=dummy_expected, + ) + ) + + result = dummy_terminate_service.terminate("DUMMY-124") + + assert result is not None + + +async def test_async_terminate_with_data(async_dummy_terminate_service): + dummy_expected = {"id": "DUMMY-123", "status": "Terminated", "name": "Terminated DUMMY-123"} + with respx.mock: + respx.post("https://api.example.com/public/v1/dummy/terminate/DUMMY-123/terminate").mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=dummy_expected, + ) + ) + + result = await async_dummy_terminate_service.terminate( + "DUMMY-123", {"name": "Terminated DUMMY-123"} + ) + + assert result is not None + + +async def test_async_terminate(async_dummy_terminate_service): + dummy_expected = {"id": "DUMMY-124", "status": "Terminated", "name": "Terminated DUMMY-124"} + with respx.mock: + respx.post("https://api.example.com/public/v1/dummy/terminate/DUMMY-124/terminate").mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=dummy_expected, + ) + ) + + result = await async_dummy_terminate_service.terminate("DUMMY-124") + + assert result is not None diff --git a/tests/unit/resources/commerce/test_subscriptions.py b/tests/unit/resources/commerce/test_subscriptions.py index 54202c5..47e0330 100644 --- a/tests/unit/resources/commerce/test_subscriptions.py +++ b/tests/unit/resources/commerce/test_subscriptions.py @@ -18,14 +18,14 @@ def async_subscriptions_service(async_http_client): return AsyncSubscriptionsService(http_client=async_http_client) -@pytest.mark.parametrize("method", ["get", "create", "delete"]) +@pytest.mark.parametrize("method", ["get", "create", "update", "iterate", "terminate", "render"]) def test_methods_present(subscriptions_service, method): result = hasattr(subscriptions_service, method) assert result is True -@pytest.mark.parametrize("method", ["get", "create", "delete"]) +@pytest.mark.parametrize("method", ["get", "create", "update", "iterate", "terminate", "render"]) def test_async_methods_present(async_subscriptions_service, method): result = hasattr(async_subscriptions_service, method)