diff --git a/e2e_config.test.json b/e2e_config.test.json index 5a47d855..5d0bde00 100644 --- a/e2e_config.test.json +++ b/e2e_config.test.json @@ -67,5 +67,5 @@ "notifications.category.id": "NTC-6157-0397", "notifications.message.id": "MSG-0000-6215-1019-0139", "notifications.subscriber.id": "NTS-0829-7123-7123", - "integration.extension.id": "EXT-4401-0953" + "integration.extension.id": "EXT-6587-4477" } diff --git a/mpt_api_client/resources/integration/extension_term_variants.py b/mpt_api_client/resources/integration/extension_term_variants.py new file mode 100644 index 00000000..a8b8061a --- /dev/null +++ b/mpt_api_client/resources/integration/extension_term_variants.py @@ -0,0 +1,34 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + CollectionMixin, +) +from mpt_api_client.models import Model + + +class ExtensionTermVariant(Model): + """Extension Term Variant resource (stub).""" + + +class ExtensionTermVariantsServiceConfig: + """Extension Term Variants service configuration.""" + + _endpoint = "/public/v1/integration/extensions/{extension_id}/terms/{term_id}/variants" + _model_class = ExtensionTermVariant + _collection_key = "data" + + +class ExtensionTermVariantsService( + CollectionMixin[ExtensionTermVariant], + Service[ExtensionTermVariant], + ExtensionTermVariantsServiceConfig, +): + """Sync service for extension term variants (stub).""" + + +class AsyncExtensionTermVariantsService( + AsyncCollectionMixin[ExtensionTermVariant], + AsyncService[ExtensionTermVariant], + ExtensionTermVariantsServiceConfig, +): + """Async service for extension term variants (stub).""" diff --git a/mpt_api_client/resources/integration/extension_terms.py b/mpt_api_client/resources/integration/extension_terms.py new file mode 100644 index 00000000..b1c73da0 --- /dev/null +++ b/mpt_api_client/resources/integration/extension_terms.py @@ -0,0 +1,87 @@ +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 +from mpt_api_client.resources.integration.extension_term_variants import ( + AsyncExtensionTermVariantsService, + ExtensionTermVariantsService, +) +from mpt_api_client.resources.integration.mixins import ( + AsyncPublishableMixin, + PublishableMixin, +) + + +class ExtensionTerm(Model): + """Extension Term resource. + + Attributes: + name: Term name. + revision: Revision number. + description: Term description. + display_order: Display order of the term. + status: Term status (Draft, Published, Unpublished, Deleted). + extension: Reference to the parent extension. + audit: Audit information (created, updated, published, unpublished events). + """ + + name: str | None + revision: int | None + description: str | None + display_order: int | None + status: str | None + extension: BaseModel | None + audit: BaseModel | None + + +class ExtensionTermsServiceConfig: + """Extension Terms service configuration.""" + + _endpoint = "/public/v1/integration/extensions/{extension_id}/terms" + _model_class = ExtensionTerm + _collection_key = "data" + + +class ExtensionTermsService( + PublishableMixin[ExtensionTerm], + ManagedResourceMixin[ExtensionTerm], + CollectionMixin[ExtensionTerm], + Service[ExtensionTerm], + ExtensionTermsServiceConfig, +): + """Sync service for the /public/v1/integration/extensions/{extensionId}/terms endpoint.""" + + def variants(self, term_id: str) -> ExtensionTermVariantsService: + """Access extension term variants service.""" + return ExtensionTermVariantsService( + http_client=self.http_client, + endpoint_params={ + "extension_id": self.endpoint_params["extension_id"], + "term_id": term_id, + }, + ) + + +class AsyncExtensionTermsService( + AsyncPublishableMixin[ExtensionTerm], + AsyncManagedResourceMixin[ExtensionTerm], + AsyncCollectionMixin[ExtensionTerm], + AsyncService[ExtensionTerm], + ExtensionTermsServiceConfig, +): + """Async service for the /public/v1/integration/extensions/{extensionId}/terms endpoint.""" + + def variants(self, term_id: str) -> AsyncExtensionTermVariantsService: + """Access async extension term variants service.""" + return AsyncExtensionTermVariantsService( + http_client=self.http_client, + endpoint_params={ + "extension_id": self.endpoint_params["extension_id"], + "term_id": term_id, + }, + ) diff --git a/mpt_api_client/resources/integration/extensions.py b/mpt_api_client/resources/integration/extensions.py index 4636d717..373a6d6e 100644 --- a/mpt_api_client/resources/integration/extensions.py +++ b/mpt_api_client/resources/integration/extensions.py @@ -13,6 +13,10 @@ ) from mpt_api_client.models import Model from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.integration.extension_terms import ( + AsyncExtensionTermsService, + ExtensionTermsService, +) from mpt_api_client.resources.integration.mixins import ( AsyncExtensionMixin, ExtensionMixin, @@ -79,6 +83,12 @@ class ExtensionsService( ): """Sync service for the /public/v1/integration/extensions endpoint.""" + def terms(self, extension_id: str) -> ExtensionTermsService: + """Return extension terms service.""" + return ExtensionTermsService( + http_client=self.http_client, endpoint_params={"extension_id": extension_id} + ) + class AsyncExtensionsService( AsyncExtensionMixin[Extension], @@ -91,3 +101,9 @@ class AsyncExtensionsService( ExtensionsServiceConfig, ): """Async service for the /public/v1/integration/extensions endpoint.""" + + def terms(self, extension_id: str) -> AsyncExtensionTermsService: + """Return async extension terms service.""" + return AsyncExtensionTermsService( + http_client=self.http_client, endpoint_params={"extension_id": extension_id} + ) diff --git a/mpt_api_client/resources/integration/mixins/__init__.py b/mpt_api_client/resources/integration/mixins/__init__.py index 8e0ba997..7d8ad30f 100644 --- a/mpt_api_client/resources/integration/mixins/__init__.py +++ b/mpt_api_client/resources/integration/mixins/__init__.py @@ -2,8 +2,14 @@ AsyncExtensionMixin, ExtensionMixin, ) +from mpt_api_client.resources.integration.mixins.publishable_mixin import ( + AsyncPublishableMixin, + PublishableMixin, +) __all__ = [ # noqa: WPS410 "AsyncExtensionMixin", + "AsyncPublishableMixin", "ExtensionMixin", + "PublishableMixin", ] diff --git a/mpt_api_client/resources/integration/mixins/publishable_mixin.py b/mpt_api_client/resources/integration/mixins/publishable_mixin.py new file mode 100644 index 00000000..d79b79db --- /dev/null +++ b/mpt_api_client/resources/integration/mixins/publishable_mixin.py @@ -0,0 +1,57 @@ +from mpt_api_client.models import ResourceData + + +class PublishableMixin[Model]: + """Publishable mixin adds the ability to publish and unpublish a resource.""" + + def publish(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Publish the resource. + + Args: + resource_id: Resource ID. + resource_data: Optional request body. + + Returns: + Updated resource. + """ + return self._resource(resource_id).post("publish", json=resource_data) # type: ignore[attr-defined, no-any-return] + + def unpublish(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Unpublish the resource. + + Args: + resource_id: Resource ID. + resource_data: Optional request body. + + Returns: + Updated resource. + """ + return self._resource(resource_id).post("unpublish", json=resource_data) # type: ignore[attr-defined, no-any-return] + + +class AsyncPublishableMixin[Model]: + """Async publishable mixin adds the ability to publish and unpublish a resource.""" + + async def publish(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Publish the resource. + + Args: + resource_id: Resource ID. + resource_data: Optional request body. + + Returns: + Updated resource. + """ + return await self._resource(resource_id).post("publish", json=resource_data) # type: ignore[attr-defined, no-any-return] + + async def unpublish(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Unpublish the resource. + + Args: + resource_id: Resource ID. + resource_data: Optional request body. + + Returns: + Updated resource. + """ + return await self._resource(resource_id).post("unpublish", json=resource_data) # type: ignore[attr-defined, no-any-return] diff --git a/tests/e2e/integration/extension_terms/__init__.py b/tests/e2e/integration/extension_terms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/integration/extension_terms/conftest.py b/tests/e2e/integration/extension_terms/conftest.py new file mode 100644 index 00000000..0f9f6a44 --- /dev/null +++ b/tests/e2e/integration/extension_terms/conftest.py @@ -0,0 +1,51 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError + + +@pytest.fixture(scope="session") +def extension_id(e2e_config): + return e2e_config["integration.extension.id"] + + +@pytest.fixture +def extension_terms_service(mpt_vendor, extension_id): + return mpt_vendor.integration.extensions.terms(extension_id) + + +@pytest.fixture +def async_extension_terms_service(async_mpt_vendor, extension_id): + return async_mpt_vendor.integration.extensions.terms(extension_id) + + +@pytest.fixture +def term_data(short_uuid): + return { + "name": f"e2e - please delete {short_uuid}", + "description": "Created by automated E2E tests. Safe to delete.", + "displayOrder": 1, + } + + +@pytest.fixture +def created_term(extension_terms_service, term_data): + term = extension_terms_service.create(term_data) + + yield term + + try: + extension_terms_service.delete(term.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete term {term.id}: {error.title}") # noqa: WPS421 + + +@pytest.fixture +async def async_created_term(async_extension_terms_service, term_data): + term = await async_extension_terms_service.create(term_data) + + yield term + + try: + await async_extension_terms_service.delete(term.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete term {term.id}: {error.title}") # noqa: WPS421 diff --git a/tests/e2e/integration/extension_terms/test_async_extension_terms.py b/tests/e2e/integration/extension_terms/test_async_extension_terms.py new file mode 100644 index 00000000..58e0a28c --- /dev/null +++ b/tests/e2e/integration/extension_terms/test_async_extension_terms.py @@ -0,0 +1,45 @@ +import pytest + +from tests.e2e.helper import assert_async_service_filter_with_iterate + +pytestmark = [ + pytest.mark.flaky, +] + + +def test_create_extension_term(async_created_term, term_data): + result = async_created_term.name + + assert result == term_data["name"] + + +async def test_filter_extension_terms(async_extension_terms_service, async_created_term): + await assert_async_service_filter_with_iterate( + async_extension_terms_service, async_created_term.id, None + ) # act + + +async def test_update_extension_term(async_extension_terms_service, async_created_term, short_uuid): + update_data = {"name": f"e2e updated {short_uuid}"} + + result = await async_extension_terms_service.update(async_created_term.id, update_data) + + assert result.name == update_data["name"] + + +async def test_publish_extension_term(async_extension_terms_service, async_created_term): + result = await async_extension_terms_service.publish(async_created_term.id) + + assert result.status == "Published" + + +async def test_unpublish_extension_term(async_extension_terms_service, async_created_term): + await async_extension_terms_service.publish(async_created_term.id) + + result = await async_extension_terms_service.unpublish(async_created_term.id) + + assert result.status == "Unpublished" + + +async def test_delete_extension_term(async_extension_terms_service, async_created_term): + await async_extension_terms_service.delete(async_created_term.id) # act diff --git a/tests/e2e/integration/extension_terms/test_sync_extension_terms.py b/tests/e2e/integration/extension_terms/test_sync_extension_terms.py new file mode 100644 index 00000000..7a611926 --- /dev/null +++ b/tests/e2e/integration/extension_terms/test_sync_extension_terms.py @@ -0,0 +1,43 @@ +import pytest + +from tests.e2e.helper import assert_service_filter_with_iterate + +pytestmark = [ + pytest.mark.flaky, +] + + +def test_create_extension_term(created_term, term_data): + result = created_term.name + + assert result == term_data["name"] + + +def test_filter_extension_terms(extension_terms_service, created_term): + assert_service_filter_with_iterate(extension_terms_service, created_term.id, None) # act + + +def test_update_extension_term(extension_terms_service, created_term, short_uuid): + update_data = {"name": f"e2e updated {short_uuid}"} + + result = extension_terms_service.update(created_term.id, update_data) + + assert result.name == update_data["name"] + + +def test_publish_extension_term(extension_terms_service, created_term): + result = extension_terms_service.publish(created_term.id) + + assert result.status == "Published" + + +def test_unpublish_extension_term(extension_terms_service, created_term): + extension_terms_service.publish(created_term.id) + + result = extension_terms_service.unpublish(created_term.id) + + assert result.status == "Unpublished" + + +def test_delete_extension_term(extension_terms_service, created_term): + extension_terms_service.delete(created_term.id) # act diff --git a/tests/unit/resources/integration/mixins/test_publishable_mixin.py b/tests/unit/resources/integration/mixins/test_publishable_mixin.py new file mode 100644 index 00000000..c8ac3e5e --- /dev/null +++ b/tests/unit/resources/integration/mixins/test_publishable_mixin.py @@ -0,0 +1,137 @@ +import httpx +import pytest +import respx + +from mpt_api_client.http.async_service import AsyncService +from mpt_api_client.http.service import Service +from mpt_api_client.resources.integration.mixins import ( + AsyncPublishableMixin, + PublishableMixin, +) +from tests.unit.conftest import DummyModel + + +class DummyPublishableService( + PublishableMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/integration/extensions/EXT-001/terms" + _model_class = DummyModel + _collection_key = "data" + + +class DummyAsyncPublishableService( + AsyncPublishableMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/integration/extensions/EXT-001/terms" + _model_class = DummyModel + _collection_key = "data" + + +@pytest.fixture +def publishable_service(http_client): + return DummyPublishableService(http_client=http_client) + + +@pytest.fixture +def async_publishable_service(async_http_client): + return DummyAsyncPublishableService(http_client=async_http_client) + + +@pytest.mark.parametrize("action", ["publish", "unpublish"]) +def test_action_with_data(publishable_service, action): + resource_data = {"id": "TERM-001", "status": "update"} + expected_response = {"id": "TERM-001", "status": "Published"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/integration/extensions/EXT-001/terms/TERM-001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = getattr(publishable_service, action)("TERM-001", resource_data) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "POST" + assert request.content == b'{"id":"TERM-001","status":"update"}' + assert result.to_dict() == expected_response + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize("action", ["publish", "unpublish"]) +def test_action_no_data(publishable_service, action): + expected_response = {"id": "TERM-001", "status": "Published"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/integration/extensions/EXT-001/terms/TERM-001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = getattr(publishable_service, action)("TERM-001") + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "POST" + assert request.content == b"" + assert result.to_dict() == expected_response + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize("action", ["publish", "unpublish"]) +async def test_async_action_with_data(async_publishable_service, action): + resource_data = {"id": "TERM-001", "status": "update"} + expected_response = {"id": "TERM-001", "status": "Published"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/integration/extensions/EXT-001/terms/TERM-001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = await getattr(async_publishable_service, action)("TERM-001", resource_data) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "POST" + assert request.content == b'{"id":"TERM-001","status":"update"}' + assert result.to_dict() == expected_response + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize("action", ["publish", "unpublish"]) +async def test_async_action_no_data(async_publishable_service, action): + expected_response = {"id": "TERM-001", "status": "Published"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/integration/extensions/EXT-001/terms/TERM-001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = await getattr(async_publishable_service, action)("TERM-001") + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "POST" + assert request.content == b"" + assert result.to_dict() == expected_response + assert isinstance(result, DummyModel) diff --git a/tests/unit/resources/integration/test_extension_terms.py b/tests/unit/resources/integration/test_extension_terms.py new file mode 100644 index 00000000..8cad5535 --- /dev/null +++ b/tests/unit/resources/integration/test_extension_terms.py @@ -0,0 +1,129 @@ +from typing import Any + +import pytest + +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.integration.extension_term_variants import ( + AsyncExtensionTermVariantsService, + ExtensionTermVariantsService, +) +from mpt_api_client.resources.integration.extension_terms import ( + AsyncExtensionTermsService, + ExtensionTerm, + ExtensionTermsService, +) +from mpt_api_client.resources.integration.extensions import ( + AsyncExtensionsService, + ExtensionsService, +) + + +@pytest.fixture +def terms_service(http_client: Any) -> ExtensionTermsService: + return ExtensionTermsService( + http_client=http_client, endpoint_params={"extension_id": "EXT-001"} + ) + + +@pytest.fixture +def async_terms_service(async_http_client: Any) -> AsyncExtensionTermsService: + return AsyncExtensionTermsService( + http_client=async_http_client, endpoint_params={"extension_id": "EXT-001"} + ) + + +@pytest.fixture +def extensions_service(http_client: Any) -> ExtensionsService: + return ExtensionsService(http_client=http_client) + + +@pytest.fixture +def async_extensions_service(async_http_client: Any) -> AsyncExtensionsService: + return AsyncExtensionsService(http_client=async_http_client) + + +@pytest.fixture +def term_data(): + return { + "id": "TERM-001", + "name": "Acceptable Use Policy", + "revision": 1, + "description": "Standard acceptable use policy", + "displayOrder": 1, + "status": "Draft", + "extension": {"id": "EXT-001"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +@pytest.mark.parametrize( + "method", ["get", "create", "update", "delete", "publish", "unpublish", "iterate"] +) +def test_mixins_present(terms_service: ExtensionTermsService, method: str) -> None: + result = hasattr(terms_service, method) + + assert result is True + + +@pytest.mark.parametrize( + "method", ["get", "create", "update", "delete", "publish", "unpublish", "iterate"] +) +def test_async_mixins_present(async_terms_service: AsyncExtensionTermsService, method: str) -> None: + result = hasattr(async_terms_service, method) + + assert result is True + + +def test_extension_term_primitive_fields(term_data: dict) -> None: + result = ExtensionTerm(term_data) + + assert result.to_dict() == term_data + + +def test_extension_term_nested_fields(term_data: dict) -> None: + result = ExtensionTerm(term_data) + + assert isinstance(result.extension, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_extension_term_create(terms_service: ExtensionTermsService) -> None: + result = hasattr(terms_service, "create") + + assert result is True + + +def test_extension_terms_variants_accessor(terms_service: ExtensionTermsService) -> None: + result = terms_service.variants("TERM-001") + + assert isinstance(result, ExtensionTermVariantsService) + assert result.http_client == terms_service.http_client + assert result.endpoint_params == {"extension_id": "EXT-001", "term_id": "TERM-001"} + + +def test_async_extension_terms_variants_accessor( + async_terms_service: AsyncExtensionTermsService, +) -> None: + result = async_terms_service.variants("TERM-001") + + assert isinstance(result, AsyncExtensionTermVariantsService) + assert result.http_client == async_terms_service.http_client + assert result.endpoint_params == {"extension_id": "EXT-001", "term_id": "TERM-001"} + + +def test_extensions_terms_accessor(extensions_service: ExtensionsService) -> None: + result = extensions_service.terms("EXT-001") + + assert isinstance(result, ExtensionTermsService) + assert result.http_client == extensions_service.http_client + assert result.endpoint_params == {"extension_id": "EXT-001"} + + +def test_async_extensions_terms_accessor( + async_extensions_service: AsyncExtensionsService, +) -> None: + result = async_extensions_service.terms("EXT-001") + + assert isinstance(result, AsyncExtensionTermsService) + assert result.http_client == async_extensions_service.http_client + assert result.endpoint_params == {"extension_id": "EXT-001"}