diff --git a/mpt_api_client/resources/integration/extension_media.py b/mpt_api_client/resources/integration/extension_media.py new file mode 100644 index 00000000..92330ac9 --- /dev/null +++ b/mpt_api_client/resources/integration/extension_media.py @@ -0,0 +1,91 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncCreateFileMixin, + AsyncDeleteMixin, + AsyncDownloadFileMixin, + AsyncModifiableResourceMixin, + CollectionMixin, + CreateFileMixin, + DeleteMixin, + DownloadFileMixin, + ModifiableResourceMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.integration.mixins import ( + AsyncMediaMixin, + AsyncPublishableMixin, + MediaMixin, + PublishableMixin, +) + + +class ExtensionMedia(Model): + """Extension Media resource. + + Attributes: + name: Media name. + revision: Revision number. + type: Media type (Video or Image). + description: Media description. + status: Media status (Draft, Published, Unpublished, Deleted). + filename: Original file name. + size: File size in bytes. + content_type: MIME content type. + display_order: Display order. + url: URL to access the media. + extension: Reference to the extension. + audit: Audit information (created, updated, published, unpublished). + """ + + name: str | None + revision: int | None + type: str | None + description: str | None + status: str | None + filename: str | None + size: int | None + content_type: str | None + display_order: int | None + url: str | None + extension: BaseModel | None + audit: BaseModel | None + + +class ExtensionMediaServiceConfig: + """Extension Media service configuration.""" + + _endpoint = "/public/v1/integration/extensions/{extension_id}/media" + _model_class = ExtensionMedia + _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "media" + + +class ExtensionMediaService( + MediaMixin[ExtensionMedia], + PublishableMixin[ExtensionMedia], + DownloadFileMixin[ExtensionMedia], + CreateFileMixin[ExtensionMedia], + ModifiableResourceMixin[ExtensionMedia], + DeleteMixin, + CollectionMixin[ExtensionMedia], + Service[ExtensionMedia], + ExtensionMediaServiceConfig, +): + """Sync service for the /public/v1/integration/extensions/{extensionId}/media endpoint.""" + + +class AsyncExtensionMediaService( + AsyncMediaMixin[ExtensionMedia], + AsyncPublishableMixin[ExtensionMedia], + AsyncDownloadFileMixin[ExtensionMedia], + AsyncCreateFileMixin[ExtensionMedia], + AsyncModifiableResourceMixin[ExtensionMedia], + AsyncDeleteMixin, + AsyncCollectionMixin[ExtensionMedia], + AsyncService[ExtensionMedia], + ExtensionMediaServiceConfig, +): + """Async service for the /public/v1/integration/extensions/{extensionId}/media endpoint.""" diff --git a/mpt_api_client/resources/integration/extensions.py b/mpt_api_client/resources/integration/extensions.py index 373a6d6e..f24e2074 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_media import ( + AsyncExtensionMediaService, + ExtensionMediaService, +) from mpt_api_client.resources.integration.extension_terms import ( AsyncExtensionTermsService, ExtensionTermsService, @@ -89,6 +93,19 @@ def terms(self, extension_id: str) -> ExtensionTermsService: http_client=self.http_client, endpoint_params={"extension_id": extension_id} ) + def media(self, extension_id: str) -> ExtensionMediaService: + """Return the media service for the given extension. + + Args: + extension_id: Extension ID. + + Returns: + ExtensionMediaService instance. + """ + return ExtensionMediaService( + http_client=self.http_client, endpoint_params={"extension_id": extension_id} + ) + class AsyncExtensionsService( AsyncExtensionMixin[Extension], @@ -107,3 +124,16 @@ def terms(self, extension_id: str) -> AsyncExtensionTermsService: return AsyncExtensionTermsService( http_client=self.http_client, endpoint_params={"extension_id": extension_id} ) + + def media(self, extension_id: str) -> AsyncExtensionMediaService: + """Return the async media service for the given extension. + + Args: + extension_id: Extension ID. + + Returns: + AsyncExtensionMediaService instance. + """ + return AsyncExtensionMediaService( + 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 7d8ad30f..ad32140b 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.media_mixin import ( + AsyncMediaMixin, + MediaMixin, +) from mpt_api_client.resources.integration.mixins.publishable_mixin import ( AsyncPublishableMixin, PublishableMixin, @@ -9,7 +13,9 @@ __all__ = [ # noqa: WPS410 "AsyncExtensionMixin", + "AsyncMediaMixin", "AsyncPublishableMixin", "ExtensionMixin", + "MediaMixin", "PublishableMixin", ] diff --git a/mpt_api_client/resources/integration/mixins/media_mixin.py b/mpt_api_client/resources/integration/mixins/media_mixin.py new file mode 100644 index 00000000..e6bf1c5c --- /dev/null +++ b/mpt_api_client/resources/integration/mixins/media_mixin.py @@ -0,0 +1,46 @@ +from mpt_api_client.http.types import FileTypes +from mpt_api_client.http.url_utils import join_url_path + + +class MediaMixin[Model]: + """Mixin that adds media-specific actions: upload_image.""" + + def upload_image(self, resource_id: str, file: FileTypes) -> Model: # noqa: WPS110 + """Upload or replace the image binary for this media item. + + Args: + resource_id: Media item ID. + file: Binary image file to upload. + + Returns: + Updated media item. + """ + url = join_url_path(self.path, resource_id, "image") # type: ignore[attr-defined] + response = self.http_client.request( # type: ignore[attr-defined] + "put", + url, + files={"file": file}, + ) + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] + + +class AsyncMediaMixin[Model]: + """Async mixin that adds media-specific actions: upload_image.""" + + async def upload_image(self, resource_id: str, file: FileTypes) -> Model: # noqa: WPS110 + """Upload or replace the image binary for this media item. + + Args: + resource_id: Media item ID. + file: Binary image file to upload. + + Returns: + Updated media item. + """ + url = join_url_path(self.path, resource_id, "image") # type: ignore[attr-defined] + response = await self.http_client.request( # type: ignore[attr-defined] + "put", + url, + files={"file": file}, + ) + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] diff --git a/tests/e2e/integration/extension_media/__init__.py b/tests/e2e/integration/extension_media/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/integration/extension_media/conftest.py b/tests/e2e/integration/extension_media/conftest.py new file mode 100644 index 00000000..7be5aea7 --- /dev/null +++ b/tests/e2e/integration/extension_media/conftest.py @@ -0,0 +1,53 @@ +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_media_service(mpt_vendor, extension_id): + return mpt_vendor.integration.extensions.media(extension_id) + + +@pytest.fixture +def async_extension_media_service(async_mpt_vendor, extension_id): + return async_mpt_vendor.integration.extensions.media(extension_id) + + +@pytest.fixture +def media_data(short_uuid): + return { + "name": f"e2e - please delete {short_uuid}", + "description": "Created by automated E2E tests. Safe to delete.", + "mediaType": "Image", + "url": "https://example.com/image.png", + "displayOrder": 1, + } + + +@pytest.fixture +def created_media(extension_media_service, media_data, logo_fd): + media = extension_media_service.create(media_data, file=logo_fd) + + yield media + + try: + extension_media_service.delete(media.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete media {media.id}: {error.title}") # noqa: WPS421 + + +@pytest.fixture +async def async_created_media(async_extension_media_service, media_data, logo_fd): + media = await async_extension_media_service.create(media_data, file=logo_fd) + + yield media + + try: + await async_extension_media_service.delete(media.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete media {media.id}: {error.title}") # noqa: WPS421 diff --git a/tests/e2e/integration/extension_media/test_async_extension_media.py b/tests/e2e/integration/extension_media/test_async_extension_media.py new file mode 100644 index 00000000..e77c94f3 --- /dev/null +++ b/tests/e2e/integration/extension_media/test_async_extension_media.py @@ -0,0 +1,53 @@ +import pytest + +from tests.e2e.helper import assert_async_service_filter_with_iterate + +pytestmark = [ + pytest.mark.flaky, +] + + +def test_create_extension_media_async(async_created_media, media_data): + result = async_created_media.name + + assert result == media_data["name"] + + +async def test_filter_extension_media_async(async_extension_media_service, async_created_media): + await assert_async_service_filter_with_iterate( + async_extension_media_service, async_created_media.id, None + ) # act + + +async def test_update_extension_media_async( + async_extension_media_service, async_created_media, short_uuid +): + update_data = {"name": f"e2e updated {short_uuid}"} + + result = await async_extension_media_service.update(async_created_media.id, update_data) + + assert result.name == update_data["name"] + + +async def test_publish_extension_media_async(async_extension_media_service, async_created_media): + result = await async_extension_media_service.publish(async_created_media.id) + + assert result.status == "Published" + + +async def test_download_extension_media_async(async_extension_media_service, async_created_media): + result = await async_extension_media_service.download(async_created_media.id) + + assert result.file_contents is not None + + +async def test_unpublish_extension_media_async(async_extension_media_service, async_created_media): + await async_extension_media_service.publish(async_created_media.id) + + result = await async_extension_media_service.unpublish(async_created_media.id) + + assert result.status == "Unpublished" + + +async def test_delete_extension_media_async(async_extension_media_service, async_created_media): + await async_extension_media_service.delete(async_created_media.id) # act diff --git a/tests/e2e/integration/extension_media/test_sync_extension_media.py b/tests/e2e/integration/extension_media/test_sync_extension_media.py new file mode 100644 index 00000000..3ce48402 --- /dev/null +++ b/tests/e2e/integration/extension_media/test_sync_extension_media.py @@ -0,0 +1,49 @@ +import pytest + +from tests.e2e.helper import assert_service_filter_with_iterate + +pytestmark = [ + pytest.mark.flaky, +] + + +def test_create_extension_media(created_media, media_data): + result = created_media.name + + assert result == media_data["name"] + + +def test_filter_extension_media(extension_media_service, created_media): + assert_service_filter_with_iterate(extension_media_service, created_media.id, None) # act + + +def test_update_extension_media(extension_media_service, created_media, short_uuid): + update_data = {"name": f"e2e updated {short_uuid}"} + + result = extension_media_service.update(created_media.id, update_data) + + assert result.name == update_data["name"] + + +def test_publish_extension_media(extension_media_service, created_media): + result = extension_media_service.publish(created_media.id) + + assert result.status == "Published" + + +def test_download_extension_media(extension_media_service, created_media): + result = extension_media_service.download(created_media.id) + + assert result.file_contents is not None + + +def test_unpublish_extension_media(extension_media_service, created_media): + extension_media_service.publish(created_media.id) + + result = extension_media_service.unpublish(created_media.id) + + assert result.status == "Unpublished" + + +def test_delete_extension_media(extension_media_service, created_media): + extension_media_service.delete(created_media.id) # act diff --git a/tests/unit/resources/integration/mixins/test_media_mixin.py b/tests/unit/resources/integration/mixins/test_media_mixin.py new file mode 100644 index 00000000..c7d3c35f --- /dev/null +++ b/tests/unit/resources/integration/mixins/test_media_mixin.py @@ -0,0 +1,87 @@ +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 ( + AsyncMediaMixin, + MediaMixin, +) +from tests.unit.conftest import DummyModel + + +class DummyMediaService( + MediaMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/integration/extensions/EXT-001/media" + _model_class = DummyModel + _collection_key = "data" + + +class DummyAsyncMediaService( + AsyncMediaMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/integration/extensions/EXT-001/media" + _model_class = DummyModel + _collection_key = "data" + + +@pytest.fixture +def media_service(http_client): + return DummyMediaService(http_client=http_client) + + +@pytest.fixture +def async_media_service(async_http_client): + return DummyAsyncMediaService(http_client=async_http_client) + + +def test_upload_image(media_service, tmp_path) -> None: # noqa: WPS210 + media_id = "MED-001" + expected_response = {"id": media_id, "filename": "photo.jpg"} + image_path = tmp_path / "photo.jpg" + image_path.write_bytes(b"fake jpeg data") + with image_path.open("rb") as image_file, respx.mock: + mock_route = respx.put( + f"https://api.example.com/public/v1/integration/extensions/EXT-001/media/{media_id}/image" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = media_service.upload_image(media_id, image_file) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "PUT" + assert result.to_dict() == expected_response + assert isinstance(result, DummyModel) + + +async def test_async_upload_image(async_media_service, tmp_path) -> None: # noqa: WPS210 + media_id = "MED-002" + expected_response = {"id": media_id, "filename": "photo.jpg"} + image_path = tmp_path / "photo.jpg" + image_path.write_bytes(b"fake jpeg data") + with image_path.open("rb") as image_file, respx.mock: + mock_route = respx.put( + f"https://api.example.com/public/v1/integration/extensions/EXT-001/media/{media_id}/image" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=expected_response, + ) + ) + + result = await async_media_service.upload_image(media_id, image_file) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "PUT" + assert result.to_dict() == expected_response + assert isinstance(result, DummyModel) diff --git a/tests/unit/resources/integration/test_extension_media.py b/tests/unit/resources/integration/test_extension_media.py new file mode 100644 index 00000000..55a39362 --- /dev/null +++ b/tests/unit/resources/integration/test_extension_media.py @@ -0,0 +1,187 @@ +import httpx +import pytest +import respx + +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.integration.extension_media import ( + AsyncExtensionMediaService, + ExtensionMedia, + ExtensionMediaService, +) +from mpt_api_client.resources.integration.extensions import ( + AsyncExtensionsService, + ExtensionsService, +) + + +@pytest.fixture +def media_service(http_client) -> ExtensionMediaService: + return ExtensionMediaService( + http_client=http_client, endpoint_params={"extension_id": "EXT-001"} + ) + + +@pytest.fixture +def async_media_service(async_http_client) -> AsyncExtensionMediaService: + return AsyncExtensionMediaService( + http_client=async_http_client, endpoint_params={"extension_id": "EXT-001"} + ) + + +FILE_SIZE = 512000 + + +@pytest.fixture +def media_data(): + return { + "id": "MED-001", + "name": "Extension Screenshot", + "revision": 1, + "type": "Image", + "description": "Main extension screenshot", + "status": "Draft", + "filename": "screenshot.png", + "size": FILE_SIZE, + "contentType": "image/png", + "displayOrder": 1, + "url": "https://example.com/screenshot.png", + "extension": {"id": "EXT-001", "name": "My Extension"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +@pytest.mark.parametrize( + "method", + [ + "get", + "create", + "update", + "delete", + "download", + "publish", + "unpublish", + "upload_image", + "iterate", + ], +) +def test_mixins_present(media_service, method: str) -> None: + result = hasattr(media_service, method) + + assert result is True + + +@pytest.mark.parametrize( + "method", + [ + "get", + "create", + "update", + "delete", + "download", + "publish", + "unpublish", + "upload_image", + "iterate", + ], +) +def test_async_mixins_present(async_media_service, method: str) -> None: + result = hasattr(async_media_service, method) + + assert result is True + + +def test_extension_media_primitive_fields(media_data) -> None: + result = ExtensionMedia(media_data) + + assert result.id == "MED-001" + assert result.name == "Extension Screenshot" + assert result.revision == 1 + assert result.type == "Image" + assert result.status == "Draft" + assert result.filename == "screenshot.png" + assert result.size == FILE_SIZE + assert result.display_order == 1 + + +def test_extension_media_nested_fields(media_data) -> None: # noqa: WPS118 + result = ExtensionMedia(media_data) + + assert isinstance(result.extension, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_extension_media_create(media_service, tmp_path) -> None: + media_payload = {"Name": "Screenshot", "MediaType": "Image", "DisplayOrder": 1} + expected_response = {"id": "MED-001", "name": "Screenshot"} + image_path = tmp_path / "screenshot.png" + image_path.write_bytes(b"fake image data") + with image_path.open("rb") as image_file, respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/integration/extensions/EXT-001/media" + ).mock(return_value=httpx.Response(httpx.codes.CREATED, json=expected_response)) + + result = media_service.create(media_payload, file=image_file) + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "POST" + assert result.to_dict() == expected_response + + +def test_extension_media_download(media_service) -> None: + image_bytes = b"\x89PNG\r\n\x1a\n" + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/integration/extensions/EXT-001/media/MED-001" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "image/png"}, + content=image_bytes, + ) + ) + + result = media_service.download("MED-001", accept="image/png") + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "GET" + assert result.file_contents == image_bytes + + +async def test_async_extension_media_download(async_media_service) -> None: + image_bytes = b"\x89PNG\r\n\x1a\n" + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/integration/extensions/EXT-001/media/MED-001" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "image/png"}, + content=image_bytes, + ) + ) + + result = await async_media_service.download("MED-001", accept="image/png") + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "GET" + assert result.file_contents == image_bytes + + +def test_extensions_media_accessor(http_client) -> None: + extensions_service = ExtensionsService(http_client=http_client) + + result = extensions_service.media("EXT-001") + + assert isinstance(result, ExtensionMediaService) + assert result.http_client is http_client + assert result.path == "/public/v1/integration/extensions/EXT-001/media" + + +def test_async_extensions_media_accessor(async_http_client) -> None: + extensions_service = AsyncExtensionsService(http_client=async_http_client) + + result = extensions_service.media("EXT-001") + + assert isinstance(result, AsyncExtensionMediaService) + assert result.http_client is async_http_client + assert result.path == "/public/v1/integration/extensions/EXT-001/media"