diff --git a/e2e_config.test.json b/e2e_config.test.json index 3cd51b91..d42cea2b 100644 --- a/e2e_config.test.json +++ b/e2e_config.test.json @@ -70,6 +70,7 @@ "notifications.subscriber.id": "NTS-0829-7123-7123", "integration.extension.id": "EXT-6587-4477", "integration.term.id": "ETC-6587-4477-0062", + "program.media.id": "PMD-9643-3741-0001", "program.program.id": "PRG-9643-3741", "program.document.file.id": "PDM-9643-3741-0001" } diff --git a/mpt_api_client/resources/program/mixins/__init__.py b/mpt_api_client/resources/program/mixins/__init__.py index d68cdbc9..7ab0b0e6 100644 --- a/mpt_api_client/resources/program/mixins/__init__.py +++ b/mpt_api_client/resources/program/mixins/__init__.py @@ -2,6 +2,10 @@ AsyncDocumentMixin, DocumentMixin, ) +from mpt_api_client.resources.program.mixins.media_mixin import ( + AsyncMediaMixin, + MediaMixin, +) from mpt_api_client.resources.program.mixins.publishable_mixin import ( AsyncPublishableMixin, PublishableMixin, @@ -9,7 +13,9 @@ __all__ = [ # noqa: WPS410 "AsyncDocumentMixin", + "AsyncMediaMixin", "AsyncPublishableMixin", "DocumentMixin", + "MediaMixin", "PublishableMixin", ] diff --git a/mpt_api_client/resources/program/mixins/media_mixin.py b/mpt_api_client/resources/program/mixins/media_mixin.py new file mode 100644 index 00000000..0392b551 --- /dev/null +++ b/mpt_api_client/resources/program/mixins/media_mixin.py @@ -0,0 +1,26 @@ +from mpt_api_client.http.mixins import ( + AsyncCreateFileMixin, + AsyncDownloadFileMixin, + CreateFileMixin, + DownloadFileMixin, +) +from mpt_api_client.resources.program.mixins.publishable_mixin import ( + AsyncPublishableMixin, + PublishableMixin, +) + + +class MediaMixin[Model]( + CreateFileMixin[Model], + DownloadFileMixin[Model], + PublishableMixin[Model], +): + """Media mixin.""" + + +class AsyncMediaMixin[Model]( + AsyncCreateFileMixin[Model], + AsyncDownloadFileMixin[Model], + AsyncPublishableMixin[Model], +): + """Media mixin.""" diff --git a/mpt_api_client/resources/program/programs.py b/mpt_api_client/resources/program/programs.py index 72bd7132..8b8eb754 100644 --- a/mpt_api_client/resources/program/programs.py +++ b/mpt_api_client/resources/program/programs.py @@ -14,6 +14,10 @@ AsyncDocumentService, DocumentService, ) +from mpt_api_client.resources.program.programs_media import ( + AsyncMediaService, + MediaService, +) class Program(Model): @@ -81,6 +85,12 @@ def documents(self, program_id: str) -> DocumentService: http_client=self.http_client, endpoint_params={"program_id": program_id} ) + def media(self, program_id: str) -> MediaService: + """Return program media service.""" + return MediaService( + http_client=self.http_client, endpoint_params={"program_id": program_id} + ) + class AsyncProgramsService( AsyncGetMixin[Program], @@ -108,3 +118,9 @@ def documents(self, program_id: str) -> AsyncDocumentService: return AsyncDocumentService( http_client=self.http_client, endpoint_params={"program_id": program_id} ) + + def media(self, program_id: str) -> AsyncMediaService: + """Return async program media service.""" + return AsyncMediaService( + http_client=self.http_client, endpoint_params={"program_id": program_id} + ) diff --git a/mpt_api_client/resources/program/programs_media.py b/mpt_api_client/resources/program/programs_media.py new file mode 100644 index 00000000..2d4612d5 --- /dev/null +++ b/mpt_api_client/resources/program/programs_media.py @@ -0,0 +1,73 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncModifiableResourceMixin, + CollectionMixin, + ModifiableResourceMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.program.mixins import ( + AsyncMediaMixin, + MediaMixin, +) + + +class Media(Model): + """Media resource. + + Attributes: + name: Media name. + type: Media type. + description: Media description. + status: Media status. + filename: Original file name. + size: File size in bytes. + content_type: MIME content type of the media file. + display_order: Display order of the media item. + url: URL to access the media file. + program: Reference to the program. + audit: Audit information (created, updated events). + """ + + name: str | 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 + program: BaseModel | None + audit: BaseModel | None + + +class MediaServiceConfig: + """Media service configuration.""" + + _endpoint = "/public/v1/program/programs/{program_id}/media" + _model_class = Media + _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "media" + + +class MediaService( + MediaMixin[Media], + ModifiableResourceMixin[Media], + CollectionMixin[Media], + Service[Media], + MediaServiceConfig, +): + """Media service.""" + + +class AsyncMediaService( + AsyncMediaMixin[Media], + AsyncModifiableResourceMixin[Media], + AsyncCollectionMixin[Media], + AsyncService[Media], + MediaServiceConfig, +): + """Async media service.""" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 75d896cb..1ce0e52a 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -97,6 +97,11 @@ def jpg_url(): return "https://sample-files.com/downloads/images/jpg/color_test_800x600_118kb.jpg" +@pytest.fixture(scope="session") +def video_url(): + return "https://www.youtube.com/watch?v=DUMMY1_0000" + + @pytest.fixture(scope="session") def e2e_config(project_root_path): filename = os.getenv("TEST_CONFIG_FILE", "e2e_config.test.json") diff --git a/tests/e2e/program/program/media/conftest.py b/tests/e2e/program/program/media/conftest.py new file mode 100644 index 00000000..c09cf795 --- /dev/null +++ b/tests/e2e/program/program/media/conftest.py @@ -0,0 +1,27 @@ +import pytest + + +@pytest.fixture +def invalid_media_id(): + return "PMD-0000-0000-0000" + + +@pytest.fixture +def media_id(e2e_config): + return e2e_config["program.media.id"] + + +@pytest.fixture +def media_data_factory(): + def factory(media_type: str = "Image"): + return { + "name": "E2E Created Program Media", + "description": "E2E Created Program Media", + "displayOrder": 1, + "type": media_type, + "mediatype": media_type, + "url": "", + "language": "en-us", + } + + return factory diff --git a/tests/e2e/program/program/media/test_async_media.py b/tests/e2e/program/program/media/test_async_media.py new file mode 100644 index 00000000..c1c9d238 --- /dev/null +++ b/tests/e2e/program/program/media/test_async_media.py @@ -0,0 +1,105 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from mpt_api_client.rql.query_builder import RQLQuery + +pytestmark = [pytest.mark.flaky] + + +@pytest.fixture +def async_vendor_media_service(async_mpt_vendor, program_id): + return async_mpt_vendor.program.programs.media(program_id) + + +@pytest.fixture +async def created_media_from_file(async_vendor_media_service, media_data_factory, logo_fd): + media_data = media_data_factory() + media = await async_vendor_media_service.create(media_data, file=logo_fd) + yield media, media_data + try: + await async_vendor_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 created_media_from_url( + async_vendor_media_service, media_data_factory, video_url, logo_fd +): + media_data = media_data_factory(media_type="Video") + + media_data["url"] = video_url + media = await async_vendor_media_service.create(media_data, file=logo_fd) + yield media, media_data + try: + await async_vendor_media_service.delete(media.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete media {media.id}: {error.title}") # noqa: WPS421 + + +def test_create_media(created_media_from_file): # noqa: AAA01 + result, media_data = created_media_from_file + + assert result.name == media_data["name"] + assert result.description == media_data["description"] + + +def test_create_media_from_url(created_media_from_url): # noqa: AAA01 + result, media_data = created_media_from_url + + assert result.name == media_data["name"] + assert result.description == media_data["description"] + + +async def test_update_media(async_vendor_media_service, created_media_from_file): + media, _ = created_media_from_file + update_data = {"name": "E2E Updated Program Media"} + + result = await async_vendor_media_service.update(media.id, update_data) + + assert result.name == update_data["name"] + + +async def test_delete_media(async_vendor_media_service, created_media_from_file): + media, _ = created_media_from_file + + result = await async_vendor_media_service.delete(media.id) + + assert result is None + + +async def test_get_media(async_vendor_media_service, media_id): + result = await async_vendor_media_service.get(media_id) + + assert result.id == media_id + + +async def test_get_media_invalid_id(async_vendor_media_service, invalid_media_id): + with pytest.raises(MPTAPIError): + await async_vendor_media_service.get(invalid_media_id) + + +async def test_filter_and_select_media(async_vendor_media_service, media_id): + select_fields = ["-revision", "-audit"] + filtered_media = async_vendor_media_service.filter(RQLQuery(id=media_id)).select(*select_fields) + + result = [media async for media in filtered_media.iterate()] + + assert len(result) == 1 + + +async def test_media_publish(async_vendor_media_service, created_media_from_file): + media, _ = created_media_from_file + + result = await async_vendor_media_service.publish(media.id) + + assert result.status == "Published" + + +async def test_media_unpublish(async_vendor_media_service, created_media_from_file): + media, _ = created_media_from_file + await async_vendor_media_service.publish(media.id) + + result = await async_vendor_media_service.unpublish(media.id) + + assert result.status == "Unpublished" diff --git a/tests/e2e/program/program/media/test_sync_media.py b/tests/e2e/program/program/media/test_sync_media.py new file mode 100644 index 00000000..5a7da6f4 --- /dev/null +++ b/tests/e2e/program/program/media/test_sync_media.py @@ -0,0 +1,103 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from mpt_api_client.rql.query_builder import RQLQuery + +pytestmark = [pytest.mark.flaky] + + +@pytest.fixture +def vendor_media_service(mpt_vendor, program_id): + return mpt_vendor.program.programs.media(program_id) + + +@pytest.fixture +def created_media_from_file(vendor_media_service, media_data_factory, logo_fd): + media_data = media_data_factory() + media = vendor_media_service.create(media_data, file=logo_fd) + yield media, media_data + try: + vendor_media_service.delete(media.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete media {media.id}: {error.title}") # noqa: WPS421 + + +@pytest.fixture +def created_media_from_url(vendor_media_service, media_data_factory, video_url, logo_fd): + media_data = media_data_factory(media_type="Video") + + media_data["url"] = video_url + media = vendor_media_service.create(media_data, file=logo_fd) + yield media, media_data + try: + vendor_media_service.delete(media.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete media {media.id}: {error.title}") # noqa: WPS421 + + +def test_create_media(created_media_from_file): # noqa: AAA01 + result, media_data = created_media_from_file + + assert result.name == media_data["name"] + assert result.description == media_data["description"] + + +def test_create_media_from_url(created_media_from_url): # noqa: AAA01 + result, media_data = created_media_from_url + + assert result.name == media_data["name"] + assert result.description == media_data["description"] + + +def test_update_media(vendor_media_service, created_media_from_file): + media, _ = created_media_from_file + update_data = {"name": "E2E Updated Program Media"} + + result = vendor_media_service.update(media.id, update_data) + + assert result.name == update_data["name"] + + +def test_delete_media(vendor_media_service, created_media_from_file): + media, _ = created_media_from_file + + result = vendor_media_service.delete(media.id) + + assert result is None + + +def test_get_media(vendor_media_service, media_id): + result = vendor_media_service.get(media_id) + + assert result.id == media_id + + +def test_get_media_invalid_id(vendor_media_service, invalid_media_id): + with pytest.raises(MPTAPIError): + vendor_media_service.get(invalid_media_id) + + +def test_filter_and_select_media(vendor_media_service, media_id): + select_fields = ["-revision", "-audit"] + filtered_media = vendor_media_service.filter(RQLQuery(id=media_id)).select(*select_fields) + + result = list(filtered_media.iterate()) + + assert len(result) == 1 + + +def test_media_publish(vendor_media_service, created_media_from_file): + media, _ = created_media_from_file + + result = vendor_media_service.publish(media.id) + + assert result.status == "Published" + + +def test_media_unpublish(vendor_media_service, created_media_from_file): + media, _ = created_media_from_file + vendor_media_service.publish(media.id) + + result = vendor_media_service.unpublish(media.id) + + assert result.status == "Unpublished" diff --git a/tests/unit/resources/program/mixin/test_media_mixin.py b/tests/unit/resources/program/mixin/test_media_mixin.py new file mode 100644 index 00000000..ede1c6fd --- /dev/null +++ b/tests/unit/resources/program/mixin/test_media_mixin.py @@ -0,0 +1,93 @@ +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.program.mixins.media_mixin import ( + AsyncMediaMixin, + MediaMixin, +) +from tests.unit.conftest import DummyModel + + +class DummyMediaService( + MediaMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/media" + _model_class = DummyModel + _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "media" + + +class DummyAsyncMediaService( + AsyncMediaMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/media" + _model_class = DummyModel + _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "media" + + +@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) + + +@pytest.mark.parametrize("method", ["create", "download", "publish", "unpublish"]) +def test_mixins_present(media_service, method): + result = hasattr(media_service, method) + + assert result is True + + +@pytest.mark.parametrize("method", ["create", "download", "publish", "unpublish"]) +def test_async_mixins_present(async_media_service, method): + result = hasattr(async_media_service, method) + + assert result is True + + +def test_media_download(media_service): + image_bytes = b"\x89PNG\r\n\x1a\n" + with respx.mock: + mock_route = respx.get("https://api.example.com/public/v1/dummy/media/MED-123").mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "image/png"}, + content=image_bytes, + ) + ) + + result = media_service.download("MED-123", 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_media_download(async_media_service): + image_bytes = b"\x89PNG\r\n\x1a\n" + with respx.mock: + mock_route = respx.get("https://api.example.com/public/v1/dummy/media/MED-123").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-123", accept="image/png") + + assert mock_route.call_count == 1 + assert mock_route.calls[0].request.method == "GET" + assert result.file_contents == image_bytes diff --git a/tests/unit/resources/program/test_programs.py b/tests/unit/resources/program/test_programs.py index 1025de4e..51d8d13b 100644 --- a/tests/unit/resources/program/test_programs.py +++ b/tests/unit/resources/program/test_programs.py @@ -8,6 +8,10 @@ AsyncDocumentService, DocumentService, ) +from mpt_api_client.resources.program.programs_media import ( + AsyncMediaService, + MediaService, +) @pytest.fixture @@ -84,6 +88,7 @@ def test_async_mixins_present(async_programs_service, method): ("service_method", "expected_service_class"), [ ("documents", DocumentService), + ("media", MediaService), ], ) def test_property_services(programs_service, service_method, expected_service_class): @@ -97,6 +102,7 @@ def test_property_services(programs_service, service_method, expected_service_cl ("service_method", "expected_service_class"), [ ("documents", AsyncDocumentService), + ("media", AsyncMediaService), ], ) def test_async_property_services(async_programs_service, service_method, expected_service_class): diff --git a/tests/unit/resources/program/test_programs_media.py b/tests/unit/resources/program/test_programs_media.py new file mode 100644 index 00000000..3dbe0d45 --- /dev/null +++ b/tests/unit/resources/program/test_programs_media.py @@ -0,0 +1,97 @@ +import pytest + +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.program.programs_media import ( + AsyncMediaService, + Media, + MediaService, +) + + +@pytest.fixture +def media_service(http_client) -> MediaService: + return MediaService(http_client=http_client, endpoint_params={"program_id": "PRG-001"}) + + +@pytest.fixture +def async_media_service(async_http_client) -> AsyncMediaService: + return AsyncMediaService( + http_client=async_http_client, endpoint_params={"program_id": "PRG-001"} + ) + + +@pytest.fixture +def media_data(): + return { + "id": "PMD-001", + "name": "Program Dummy Video", + "type": "Video", + "description": "Dummy video for the program", + "status": "Active", + "filename": "intro.mp4", + "size": 10485760, + "contentType": "video/mp4", + "displayOrder": 1, + "url": "https://example.com/intro.mp4", + "program": {"id": "PRG-001", "name": "Dummy Program"}, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +def test_endpoint(media_service): + result = media_service.path == "/public/v1/program/programs/PRG-001/media" + + assert result is True + + +def test_async_endpoint(async_media_service): + result = async_media_service.path == "/public/v1/program/programs/PRG-001/media" + + assert result is True + + +@pytest.mark.parametrize( + "method", + ["get", "create", "delete", "update", "download", "publish", "unpublish", "iterate"], +) +def test_methods_present(media_service, method): + result = hasattr(media_service, method) + + assert result is True + + +@pytest.mark.parametrize( + "method", + ["get", "create", "delete", "update", "download", "publish", "unpublish", "iterate"], +) +def test_async_methods_present(async_media_service, method): + result = hasattr(async_media_service, method) + + assert result is True + + +def test_media_primitive_fields(media_data): + result = Media(media_data) + + assert result.to_dict() == media_data + + +def test_media_nested_fields_are_base_models(media_data): + result = Media(media_data) + + assert isinstance(result.program, BaseModel) + assert isinstance(result.audit, BaseModel) + + +def test_media_optional_fields_absent(): + result = Media({"id": "PMD-001"}) + + assert result.id == "PMD-001" + assert not hasattr(result, "name") + assert not hasattr(result, "type") + assert not hasattr(result, "description") + assert not hasattr(result, "status") + assert not hasattr(result, "filename") + assert not hasattr(result, "size") + assert not hasattr(result, "content_type") + assert not hasattr(result, "audit")