From b21ee42d2cb55feddd52974d690a63bf4416f29a Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Tue, 7 Apr 2026 11:15:31 +0100 Subject: [PATCH 1/2] MPT-19908: add /public/v1/integration/installations endpoint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>  Conflicts:  mpt_api_client/resources/integration/integration.py  mpt_api_client/resources/integration/mixins/__init__.py  tests/unit/resources/integration/test_integration.py --- .../resources/integration/installations.py | 69 +++++++++++ .../resources/integration/integration.py | 14 +++ .../resources/integration/mixins/__init__.py | 6 + .../integration/mixins/installation_mixin.py | 105 ++++++++++++++++ .../mixins/test_installation_mixin.py | 91 ++++++++++++++ .../integration/test_installations.py | 114 ++++++++++++++++++ .../resources/integration/test_integration.py | 6 + 7 files changed, 405 insertions(+) create mode 100644 mpt_api_client/resources/integration/installations.py create mode 100644 mpt_api_client/resources/integration/mixins/installation_mixin.py create mode 100644 tests/unit/resources/integration/mixins/test_installation_mixin.py create mode 100644 tests/unit/resources/integration/test_installations.py diff --git a/mpt_api_client/resources/integration/installations.py b/mpt_api_client/resources/integration/installations.py new file mode 100644 index 00000000..32d24b69 --- /dev/null +++ b/mpt_api_client/resources/integration/installations.py @@ -0,0 +1,69 @@ +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.mixins import ( + AsyncInstallationMixin, + InstallationMixin, +) + + +class Installation(Model): + """Installation resource. + + Attributes: + name: Installation name. + revision: Revision number. + account: Reference to the account. + extension: Reference to the extension. + status: Installation status (Invited, Installed, Uninstalled, Expired). + configuration: Installation configuration data. + invitation: Invitation details. + modules: Modules included in the installation. + terms: Accepted terms for this installation. + audit: Audit information (created, updated, invited, installed, expired, uninstalled). + """ + + name: str | None + revision: int | None + account: BaseModel | None + extension: BaseModel | None + status: str | None + configuration: BaseModel | None + invitation: BaseModel | None + modules: list[BaseModel] | None + terms: list[BaseModel] | None + audit: BaseModel | None + + +class InstallationsServiceConfig: + """Installations service configuration.""" + + _endpoint = "/public/v1/integration/installations" + _model_class = Installation + _collection_key = "data" + + +class InstallationsService( + InstallationMixin[Installation], + ManagedResourceMixin[Installation], + CollectionMixin[Installation], + Service[Installation], + InstallationsServiceConfig, +): + """Sync service for the /public/v1/integration/installations endpoint.""" + + +class AsyncInstallationsService( + AsyncInstallationMixin[Installation], + AsyncManagedResourceMixin[Installation], + AsyncCollectionMixin[Installation], + AsyncService[Installation], + InstallationsServiceConfig, +): + """Async service for the /public/v1/integration/installations endpoint.""" diff --git a/mpt_api_client/resources/integration/integration.py b/mpt_api_client/resources/integration/integration.py index 52fe18b6..6a24bab5 100644 --- a/mpt_api_client/resources/integration/integration.py +++ b/mpt_api_client/resources/integration/integration.py @@ -7,6 +7,10 @@ AsyncExtensionsService, ExtensionsService, ) +from mpt_api_client.resources.integration.installations import ( + AsyncInstallationsService, + InstallationsService, +) class Integration: @@ -25,6 +29,11 @@ def categories(self) -> CategoriesService: """Categories service.""" return CategoriesService(http_client=self.http_client) + @property + def installations(self) -> InstallationsService: + """Installations service.""" + return InstallationsService(http_client=self.http_client) + class AsyncIntegration: """Async Integration MPT API Module.""" @@ -41,3 +50,8 @@ def extensions(self) -> AsyncExtensionsService: def categories(self) -> AsyncCategoriesService: """Categories service.""" return AsyncCategoriesService(http_client=self.http_client) + + @property + def installations(self) -> AsyncInstallationsService: + """Installations service.""" + return AsyncInstallationsService(http_client=self.http_client) diff --git a/mpt_api_client/resources/integration/mixins/__init__.py b/mpt_api_client/resources/integration/mixins/__init__.py index 67fe3d1d..a9303305 100644 --- a/mpt_api_client/resources/integration/mixins/__init__.py +++ b/mpt_api_client/resources/integration/mixins/__init__.py @@ -2,6 +2,10 @@ AsyncExtensionMixin, ExtensionMixin, ) +from mpt_api_client.resources.integration.mixins.installation_mixin import ( + AsyncInstallationMixin, + InstallationMixin, +) from mpt_api_client.resources.integration.mixins.media_mixin import ( AsyncMediaMixin, MediaMixin, @@ -9,7 +13,9 @@ __all__ = [ # noqa: WPS410 "AsyncExtensionMixin", + "AsyncInstallationMixin", "AsyncMediaMixin", "ExtensionMixin", + "InstallationMixin", "MediaMixin", ] diff --git a/mpt_api_client/resources/integration/mixins/installation_mixin.py b/mpt_api_client/resources/integration/mixins/installation_mixin.py new file mode 100644 index 00000000..8d598e7c --- /dev/null +++ b/mpt_api_client/resources/integration/mixins/installation_mixin.py @@ -0,0 +1,105 @@ +from mpt_api_client.models import ResourceData + + +class InstallationMixin[Model]: + """Mixin that adds installation lifecycle actions: invite, install, uninstall, expire.""" + + def invite(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Invite an installation. + + Args: + resource_id: Installation ID. + resource_data: Optional request body. + + Returns: + Updated installation. + """ + return self._resource(resource_id).post("invite", json=resource_data) # type: ignore[attr-defined, no-any-return] + + def install(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Mark an installation as installed. + + Args: + resource_id: Installation ID. + resource_data: Optional request body. + + Returns: + Updated installation. + """ + return self._resource(resource_id).post("install", json=resource_data) # type: ignore[attr-defined, no-any-return] + + def uninstall(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Uninstall an installation. + + Args: + resource_id: Installation ID. + resource_data: Optional request body. + + Returns: + Updated installation. + """ + return self._resource(resource_id).post("uninstall", json=resource_data) # type: ignore[attr-defined, no-any-return] + + def expire(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Expire an installation. + + Args: + resource_id: Installation ID. + resource_data: Optional request body. + + Returns: + Updated installation. + """ + return self._resource(resource_id).post("expire", json=resource_data) # type: ignore[attr-defined, no-any-return] + + +class AsyncInstallationMixin[Model]: + """Async mixin for installation lifecycle actions: invite, install, uninstall, expire.""" + + async def invite(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Invite an installation. + + Args: + resource_id: Installation ID. + resource_data: Optional request body. + + Returns: + Updated installation. + """ + return await self._resource(resource_id).post("invite", json=resource_data) # type: ignore[attr-defined, no-any-return] + + async def install(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Mark an installation as installed. + + Args: + resource_id: Installation ID. + resource_data: Optional request body. + + Returns: + Updated installation. + """ + return await self._resource(resource_id).post("install", json=resource_data) # type: ignore[attr-defined, no-any-return] + + async def uninstall(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Uninstall an installation. + + Args: + resource_id: Installation ID. + resource_data: Optional request body. + + Returns: + Updated installation. + """ + return await self._resource(resource_id).post("uninstall", json=resource_data) # type: ignore[attr-defined, no-any-return] + + async def expire(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Expire an installation. + + Args: + resource_id: Installation ID. + resource_data: Optional request body. + + Returns: + Updated installation. + """ + return await self._resource(resource_id).post("expire", json=resource_data) # type: ignore[attr-defined, no-any-return] diff --git a/tests/unit/resources/integration/mixins/test_installation_mixin.py b/tests/unit/resources/integration/mixins/test_installation_mixin.py new file mode 100644 index 00000000..69501a98 --- /dev/null +++ b/tests/unit/resources/integration/mixins/test_installation_mixin.py @@ -0,0 +1,91 @@ +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 ( + AsyncInstallationMixin, + InstallationMixin, +) +from tests.unit.conftest import DummyModel + + +class DummyInstallationService( + InstallationMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/integration/installations" + _model_class = DummyModel + _collection_key = "data" + + +class DummyAsyncInstallationService( + AsyncInstallationMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/integration/installations" + _model_class = DummyModel + _collection_key = "data" + + +@pytest.fixture +def installation_service(http_client): + return DummyInstallationService(http_client=http_client) + + +@pytest.fixture +def async_installation_service(async_http_client): + return DummyAsyncInstallationService(http_client=async_http_client) + + +@pytest.mark.parametrize( + "action", + ["invite", "install", "uninstall", "expire"], +) +def test_post_actions(installation_service, action): + installation_id = "INS-001" + expected_response = {"id": installation_id, "status": "updated"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/integration/installations/{installation_id}/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = getattr(installation_service, action)(installation_id) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "POST" + assert result.to_dict() == expected_response + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize( + "action", + ["invite", "install", "uninstall", "expire"], +) +async def test_async_post_actions(async_installation_service, action): + installation_id = "INS-001" + expected_response = {"id": installation_id, "status": "updated"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/integration/installations/{installation_id}/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = await getattr(async_installation_service, action)(installation_id) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "POST" + assert result.to_dict() == expected_response + assert isinstance(result, DummyModel) diff --git a/tests/unit/resources/integration/test_installations.py b/tests/unit/resources/integration/test_installations.py new file mode 100644 index 00000000..aa2684f4 --- /dev/null +++ b/tests/unit/resources/integration/test_installations.py @@ -0,0 +1,114 @@ +import httpx +import pytest +import respx + +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.integration.installations import ( + AsyncInstallationsService, + Installation, + InstallationsService, +) + + +@pytest.fixture +def installations_service(http_client): + return InstallationsService(http_client=http_client) + + +@pytest.fixture +def async_installations_service(async_http_client): + return AsyncInstallationsService(http_client=async_http_client) + + +@pytest.mark.parametrize( + "method", + [ + "get", + "create", + "update", + "delete", + "invite", + "install", + "uninstall", + "expire", + "iterate", + ], +) +def test_mixins_present(installations_service, method): + result = hasattr(installations_service, method) + + assert result is True + + +@pytest.mark.parametrize( + "method", + [ + "get", + "create", + "update", + "delete", + "invite", + "install", + "uninstall", + "expire", + "iterate", + ], +) +def test_async_mixins_present(async_installations_service, method): + result = hasattr(async_installations_service, method) + + assert result is True + + +@pytest.fixture +def installation_data(): + return { + "id": "INS-001", + "name": "My Installation", + "revision": 2, + "account": {"id": "ACC-001", "name": "Account"}, + "extension": {"id": "EXT-001", "name": "Extension"}, + "status": "Installed", + "configuration": {"key": "value"}, + "invitation": {"url": "https://example.com/invite"}, + "modules": [{"id": "MOD-001"}], + "terms": [{"id": "TERM-001"}], + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +def test_installation_primitive_fields(installation_data): + result = Installation(installation_data) + + assert result.id == "INS-001" + assert result.name == "My Installation" + assert result.revision == 2 + assert result.status == "Installed" + + +def test_installation_nested_fields(installation_data): + result = Installation(installation_data) + + assert isinstance(result.account, BaseModel) + assert isinstance(result.extension, BaseModel) + assert isinstance(result.configuration, BaseModel) + assert isinstance(result.invitation, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_installation_create(installations_service): + installation_data = { + "extension": {"id": "EXT-001"}, + "account": {"id": "ACC-001"}, + } + expected_response = {"id": "INS-001", "name": "My Installation", "status": "Invited"} + with respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/integration/installations").mock( + return_value=httpx.Response(httpx.codes.CREATED, json=expected_response) + ) + + result = installations_service.create(installation_data) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "POST" + assert result.to_dict() == expected_response diff --git a/tests/unit/resources/integration/test_integration.py b/tests/unit/resources/integration/test_integration.py index 371c27a5..9b83ea03 100644 --- a/tests/unit/resources/integration/test_integration.py +++ b/tests/unit/resources/integration/test_integration.py @@ -8,6 +8,10 @@ AsyncExtensionsService, ExtensionsService, ) +from mpt_api_client.resources.integration.installations import ( + AsyncInstallationsService, + InstallationsService, +) from mpt_api_client.resources.integration.integration import ( AsyncIntegration, Integration, @@ -43,6 +47,7 @@ def test_async_integration_initialization(async_http_client): [ ("extensions", ExtensionsService), ("categories", CategoriesService), + ("installations", InstallationsService), ], ) def test_integration_properties(integration, property_name, expected_service_class): @@ -57,6 +62,7 @@ def test_integration_properties(integration, property_name, expected_service_cla [ ("extensions", AsyncExtensionsService), ("categories", AsyncCategoriesService), + ("installations", AsyncInstallationsService), ], ) def test_async_integration_properties(async_integration, property_name, expected_service_class): From 5be5e0ce5b702788546bea6a1b421ad9839f4f7d Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Tue, 7 Apr 2026 11:19:46 +0100 Subject: [PATCH 2/2] MPT-19908: add e2e tests for /public/v1/integration/installations --- e2e_config.test.json | 1 + .../integration/mixins/installation_mixin.py | 74 +++++++------ pyproject.toml | 1 + tests/e2e/integration/conftest.py | 6 ++ tests/e2e/integration/extensions/conftest.py | 13 +-- .../extensions/test_async_extensions.py | 4 - .../extensions/test_sync_extensions.py | 3 - .../e2e/integration/installations/__init__.py | 0 .../e2e/integration/installations/conftest.py | 98 +++++++++++++++++ tests/e2e/integration/installations/helper.py | 52 +++++++++ .../installations/test_async_installations.py | 76 +++++++++++++ .../installations/test_sync_installations.py | 75 +++++++++++++ .../mixins/test_installation_mixin.py | 100 +++++++++++++++++- .../integration/test_installations.py | 16 +-- 14 files changed, 453 insertions(+), 66 deletions(-) create mode 100644 tests/e2e/integration/conftest.py create mode 100644 tests/e2e/integration/installations/__init__.py create mode 100644 tests/e2e/integration/installations/conftest.py create mode 100644 tests/e2e/integration/installations/helper.py create mode 100644 tests/e2e/integration/installations/test_async_installations.py create mode 100644 tests/e2e/integration/installations/test_sync_installations.py diff --git a/e2e_config.test.json b/e2e_config.test.json index ee1c9e4e..ae4ec07f 100644 --- a/e2e_config.test.json +++ b/e2e_config.test.json @@ -69,6 +69,7 @@ "notifications.message.id": "MSG-0000-6215-1019-0139", "notifications.subscriber.id": "NTS-0829-7123-7123", "integration.extension.id": "EXT-6587-4477", + "integration.installation.id": "EXI-0022-3978-5547", "integration.term.id": "ETC-6587-4477-0062", "program.certificate.id": "CER-9646-2171-8417", "program.document.file.id": "PDM-9643-3741-0001", diff --git a/mpt_api_client/resources/integration/mixins/installation_mixin.py b/mpt_api_client/resources/integration/mixins/installation_mixin.py index 8d598e7c..ec5d7e24 100644 --- a/mpt_api_client/resources/integration/mixins/installation_mixin.py +++ b/mpt_api_client/resources/integration/mixins/installation_mixin.py @@ -2,22 +2,22 @@ class InstallationMixin[Model]: - """Mixin that adds installation lifecycle actions: invite, install, uninstall, expire.""" + """Mixin that adds installation actions: redeem, renew, and token retrieval.""" - def invite(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Invite an installation. + def redeem(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Redeem an installation invitation. Args: resource_id: Installation ID. - resource_data: Optional request body. + resource_data: Redeem payload, for example ``{"code": "...", "modules": [...]}``. Returns: Updated installation. """ - return self._resource(resource_id).post("invite", json=resource_data) # type: ignore[attr-defined, no-any-return] + return self._resource(resource_id).post("redeem", json=resource_data) # type: ignore[attr-defined, no-any-return] - def install(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Mark an installation as installed. + def renew(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Renew an installation. Args: resource_id: Installation ID. @@ -26,50 +26,49 @@ def install(self, resource_id: str, resource_data: ResourceData | None = None) - Returns: Updated installation. """ - return self._resource(resource_id).post("install", json=resource_data) # type: ignore[attr-defined, no-any-return] + return self._resource(resource_id).post("renew", json=resource_data) # type: ignore[attr-defined, no-any-return] - def uninstall(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Uninstall an installation. + def token(self, resource_id: str) -> Model: + """Retrieve an access token for a specific installation. Args: resource_id: Installation ID. - resource_data: Optional request body. Returns: - Updated installation. + Token response. """ - return self._resource(resource_id).post("uninstall", json=resource_data) # type: ignore[attr-defined, no-any-return] + return self._resource(resource_id).post("token") # type: ignore[attr-defined, no-any-return] - def expire(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Expire an installation. + def token_for_account(self, account_id: str | None = None) -> Model: + """Retrieve an installation token for an account. Args: - resource_id: Installation ID. - resource_data: Optional request body. + account_id: Optional account ID sent as ``account.id`` query parameter. Returns: - Updated installation. + Token response. """ - return self._resource(resource_id).post("expire", json=resource_data) # type: ignore[attr-defined, no-any-return] + query_params = {"account.id": account_id} if account_id else None + return self._resource("-").post("token", query_params=query_params) # type: ignore[attr-defined, no-any-return] class AsyncInstallationMixin[Model]: - """Async mixin for installation lifecycle actions: invite, install, uninstall, expire.""" + """Async mixin for installation actions: redeem, renew, and token retrieval.""" - async def invite(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Invite an installation. + async def redeem(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Redeem an installation invitation. Args: resource_id: Installation ID. - resource_data: Optional request body. + resource_data: Redeem payload, for example ``{"code": "...", "modules": [...]}``. Returns: Updated installation. """ - return await self._resource(resource_id).post("invite", json=resource_data) # type: ignore[attr-defined, no-any-return] + return await self._resource(resource_id).post("redeem", json=resource_data) # type: ignore[attr-defined, no-any-return] - async def install(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Mark an installation as installed. + async def renew(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Renew an installation. Args: resource_id: Installation ID. @@ -78,28 +77,27 @@ async def install(self, resource_id: str, resource_data: ResourceData | None = N Returns: Updated installation. """ - return await self._resource(resource_id).post("install", json=resource_data) # type: ignore[attr-defined, no-any-return] + return await self._resource(resource_id).post("renew", json=resource_data) # type: ignore[attr-defined, no-any-return] - async def uninstall(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Uninstall an installation. + async def token(self, resource_id: str) -> Model: + """Retrieve an access token for a specific installation. Args: resource_id: Installation ID. - resource_data: Optional request body. Returns: - Updated installation. + Token response. """ - return await self._resource(resource_id).post("uninstall", json=resource_data) # type: ignore[attr-defined, no-any-return] + return await self._resource(resource_id).post("token") # type: ignore[attr-defined, no-any-return] - async def expire(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Expire an installation. + async def token_for_account(self, account_id: str | None = None) -> Model: + """Retrieve an installation token for an account. Args: - resource_id: Installation ID. - resource_data: Optional request body. + account_id: Optional account ID sent as ``account.id`` query parameter. Returns: - Updated installation. + Token response. """ - return await self._resource(resource_id).post("expire", json=resource_data) # type: ignore[attr-defined, no-any-return] + query_params = {"account.id": account_id} if account_id else None + return await self._resource("-").post("token", query_params=query_params) # type: ignore[attr-defined, no-any-return] diff --git a/pyproject.toml b/pyproject.toml index 20622d58..f5c8e487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -149,6 +149,7 @@ per-file-ignores = [ "tests/unit/resources/catalog/test_products.py: WPS202 WPS210", "tests/e2e/integration/*.py: WPS453", "tests/e2e/integration/extensions/*.py: WPS453 WPS202", + "tests/e2e/integration/installations/*.py: WPS453 WPS202", "tests/unit/resources/integration/*.py: WPS202 WPS210 WPS218 WPS453", "tests/unit/resources/integration/mixins/*.py: WPS453 WPS202", "tests/unit/resources/commerce/*.py: WPS202 WPS204", diff --git a/tests/e2e/integration/conftest.py b/tests/e2e/integration/conftest.py new file mode 100644 index 00000000..cfd75018 --- /dev/null +++ b/tests/e2e/integration/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def extension_id(e2e_config): + return e2e_config["integration.extension.id"] diff --git a/tests/e2e/integration/extensions/conftest.py b/tests/e2e/integration/extensions/conftest.py index b05eeaa8..2a4d4613 100644 --- a/tests/e2e/integration/extensions/conftest.py +++ b/tests/e2e/integration/extensions/conftest.py @@ -4,18 +4,13 @@ @pytest.fixture -def extensions_service(mpt_ops): - return mpt_ops.integration.extensions +def extensions_service(mpt_vendor): + return mpt_vendor.integration.extensions @pytest.fixture -def async_extensions_service(async_mpt_ops): - return async_mpt_ops.integration.extensions - - -@pytest.fixture(scope="session") -def extension_id(e2e_config): - return e2e_config["integration.extension.id"] +def async_extensions_service(async_mpt_vendor): + return async_mpt_vendor.integration.extensions @pytest.fixture diff --git a/tests/e2e/integration/extensions/test_async_extensions.py b/tests/e2e/integration/extensions/test_async_extensions.py index 6531621c..cdb05f32 100644 --- a/tests/e2e/integration/extensions/test_async_extensions.py +++ b/tests/e2e/integration/extensions/test_async_extensions.py @@ -7,7 +7,6 @@ pytestmark = [pytest.mark.flaky] -@pytest.mark.skip(reason="unable to create extensions for testing") def test_create_extension(async_created_extension, extension_data): result = async_created_extension.name @@ -27,7 +26,6 @@ async def test_get_extension_not_found(async_extensions_service): await async_extensions_service.get(bogus_id) -@pytest.mark.skip(reason="unable to create extensions for testing") async def test_update_extension( async_extensions_service, async_created_extension, logo_fd, short_uuid ): @@ -40,7 +38,6 @@ async def test_update_extension( assert result.name == update_data["name"] -@pytest.mark.skip(reason="unable to create extensions for testing") async def test_delete_extension(async_extensions_service, async_created_extension): await async_extensions_service.delete(async_created_extension.id) # act @@ -51,7 +48,6 @@ async def test_filter_extensions(async_extensions_service, extension_id): ) # act -@pytest.mark.skip(reason="unable to create extensions for testing") async def test_download_icon(async_extensions_service, async_created_extension): result = await async_extensions_service.download_icon(async_created_extension.id) diff --git a/tests/e2e/integration/extensions/test_sync_extensions.py b/tests/e2e/integration/extensions/test_sync_extensions.py index 3a91049f..87af6b54 100644 --- a/tests/e2e/integration/extensions/test_sync_extensions.py +++ b/tests/e2e/integration/extensions/test_sync_extensions.py @@ -9,7 +9,6 @@ ] -@pytest.mark.skip(reason="unable to create extensions for testing") def test_create_extension(created_extension, extension_data): result = created_extension.name @@ -29,7 +28,6 @@ def test_get_extension_not_found(extensions_service): extensions_service.get(bogus_id) -@pytest.mark.skip(reason="unable to create extensions for testing") def test_update_extension(extensions_service, created_extension, logo_fd, short_uuid): update_data = {"name": f"e2e - please delete {short_uuid}"} @@ -38,7 +36,6 @@ def test_update_extension(extensions_service, created_extension, logo_fd, short_ assert result.name == update_data["name"] -@pytest.mark.skip(reason="unable to create extensions for testing") def test_delete_extension(extensions_service, created_extension): extensions_service.delete(created_extension.id) # act diff --git a/tests/e2e/integration/installations/__init__.py b/tests/e2e/integration/installations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/integration/installations/conftest.py b/tests/e2e/integration/installations/conftest.py new file mode 100644 index 00000000..e54477f6 --- /dev/null +++ b/tests/e2e/integration/installations/conftest.py @@ -0,0 +1,98 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError + + +@pytest.fixture +def installations_service(mpt_vendor): + return mpt_vendor.integration.installations + + +@pytest.fixture +def async_installations_service(async_mpt_vendor): + return async_mpt_vendor.integration.installations + + +@pytest.fixture(scope="session") +def installation_id(e2e_config): + return e2e_config["integration.installation.id"] + + +@pytest.fixture +def installation_modules(): + return [ + {"id": "MOD-0478"}, + {"id": "MOD-1239"}, + {"id": "MOD-1756"}, + {"id": "MOD-4525"}, + {"id": "MOD-8352"}, + {"id": "MOD-8743"}, + {"id": "MOD-9042"}, + ] + + +@pytest.fixture +def installation_data(extension_id, installation_modules): + return { + "extension": {"id": extension_id}, + "modules": installation_modules, + } + + +@pytest.fixture +def invite_data(extension_id): + return { + "extension": {"id": extension_id}, + "invitation": { + "message": "E2E testing - Delete", + "validity": "7d", + }, + } + + +@pytest.fixture +def created_installation(installations_service, installation_data): + installation = installations_service.create(installation_data) + + yield installation + + try: + installations_service.delete(installation.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete installation {installation.id}: {error.title}") # noqa: WPS421 + + +@pytest.fixture +def created_installation_invite(installations_service, invite_data): + invite = installations_service.create(invite_data) + + yield invite + + try: + installations_service.delete(invite.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete installation {invite.id}: {error.title}") # noqa: WPS421 + + +@pytest.fixture +async def async_created_installation(async_installations_service, installation_data): + installation = await async_installations_service.create(installation_data) + + yield installation + + try: + await async_installations_service.delete(installation.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete installation {installation.id}: {error.title}") # noqa: WPS421 + + +@pytest.fixture +async def async_created_installation_invite(async_installations_service, invite_data): + invite = await async_installations_service.create(invite_data) + + yield invite + + try: + await async_installations_service.delete(invite.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete installation {invite.id}: {error.title}") # noqa: WPS421 diff --git a/tests/e2e/integration/installations/helper.py b/tests/e2e/integration/installations/helper.py new file mode 100644 index 00000000..95f5bc23 --- /dev/null +++ b/tests/e2e/integration/installations/helper.py @@ -0,0 +1,52 @@ +import base64 +import json +from urllib.parse import parse_qs, urlparse + + +def _invitation_payload_candidates(invitation_url: str) -> list[str]: + candidates = [invitation_url] + parsed_url = urlparse(invitation_url) + + if parsed_url.query: + query_params = parse_qs(parsed_url.query) + for key in ("payload", "data", "token", "invitation"): + candidates.extend(query_params.get(key, [])) + + if parsed_url.fragment: + candidates.append(parsed_url.fragment) + + if parsed_url.path: + last_segment = parsed_url.path.rsplit("/", maxsplit=1)[-1] + if last_segment: + candidates.append(last_segment) + + return candidates + + +def _decode_base64_json(candidate: str) -> dict[str, str] | None: + normalized_candidate = candidate.strip() + if not normalized_candidate: + return None + + padding = "=" * (-len(normalized_candidate) % 4) + + try: # noqa: WPS229 + decoded_bytes = base64.urlsafe_b64decode(normalized_candidate + padding) + decoded_payload = json.loads(decoded_bytes.decode("utf-8")) + except (ValueError, json.JSONDecodeError): + return None + + if isinstance(decoded_payload, dict) and decoded_payload.get("code"): + return decoded_payload + + return None + + +def decode_invitation_payload(invitation_url: str) -> dict[str, str]: + """Decode an invitation payload from the API invitation URL field.""" + for candidate in _invitation_payload_candidates(invitation_url): + decoded_payload = _decode_base64_json(candidate) + if decoded_payload: + return decoded_payload + + raise AssertionError(f"Unable to decode invitation payload from URL: {invitation_url}") diff --git a/tests/e2e/integration/installations/test_async_installations.py b/tests/e2e/integration/installations/test_async_installations.py new file mode 100644 index 00000000..54249bc8 --- /dev/null +++ b/tests/e2e/integration/installations/test_async_installations.py @@ -0,0 +1,76 @@ +import pytest + +from tests.e2e.helper import assert_async_service_filter_with_iterate +from tests.e2e.integration.installations.helper import decode_invitation_payload + +pytestmark = [pytest.mark.flaky] + + +def test_create_installation(async_created_installation, installation_data): + result = async_created_installation.extension + + assert result.id == installation_data["extension"]["id"] + + +async def test_get_installation(async_installations_service, async_created_installation): + result = await async_installations_service.get(async_created_installation.id) + + assert result.id == async_created_installation.id + + +async def test_filter_installations(async_installations_service, async_created_installation): + await assert_async_service_filter_with_iterate( + async_installations_service, async_created_installation.id, None + ) # act + + +async def test_redeem_installation( + async_installations_service, + async_created_installation_invite, + installation_modules, +): + invitation_payload = decode_invitation_payload( + async_created_installation_invite.invitation.url, + ) + if not invitation_payload.get("installationId") == async_created_installation_invite.id: + raise ValueError( + f"Installation ID mismatch: expected {async_created_installation_invite.id}, " + f"got {invitation_payload.get('installationId')}" + ) + + redeem_invitation_data = { + "code": invitation_payload["code"], + "modules": installation_modules, + } + + result = await async_installations_service.redeem( + async_created_installation_invite.id, + redeem_invitation_data, + ) + + assert result.id == async_created_installation_invite.id + + +@pytest.mark.skip(reason="skip due to not enough clarity on the endpoint") +async def test_renew_installation(async_installations_service, async_created_installation): + result = await async_installations_service.renew(async_created_installation.id) + + assert result.id == async_created_installation.id + + +@pytest.mark.skip(reason="skip due to not enough clarity on the endpoint") +async def test_installation_token(async_installations_service, installation_id): + result = await async_installations_service.token(installation_id) + + assert isinstance(result.token, str) + + +@pytest.mark.skip(reason="skip due to not enough clarity on the endpoint") +async def test_installation_account_token(async_installations_service, account_id): + result = await async_installations_service.token_for_account(account_id) + + assert isinstance(result.token, str) + + +async def test_delete_installation(async_installations_service, async_created_installation): + await async_installations_service.delete(async_created_installation.id) # act diff --git a/tests/e2e/integration/installations/test_sync_installations.py b/tests/e2e/integration/installations/test_sync_installations.py new file mode 100644 index 00000000..0b556135 --- /dev/null +++ b/tests/e2e/integration/installations/test_sync_installations.py @@ -0,0 +1,75 @@ +import pytest + +from tests.e2e.helper import assert_service_filter_with_iterate +from tests.e2e.integration.installations.helper import decode_invitation_payload + +pytestmark = [ + pytest.mark.flaky, +] + + +def test_get_installation(installations_service, created_installation_invite): + result = installations_service.get(created_installation_invite.id) + + assert result.id == created_installation_invite.id + + +def test_filter_installations(installations_service, created_installation_invite): + assert_service_filter_with_iterate( + installations_service, created_installation_invite.id, None + ) # act + + +def test_create_installation(created_installation, created_installation_invite, invite_data): + result = created_installation.extension + + assert result.id == invite_data["extension"]["id"] + + +def test_redeem_installation( + installations_service, + created_installation_invite, + installation_modules, +): + invitation_payload = decode_invitation_payload(created_installation_invite.invitation.url) + if invitation_payload.get("installationId") != created_installation_invite.id: + raise ValueError( + f"Installation ID mismatch: expected {created_installation_invite.id}, " + f"got {invitation_payload.get('installationId')}" + ) + redeem_invitation_data = { + "code": invitation_payload["code"], + "modules": installation_modules, + } + + result = installations_service.redeem( + created_installation_invite.id, + redeem_invitation_data, + ) + + assert result.id == created_installation_invite.id + + +@pytest.mark.skip(reason="skip due to not enough clarity on the endpoint") +def test_renew_installation(installations_service, created_installation): + result = installations_service.renew(created_installation.id) + + assert result.id == created_installation.id + + +@pytest.mark.skip(reason="skip due to not enough clarity on the endpoint") +def test_installation_token(installations_service, installation_id): + result = installations_service.token(installation_id) + + assert isinstance(result.token, str) + + +@pytest.mark.skip(reason="deletes real resources; run manually only") +def test_installation_account_token(installations_service, account_id): + result = installations_service.token_for_account(account_id) + + assert isinstance(result.token, str) + + +def test_delete_installation(installations_service, created_installation): + installations_service.delete(created_installation.id) # act diff --git a/tests/unit/resources/integration/mixins/test_installation_mixin.py b/tests/unit/resources/integration/mixins/test_installation_mixin.py index 69501a98..9beceedb 100644 --- a/tests/unit/resources/integration/mixins/test_installation_mixin.py +++ b/tests/unit/resources/integration/mixins/test_installation_mixin.py @@ -41,11 +41,11 @@ def async_installation_service(async_http_client): @pytest.mark.parametrize( "action", - ["invite", "install", "uninstall", "expire"], + ["renew", "token"], ) def test_post_actions(installation_service, action): installation_id = "INS-001" - expected_response = {"id": installation_id, "status": "updated"} + expected_response = {"id": installation_id, "status": "Installed"} with respx.mock: mock_route = respx.post( f"https://api.example.com/public/v1/integration/installations/{installation_id}/{action}" @@ -65,13 +65,39 @@ def test_post_actions(installation_service, action): assert isinstance(result, DummyModel) +def test_redeem(installation_service): + installation_id = "INS-001" + expected_response = {"id": installation_id, "status": "Installed"} + payload = {"code": "ABC123", "modules": [{"id": "MOD-001"}]} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/integration/installations/{installation_id}/redeem" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = installation_service.redeem(installation_id, payload) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "POST" + assert ( + mock_route.calls[0].request.content == b'{"code":"ABC123","modules":[{"id":"MOD-001"}]}' + ) + assert result.to_dict() == expected_response + assert isinstance(result, DummyModel) + + @pytest.mark.parametrize( "action", - ["invite", "install", "uninstall", "expire"], + ["renew", "token"], ) async def test_async_post_actions(async_installation_service, action): installation_id = "INS-001" - expected_response = {"id": installation_id, "status": "updated"} + expected_response = {"id": installation_id, "status": "Installed"} with respx.mock: mock_route = respx.post( f"https://api.example.com/public/v1/integration/installations/{installation_id}/{action}" @@ -89,3 +115,69 @@ async def test_async_post_actions(async_installation_service, action): assert mock_route.calls[0].request.method == "POST" assert result.to_dict() == expected_response assert isinstance(result, DummyModel) + + +async def test_async_redeem(async_installation_service): + installation_id = "INS-001" + expected_response = {"id": installation_id, "status": "Installed"} + payload = {"code": "ABC123", "modules": [{"id": "MOD-001"}]} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/integration/installations/{installation_id}/redeem" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = await async_installation_service.redeem(installation_id, payload) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "POST" + assert ( + mock_route.calls[0].request.content == b'{"code":"ABC123","modules":[{"id":"MOD-001"}]}' + ) + assert result.to_dict() == expected_response + assert isinstance(result, DummyModel) + + +def test_token_for_account(installation_service): + expected_response = {"token": "token-value"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/integration/installations/-/token", + params={"account.id": "ACC-001"}, + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = installation_service.token_for_account("ACC-001") + + assert mock_route.call_count == 1 + assert result.to_dict() == expected_response + + +async def test_async_token_for_account(async_installation_service): + expected_response = {"token": "token-value"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/integration/installations/-/token", + params={"account.id": "ACC-001"}, + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = await async_installation_service.token_for_account("ACC-001") + + assert mock_route.call_count == 1 + assert result.to_dict() == expected_response diff --git a/tests/unit/resources/integration/test_installations.py b/tests/unit/resources/integration/test_installations.py index aa2684f4..0f07c961 100644 --- a/tests/unit/resources/integration/test_installations.py +++ b/tests/unit/resources/integration/test_installations.py @@ -27,10 +27,10 @@ def async_installations_service(async_http_client): "create", "update", "delete", - "invite", - "install", - "uninstall", - "expire", + "redeem", + "renew", + "token", + "token_for_account", "iterate", ], ) @@ -47,10 +47,10 @@ def test_mixins_present(installations_service, method): "create", "update", "delete", - "invite", - "install", - "uninstall", - "expire", + "redeem", + "renew", + "token", + "token_for_account", "iterate", ], )