diff --git a/mpt_api_client/mpt_client.py b/mpt_api_client/mpt_client.py index da465ef1..8eb2c3ee 100644 --- a/mpt_api_client/mpt_client.py +++ b/mpt_api_client/mpt_client.py @@ -9,6 +9,7 @@ AsyncCatalog, AsyncCommerce, AsyncExchange, + AsyncExtensibility, AsyncHelpdesk, AsyncNotifications, Audit, @@ -16,6 +17,7 @@ Catalog, Commerce, Exchange, + Extensibility, Helpdesk, Notifications, ) @@ -90,6 +92,11 @@ def exchange(self) -> AsyncExchange: """Exchange MPT API Client.""" return AsyncExchange(http_client=self.http_client) + @property + def extensibility(self) -> AsyncExtensibility: + """Extensibility MPT API Client.""" + return AsyncExtensibility(http_client=self.http_client) + class MPTClient: """MPT API Client.""" @@ -164,3 +171,8 @@ def helpdesk(self) -> Helpdesk: def exchange(self) -> Exchange: """Exchange MPT API Client.""" return Exchange(http_client=self.http_client) + + @property + def extensibility(self) -> Extensibility: + """Extensibility MPT API Client.""" + return Extensibility(http_client=self.http_client) diff --git a/mpt_api_client/resources/__init__.py b/mpt_api_client/resources/__init__.py index d66c9b09..7022837f 100644 --- a/mpt_api_client/resources/__init__.py +++ b/mpt_api_client/resources/__init__.py @@ -4,6 +4,7 @@ from mpt_api_client.resources.catalog import AsyncCatalog, Catalog from mpt_api_client.resources.commerce import AsyncCommerce, Commerce from mpt_api_client.resources.exchange import AsyncExchange, Exchange +from mpt_api_client.resources.extensibility import AsyncExtensibility, Extensibility from mpt_api_client.resources.helpdesk import AsyncHelpdesk, Helpdesk from mpt_api_client.resources.notifications import AsyncNotifications, Notifications @@ -15,6 +16,7 @@ "AsyncCatalog", "AsyncCommerce", "AsyncExchange", + "AsyncExtensibility", "AsyncHelpdesk", "AsyncNotifications", "Audit", @@ -22,6 +24,7 @@ "Catalog", "Commerce", "Exchange", + "Extensibility", "Helpdesk", "Notifications", ] diff --git a/mpt_api_client/resources/extensibility/__init__.py b/mpt_api_client/resources/extensibility/__init__.py new file mode 100644 index 00000000..68c837fa --- /dev/null +++ b/mpt_api_client/resources/extensibility/__init__.py @@ -0,0 +1,9 @@ +from mpt_api_client.resources.extensibility.extensibility import ( + AsyncExtensibility, + Extensibility, +) + +__all__ = [ # noqa: WPS410 + "AsyncExtensibility", + "Extensibility", +] diff --git a/mpt_api_client/resources/extensibility/extensibility.py b/mpt_api_client/resources/extensibility/extensibility.py new file mode 100644 index 00000000..d23faf0b --- /dev/null +++ b/mpt_api_client/resources/extensibility/extensibility.py @@ -0,0 +1,29 @@ +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.resources.extensibility.extensions import ( + AsyncExtensionsService, + ExtensionsService, +) + + +class Extensibility: + """Extensibility MPT API Module.""" + + def __init__(self, *, http_client: HTTPClient): + self.http_client = http_client + + @property + def extensions(self) -> ExtensionsService: + """Extensions service.""" + return ExtensionsService(http_client=self.http_client) + + +class AsyncExtensibility: + """Async Extensibility MPT API Module.""" + + def __init__(self, *, http_client: AsyncHTTPClient): + self.http_client = http_client + + @property + def extensions(self) -> AsyncExtensionsService: + """Extensions service.""" + return AsyncExtensionsService(http_client=self.http_client) diff --git a/mpt_api_client/resources/extensibility/extensions.py b/mpt_api_client/resources/extensibility/extensions.py new file mode 100644 index 00000000..b6c9b1d8 --- /dev/null +++ b/mpt_api_client/resources/extensibility/extensions.py @@ -0,0 +1,93 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncCreateFileMixin, + AsyncDeleteMixin, + AsyncGetMixin, + AsyncUpdateFileMixin, + CollectionMixin, + CreateFileMixin, + DeleteMixin, + GetMixin, + UpdateFileMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.extensibility.mixins import ( + AsyncExtensionMixin, + ExtensionMixin, +) + + +class Extension(Model): + """Extension resource. + + Attributes: + name: Display name of the extension. + icon: URL or identifier for the extension icon. + revision: Revision number. + status: Extension status (Draft, Private, Public, Deleted). + website: Extension website URL. + short_description: Short description of the extension. + long_description: Long description of the extension. + vendor: Reference to the vendor account. + categories: Categories assigned to the extension. + modules: Modules referenced by the extension. + statistics: Extension usage statistics. + configuration: Extension configuration data. + meta: Metadata reference. + service: Service details. + audit: Audit information (created, updated events). + """ + + name: str | None + icon: str | None + revision: int | None + status: str | None + website: str | None + short_description: str | None + long_description: str | None + vendor: BaseModel | None + categories: list[BaseModel] | None + modules: list[BaseModel] | None + statistics: BaseModel | None + configuration: BaseModel | None + meta: BaseModel | None + service: BaseModel | None + audit: BaseModel | None + + +class ExtensionsServiceConfig: + """Extensions service configuration.""" + + _endpoint = "/public/v1/extensibility/extensions" + _model_class = Extension + _collection_key = "data" + _upload_file_key = "icon" + _upload_data_key = "extension" + + +class ExtensionsService( + ExtensionMixin[Extension], + CreateFileMixin[Extension], + UpdateFileMixin[Extension], + GetMixin[Extension], + DeleteMixin, + CollectionMixin[Extension], + Service[Extension], + ExtensionsServiceConfig, +): + """Sync service for the /public/v1/extensibility/extensions endpoint.""" + + +class AsyncExtensionsService( + AsyncExtensionMixin[Extension], + AsyncCreateFileMixin[Extension], + AsyncUpdateFileMixin[Extension], + AsyncGetMixin[Extension], + AsyncDeleteMixin, + AsyncCollectionMixin[Extension], + AsyncService[Extension], + ExtensionsServiceConfig, +): + """Async service for the /public/v1/extensibility/extensions endpoint.""" diff --git a/mpt_api_client/resources/extensibility/mixins/__init__.py b/mpt_api_client/resources/extensibility/mixins/__init__.py new file mode 100644 index 00000000..dc6e031a --- /dev/null +++ b/mpt_api_client/resources/extensibility/mixins/__init__.py @@ -0,0 +1,9 @@ +from mpt_api_client.resources.extensibility.mixins.extension_mixin import ( + AsyncExtensionMixin, + ExtensionMixin, +) + +__all__ = [ # noqa: WPS410 + "AsyncExtensionMixin", + "ExtensionMixin", +] diff --git a/mpt_api_client/resources/extensibility/mixins/extension_mixin.py b/mpt_api_client/resources/extensibility/mixins/extension_mixin.py new file mode 100644 index 00000000..72a7ea81 --- /dev/null +++ b/mpt_api_client/resources/extensibility/mixins/extension_mixin.py @@ -0,0 +1,121 @@ +from mpt_api_client.models import FileModel + + +class ExtensionMixin[Model]: + """Mixin that adds extension-specific actions: publish, unpublish, regenerate, token, icon.""" + + def publish(self, resource_id: str) -> Model: + """Publish the extension, moving it to Public status. + + Args: + resource_id: Extension ID. + + Returns: + Updated extension. + """ + return self._resource(resource_id).post("publish") # type: ignore[attr-defined, no-any-return] + + def unpublish(self, resource_id: str) -> Model: + """Unpublish the extension, moving it to Private status. + + Args: + resource_id: Extension ID. + + Returns: + Updated extension. + """ + return self._resource(resource_id).post("unpublish") # type: ignore[attr-defined, no-any-return] + + def regenerate(self, resource_id: str) -> Model: + """Regenerate the extension credentials. + + Args: + resource_id: Extension ID. + + Returns: + Updated extension. + """ + return self._resource(resource_id).post("regenerate") # type: ignore[attr-defined, no-any-return] + + def token(self, resource_id: str) -> Model: + """Retrieve an access token for the extension. + + Args: + resource_id: Extension ID. + + Returns: + Token response. + """ + return self._resource(resource_id).post("token") # type: ignore[attr-defined, no-any-return] + + def download_icon(self, resource_id: str) -> FileModel: + """Download the icon for the given extension. + + Args: + resource_id: Extension ID. + + Returns: + File model containing the downloaded icon. + """ + response = self._resource(resource_id).do_request("GET", "icon") # type: ignore[attr-defined] + return FileModel(response) + + +class AsyncExtensionMixin[Model]: + """Async mixin for extension-specific actions: publish, unpublish, regenerate, token, icon.""" + + async def publish(self, resource_id: str) -> Model: + """Publish the extension, moving it to Public status. + + Args: + resource_id: Extension ID. + + Returns: + Updated extension. + """ + return await self._resource(resource_id).post("publish") # type: ignore[attr-defined, no-any-return] + + async def unpublish(self, resource_id: str) -> Model: + """Unpublish the extension, moving it to Private status. + + Args: + resource_id: Extension ID. + + Returns: + Updated extension. + """ + return await self._resource(resource_id).post("unpublish") # type: ignore[attr-defined, no-any-return] + + async def regenerate(self, resource_id: str) -> Model: + """Regenerate the extension credentials. + + Args: + resource_id: Extension ID. + + Returns: + Updated extension. + """ + return await self._resource(resource_id).post("regenerate") # type: ignore[attr-defined, no-any-return] + + async def token(self, resource_id: str) -> Model: + """Retrieve an access token for the extension. + + Args: + resource_id: Extension ID. + + Returns: + Token response. + """ + return await self._resource(resource_id).post("token") # type: ignore[attr-defined, no-any-return] + + async def download_icon(self, resource_id: str) -> FileModel: + """Download the icon for the given extension. + + Args: + resource_id: Extension ID. + + Returns: + File model containing the downloaded icon. + """ + response = await self._resource(resource_id).do_request("GET", "icon") # type: ignore[attr-defined] + return FileModel(response) diff --git a/pyproject.toml b/pyproject.toml index fd18d3a8..fe2562a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,6 +123,7 @@ per-file-ignores = [ "mpt_api_client/resources/catalog/products.py: WPS204 WPS214 WPS215 WPS235", "mpt_api_client/resources/commerce/*.py: WPS235 WPS215", "mpt_api_client/resources/exchange/*.py: WPS235 WPS215", + "mpt_api_client/resources/extensibility/*.py: WPS214 WPS215 WPS235", "mpt_api_client/resources/helpdesk/*.py: WPS204 WPS215 WPS214", "mpt_api_client/rql/query_builder.py: WPS110 WPS115 WPS210 WPS214", "tests/e2e/accounts/*.py: WPS430 WPS202", @@ -142,6 +143,7 @@ per-file-ignores = [ "tests/unit/http/mixins/*: WPS204 WPS202 WPS210", "tests/unit/resources/accounts/*.py: WPS204 WPS202 WPS210", "tests/unit/resources/catalog/test_products.py: WPS202 WPS210", + "tests/unit/resources/extensibility/*.py: WPS202 WPS210 WPS218", "tests/unit/resources/commerce/*.py: WPS202 WPS204", "tests/unit/test_mpt_client.py: WPS235", "tests/*: WPS432 WPS202", diff --git a/tests/unit/resources/extensibility/__init__.py b/tests/unit/resources/extensibility/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/resources/extensibility/mixins/__init__.py b/tests/unit/resources/extensibility/mixins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/resources/extensibility/mixins/test_extension_mixin.py b/tests/unit/resources/extensibility/mixins/test_extension_mixin.py new file mode 100644 index 00000000..9904a05c --- /dev/null +++ b/tests/unit/resources/extensibility/mixins/test_extension_mixin.py @@ -0,0 +1,136 @@ +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.models import FileModel +from mpt_api_client.resources.extensibility.mixins import ( + AsyncExtensionMixin, + ExtensionMixin, +) +from tests.unit.conftest import DummyModel + + +class DummyExtensionService( + ExtensionMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/extensibility/extensions" + _model_class = DummyModel + _collection_key = "data" + + +class DummyAsyncExtensionService( + AsyncExtensionMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/extensibility/extensions" + _model_class = DummyModel + _collection_key = "data" + + +@pytest.fixture +def extension_service(http_client): + return DummyExtensionService(http_client=http_client) + + +@pytest.fixture +def async_extension_service(async_http_client): + return DummyAsyncExtensionService(http_client=async_http_client) + + +@pytest.mark.parametrize( + "action", + ["publish", "unpublish", "regenerate", "token"], +) +def test_post_actions(extension_service, action): + extension_id = "EXT-001" + expected_response = {"id": extension_id, "status": "updated"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/extensibility/extensions/{extension_id}/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = getattr(extension_service, action)(extension_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) + + +def test_download_icon(extension_service): + extension_id = "EXT-001" + icon_bytes = b"\x89PNG\r\n\x1a\n" + with respx.mock: + mock_route = respx.get( + f"https://api.example.com/public/v1/extensibility/extensions/{extension_id}/icon" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "image/png"}, + content=icon_bytes, + ) + ) + + result = extension_service.download_icon(extension_id) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "GET" + assert isinstance(result, FileModel) + assert result.file_contents == icon_bytes + + +@pytest.mark.parametrize( + "action", + ["publish", "unpublish", "regenerate", "token"], +) +async def test_async_post_actions(async_extension_service, action): + extension_id = "EXT-001" + expected_response = {"id": extension_id, "status": "updated"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/extensibility/extensions/{extension_id}/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = await getattr(async_extension_service, action)(extension_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) + + +async def test_async_download_icon(async_extension_service): + extension_id = "EXT-001" + icon_bytes = b"\x89PNG\r\n\x1a\n" + with respx.mock: + mock_route = respx.get( + f"https://api.example.com/public/v1/extensibility/extensions/{extension_id}/icon" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "image/png"}, + content=icon_bytes, + ) + ) + + result = await async_extension_service.download_icon(extension_id) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "GET" + assert isinstance(result, FileModel) + assert result.file_contents == icon_bytes diff --git a/tests/unit/resources/extensibility/test_extensibility.py b/tests/unit/resources/extensibility/test_extensibility.py new file mode 100644 index 00000000..2833ccaf --- /dev/null +++ b/tests/unit/resources/extensibility/test_extensibility.py @@ -0,0 +1,60 @@ +import pytest + +from mpt_api_client.resources.extensibility.extensibility import ( + AsyncExtensibility, + Extensibility, +) +from mpt_api_client.resources.extensibility.extensions import ( + AsyncExtensionsService, + ExtensionsService, +) + + +@pytest.fixture +def extensibility(http_client): + return Extensibility(http_client=http_client) + + +@pytest.fixture +def async_extensibility(async_http_client): + return AsyncExtensibility(http_client=async_http_client) + + +def test_extensibility_initialization(http_client): + result = Extensibility(http_client=http_client) + + assert result.http_client is http_client + assert isinstance(result, Extensibility) + + +def test_async_extensibility_initialization(async_http_client): + result = AsyncExtensibility(http_client=async_http_client) + + assert result.http_client is async_http_client + assert isinstance(result, AsyncExtensibility) + + +@pytest.mark.parametrize( + ("property_name", "expected_service_class"), + [ + ("extensions", ExtensionsService), + ], +) +def test_extensibility_properties(extensibility, property_name, expected_service_class): + result = getattr(extensibility, property_name) + + assert isinstance(result, expected_service_class) + assert result.http_client is extensibility.http_client + + +@pytest.mark.parametrize( + ("property_name", "expected_service_class"), + [ + ("extensions", AsyncExtensionsService), + ], +) +def test_async_extensibility_properties(async_extensibility, property_name, expected_service_class): + result = getattr(async_extensibility, property_name) + + assert isinstance(result, expected_service_class) + assert result.http_client is async_extensibility.http_client diff --git a/tests/unit/resources/extensibility/test_extensions.py b/tests/unit/resources/extensibility/test_extensions.py new file mode 100644 index 00000000..751779a2 --- /dev/null +++ b/tests/unit/resources/extensibility/test_extensions.py @@ -0,0 +1,184 @@ +import httpx +import pytest +import respx + +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.extensibility.extensions import ( + AsyncExtensionsService, + Extension, + ExtensionsService, +) + + +@pytest.fixture +def extensions_service(http_client): + return ExtensionsService(http_client=http_client) + + +@pytest.fixture +def async_extensions_service(async_http_client): + return AsyncExtensionsService(http_client=async_http_client) + + +@pytest.mark.parametrize( + "method", + [ + "get", + "create", + "update", + "delete", + "publish", + "unpublish", + "regenerate", + "token", + "download_icon", + "iterate", + ], +) +def test_mixins_present(extensions_service, method): + result = hasattr(extensions_service, method) + + assert result is True + + +@pytest.mark.parametrize( + "method", + [ + "get", + "create", + "update", + "delete", + "publish", + "unpublish", + "regenerate", + "token", + "download_icon", + "iterate", + ], +) +def test_async_mixins_present(async_extensions_service, method): + result = hasattr(async_extensions_service, method) + + assert result is True + + +def test_extension_create(extensions_service, tmp_path): + extension_data = {"name": "My Extension", "shortDescription": "A test extension"} + expected_response = {"id": "EXT-001", "name": "My Extension"} + icon_path = tmp_path / "icon.png" + icon_path.write_bytes(b"fake image data") + with icon_path.open("rb") as icon_file, respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/extensibility/extensions").mock( + return_value=httpx.Response(httpx.codes.CREATED, json=expected_response) + ) + + result = extensions_service.create(extension_data, file=icon_file) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "POST" + assert result.to_dict() == expected_response + + +async def test_async_extension_create(async_extensions_service, tmp_path): + extension_data = {"name": "Async Extension", "shortDescription": "An async test extension"} + expected_response = {"id": "EXT-002", "name": "Async Extension"} + icon_path = tmp_path / "icon.png" + icon_path.write_bytes(b"fake image data") + with icon_path.open("rb") as icon_file, respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/extensibility/extensions").mock( + return_value=httpx.Response(httpx.codes.CREATED, json=expected_response) + ) + + result = await async_extensions_service.create(extension_data, file=icon_file) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "POST" + assert result.to_dict() == expected_response + + +def test_extension_update(extensions_service, tmp_path): + extension_id = "EXT-001" + update_data = {"name": "Updated Extension"} + expected_response = {"id": extension_id, "name": "Updated Extension"} + icon_path = tmp_path / "icon.png" + icon_path.write_bytes(b"new icon data") + with icon_path.open("rb") as icon_file, respx.mock: + mock_route = respx.put( + f"https://api.example.com/public/v1/extensibility/extensions/{extension_id}" + ).mock(return_value=httpx.Response(httpx.codes.OK, json=expected_response)) + + result = extensions_service.update(extension_id, update_data, file=icon_file) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "PUT" + assert result.to_dict() == expected_response + + +async def test_async_extension_update(async_extensions_service, tmp_path): + extension_id = "EXT-002" + update_data = {"name": "Async Updated Extension"} + expected_response = {"id": extension_id, "name": "Async Updated Extension"} + icon_path = tmp_path / "icon.png" + icon_path.write_bytes(b"new async icon data") + with icon_path.open("rb") as icon_file, respx.mock: + mock_route = respx.put( + f"https://api.example.com/public/v1/extensibility/extensions/{extension_id}" + ).mock(return_value=httpx.Response(httpx.codes.OK, json=expected_response)) + + result = await async_extensions_service.update(extension_id, update_data, file=icon_file) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "PUT" + assert result.to_dict() == expected_response + + +@pytest.fixture +def extension_data(): + return { + "id": "EXT-001", + "name": "My Extension", + "icon": "https://example.com/icon.png", + "revision": 1, + "status": "Draft", + "website": "https://example.com", + "shortDescription": "Short description", + "longDescription": "Long description", + "vendor": {"id": "ACC-001", "name": "Vendor"}, + "categories": [{"id": "CAT-001", "name": "Category"}], + "modules": [{"id": "MOD-001", "name": "Module"}], + "statistics": {"installations": 5}, + "configuration": {"key": "value"}, + "meta": {"id": "META-001"}, + "service": {"url": "https://service.example.com"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +def test_extension_primitive_fields(extension_data): + result = Extension(extension_data) + + assert result.id == "EXT-001" + assert result.name == "My Extension" + assert result.revision == 1 + assert result.status == "Draft" + assert result.website == "https://example.com" + + +def test_extension_nested_fields_are_base_models(extension_data): + result = Extension(extension_data) + + assert isinstance(result.vendor, BaseModel) + assert isinstance(result.statistics, BaseModel) + assert isinstance(result.configuration, BaseModel) + assert isinstance(result.meta, BaseModel) + assert isinstance(result.service, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_extension_optional_fields_absent(): + result = Extension({"id": "EXT-001"}) + + assert result.id == "EXT-001" + assert not hasattr(result, "name") + assert not hasattr(result, "status") + assert not hasattr(result, "audit") diff --git a/tests/unit/test_mpt_client.py b/tests/unit/test_mpt_client.py index 684d5998..51b3315b 100644 --- a/tests/unit/test_mpt_client.py +++ b/tests/unit/test_mpt_client.py @@ -10,6 +10,7 @@ AsyncCatalog, AsyncCommerce, AsyncExchange, + AsyncExtensibility, AsyncHelpdesk, AsyncNotifications, Audit, @@ -17,6 +18,7 @@ Catalog, Commerce, Exchange, + Extensibility, Helpdesk, Notifications, ) @@ -38,6 +40,7 @@ def get_mpt_client(): ("notifications", Notifications), ("helpdesk", Helpdesk), ("exchange", Exchange), + ("extensibility", Extensibility), ], ) def test_mpt_client(resource_name: str, expected_type: type) -> None: @@ -70,6 +73,7 @@ def test_mpt_client_env(monkeypatch: pytest.MonkeyPatch) -> None: ("notifications", AsyncNotifications), ("helpdesk", AsyncHelpdesk), ("exchange", AsyncExchange), + ("extensibility", AsyncExtensibility), ], ) def test_async_mpt_client(resource_name: str, expected_type: type) -> None: