From f9bdf14cc076410c097583bac98f5a47d9ce56d7 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Mon, 2 Feb 2026 16:31:41 +0000 Subject: [PATCH] MPT-16437 Reorganise resource catalog mixins structure --- .../resources/catalog/mixins/__init__.py | 27 + .../catalog/mixins/activatable_mixin.py | 55 ++ .../catalog/mixins/document_mixin.py | 26 + .../resources/catalog/mixins/media_mixin.py | 26 + .../publishable_mixin.py} | 93 ---- pyproject.toml | 5 - .../unit/resources/catalog/mixins/__init__.py | 0 .../catalog/mixins/test_activatable_mixin.py | 159 ++++++ .../catalog/mixins/test_document_mixin.py | 159 ++++++ .../catalog/mixins/test_publishable_mixin.py | 163 ++++++ tests/unit/resources/catalog/test_mixins.py | 469 ------------------ 11 files changed, 615 insertions(+), 567 deletions(-) create mode 100644 mpt_api_client/resources/catalog/mixins/__init__.py create mode 100644 mpt_api_client/resources/catalog/mixins/activatable_mixin.py create mode 100644 mpt_api_client/resources/catalog/mixins/document_mixin.py create mode 100644 mpt_api_client/resources/catalog/mixins/media_mixin.py rename mpt_api_client/resources/catalog/{mixins.py => mixins/publishable_mixin.py} (50%) create mode 100644 tests/unit/resources/catalog/mixins/__init__.py create mode 100644 tests/unit/resources/catalog/mixins/test_activatable_mixin.py create mode 100644 tests/unit/resources/catalog/mixins/test_document_mixin.py create mode 100644 tests/unit/resources/catalog/mixins/test_publishable_mixin.py delete mode 100644 tests/unit/resources/catalog/test_mixins.py diff --git a/mpt_api_client/resources/catalog/mixins/__init__.py b/mpt_api_client/resources/catalog/mixins/__init__.py new file mode 100644 index 00000000..e24094c7 --- /dev/null +++ b/mpt_api_client/resources/catalog/mixins/__init__.py @@ -0,0 +1,27 @@ +from mpt_api_client.resources.catalog.mixins.activatable_mixin import ( + ActivatableMixin, + AsyncActivatableMixin, +) +from mpt_api_client.resources.catalog.mixins.document_mixin import ( + AsyncDocumentMixin, + DocumentMixin, +) +from mpt_api_client.resources.catalog.mixins.media_mixin import ( + AsyncMediaMixin, + MediaMixin, +) +from mpt_api_client.resources.catalog.mixins.publishable_mixin import ( + AsyncPublishableMixin, + PublishableMixin, +) + +__all__ = [ # noqa: WPS410 + "ActivatableMixin", + "AsyncActivatableMixin", + "AsyncDocumentMixin", + "AsyncMediaMixin", + "AsyncPublishableMixin", + "DocumentMixin", + "MediaMixin", + "PublishableMixin", +] diff --git a/mpt_api_client/resources/catalog/mixins/activatable_mixin.py b/mpt_api_client/resources/catalog/mixins/activatable_mixin.py new file mode 100644 index 00000000..a8ad9ff3 --- /dev/null +++ b/mpt_api_client/resources/catalog/mixins/activatable_mixin.py @@ -0,0 +1,55 @@ +from mpt_api_client.models import ResourceData + + +class ActivatableMixin[Model]: + """Activatable mixin adds the ability to activate and deactivate.""" + + def activate(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Update state to Active. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "activate", json=resource_data + ) + + def deactivate(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Update state to Inactive. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "deactivate", json=resource_data + ) + + +class AsyncActivatableMixin[Model]: + """Activatable mixin adds the ability to activate and deactivate.""" + + async def activate(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Update state to Active. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "activate", json=resource_data + ) + + async def deactivate( + self, resource_id: str, resource_data: ResourceData | None = None + ) -> Model: + """Update state to Inactive. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "deactivate", json=resource_data + ) diff --git a/mpt_api_client/resources/catalog/mixins/document_mixin.py b/mpt_api_client/resources/catalog/mixins/document_mixin.py new file mode 100644 index 00000000..3021b0a7 --- /dev/null +++ b/mpt_api_client/resources/catalog/mixins/document_mixin.py @@ -0,0 +1,26 @@ +from mpt_api_client.http.mixins import ( + AsyncCreateFileMixin, + AsyncDownloadFileMixin, + CreateFileMixin, + DownloadFileMixin, +) +from mpt_api_client.resources.catalog.mixins.publishable_mixin import ( + AsyncPublishableMixin, + PublishableMixin, +) + + +class AsyncDocumentMixin[Model]( + AsyncCreateFileMixin[Model], + AsyncDownloadFileMixin[Model], + AsyncPublishableMixin[Model], +): + """Async document mixin.""" + + +class DocumentMixin[Model]( + CreateFileMixin[Model], + DownloadFileMixin[Model], + PublishableMixin[Model], +): + """Document mixin.""" diff --git a/mpt_api_client/resources/catalog/mixins/media_mixin.py b/mpt_api_client/resources/catalog/mixins/media_mixin.py new file mode 100644 index 00000000..aab9f66d --- /dev/null +++ b/mpt_api_client/resources/catalog/mixins/media_mixin.py @@ -0,0 +1,26 @@ +from mpt_api_client.http.mixins import ( + AsyncCreateFileMixin, + AsyncDownloadFileMixin, + CreateFileMixin, + DownloadFileMixin, +) +from mpt_api_client.resources.catalog.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/catalog/mixins.py b/mpt_api_client/resources/catalog/mixins/publishable_mixin.py similarity index 50% rename from mpt_api_client/resources/catalog/mixins.py rename to mpt_api_client/resources/catalog/mixins/publishable_mixin.py index e7b2970e..05ce4aed 100644 --- a/mpt_api_client/resources/catalog/mixins.py +++ b/mpt_api_client/resources/catalog/mixins/publishable_mixin.py @@ -1,13 +1,6 @@ -from mpt_api_client.http.mixins import ( - AsyncCreateFileMixin, - AsyncDownloadFileMixin, - CreateFileMixin, - DownloadFileMixin, -) from mpt_api_client.models import ResourceData -# TODO: Consider moving publishable and activatable mixins to http/mixins class PublishableMixin[Model]: """Publishable mixin adds the ability to review, publish and unpublish.""" @@ -80,89 +73,3 @@ async def unpublish(self, resource_id: str, resource_data: ResourceData | None = return await self._resource_action( # type: ignore[attr-defined, no-any-return] resource_id, "POST", "unpublish", json=resource_data ) - - -class AsyncDocumentMixin[Model]( - AsyncCreateFileMixin[Model], - AsyncDownloadFileMixin[Model], - AsyncPublishableMixin[Model], -): - """Async document mixin.""" - - -class DocumentMixin[Model]( - CreateFileMixin[Model], - DownloadFileMixin[Model], - PublishableMixin[Model], -): - """Document mixin.""" - - -class MediaMixin[Model]( - CreateFileMixin[Model], - DownloadFileMixin[Model], - PublishableMixin[Model], -): - """Media mixin.""" - - -class ActivatableMixin[Model]: - """Activatable mixin adds the ability to activate and deactivate.""" - - def activate(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Update state to Active. - - Args: - resource_id: Resource ID - resource_data: Resource data will be updated - """ - return self._resource_action( # type: ignore[attr-defined, no-any-return] - resource_id, "POST", "activate", json=resource_data - ) - - def deactivate(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Update state to Inactive. - - Args: - resource_id: Resource ID - resource_data: Resource data will be updated - """ - return self._resource_action( # type: ignore[attr-defined, no-any-return] - resource_id, "POST", "deactivate", json=resource_data - ) - - -class AsyncActivatableMixin[Model]: - """Activatable mixin adds the ability to activate and deactivate.""" - - async def activate(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Update state to Active. - - Args: - resource_id: Resource ID - resource_data: Resource data will be updated - """ - return await self._resource_action( # type: ignore[attr-defined, no-any-return] - resource_id, "POST", "activate", json=resource_data - ) - - async def deactivate( - self, resource_id: str, resource_data: ResourceData | None = None - ) -> Model: - """Update state to Inactive. - - Args: - resource_id: Resource ID - resource_data: Resource data will be updated - """ - return await self._resource_action( # type: ignore[attr-defined, no-any-return] - resource_id, "POST", "deactivate", json=resource_data - ) - - -class AsyncMediaMixin[Model]( - AsyncCreateFileMixin[Model], - AsyncDownloadFileMixin[Model], - AsyncPublishableMixin[Model], -): - """Media mixin.""" diff --git a/pyproject.toml b/pyproject.toml index da3604ef..2ddd0cda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,6 @@ per-file-ignores = [ "mpt_api_client/resources/accounts/*.py: WPS202 WPS215 WPS214 WPS235 WPS453", "mpt_api_client/resources/billing/*.py: WPS202 WPS204 WPS214 WPS215 WPS235 WPS110", "mpt_api_client/resources/catalog/*.py: WPS110 WPS214 WPS215 WPS235", - "mpt_api_client/resources/catalog/mixins.py: WPS110 WPS202 WPS214 WPS215 WPS235", "mpt_api_client/resources/catalog/products.py: WPS204 WPS214 WPS215 WPS235", "mpt_api_client/resources/commerce/*.py: WPS235 WPS215", "mpt_api_client/rql/query_builder.py: WPS110 WPS115 WPS210 WPS214", @@ -143,11 +142,7 @@ per-file-ignores = [ "tests/unit/resources/commerce/*.py: WPS202 WPS204", "tests/unit/resources/*/test_mixins.py: WPS118 WPS202 WPS204 WPS235", "tests/unit/test_mpt_client.py: WPS235", - "tests/seed/catalog/test_product.py: WPS202 WPS204 WPS219", "tests/*: WPS432 WPS202", - "seed/*: WPS404", - "seed/accounts/*.py: WPS204 WPS404 WPS453", - "seed/catalog/product.py: WPS202 WPS204 WPS217 WPS201 WPS213 WPS404", ] [tool.ruff] diff --git a/tests/unit/resources/catalog/mixins/__init__.py b/tests/unit/resources/catalog/mixins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/resources/catalog/mixins/test_activatable_mixin.py b/tests/unit/resources/catalog/mixins/test_activatable_mixin.py new file mode 100644 index 00000000..84e99ab6 --- /dev/null +++ b/tests/unit/resources/catalog/mixins/test_activatable_mixin.py @@ -0,0 +1,159 @@ +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.catalog.mixins import ( + ActivatableMixin, + AsyncActivatableMixin, +) +from tests.unit.conftest import DummyModel + + +class DummyActivatableService( + ActivatableMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/activatable/" + _model_class = DummyModel + _collection_key = "data" + + +class DummyAsyncActivatableService( + AsyncActivatableMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/activatable/" + _model_class = DummyModel + _collection_key = "data" + + +@pytest.fixture +def activatable_service(http_client): + return DummyActivatableService(http_client=http_client) + + +@pytest.fixture +def async_activatable_service(async_http_client): + return DummyAsyncActivatableService(http_client=async_http_client) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("activate", {"id": "OBJ-0000-0001", "status": "update"}), + ("deactivate", {"id": "OBJ-0000-0001", "status": "update"}), + ], +) +def test_actions(activatable_service, action, input_status): + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/activatable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = getattr(activatable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("activate", None), + ("deactivate", None), + ], +) +def test_actions_no_data(activatable_service, action, input_status): + request_expected_content = b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/activatable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = getattr(activatable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("activate", {"id": "OBJ-0000-0001", "status": "update"}), + ("deactivate", {"id": "OBJ-0000-0001", "status": "update"}), + ], +) +async def test_async_actions(async_activatable_service, action, input_status): + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/activatable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = await getattr(async_activatable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("activate", None), + ("deactivate", None), + ], +) +async def test_async_actions_no_data(async_activatable_service, action, input_status): + request_expected_content = b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/activatable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = await getattr(async_activatable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) diff --git a/tests/unit/resources/catalog/mixins/test_document_mixin.py b/tests/unit/resources/catalog/mixins/test_document_mixin.py new file mode 100644 index 00000000..4e8224b9 --- /dev/null +++ b/tests/unit/resources/catalog/mixins/test_document_mixin.py @@ -0,0 +1,159 @@ +import io + +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.catalog.mixins import ( + AsyncDocumentMixin, + DocumentMixin, +) +from tests.unit.conftest import DummyModel + + +class DummyDocumentService( + DocumentMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/documents" + _model_class = DummyModel + _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "document" + + +class DummyAsyncDocumentService( + AsyncDocumentMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/documents" + _model_class = DummyModel + _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "document" + + +@pytest.fixture +def document_service(http_client): + return DummyDocumentService(http_client=http_client) + + +@pytest.fixture +def async_document_service(async_http_client): + return DummyAsyncDocumentService(http_client=async_http_client) + + +def test_document_create_with_url(document_service): + resource_data = { + "name": "My Doc", + "description": "My Doc", + "url": "https://example.com/file.pdf", + } + with respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=resource_data, + ) + ) + new_doc = document_service.create(resource_data=resource_data) + + result = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="document"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"My Doc","description":"My Doc","url":"https://example.com/file.pdf"}\r\n' + in result.content + ) + assert b'Content-Disposition: form-data; name="file"' not in result.content + assert "multipart/form-data" in result.headers["Content-Type"] + assert new_doc.to_dict() == resource_data + assert isinstance(new_doc, DummyModel) + + +def test_document_create_with_file(document_service): # noqa: WPS210 + resource_data = {"id": "DOC-125", "name": "Data And File"} + response_data = resource_data + file_tuple = ("manual.pdf", io.BytesIO(b"PDF DATA"), "application/pdf") + with respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( + return_value=httpx.Response(status_code=httpx.codes.OK, json=response_data) + ) + + result = document_service.create(resource_data=resource_data, file=file_tuple) + + request = mock_route.calls[0].request + # JSON part + assert ( + b'Content-Disposition: form-data; name="document"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"id":"DOC-125","name":"Data And File"}\r\n' in request.content + ) + # File part + assert ( + b'Content-Disposition: form-data; name="file"; filename="manual.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF DATA\r\n" in request.content + ) + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == response_data + assert isinstance(result, DummyModel) + + +async def test_async_document_create_with_url(async_document_service): + resource_data = { + "name": "My Doc", + "description": "My Doc", + "url": "https://example.com/file.pdf", + } + with respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=resource_data, + ) + ) + + result = await async_document_service.create(resource_data=resource_data) + + request = mock_route.calls[0].request + assert ( + b'Content-Disposition: form-data; name="document"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"My Doc","description":"My Doc","url":"https://example.com/file.pdf"}\r\n' + in request.content + ) + assert b'Content-Disposition: form-data; name="file"' not in request.content + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == resource_data + assert isinstance(result, DummyModel) + + +async def test_async_document_create_with_file(async_document_service): # noqa: WPS210 + resource_data = {"id": "DOC-125", "name": "Data And File"} + response_data = resource_data + file_tuple = ("manual.pdf", io.BytesIO(b"PDF DATA"), "application/pdf") + with respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( + return_value=httpx.Response(status_code=httpx.codes.OK, json=response_data) + ) + + result = await async_document_service.create(resource_data, file_tuple) + + request = mock_route.calls[0].request + assert ( + b'Content-Disposition: form-data; name="document"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"id":"DOC-125","name":"Data And File"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="file"; filename="manual.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF DATA\r\n" in request.content + ) + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == response_data + assert isinstance(result, DummyModel) diff --git a/tests/unit/resources/catalog/mixins/test_publishable_mixin.py b/tests/unit/resources/catalog/mixins/test_publishable_mixin.py new file mode 100644 index 00000000..18ea1884 --- /dev/null +++ b/tests/unit/resources/catalog/mixins/test_publishable_mixin.py @@ -0,0 +1,163 @@ +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.catalog.mixins import ( + AsyncPublishableMixin, + PublishableMixin, +) +from tests.unit.conftest import DummyModel + + +class DummyPublishableService( + PublishableMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/publishable/" + _model_class = DummyModel + _collection_key = "data" + + +class DummyAsyncPublishableService( + AsyncPublishableMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/publishable/" + _model_class = DummyModel + _collection_key = "data" + + +@pytest.fixture +def publishable_service(http_client): + return DummyPublishableService(http_client=http_client) + + +@pytest.fixture +def async_publishable_service(async_http_client): + return DummyAsyncPublishableService(http_client=async_http_client) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("review", {"id": "PRD-123", "status": "update"}), + ("publish", {"id": "PRD-123", "status": "update"}), + ("unpublish", {"id": "PRD-123", "status": "update"}), + ], +) +def test_custom_resource_actions(publishable_service, action, input_status): + request_expected_content = b'{"id":"PRD-123","status":"update"}' + response_expected_data = {"id": "PRD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/publishable/PRD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = getattr(publishable_service, action)("PRD-123", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize( + ("action"), + [ + ("review"), + ("publish"), + ("unpublish"), + ], +) +def test_custom_resource_actions_no_data(publishable_service, action): + request_expected_content = b"" + response_expected_data = {"id": "PRD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/publishable/PRD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = getattr(publishable_service, action)("PRD-123") + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("review", {"id": "PRD-123", "status": "update"}), + ("publish", {"id": "PRD-123", "status": "update"}), + ("unpublish", {"id": "PRD-123", "status": "update"}), + ], +) +async def test_async_custom_resource_actions(async_publishable_service, action, input_status): + request_expected_content = b'{"id":"PRD-123","status":"update"}' + response_expected_data = {"id": "PRD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/publishable/PRD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = await getattr(async_publishable_service, action)("PRD-123", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize( + ("action"), + [ + ("review"), + ("publish"), + ("unpublish"), + ], +) +async def test_async_custom_resource_actions_no_data(async_publishable_service, action): + request_expected_content = b"" + response_expected_data = {"id": "PRD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/publishable/PRD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = await getattr(async_publishable_service, action)("PRD-123") + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) diff --git a/tests/unit/resources/catalog/test_mixins.py b/tests/unit/resources/catalog/test_mixins.py deleted file mode 100644 index b2d5fa7e..00000000 --- a/tests/unit/resources/catalog/test_mixins.py +++ /dev/null @@ -1,469 +0,0 @@ -import io - -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.catalog.mixins import ( - ActivatableMixin, - AsyncActivatableMixin, - AsyncDocumentMixin, - AsyncPublishableMixin, - DocumentMixin, - PublishableMixin, -) -from tests.unit.conftest import DummyModel - - -class DummyPublishableService( - PublishableMixin[DummyModel], - Service[DummyModel], -): - _endpoint = "/public/v1/dummy/publishable/" - _model_class = DummyModel - _collection_key = "data" - - -class DummyAsyncPublishableService( - AsyncPublishableMixin[DummyModel], - AsyncService[DummyModel], -): - _endpoint = "/public/v1/dummy/publishable/" - _model_class = DummyModel - _collection_key = "data" - - -class DummyActivatableService( - ActivatableMixin[DummyModel], - Service[DummyModel], -): - _endpoint = "/public/v1/dummy/activatable/" - _model_class = DummyModel - _collection_key = "data" - - -class DummyAsyncActivatableService( - AsyncActivatableMixin[DummyModel], - AsyncService[DummyModel], -): - _endpoint = "/public/v1/dummy/activatable/" - _model_class = DummyModel - _collection_key = "data" - - -class DummyDocumentService( - DocumentMixin[DummyModel], - Service[DummyModel], -): - _endpoint = "/public/v1/dummy/documents" - _model_class = DummyModel - _collection_key = "data" - _upload_file_key = "file" - _upload_data_key = "document" - - -class DummyAsyncDocumentService( - AsyncDocumentMixin[DummyModel], - AsyncService[DummyModel], -): - _endpoint = "/public/v1/dummy/documents" - _model_class = DummyModel - _collection_key = "data" - _upload_file_key = "file" - _upload_data_key = "document" - - -@pytest.fixture -def publishable_service(http_client): - return DummyPublishableService(http_client=http_client) - - -@pytest.fixture -def async_publishable_service(async_http_client): - return DummyAsyncPublishableService(http_client=async_http_client) - - -@pytest.fixture -def activatable_service(http_client): - return DummyActivatableService(http_client=http_client) - - -@pytest.fixture -def async_activatable_service(async_http_client): - return DummyAsyncActivatableService(http_client=async_http_client) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("review", {"id": "PRD-123", "status": "update"}), - ("publish", {"id": "PRD-123", "status": "update"}), - ("unpublish", {"id": "PRD-123", "status": "update"}), - ], -) -def test_custom_resource_actions(publishable_service, action, input_status): - request_expected_content = b'{"id":"PRD-123","status":"update"}' - response_expected_data = {"id": "PRD-123", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/publishable/PRD-123/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = getattr(publishable_service, action)("PRD-123", input_status) - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("review", None), - ("publish", None), - ("unpublish", None), - ], -) -def test_custom_resource_actions_no_data(publishable_service, action, input_status): - request_expected_content = b"" - response_expected_data = {"id": "PRD-123", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/publishable/PRD-123/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = getattr(publishable_service, action)("PRD-123") - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("review", {"id": "PRD-123", "status": "update"}), - ("publish", {"id": "PRD-123", "status": "update"}), - ("unpublish", {"id": "PRD-123", "status": "update"}), - ], -) -async def test_async_custom_resource_actions(async_publishable_service, action, input_status): - request_expected_content = b'{"id":"PRD-123","status":"update"}' - response_expected_data = {"id": "PRD-123", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/publishable/PRD-123/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = await getattr(async_publishable_service, action)("PRD-123", input_status) - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("review", None), - ("publish", None), - ("unpublish", None), - ], -) -async def test_async_custom_resource_actions_no_data( - async_publishable_service, action, input_status -): - request_expected_content = b"" - response_expected_data = {"id": "PRD-123", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/publishable/PRD-123/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = await getattr(async_publishable_service, action)("PRD-123") - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("activate", {"id": "OBJ-0000-0001", "status": "update"}), - ("deactivate", {"id": "OBJ-0000-0001", "status": "update"}), - ], -) -def test_custom_resource_activatable_actions(activatable_service, action, input_status): - request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' - response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/activatable/OBJ-0000-0001/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = getattr(activatable_service, action)("OBJ-0000-0001", input_status) - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("activate", None), - ("deactivate", None), - ], -) -def test_custom_resource_activatable_actions_no_data(activatable_service, action, input_status): - request_expected_content = b"" - response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/activatable/OBJ-0000-0001/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = getattr(activatable_service, action)("OBJ-0000-0001", input_status) - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("activate", {"id": "OBJ-0000-0001", "status": "update"}), - ("deactivate", {"id": "OBJ-0000-0001", "status": "update"}), - ], -) -async def test_async_custom_resource_activatable_actions( - async_activatable_service, action, input_status -): - request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' - response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/activatable/OBJ-0000-0001/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = await getattr(async_activatable_service, action)("OBJ-0000-0001", input_status) - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("activate", None), - ("deactivate", None), - ], -) -async def test_async_custom_resource_activatable_actions_no_data( - async_activatable_service, action, input_status -): - request_expected_content = b"" - response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/activatable/OBJ-0000-0001/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = await getattr(async_activatable_service, action)("OBJ-0000-0001", input_status) - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.fixture -def document_service(http_client): - return DummyDocumentService(http_client=http_client) - - -@pytest.fixture -def async_document_service(async_http_client): - return DummyAsyncDocumentService(http_client=async_http_client) - - -def test_document_create_with_url(document_service): - resource_data = { - "name": "My Doc", - "description": "My Doc", - "url": "https://example.com/file.pdf", - } - with respx.mock: - mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - json=resource_data, - ) - ) - new_doc = document_service.create(resource_data=resource_data) - - result = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="document"\r\n' - b"Content-Type: application/json\r\n\r\n" - b'{"name":"My Doc","description":"My Doc","url":"https://example.com/file.pdf"}\r\n' - in result.content - ) - assert b'Content-Disposition: form-data; name="file"' not in result.content - assert "multipart/form-data" in result.headers["Content-Type"] - assert new_doc.to_dict() == resource_data - assert isinstance(new_doc, DummyModel) - - -def test_document_create_with_file(document_service): # noqa: WPS210 - resource_data = {"id": "DOC-125", "name": "Data And File"} - response_data = resource_data - file_tuple = ("manual.pdf", io.BytesIO(b"PDF DATA"), "application/pdf") - with respx.mock: - mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( - return_value=httpx.Response(status_code=httpx.codes.OK, json=response_data) - ) - - result = document_service.create(resource_data=resource_data, file=file_tuple) - - request = mock_route.calls[0].request - # JSON part - assert ( - b'Content-Disposition: form-data; name="document"\r\n' - b"Content-Type: application/json\r\n\r\n" - b'{"id":"DOC-125","name":"Data And File"}\r\n' in request.content - ) - # File part - assert ( - b'Content-Disposition: form-data; name="file"; filename="manual.pdf"\r\n' - b"Content-Type: application/pdf\r\n\r\n" - b"PDF DATA\r\n" in request.content - ) - assert "multipart/form-data" in request.headers["Content-Type"] - assert result.to_dict() == response_data - assert isinstance(result, DummyModel) - - -async def test_async_document_create_with_url(async_document_service): - resource_data = { - "name": "My Doc", - "description": "My Doc", - "url": "https://example.com/file.pdf", - } - with respx.mock: - mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - json=resource_data, - ) - ) - - result = await async_document_service.create(resource_data=resource_data) - - request = mock_route.calls[0].request - assert ( - b'Content-Disposition: form-data; name="document"\r\n' - b"Content-Type: application/json\r\n\r\n" - b'{"name":"My Doc","description":"My Doc","url":"https://example.com/file.pdf"}\r\n' - in request.content - ) - assert b'Content-Disposition: form-data; name="file"' not in request.content - assert "multipart/form-data" in request.headers["Content-Type"] - assert result.to_dict() == resource_data - assert isinstance(result, DummyModel) - - -async def test_async_document_create_with_file(async_document_service): # noqa: WPS210 - resource_data = {"id": "DOC-125", "name": "Data And File"} - response_data = resource_data - file_tuple = ("manual.pdf", io.BytesIO(b"PDF DATA"), "application/pdf") - with respx.mock: - mock_route = respx.post("https://api.example.com/public/v1/dummy/documents").mock( - return_value=httpx.Response(status_code=httpx.codes.OK, json=response_data) - ) - - result = await async_document_service.create(resource_data, file_tuple) - - request = mock_route.calls[0].request - assert ( - b'Content-Disposition: form-data; name="document"\r\n' - b"Content-Type: application/json\r\n\r\n" - b'{"id":"DOC-125","name":"Data And File"}\r\n' in request.content - ) - assert ( - b'Content-Disposition: form-data; name="file"; filename="manual.pdf"\r\n' - b"Content-Type: application/pdf\r\n\r\n" - b"PDF DATA\r\n" in request.content - ) - assert "multipart/form-data" in request.headers["Content-Type"] - assert result.to_dict() == response_data - assert isinstance(result, DummyModel)