From b177d203f6a0e237b9ee0388d93905a45c2a38e6 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Wed, 10 Sep 2025 16:37:04 +0100 Subject: [PATCH 1/4] MPT-13321 Add catalog products documents --- mpt_api_client/resources/catalog/products.py | 16 ++ .../resources/catalog/products_documents.py | 143 +++++++++++++ setup.cfg | 1 + tests/resources/catalog/test_products.py | 6 + .../catalog/test_products_documents.py | 190 ++++++++++++++++++ 5 files changed, 356 insertions(+) create mode 100644 mpt_api_client/resources/catalog/products_documents.py create mode 100644 tests/resources/catalog/test_products_documents.py diff --git a/mpt_api_client/resources/catalog/products.py b/mpt_api_client/resources/catalog/products.py index 850e1ed6..1792a49e 100644 --- a/mpt_api_client/resources/catalog/products.py +++ b/mpt_api_client/resources/catalog/products.py @@ -11,6 +11,10 @@ AsyncPublishableMixin, PublishableMixin, ) +from mpt_api_client.resources.catalog.products_documents import ( + AsyncDocumentService, + DocumentService, +) from mpt_api_client.resources.catalog.products_item_groups import ( AsyncItemGroupsService, ItemGroupsService, @@ -69,6 +73,12 @@ def media(self, product_id: str) -> MediaService: http_client=self.http_client, endpoint_params={"product_id": product_id} ) + def documents(self, product_id: str) -> DocumentService: + """Return documents service.""" + return DocumentService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) + def product_parameters(self, product_id: str) -> ParametersService: """Return product_parameters service.""" return ParametersService( @@ -104,6 +114,12 @@ def media(self, product_id: str) -> AsyncMediaService: http_client=self.http_client, endpoint_params={"product_id": product_id} ) + def documents(self, product_id: str) -> AsyncDocumentService: + """Return documents service.""" + return AsyncDocumentService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) + def product_parameters(self, product_id: str) -> AsyncParametersService: """Return product_parameters service.""" return AsyncParametersService( diff --git a/mpt_api_client/resources/catalog/products_documents.py b/mpt_api_client/resources/catalog/products_documents.py new file mode 100644 index 00000000..80da5015 --- /dev/null +++ b/mpt_api_client/resources/catalog/products_documents.py @@ -0,0 +1,143 @@ +import json +from typing import override + +from httpx import Response +from httpx._types import FileTypes + +from mpt_api_client.http import AsyncService, CreateMixin, DeleteMixin, Service +from mpt_api_client.http.mixins import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncUpdateMixin, + UpdateMixin, +) +from mpt_api_client.models import FileModel, Model, ResourceData +from mpt_api_client.resources.catalog.mixins import AsyncPublishableMixin, PublishableMixin + + +def _json_to_file_payload(resource_data: ResourceData) -> bytes: + return json.dumps( + resource_data, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + + +class Document(Model): + """Document resource.""" + + +class DocumentServiceConfig: + """Document service configuration.""" + + _endpoint = "/public/v1/catalog/products/{product_id}/documents" + _model_class = Document + _collection_key = "data" + + +class DocumentService( + CreateMixin[Document], + DeleteMixin, + UpdateMixin[Document], + PublishableMixin[Document], + Service[Document], + DocumentServiceConfig, +): + """Document service.""" + + @override + def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + ) -> Document: + """Create Document resource. + + Include the document as a file or add an url in resource_data to be uploaded. + + Args: + resource_data: Resource data. + files: Files data. + + Returns: + Document resource. + """ + files = files or {} + + if resource_data: + files["_attachment_data"] = ( + None, + _json_to_file_payload(resource_data), + "application/json", + ) + + response = self.http_client.post(self.endpoint, files=files) + response.raise_for_status() + return Document.from_response(response) + + def download(self, document_id: str) -> FileModel: + """Download the document file for the given document ID. + + Args: + document_id: Document ID. + + Returns: + Document file. + """ + response: Response = self._resource_do_request( + document_id, method="GET", headers={"Accept": "*"} + ) + return FileModel(response) + + +class AsyncDocumentService( + AsyncCreateMixin[Document], + AsyncDeleteMixin, + AsyncUpdateMixin[Document], + AsyncPublishableMixin[Document], + AsyncService[Document], + DocumentServiceConfig, +): + """Document service.""" + + @override + async def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + ) -> Document: + """Create Document resource. + + Include the document as a file or add an url in resource_data to be uploaded. + + Args: + resource_data: Resource data. + files: Files data. + + Returns: + Document resource. + """ + files = files or {} + + if resource_data: + files["_attachment_data"] = ( + None, + _json_to_file_payload(resource_data), + "application/json", + ) + + response = await self.http_client.post(self.endpoint, files=files) + response.raise_for_status() + return Document.from_response(response) + + async def download(self, document_id: str) -> FileModel: + """Download the document file for the given document ID. + + Args: + document_id: Document ID. + + Returns: + Document file. + """ + response = await self._resource_do_request( + document_id, method="GET", headers={"Accept": "*"} + ) + return FileModel(response) diff --git a/setup.cfg b/setup.cfg index f67d1233..8ee2d8ac 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,6 +39,7 @@ per-file-ignores = mpt_api_client/resources/catalog/products_parameter_groups.py: WPS215 mpt_api_client/resources/catalog/products_parameters.py: WPS215 mpt_api_client/resources/catalog/products_media.py: WPS215 + mpt_api_client/resources/catalog/products_documents.py: WPS215 tests/http/test_async_service.py: WPS204 WPS202 tests/http/test_service.py: WPS204 WPS202 diff --git a/tests/resources/catalog/test_products.py b/tests/resources/catalog/test_products.py index 7d30ec00..81d070e9 100644 --- a/tests/resources/catalog/test_products.py +++ b/tests/resources/catalog/test_products.py @@ -1,6 +1,10 @@ import pytest from mpt_api_client.resources.catalog.products import AsyncProductsService, ProductsService +from mpt_api_client.resources.catalog.products_documents import ( + AsyncDocumentService, + DocumentService, +) from mpt_api_client.resources.catalog.products_item_groups import ( AsyncItemGroupsService, ItemGroupsService, @@ -49,6 +53,7 @@ def test_async_mixins_present(async_products_service, method): ("item_groups", ItemGroupsService), ("parameter_groups", ParameterGroupsService), ("media", MediaService), + ("documents", DocumentService), ("product_parameters", ParametersService), ], ) @@ -65,6 +70,7 @@ def test_property_services(products_service, service_method, expected_service_cl ("item_groups", AsyncItemGroupsService), ("parameter_groups", AsyncParameterGroupsService), ("media", AsyncMediaService), + ("documents", AsyncDocumentService), ("product_parameters", AsyncParametersService), ], ) diff --git a/tests/resources/catalog/test_products_documents.py b/tests/resources/catalog/test_products_documents.py new file mode 100644 index 00000000..2d8a3c2c --- /dev/null +++ b/tests/resources/catalog/test_products_documents.py @@ -0,0 +1,190 @@ +import io + +import httpx +import pytest +import respx + +from mpt_api_client.resources.catalog.products_documents import ( + AsyncDocumentService, + DocumentService, +) + + +@pytest.fixture +def document_service(http_client): + return DocumentService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) + + +@pytest.fixture +def async_document_service(async_http_client): + return AsyncDocumentService( + http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} + ) + + +def test_endpoint(document_service): + assert document_service.endpoint == "/public/v1/catalog/products/PRD-001/documents" + + +def test_async_endpoint(async_document_service): + assert async_document_service.endpoint == "/public/v1/catalog/products/PRD-001/documents" + + +async def test_async_create(async_document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = await async_document_service.create( + {"name": "Product document"}, files=files + ) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_attachment_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Product document"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +async def test_async_create_no_data(async_document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = await async_document_service.create(files=files) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +async def test_async_download(async_document_service): + document_content = b"PDF file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents/DOC-456" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; ' + 'filename="product_document.pdf"', + }, + content=document_content, + ) + ) + + file_model = await async_document_service.download("DOC-456") + + assert mock_route.called + assert file_model.response.status_code == 200 + assert file_model.response.content == document_content + assert file_model.filename == "product_document.pdf" + + +def test_create(document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = document_service.create({"name": "Product document"}, files=files) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_attachment_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Product document"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +def test_create_no_data(document_service): + document_data = {"id": "DOC-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents" + ).mock( + return_value=httpx.Response( + status_code=200, + json=document_data, + ) + ) + files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + new_document = document_service.create(files=files) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' + b"Content-Type: application/pdf\r\n\r\n" + b"PDF content\r\n" in request.content + ) + assert new_document.to_dict() == document_data + + +def test_download(document_service): + document_content = b"PDF file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/documents/DOC-456" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; ' + 'filename="product_document.pdf"', + }, + content=document_content, + ) + ) + + file_model = document_service.download("DOC-456") + + assert mock_route.called + assert file_model.response.status_code == 200 + assert file_model.response.content == document_content + assert file_model.filename == "product_document.pdf" From 6897e0855bcf095c47917dbc26c99ecbe383e708 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Wed, 10 Sep 2025 16:57:53 +0100 Subject: [PATCH 2/4] MPT-13325 Add catalog products templates --- mpt_api_client/resources/catalog/products.py | 16 ++++++++ .../resources/catalog/products_templates.py | 40 +++++++++++++++++++ setup.cfg | 1 + tests/resources/catalog/test_products.py | 6 +++ .../catalog/test_products_templates.py | 36 +++++++++++++++++ 5 files changed, 99 insertions(+) create mode 100644 mpt_api_client/resources/catalog/products_templates.py create mode 100644 tests/resources/catalog/test_products_templates.py diff --git a/mpt_api_client/resources/catalog/products.py b/mpt_api_client/resources/catalog/products.py index 1792a49e..789af8fd 100644 --- a/mpt_api_client/resources/catalog/products.py +++ b/mpt_api_client/resources/catalog/products.py @@ -31,6 +31,10 @@ AsyncParametersService, ParametersService, ) +from mpt_api_client.resources.catalog.products_templates import ( + AsyncTemplatesService, + TemplatesService, +) class Product(Model): @@ -85,6 +89,12 @@ def product_parameters(self, product_id: str) -> ParametersService: http_client=self.http_client, endpoint_params={"product_id": product_id} ) + def templates(self, product_id: str) -> TemplatesService: + """Return templates service.""" + return TemplatesService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) + class AsyncProductsService( AsyncCreateMixin[Product], @@ -125,3 +135,9 @@ def product_parameters(self, product_id: str) -> AsyncParametersService: return AsyncParametersService( http_client=self.http_client, endpoint_params={"product_id": product_id} ) + + def templates(self, product_id: str) -> AsyncTemplatesService: + """Return templates service.""" + return AsyncTemplatesService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) diff --git a/mpt_api_client/resources/catalog/products_templates.py b/mpt_api_client/resources/catalog/products_templates.py new file mode 100644 index 00000000..c7a0b461 --- /dev/null +++ b/mpt_api_client/resources/catalog/products_templates.py @@ -0,0 +1,40 @@ +from mpt_api_client.http import AsyncService, CreateMixin, DeleteMixin, Service +from mpt_api_client.http.mixins import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncUpdateMixin, + UpdateMixin, +) +from mpt_api_client.models import Model + + +class Template(Model): + """Template resource.""" + + +class TemplatesServiceConfig: + """Templates service configuration.""" + + _endpoint = "/public/v1/catalog/products/{product_id}/templates" + _model_class = Template + _collection_key = "data" + + +class TemplatesService( + CreateMixin[Template], + DeleteMixin, + UpdateMixin[Template], + Service[Template], + TemplatesServiceConfig, +): + """Templates service.""" + + +class AsyncTemplatesService( + AsyncCreateMixin[Template], + AsyncDeleteMixin, + AsyncUpdateMixin[Template], + AsyncService[Template], + TemplatesServiceConfig, +): + """Templates service.""" diff --git a/setup.cfg b/setup.cfg index 8ee2d8ac..45ca7d5e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,6 +40,7 @@ per-file-ignores = mpt_api_client/resources/catalog/products_parameters.py: WPS215 mpt_api_client/resources/catalog/products_media.py: WPS215 mpt_api_client/resources/catalog/products_documents.py: WPS215 + mpt_api_client/resources/catalog/products_templates.py: WPS215 tests/http/test_async_service.py: WPS204 WPS202 tests/http/test_service.py: WPS204 WPS202 diff --git a/tests/resources/catalog/test_products.py b/tests/resources/catalog/test_products.py index 81d070e9..2113b3d0 100644 --- a/tests/resources/catalog/test_products.py +++ b/tests/resources/catalog/test_products.py @@ -21,6 +21,10 @@ AsyncParametersService, ParametersService, ) +from mpt_api_client.resources.catalog.products_templates import ( + AsyncTemplatesService, + TemplatesService, +) @pytest.fixture @@ -55,6 +59,7 @@ def test_async_mixins_present(async_products_service, method): ("media", MediaService), ("documents", DocumentService), ("product_parameters", ParametersService), + ("templates", TemplatesService), ], ) def test_property_services(products_service, service_method, expected_service_class): @@ -72,6 +77,7 @@ def test_property_services(products_service, service_method, expected_service_cl ("media", AsyncMediaService), ("documents", AsyncDocumentService), ("product_parameters", AsyncParametersService), + ("templates", AsyncTemplatesService), ], ) def test_async_property_services(async_products_service, service_method, expected_service_class): diff --git a/tests/resources/catalog/test_products_templates.py b/tests/resources/catalog/test_products_templates.py new file mode 100644 index 00000000..c175c4e8 --- /dev/null +++ b/tests/resources/catalog/test_products_templates.py @@ -0,0 +1,36 @@ +import pytest + +from mpt_api_client.resources.catalog.products_templates import ( + AsyncTemplatesService, + TemplatesService, +) + + +@pytest.fixture +def templates_service(http_client): + return TemplatesService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) + + +@pytest.fixture +def async_templates_service(async_http_client): + return AsyncTemplatesService( + http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} + ) + + +def test_endpoint(templates_service): + assert templates_service.endpoint == "/public/v1/catalog/products/PRD-001/templates" + + +def test_async_endpoint(async_templates_service): + assert async_templates_service.endpoint == "/public/v1/catalog/products/PRD-001/templates" + + +@pytest.mark.parametrize("method", ["get", "create", "delete", "update", "fetch_page", "iterate"]) +def test_methods_present(templates_service, method): + assert hasattr(templates_service, method) + + +@pytest.mark.parametrize("method", ["get", "create", "delete", "update", "fetch_page", "iterate"]) +def test_async_methods_present(async_templates_service, method): + assert hasattr(async_templates_service, method) From 8da8b0b8b06c698df3511c491e263395eef4c53d Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Wed, 10 Sep 2025 17:18:57 +0100 Subject: [PATCH 3/4] MPT-13323 Add catalog products terms --- .../resources/catalog/product_terms.py | 43 +++++++++++++++++++ mpt_api_client/resources/catalog/products.py | 14 ++++++ setup.cfg | 1 + tests/resources/catalog/test_product_terms.py | 40 +++++++++++++++++ tests/resources/catalog/test_products.py | 6 +++ 5 files changed, 104 insertions(+) create mode 100644 mpt_api_client/resources/catalog/product_terms.py create mode 100644 tests/resources/catalog/test_product_terms.py diff --git a/mpt_api_client/resources/catalog/product_terms.py b/mpt_api_client/resources/catalog/product_terms.py new file mode 100644 index 00000000..e3ac9b6b --- /dev/null +++ b/mpt_api_client/resources/catalog/product_terms.py @@ -0,0 +1,43 @@ +from mpt_api_client.http import AsyncService, CreateMixin, DeleteMixin, Service +from mpt_api_client.http.mixins import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncUpdateMixin, + UpdateMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.resources.catalog.mixins import AsyncPublishableMixin, PublishableMixin + + +class Term(Model): + """Term resource.""" + + +class TermServiceConfig: + """Term service configuration.""" + + _endpoint = "/public/v1/catalog/products/{product_id}/terms" + _model_class = Term + _collection_key = "data" + + +class TermService( + CreateMixin[Term], + DeleteMixin, + UpdateMixin[Term], + PublishableMixin[Term], + Service[Term], + TermServiceConfig, +): + """Term service.""" + + +class AsyncTermService( + AsyncCreateMixin[Term], + AsyncDeleteMixin, + AsyncUpdateMixin[Term], + AsyncPublishableMixin[Term], + AsyncService[Term], + TermServiceConfig, +): + """Async Term service.""" diff --git a/mpt_api_client/resources/catalog/products.py b/mpt_api_client/resources/catalog/products.py index 789af8fd..6dd7f82e 100644 --- a/mpt_api_client/resources/catalog/products.py +++ b/mpt_api_client/resources/catalog/products.py @@ -11,6 +11,10 @@ AsyncPublishableMixin, PublishableMixin, ) +from mpt_api_client.resources.catalog.product_terms import ( + AsyncTermService, + TermService, +) from mpt_api_client.resources.catalog.products_documents import ( AsyncDocumentService, DocumentService, @@ -95,6 +99,10 @@ def templates(self, product_id: str) -> TemplatesService: http_client=self.http_client, endpoint_params={"product_id": product_id} ) + def terms(self, product_id: str) -> TermService: + """Return terms service.""" + return TermService(http_client=self.http_client, endpoint_params={"product_id": product_id}) + class AsyncProductsService( AsyncCreateMixin[Product], @@ -141,3 +149,9 @@ def templates(self, product_id: str) -> AsyncTemplatesService: return AsyncTemplatesService( http_client=self.http_client, endpoint_params={"product_id": product_id} ) + + def terms(self, product_id: str) -> AsyncTermService: + """Return terms service.""" + return AsyncTermService( + http_client=self.http_client, endpoint_params={"product_id": product_id} + ) diff --git a/setup.cfg b/setup.cfg index 45ca7d5e..15c60b8f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,6 +41,7 @@ per-file-ignores = mpt_api_client/resources/catalog/products_media.py: WPS215 mpt_api_client/resources/catalog/products_documents.py: WPS215 mpt_api_client/resources/catalog/products_templates.py: WPS215 + mpt_api_client/resources/catalog/product_terms.py: WPS215 tests/http/test_async_service.py: WPS204 WPS202 tests/http/test_service.py: WPS204 WPS202 diff --git a/tests/resources/catalog/test_product_terms.py b/tests/resources/catalog/test_product_terms.py new file mode 100644 index 00000000..343d4737 --- /dev/null +++ b/tests/resources/catalog/test_product_terms.py @@ -0,0 +1,40 @@ +import pytest + +from mpt_api_client.resources.catalog.product_terms import ( + AsyncTermService, + TermService, +) + + +@pytest.fixture +def term_service(http_client): + return TermService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) + + +@pytest.fixture +def async_term_service(async_http_client): + return AsyncTermService( + http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} + ) + + +def test_endpoint(term_service): + assert term_service.endpoint == "/public/v1/catalog/products/PRD-001/terms" + + +def test_async_endpoint(async_term_service): + assert async_term_service.endpoint == "/public/v1/catalog/products/PRD-001/terms" + + +@pytest.mark.parametrize( + "method", ["get", "create", "delete", "update", "review", "publish", "unpublish"] +) +def test_methods_present(term_service, method): + assert hasattr(term_service, method) + + +@pytest.mark.parametrize( + "method", ["get", "create", "delete", "update", "review", "publish", "unpublish"] +) +def test_async_methods_present(async_term_service, method): + assert hasattr(async_term_service, method) diff --git a/tests/resources/catalog/test_products.py b/tests/resources/catalog/test_products.py index 2113b3d0..787f5283 100644 --- a/tests/resources/catalog/test_products.py +++ b/tests/resources/catalog/test_products.py @@ -1,5 +1,9 @@ import pytest +from mpt_api_client.resources.catalog.product_terms import ( + AsyncTermService, + TermService, +) from mpt_api_client.resources.catalog.products import AsyncProductsService, ProductsService from mpt_api_client.resources.catalog.products_documents import ( AsyncDocumentService, @@ -60,6 +64,7 @@ def test_async_mixins_present(async_products_service, method): ("documents", DocumentService), ("product_parameters", ParametersService), ("templates", TemplatesService), + ("terms", TermService), ], ) def test_property_services(products_service, service_method, expected_service_class): @@ -78,6 +83,7 @@ def test_property_services(products_service, service_method, expected_service_cl ("documents", AsyncDocumentService), ("product_parameters", AsyncParametersService), ("templates", AsyncTemplatesService), + ("terms", AsyncTermService), ], ) def test_async_property_services(async_products_service, service_method, expected_service_class): From 17b751ace512f0b04f52793d90880675f5e57961 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Thu, 11 Sep 2025 10:36:26 +0100 Subject: [PATCH 4/4] MPT-13324 Add catalog products terms variants - Add catalog product terms variants - Refactored create and download into FileOperationsMixin --- mpt_api_client/http/mixins.py | 106 +++++++++- .../catalog/product_term_variants.py | 47 +++++ .../resources/catalog/product_terms.py | 18 ++ .../resources/catalog/products_documents.py | 111 +---------- .../resources/catalog/products_media.py | 112 ++--------- .../commerce/agreements_attachments.py | 139 ++----------- setup.cfg | 4 + tests/http/conftest.py | 4 +- tests/http/test_base_service.py | 10 +- tests/http/test_mixins.py | 186 ++++++++++++++++++ tests/http/test_service.py | 2 +- .../catalog/test_product_term_variants.py | 46 +++++ tests/resources/catalog/test_product_terms.py | 34 +++- .../catalog/test_products_documents.py | 178 ++--------------- .../resources/catalog/test_products_media.py | 176 +---------------- .../commerce/test_agreements_attachments.py | 172 +--------------- 16 files changed, 507 insertions(+), 838 deletions(-) create mode 100644 mpt_api_client/resources/catalog/product_term_variants.py create mode 100644 tests/resources/catalog/test_product_term_variants.py diff --git a/mpt_api_client/http/mixins.py b/mpt_api_client/http/mixins.py index 645e4d8b..11f655de 100644 --- a/mpt_api_client/http/mixins.py +++ b/mpt_api_client/http/mixins.py @@ -1,6 +1,16 @@ +import json from urllib.parse import urljoin -from mpt_api_client.models import ResourceData +from httpx import Response +from httpx._types import FileTypes + +from mpt_api_client.models import FileModel, ResourceData + + +def _json_to_file_payload(resource_data: ResourceData) -> bytes: + return json.dumps( + resource_data, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") class CreateMixin[Model]: @@ -48,6 +58,53 @@ def update(self, resource_id: str, resource_data: ResourceData) -> Model: return self._resource_action(resource_id, "PUT", json=resource_data) # type: ignore[attr-defined, no-any-return] +class FileOperationsMixin[Model]: + """Mixin that provides create and download methods for file-based resources.""" + + def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + data_key: str = "_attachment_data", + ) -> Model: + """Create resource with file support. + + Args: + resource_data: Resource data. + files: Files data. + data_key: Key to use for the JSON data in the multipart form. + + Returns: + Created resource. + """ + files = files or {} + + if resource_data: + files[data_key] = ( + None, + _json_to_file_payload(resource_data), + "application/json", + ) + + response = self.http_client.post(self.endpoint, files=files) # type: ignore[attr-defined] + response.raise_for_status() + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] + + def download(self, resource_id: str) -> FileModel: + """Download the file for the given resource ID. + + Args: + resource_id: Resource ID. + + Returns: + File model containing the downloaded file. + """ + response: Response = self._resource_do_request( # type: ignore[attr-defined] + resource_id, method="GET", headers={"Accept": "*"} + ) + return FileModel(response) + + class AsyncCreateMixin[Model]: """Create resource mixin.""" @@ -92,3 +149,50 @@ async def update(self, resource_id: str, resource_data: ResourceData) -> Model: """ return await self._resource_action(resource_id, "PUT", json=resource_data) # type: ignore[attr-defined, no-any-return] + + +class AsyncFileOperationsMixin[Model]: + """Async mixin that provides create and download methods for file-based resources.""" + + async def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + data_key: str = "_attachment_data", + ) -> Model: + """Create resource with file support. + + Args: + resource_data: Resource data. + files: Files data. + data_key: Key to use for the JSON data in the multipart form. + + Returns: + Created resource. + """ + files = files or {} + + if resource_data: + files[data_key] = ( + None, + _json_to_file_payload(resource_data), + "application/json", + ) + + response = await self.http_client.post(self.endpoint, files=files) # type: ignore[attr-defined] + response.raise_for_status() + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] + + async def download(self, resource_id: str) -> FileModel: + """Download the file for the given resource ID. + + Args: + resource_id: Resource ID. + + Returns: + File model containing the downloaded file. + """ + response = await self._resource_do_request( # type: ignore[attr-defined] + resource_id, method="GET", headers={"Accept": "*"} + ) + return FileModel(response) diff --git a/mpt_api_client/resources/catalog/product_term_variants.py b/mpt_api_client/resources/catalog/product_term_variants.py new file mode 100644 index 00000000..15764ab9 --- /dev/null +++ b/mpt_api_client/resources/catalog/product_term_variants.py @@ -0,0 +1,47 @@ +from mpt_api_client.http import AsyncService, DeleteMixin, Service +from mpt_api_client.http.mixins import ( + AsyncDeleteMixin, + AsyncFileOperationsMixin, + AsyncUpdateMixin, + FileOperationsMixin, + UpdateMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.resources.catalog.mixins import ( + AsyncPublishableMixin, + PublishableMixin, +) + + +class TermVariant(Model): + """Term variant resource.""" + + +class TermVariantServiceConfig: + """Term variant service configuration.""" + + _endpoint = "/public/v1/catalog/products/terms/{term_id}/variants" + _model_class = TermVariant + _collection_key = "data" + + +class TermVariantService( + FileOperationsMixin[TermVariant], + DeleteMixin, + UpdateMixin[TermVariant], + PublishableMixin[TermVariant], + Service[TermVariant], + TermVariantServiceConfig, +): + """Term variant service.""" + + +class AsyncTermVariantService( + AsyncFileOperationsMixin[TermVariant], + AsyncDeleteMixin, + AsyncUpdateMixin[TermVariant], + AsyncPublishableMixin[TermVariant], + AsyncService[TermVariant], + TermVariantServiceConfig, +): + """Async Term variant service.""" diff --git a/mpt_api_client/resources/catalog/product_terms.py b/mpt_api_client/resources/catalog/product_terms.py index e3ac9b6b..44190c37 100644 --- a/mpt_api_client/resources/catalog/product_terms.py +++ b/mpt_api_client/resources/catalog/product_terms.py @@ -7,6 +7,10 @@ ) from mpt_api_client.models import Model from mpt_api_client.resources.catalog.mixins import AsyncPublishableMixin, PublishableMixin +from mpt_api_client.resources.catalog.product_term_variants import ( + AsyncTermVariantService, + TermVariantService, +) class Term(Model): @@ -31,6 +35,13 @@ class TermService( ): """Term service.""" + def variants(self, term_id: str) -> TermVariantService: + """Access term variants service.""" + return TermVariantService( + http_client=self.http_client, + endpoint_params={"term_id": term_id}, + ) + class AsyncTermService( AsyncCreateMixin[Term], @@ -41,3 +52,10 @@ class AsyncTermService( TermServiceConfig, ): """Async Term service.""" + + def variants(self, term_id: str) -> AsyncTermVariantService: + """Access async term variants service.""" + return AsyncTermVariantService( + http_client=self.http_client, + endpoint_params={"term_id": term_id}, + ) diff --git a/mpt_api_client/resources/catalog/products_documents.py b/mpt_api_client/resources/catalog/products_documents.py index 80da5015..19225ce4 100644 --- a/mpt_api_client/resources/catalog/products_documents.py +++ b/mpt_api_client/resources/catalog/products_documents.py @@ -1,26 +1,15 @@ -import json -from typing import override - -from httpx import Response -from httpx._types import FileTypes - -from mpt_api_client.http import AsyncService, CreateMixin, DeleteMixin, Service +from mpt_api_client.http import AsyncService, DeleteMixin, Service from mpt_api_client.http.mixins import ( - AsyncCreateMixin, AsyncDeleteMixin, + AsyncFileOperationsMixin, AsyncUpdateMixin, + FileOperationsMixin, UpdateMixin, ) -from mpt_api_client.models import FileModel, Model, ResourceData +from mpt_api_client.models import Model from mpt_api_client.resources.catalog.mixins import AsyncPublishableMixin, PublishableMixin -def _json_to_file_payload(resource_data: ResourceData) -> bytes: - return json.dumps( - resource_data, ensure_ascii=False, separators=(",", ":"), allow_nan=False - ).encode("utf-8") - - class Document(Model): """Document resource.""" @@ -34,7 +23,7 @@ class DocumentServiceConfig: class DocumentService( - CreateMixin[Document], + FileOperationsMixin[Document], DeleteMixin, UpdateMixin[Document], PublishableMixin[Document], @@ -43,53 +32,9 @@ class DocumentService( ): """Document service.""" - @override - def create( - self, - resource_data: ResourceData | None = None, - files: dict[str, FileTypes] | None = None, # noqa: WPS221 - ) -> Document: - """Create Document resource. - - Include the document as a file or add an url in resource_data to be uploaded. - - Args: - resource_data: Resource data. - files: Files data. - - Returns: - Document resource. - """ - files = files or {} - - if resource_data: - files["_attachment_data"] = ( - None, - _json_to_file_payload(resource_data), - "application/json", - ) - - response = self.http_client.post(self.endpoint, files=files) - response.raise_for_status() - return Document.from_response(response) - - def download(self, document_id: str) -> FileModel: - """Download the document file for the given document ID. - - Args: - document_id: Document ID. - - Returns: - Document file. - """ - response: Response = self._resource_do_request( - document_id, method="GET", headers={"Accept": "*"} - ) - return FileModel(response) - class AsyncDocumentService( - AsyncCreateMixin[Document], + AsyncFileOperationsMixin[Document], AsyncDeleteMixin, AsyncUpdateMixin[Document], AsyncPublishableMixin[Document], @@ -97,47 +42,3 @@ class AsyncDocumentService( DocumentServiceConfig, ): """Document service.""" - - @override - async def create( - self, - resource_data: ResourceData | None = None, - files: dict[str, FileTypes] | None = None, # noqa: WPS221 - ) -> Document: - """Create Document resource. - - Include the document as a file or add an url in resource_data to be uploaded. - - Args: - resource_data: Resource data. - files: Files data. - - Returns: - Document resource. - """ - files = files or {} - - if resource_data: - files["_attachment_data"] = ( - None, - _json_to_file_payload(resource_data), - "application/json", - ) - - response = await self.http_client.post(self.endpoint, files=files) - response.raise_for_status() - return Document.from_response(response) - - async def download(self, document_id: str) -> FileModel: - """Download the document file for the given document ID. - - Args: - document_id: Document ID. - - Returns: - Document file. - """ - response = await self._resource_do_request( - document_id, method="GET", headers={"Accept": "*"} - ) - return FileModel(response) diff --git a/mpt_api_client/resources/catalog/products_media.py b/mpt_api_client/resources/catalog/products_media.py index 4ce4bb47..7712b430 100644 --- a/mpt_api_client/resources/catalog/products_media.py +++ b/mpt_api_client/resources/catalog/products_media.py @@ -1,24 +1,20 @@ -import json from typing import override -from httpx import Response from httpx._types import FileTypes -from mpt_api_client.http import AsyncService, CreateMixin, DeleteMixin, Service +from mpt_api_client.http import AsyncService, DeleteMixin, Service from mpt_api_client.http.mixins import ( - AsyncCreateMixin, AsyncDeleteMixin, + AsyncFileOperationsMixin, AsyncUpdateMixin, + FileOperationsMixin, UpdateMixin, ) -from mpt_api_client.models import FileModel, Model, ResourceData -from mpt_api_client.resources.catalog.mixins import AsyncPublishableMixin, PublishableMixin - - -def _json_to_file_payload(resource_data: ResourceData) -> bytes: - return json.dumps( - resource_data, ensure_ascii=False, separators=(",", ":"), allow_nan=False - ).encode("utf-8") +from mpt_api_client.models import Model, ResourceData +from mpt_api_client.resources.catalog.mixins import ( + AsyncPublishableMixin, + PublishableMixin, +) class Media(Model): @@ -34,7 +30,7 @@ class MediaServiceConfig: class MediaService( - CreateMixin[Media], + FileOperationsMixin[Media], DeleteMixin, UpdateMixin[Media], PublishableMixin[Media], @@ -47,7 +43,8 @@ class MediaService( def create( self, resource_data: ResourceData | None = None, - files: dict[str, FileTypes] | None = None, # noqa: WPS221 + files: dict[str, FileTypes] | None = None, + data_key: str = "_media_data", ) -> Media: """Create Media resource. @@ -77,54 +74,16 @@ def create( Args: resource_data: Resource data. files: Files data. + data_key: Key to use for the JSON data in the multipart form. Returns: Media resource. """ - files = files or {} - - # Note: This is a workaround to fulfill MPT API request format - # - # HTTPx does not support sending json and files in the same call - # currently only supports sending form-data and files in the same call. - # https://www.python-httpx.org/quickstart/#sending-multipart-file-uploads - # - # MPT API expects files and data to be submitted in a multipart form-data upload. - # - # Current workaround is to send the json data as an unnamed file. - # This ends adding the json as payload multipart data. - # - # json.dumps is setup using the same params of httpx json encoder to produce the same - # encodings. - - if resource_data: - files["_media_data"] = ( - None, - _json_to_file_payload(resource_data), - "application/json", - ) - - response = self.http_client.post(self.endpoint, files=files) - response.raise_for_status() - return Media.from_response(response) - - def download(self, media_id: str) -> FileModel: - """Download the media file for the given media ID. - - Args: - media_id: Media ID. - - Returns: - Media file. - """ - response: Response = self._resource_do_request( - media_id, method="GET", headers={"Accept": "*"} - ) - return FileModel(response) + return super().create(resource_data=resource_data, files=files, data_key=data_key) class AsyncMediaService( - AsyncCreateMixin[Media], + AsyncFileOperationsMixin[Media], AsyncDeleteMixin, AsyncUpdateMixin[Media], AsyncPublishableMixin[Media], @@ -137,52 +96,17 @@ class AsyncMediaService( async def create( self, resource_data: ResourceData | None = None, - files: dict[str, FileTypes] | None = None, # noqa: WPS221 + files: dict[str, FileTypes] | None = None, + data_key: str = "_media_data", ) -> Media: """Create Media resource. Args: resource_data: Resource data. files: Files data. + data_key: Key to use for the JSON data in the multipart form. Returns: Media resource. """ - files = files or {} - - # Note: This is a workaround to fulfill MPT API request format - # - # HTTPx does not support sending json and files in the same call - # currently only supports sending form-data and files in the same call. - # https://www.python-httpx.org/quickstart/#sending-multipart-file-uploads - # - # MPT API expects files and data to be submitted in a multipart form-data upload. - # - # Current workaround is to send the json data as an unnamed file. - # This ends adding the json as payload multipart data. - # - # json.dumps is setup using the same params of httpx json encoder to produce the same - # encodings. - - if resource_data: - files["_media_data"] = ( - None, - _json_to_file_payload(resource_data), - "application/json", - ) - - response = await self.http_client.post(self.endpoint, files=files) - response.raise_for_status() - return Media.from_response(response) - - async def download(self, media_id: str) -> FileModel: - """Download the media file for the given media ID. - - Args: - media_id: Media ID. - - Returns: - Media file. - """ - response = await self._resource_do_request(media_id, method="GET", headers={"Accept": "*"}) - return FileModel(response) + return await super().create(resource_data=resource_data, files=files, data_key=data_key) diff --git a/mpt_api_client/resources/commerce/agreements_attachments.py b/mpt_api_client/resources/commerce/agreements_attachments.py index e388c0fc..81428513 100644 --- a/mpt_api_client/resources/commerce/agreements_attachments.py +++ b/mpt_api_client/resources/commerce/agreements_attachments.py @@ -1,16 +1,9 @@ -import json - -from httpx import Response -from httpx._types import FileTypes - from mpt_api_client.http import AsyncDeleteMixin, AsyncService, DeleteMixin, Service -from mpt_api_client.models import FileModel, Model, ResourceData - - -def _json_to_file_payload(resource_data: ResourceData) -> bytes: - return json.dumps( - resource_data, ensure_ascii=False, separators=(",", ":"), allow_nan=False - ).encode("utf-8") +from mpt_api_client.http.mixins import ( + AsyncFileOperationsMixin, + FileOperationsMixin, +) +from mpt_api_client.models import Model class AgreementAttachment(Model): @@ -26,124 +19,18 @@ class AgreementsAttachmentServiceConfig: class AgreementsAttachmentService( - DeleteMixin, Service[AgreementAttachment], AgreementsAttachmentServiceConfig + FileOperationsMixin[AgreementAttachment], + DeleteMixin, + Service[AgreementAttachment], + AgreementsAttachmentServiceConfig, ): """Attachments service.""" - def create( - self, - resource_data: ResourceData | None = None, - files: dict[str, FileTypes] | None = None, # noqa: WPS221 - ) -> AgreementAttachment: - """Create AgreementAttachment resource. - - Args: - resource_data: Resource data. - files: Files data. - - Returns: - AgreementAttachment resource. - """ - files = files or {} - - # Note: This is a workaround to fulfill MPT API request format - # - # HTTPx does not support sending json and files in the same call - # currently only supports sending form-data and files in the same call. - # https://www.python-httpx.org/quickstart/#sending-multipart-file-uploads - # - # MPT API expects files and data to be submitted in a multipart form-data upload. - # https://softwareone.atlassian.net/wiki/spaces/mpt/pages/5212079859/Commerce+API#Create-Agreement-Attachment - # - # Current workaround is to send the json data as an unnamed file. - # This ends adding the json as payload multipart data. - # - # json.dumps is setup using the same params of httpx json encoder to produce the same - # encodings. - - if resource_data: - files["_attachment_data"] = ( - None, - _json_to_file_payload(resource_data), - "application/json", - ) - - response = self.http_client.post(self.endpoint, files=files) - response.raise_for_status() - return AgreementAttachment.from_response(response) - - def download(self, agreement_id: str) -> FileModel: - """Renders the template for the given Agreement id. - - Args: - agreement_id: Agreement ID. - - Returns: - Agreement template. - """ - response: Response = self._resource_do_request( - agreement_id, method="GET", headers={"Accept": "*"} - ) - return FileModel(response) - class AsyncAgreementsAttachmentService( - AsyncDeleteMixin, AsyncService[AgreementAttachment], AgreementsAttachmentServiceConfig + AsyncFileOperationsMixin[AgreementAttachment], + AsyncDeleteMixin, + AsyncService[AgreementAttachment], + AgreementsAttachmentServiceConfig, ): """Attachments service.""" - - async def create( - self, - resource_data: ResourceData | None = None, - files: dict[str, FileTypes] | None = None, # noqa: WPS221 - ) -> AgreementAttachment: - """Create AgreementAttachment resource. - - Args: - resource_data: Resource data. - files: Files data. - - Returns: - AgreementAttachment resource. - """ - files = files or {} - - # Note: This is a workaround to fulfill MPT API request format - # - # HTTPx does not support sending json and files in the same call - # currently only supports sending form-data and files in the same call. - # https://www.python-httpx.org/quickstart/#sending-multipart-file-uploads - # - # MPT API expects files and data to be submitted in a multipart form-data upload. - # https://softwareone.atlassian.net/wiki/spaces/mpt/pages/5212079859/Commerce+API#Create-Agreement-Attachment - # - # Current workaround is to send the json data as an unnamed file. - # This ends adding the json as payload multipart data. - # - # json.dumps is setup using the same params of httpx json encoder to produce the same - # encodings. - - if resource_data: - files["_attachment_data"] = ( - None, - _json_to_file_payload(resource_data), - "application/json", - ) - - response = await self.http_client.post(self.endpoint, files=files) - response.raise_for_status() - return AgreementAttachment.from_response(response) - - async def download(self, agreement_id: str) -> FileModel: - """Renders the template for the given Agreement id. - - Args: - agreement_id: Agreement ID. - - Returns: - Agreement template. - """ - response = await self._resource_do_request( - agreement_id, method="GET", headers={"Accept": "*"} - ) - return FileModel(response) diff --git a/setup.cfg b/setup.cfg index 15c60b8f..1a3de1ba 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,8 +42,12 @@ per-file-ignores = mpt_api_client/resources/catalog/products_documents.py: WPS215 mpt_api_client/resources/catalog/products_templates.py: WPS215 mpt_api_client/resources/catalog/product_terms.py: WPS215 + mpt_api_client/resources/catalog/product_term_variants.py: WPS215 + mpt_api_client/resources/commerce/agreements_attachments.py: WPS215 + mpt_api_client/http/mixins.py: WPS202 tests/http/test_async_service.py: WPS204 WPS202 tests/http/test_service.py: WPS204 WPS202 + tests/http/test_mixins.py: WPS204 WPS202 tests/*: # Allow magic strings. diff --git a/tests/http/conftest.py b/tests/http/conftest.py index 21e1c5c8..624d14cf 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -35,12 +35,12 @@ class AsyncDummyService( # noqa: WPS215 @pytest.fixture -def dummy_service(http_client) -> DummyService: +def dummy_service(http_client): return DummyService(http_client=http_client) @pytest.fixture -def async_dummy_service(async_http_client) -> AsyncDummyService: +def async_dummy_service(async_http_client): return AsyncDummyService(http_client=async_http_client) diff --git a/tests/http/test_base_service.py b/tests/http/test_base_service.py index de863aff..39a414b5 100644 --- a/tests/http/test_base_service.py +++ b/tests/http/test_base_service.py @@ -30,7 +30,7 @@ def test_filter(base_dummy_service, filter_status_active): assert new_collection.query_rql == filter_status_active -def test_multiple_filters(base_dummy_service) -> None: +def test_multiple_filters(base_dummy_service): filter_query = RQLQuery(status="active") filter_query2 = RQLQuery(name="test") @@ -40,7 +40,7 @@ def test_multiple_filters(base_dummy_service) -> None: assert new_collection.query_rql == filter_query & filter_query2 -def test_select(base_dummy_service) -> None: +def test_select(base_dummy_service): new_collection = base_dummy_service.select("agreement", "-product") assert base_dummy_service.query_select is None @@ -48,7 +48,7 @@ def test_select(base_dummy_service) -> None: assert new_collection.query_select == ["agreement", "-product"] -def test_select_exception(base_dummy_service) -> None: +def test_select_exception(base_dummy_service): with pytest.raises(ValueError): base_dummy_service.select("agreement").select("product") @@ -68,7 +68,7 @@ def test_order_by_exception(base_dummy_service): base_dummy_service.order_by("created").order_by("name") -def test_url(base_dummy_service, filter_status_active) -> None: +def test_url(base_dummy_service, filter_status_active): custom_collection = ( base_dummy_service.filter(filter_status_active) .select("-audit", "product.agreements", "-product.agreements.product") @@ -85,7 +85,7 @@ def test_url(base_dummy_service, filter_status_active) -> None: ) -def test_clone(base_dummy_service, filter_status_active) -> None: +def test_clone(base_dummy_service, filter_status_active): configured = ( base_dummy_service.filter(filter_status_active) .order_by("created", "-name") diff --git a/tests/http/test_mixins.py b/tests/http/test_mixins.py index 0a06e1d5..11c503a4 100644 --- a/tests/http/test_mixins.py +++ b/tests/http/test_mixins.py @@ -1,8 +1,27 @@ +import io import json import httpx +import pytest import respx +from mpt_api_client.resources.catalog.products_media import ( + AsyncMediaService, + MediaService, +) + + +@pytest.fixture +def media_service(http_client): + return MediaService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) + + +@pytest.fixture +def async_media_service(async_http_client): + return AsyncMediaService( + http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} + ) + async def test_async_create_mixin(async_dummy_service): # noqa: WPS210 resource_data = {"name": "Test Resource", "status": "active"} @@ -98,3 +117,170 @@ def test_sync_update_resource(dummy_service): request = mock_route.calls[0].request assert mock_route.call_count == 1 assert json.loads(request.content.decode()) == resource_data + + +# FileOperationsMixin tests +async def test_async_file_create_with_data(async_media_service): + """Test FileOperationsMixin async create method with resource data.""" + media_data = {"id": "MED-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/media" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=media_data, + ) + ) + files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} + new_media = await async_media_service.create({"name": "Product image"}, files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_media_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Product image"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"Image content\r\n" in request.content + ) + assert new_media.to_dict() == media_data + + +async def test_async_file_create_no_data(async_media_service): + """Test FileOperationsMixin async create method without resource data.""" + media_data = {"id": "MED-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/media" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=media_data, + ) + ) + files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} + new_media = await async_media_service.create(files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"Image content\r\n" in request.content + ) + assert new_media.to_dict() == media_data + + +async def test_async_file_download(async_media_service): + """Test FileOperationsMixin async download method.""" + media_content = b"Image file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/media/MED-456" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', + }, + content=media_content, + ) + ) + + downloaded_file = await async_media_service.download("MED-456") + request = mock_route.calls[0].request + accept_header = (b"Accept", b"*") + assert accept_header in request.headers.raw + assert mock_route.call_count == 1 + assert downloaded_file.file_contents == media_content + assert downloaded_file.content_type == "application/octet-stream" + assert downloaded_file.filename == "product_image.jpg" + + +def test_sync_file_download(media_service): + """Test FileOperationsMixin download method.""" + media_content = b"Image file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/media/MED-456" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', + }, + content=media_content, + ) + ) + + downloaded_file = media_service.download("MED-456") + request = mock_route.calls[0].request + accept_header = (b"Accept", b"*") + assert accept_header in request.headers.raw + assert mock_route.call_count == 1 + assert downloaded_file.file_contents == media_content + assert downloaded_file.content_type == "application/octet-stream" + assert downloaded_file.filename == "product_image.jpg" + + +def test_sync_file_create_with_data(media_service): + """Test FileOperationsMixin create method with resource data.""" + media_data = {"id": "MED-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/media" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=media_data, + ) + ) + files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} + new_media = media_service.create({"name": "Product image"}, files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_media_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Product image"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"Image content\r\n" in request.content + ) + assert new_media.to_dict() == media_data + + +def test_sync_file_create_no_data(media_service): + """Test FileOperationsMixin create method without resource data.""" + media_data = {"id": "MED-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/catalog/products/PRD-001/media" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=media_data, + ) + ) + files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} + new_media = media_service.create(files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"Image content\r\n" in request.content + ) + assert new_media.to_dict() == media_data diff --git a/tests/http/test_service.py b/tests/http/test_service.py index 3768f778..e83cbb68 100644 --- a/tests/http/test_service.py +++ b/tests/http/test_service.py @@ -64,7 +64,7 @@ def test_sync_fetch_one_with_filters(dummy_service, single_result_response, filt ) -def test_sync_fetch_page_with_filter(dummy_service, list_response, filter_status_active) -> None: +def test_sync_fetch_page_with_filter(dummy_service, list_response, filter_status_active): custom_collection = ( dummy_service.filter(filter_status_active) .select("-audit", "product.agreements", "-product.agreements.product") diff --git a/tests/resources/catalog/test_product_term_variants.py b/tests/resources/catalog/test_product_term_variants.py new file mode 100644 index 00000000..42ab3ee9 --- /dev/null +++ b/tests/resources/catalog/test_product_term_variants.py @@ -0,0 +1,46 @@ +from typing import Any + +import pytest + +from mpt_api_client.resources.catalog.product_term_variants import ( + AsyncTermVariantService, + TermVariantService, +) + + +@pytest.fixture +def term_variant_service(http_client: Any) -> TermVariantService: + return TermVariantService(http_client=http_client, endpoint_params={"term_id": "TRM-001"}) + + +@pytest.fixture +def async_term_variant_service(async_http_client: Any) -> AsyncTermVariantService: + return AsyncTermVariantService( + http_client=async_http_client, endpoint_params={"term_id": "TRM-001"} + ) + + +def test_endpoint(term_variant_service: TermVariantService) -> None: + assert term_variant_service.endpoint == "/public/v1/catalog/products/terms/TRM-001/variants" + + +def test_async_endpoint(async_term_variant_service: AsyncTermVariantService) -> None: + assert ( + async_term_variant_service.endpoint == "/public/v1/catalog/products/terms/TRM-001/variants" + ) + + +@pytest.mark.parametrize( + "method", ["get", "create", "delete", "update", "download", "review", "publish", "unpublish"] +) +def test_methods_present(term_variant_service: TermVariantService, method: str) -> None: + assert hasattr(term_variant_service, method) + + +@pytest.mark.parametrize( + "method", ["get", "create", "delete", "update", "download", "review", "publish", "unpublish"] +) +def test_async_methods_present( + async_term_variant_service: AsyncTermVariantService, method: str +) -> None: + assert hasattr(async_term_variant_service, method) diff --git a/tests/resources/catalog/test_product_terms.py b/tests/resources/catalog/test_product_terms.py index 343d4737..42b6fd80 100644 --- a/tests/resources/catalog/test_product_terms.py +++ b/tests/resources/catalog/test_product_terms.py @@ -1,5 +1,11 @@ +from typing import Any + import pytest +from mpt_api_client.resources.catalog.product_term_variants import ( + AsyncTermVariantService, + TermVariantService, +) from mpt_api_client.resources.catalog.product_terms import ( AsyncTermService, TermService, @@ -7,34 +13,50 @@ @pytest.fixture -def term_service(http_client): +def term_service(http_client: Any) -> TermService: return TermService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) @pytest.fixture -def async_term_service(async_http_client): +def async_term_service(async_http_client: Any) -> AsyncTermService: return AsyncTermService( http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} ) -def test_endpoint(term_service): +def test_endpoint(term_service: TermService) -> None: assert term_service.endpoint == "/public/v1/catalog/products/PRD-001/terms" -def test_async_endpoint(async_term_service): +def test_async_endpoint(async_term_service: AsyncTermService) -> None: assert async_term_service.endpoint == "/public/v1/catalog/products/PRD-001/terms" @pytest.mark.parametrize( "method", ["get", "create", "delete", "update", "review", "publish", "unpublish"] ) -def test_methods_present(term_service, method): +def test_methods_present(term_service: TermService, method: str) -> None: assert hasattr(term_service, method) @pytest.mark.parametrize( "method", ["get", "create", "delete", "update", "review", "publish", "unpublish"] ) -def test_async_methods_present(async_term_service, method): +def test_async_methods_present(async_term_service: AsyncTermService, method: str) -> None: assert hasattr(async_term_service, method) + + +def test_variants_property(term_service: TermService) -> None: + """Test that variants property returns TermVariantService.""" + variants = term_service.variants("TCS-001") + assert isinstance(variants, TermVariantService) + assert variants.http_client == term_service.http_client + assert variants.endpoint_params == {"term_id": "TCS-001"} + + +def test_async_variants_property(async_term_service: AsyncTermService) -> None: + """Test that variants property returns AsyncTermVariantService.""" + variants = async_term_service.variants("TCS-001") + assert isinstance(variants, AsyncTermVariantService) + assert variants.http_client == async_term_service.http_client + assert variants.endpoint_params == {"term_id": "TCS-001"} diff --git a/tests/resources/catalog/test_products_documents.py b/tests/resources/catalog/test_products_documents.py index 2d8a3c2c..df06654a 100644 --- a/tests/resources/catalog/test_products_documents.py +++ b/tests/resources/catalog/test_products_documents.py @@ -1,8 +1,4 @@ -import io - -import httpx import pytest -import respx from mpt_api_client.resources.catalog.products_documents import ( AsyncDocumentService, @@ -11,180 +7,34 @@ @pytest.fixture -def document_service(http_client): +def document_service(http_client) -> DocumentService: return DocumentService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) @pytest.fixture -def async_document_service(async_http_client): +def async_document_service(async_http_client) -> AsyncDocumentService: return AsyncDocumentService( http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} ) -def test_endpoint(document_service): +def test_endpoint(document_service) -> None: assert document_service.endpoint == "/public/v1/catalog/products/PRD-001/documents" -def test_async_endpoint(async_document_service): +def test_async_endpoint(async_document_service) -> None: assert async_document_service.endpoint == "/public/v1/catalog/products/PRD-001/documents" -async def test_async_create(async_document_service): - document_data = {"id": "DOC-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/catalog/products/PRD-001/documents" - ).mock( - return_value=httpx.Response( - status_code=200, - json=document_data, - ) - ) - files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} - new_document = await async_document_service.create( - {"name": "Product document"}, files=files - ) - - request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="_attachment_data"\r\n' - b"Content-Type: application/json\r\n\r\n" - b'{"name":"Product document"}\r\n' in request.content - ) - assert ( - b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' - b"Content-Type: application/pdf\r\n\r\n" - b"PDF content\r\n" in request.content - ) - assert new_document.to_dict() == document_data - - -async def test_async_create_no_data(async_document_service): - document_data = {"id": "DOC-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/catalog/products/PRD-001/documents" - ).mock( - return_value=httpx.Response( - status_code=200, - json=document_data, - ) - ) - files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} - new_document = await async_document_service.create(files=files) - - request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' - b"Content-Type: application/pdf\r\n\r\n" - b"PDF content\r\n" in request.content - ) - assert new_document.to_dict() == document_data - - -async def test_async_download(async_document_service): - document_content = b"PDF file content or binary data" - - with respx.mock: - mock_route = respx.get( - "https://api.example.com/public/v1/catalog/products/PRD-001/documents/DOC-456" - ).mock( - return_value=httpx.Response( - status_code=200, - headers={ - "content-type": "application/octet-stream", - "content-disposition": 'form-data; name="file"; ' - 'filename="product_document.pdf"', - }, - content=document_content, - ) - ) - - file_model = await async_document_service.download("DOC-456") - - assert mock_route.called - assert file_model.response.status_code == 200 - assert file_model.response.content == document_content - assert file_model.filename == "product_document.pdf" - - -def test_create(document_service): - document_data = {"id": "DOC-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/catalog/products/PRD-001/documents" - ).mock( - return_value=httpx.Response( - status_code=200, - json=document_data, - ) - ) - files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} - new_document = document_service.create({"name": "Product document"}, files=files) - - request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="_attachment_data"\r\n' - b"Content-Type: application/json\r\n\r\n" - b'{"name":"Product document"}\r\n' in request.content - ) - assert ( - b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' - b"Content-Type: application/pdf\r\n\r\n" - b"PDF content\r\n" in request.content - ) - assert new_document.to_dict() == document_data - - -def test_create_no_data(document_service): - document_data = {"id": "DOC-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/catalog/products/PRD-001/documents" - ).mock( - return_value=httpx.Response( - status_code=200, - json=document_data, - ) - ) - files = {"document": ("test.pdf", io.BytesIO(b"PDF content"), "application/pdf")} - new_document = document_service.create(files=files) - - request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="document"; filename="test.pdf"\r\n' - b"Content-Type: application/pdf\r\n\r\n" - b"PDF content\r\n" in request.content - ) - assert new_document.to_dict() == document_data - - -def test_download(document_service): - document_content = b"PDF file content or binary data" - - with respx.mock: - mock_route = respx.get( - "https://api.example.com/public/v1/catalog/products/PRD-001/documents/DOC-456" - ).mock( - return_value=httpx.Response( - status_code=200, - headers={ - "content-type": "application/octet-stream", - "content-disposition": 'form-data; name="file"; ' - 'filename="product_document.pdf"', - }, - content=document_content, - ) - ) +@pytest.mark.parametrize( + "method", ["get", "create", "delete", "update", "download", "review", "publish", "unpublish"] +) +def test_methods_present(document_service, method: str) -> None: + assert hasattr(document_service, method) - file_model = document_service.download("DOC-456") - assert mock_route.called - assert file_model.response.status_code == 200 - assert file_model.response.content == document_content - assert file_model.filename == "product_document.pdf" +@pytest.mark.parametrize( + "method", ["get", "create", "delete", "update", "download", "review", "publish", "unpublish"] +) +def test_async_methods_present(async_document_service, method: str) -> None: + assert hasattr(async_document_service, method) diff --git a/tests/resources/catalog/test_products_media.py b/tests/resources/catalog/test_products_media.py index dcbbaaea..58a0d5ed 100644 --- a/tests/resources/catalog/test_products_media.py +++ b/tests/resources/catalog/test_products_media.py @@ -1,8 +1,4 @@ -import io - -import httpx import pytest -import respx from mpt_api_client.resources.catalog.products_media import ( AsyncMediaService, @@ -11,194 +7,34 @@ @pytest.fixture -def media_service(http_client): +def media_service(http_client) -> MediaService: return MediaService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) @pytest.fixture -def async_media_service(async_http_client): +def async_media_service(async_http_client) -> AsyncMediaService: return AsyncMediaService( http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} ) -def test_endpoint(media_service): +def test_endpoint(media_service) -> None: assert media_service.endpoint == "/public/v1/catalog/products/PRD-001/media" -def test_async_endpoint(async_media_service): +def test_async_endpoint(async_media_service) -> None: assert async_media_service.endpoint == "/public/v1/catalog/products/PRD-001/media" -async def test_async_create(async_media_service): - media_data = {"id": "MED-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/catalog/products/PRD-001/media" - ).mock( - return_value=httpx.Response( - status_code=200, - json=media_data, - ) - ) - files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} - new_media = await async_media_service.create({"name": "Product image"}, files=files) - - request: httpx.Request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="_media_data"\r\n' - b"Content-Type: application/json\r\n\r\n" - b'{"name":"Product image"}\r\n' in request.content - ) - assert ( - b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' - b"Content-Type: image/jpeg\r\n\r\n" - b"Image content\r\n" in request.content - ) - assert new_media.to_dict() == media_data - - -async def test_async_create_no_data(async_media_service): - media_data = {"id": "MED-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/catalog/products/PRD-001/media" - ).mock( - return_value=httpx.Response( - status_code=200, - json=media_data, - ) - ) - files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} - new_media = await async_media_service.create(files=files) - - request: httpx.Request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' - b"Content-Type: image/jpeg\r\n\r\n" - b"Image content\r\n" in request.content - ) - assert new_media.to_dict() == media_data - - -async def test_async_download(async_media_service): - media_content = b"Image file content or binary data" - - with respx.mock: - mock_route = respx.get( - "https://api.example.com/public/v1/catalog/products/PRD-001/media/MED-456" - ).mock( - return_value=httpx.Response( - status_code=200, - headers={ - "content-type": "application/octet-stream", - "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', - }, - content=media_content, - ) - ) - - downloaded_file = await async_media_service.download("MED-456") - request = mock_route.calls[0].request - accept_header = (b"Accept", b"*") - assert accept_header in request.headers.raw - assert mock_route.call_count == 1 - assert downloaded_file.file_contents == media_content - assert downloaded_file.content_type == "application/octet-stream" - assert downloaded_file.filename == "product_image.jpg" - - -def test_download(media_service): - media_content = b"Image file content or binary data" - - with respx.mock: - mock_route = respx.get( - "https://api.example.com/public/v1/catalog/products/PRD-001/media/MED-456" - ).mock( - return_value=httpx.Response( - status_code=200, - headers={ - "content-type": "application/octet-stream", - "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', - }, - content=media_content, - ) - ) - - downloaded_file = media_service.download("MED-456") - request = mock_route.calls[0].request - accept_header = (b"Accept", b"*") - assert accept_header in request.headers.raw - assert mock_route.call_count == 1 - assert downloaded_file.file_contents == media_content - assert downloaded_file.content_type == "application/octet-stream" - assert downloaded_file.filename == "product_image.jpg" - - -def test_create(media_service): - media_data = {"id": "MED-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/catalog/products/PRD-001/media" - ).mock( - return_value=httpx.Response( - status_code=200, - json=media_data, - ) - ) - files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} - new_media = media_service.create({"name": "Product image"}, files=files) - - request: httpx.Request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="_media_data"\r\n' - b"Content-Type: application/json\r\n\r\n" - b'{"name":"Product image"}\r\n' in request.content - ) - assert ( - b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' - b"Content-Type: image/jpeg\r\n\r\n" - b"Image content\r\n" in request.content - ) - assert new_media.to_dict() == media_data - - -def test_create_no_data(media_service): - media_data = {"id": "MED-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/catalog/products/PRD-001/media" - ).mock( - return_value=httpx.Response( - status_code=200, - json=media_data, - ) - ) - files = {"media": ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg")} - new_media = media_service.create(files=files) - - request: httpx.Request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="media"; filename="test.jpg"\r\n' - b"Content-Type: image/jpeg\r\n\r\n" - b"Image content\r\n" in request.content - ) - assert new_media.to_dict() == media_data - - @pytest.mark.parametrize( "method", ["get", "create", "delete", "update", "download", "review", "publish", "unpublish"] ) -def test_methods_present(media_service, method): +def test_methods_present(media_service, method: str) -> None: assert hasattr(media_service, method) @pytest.mark.parametrize( "method", ["get", "create", "delete", "update", "download", "review", "publish", "unpublish"] ) -def test_async_methods_present(async_media_service, method): +def test_async_methods_present(async_media_service, method: str) -> None: assert hasattr(async_media_service, method) diff --git a/tests/resources/commerce/test_agreements_attachments.py b/tests/resources/commerce/test_agreements_attachments.py index ffa57411..be656d68 100644 --- a/tests/resources/commerce/test_agreements_attachments.py +++ b/tests/resources/commerce/test_agreements_attachments.py @@ -1,8 +1,4 @@ -import io - -import httpx import pytest -import respx from mpt_api_client.resources.commerce.agreements_attachments import ( AgreementsAttachmentService, @@ -11,184 +7,32 @@ @pytest.fixture -def attachment_service(http_client): +def attachment_service(http_client) -> AgreementsAttachmentService: return AgreementsAttachmentService( http_client=http_client, endpoint_params={"agreement_id": "AGR-123"} ) @pytest.fixture -def async_attachment_service(async_http_client): +def async_attachment_service(async_http_client) -> AsyncAgreementsAttachmentService: return AsyncAgreementsAttachmentService( http_client=async_http_client, endpoint_params={"agreement_id": "AGR-123"} ) -async def test_async_create(async_attachment_service): - attachment_data = {"id": "ATT-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments" - ).mock( - return_value=httpx.Response( - status_code=200, - json=attachment_data, - ) - ) - files = {"attachment": ("test.txt", io.BytesIO(b"Hello"), "text/plain")} - new_attachment = await async_attachment_service.create({"name": "Upload test"}, files=files) - - request: httpx.Request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="_attachment_data"\r\n' - b"Content-Type: application/json\r\n\r\n" - b'{"name":"Upload test"}\r\n' in request.content - ) - assert ( - b'Content-Disposition: form-data; name="attachment"; filename="test.txt"\r\n' - b"Content-Type: text/plain\r\n\r\n" - b"Hello\r\n" in request.content - ) - assert new_attachment.to_dict() == attachment_data - - -async def test_async_create_no_data(async_attachment_service): - attachment_data = {"id": "ATT-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments" - ).mock( - return_value=httpx.Response( - status_code=200, - json=attachment_data, - ) - ) - files = {"attachment": ("test.txt", io.BytesIO(b"Hello"), "text/plain")} - new_attachment = await async_attachment_service.create(files=files) - - request: httpx.Request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="attachment"; filename="test.txt"\r\n' - b"Content-Type: text/plain\r\n\r\n" - b"Hello\r\n" in request.content - ) - assert new_attachment.to_dict() == attachment_data - - -async def test_async_download(async_attachment_service): - attachment_content = b"PDF file content or binary data" - - with respx.mock: - mock_route = respx.get( - "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments/ATT-456" - ).mock( - return_value=httpx.Response( - status_code=200, - headers={ - "content-type": "application/octet-stream", - "content-disposition": 'form-data; name="file"; filename="test.txt.pdf"', - }, - content=attachment_content, - ) - ) - - downloaded_file = await async_attachment_service.download("ATT-456") - request = mock_route.calls[0].request - accept_header = (b"Accept", b"*") - assert accept_header in request.headers.raw - assert mock_route.call_count == 1 - assert downloaded_file.file_contents == attachment_content - assert downloaded_file.content_type == "application/octet-stream" - assert downloaded_file.filename == "test.txt.pdf" +def test_endpoint(attachment_service) -> None: + assert attachment_service.endpoint == "/public/v1/commerce/agreements/AGR-123/attachments" -def test_download(attachment_service): - attachment_content = b"PDF file content or binary data" - - with respx.mock: - mock_route = respx.get( - "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments/ATT-456" - ).mock( - return_value=httpx.Response( - status_code=200, - headers={ - "content-type": "application/octet-stream", - "content-disposition": 'form-data; name="file"; filename="test.txt.pdf"', - }, - content=attachment_content, - ) - ) - - downloaded_file = attachment_service.download("ATT-456") - request = mock_route.calls[0].request - accept_header = (b"Accept", b"*") - assert accept_header in request.headers.raw - assert mock_route.call_count == 1 - assert downloaded_file.file_contents == attachment_content - assert downloaded_file.content_type == "application/octet-stream" - assert downloaded_file.filename == "test.txt.pdf" - - -def test_create(attachment_service): - attachment_data = {"id": "ATT-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments" - ).mock( - return_value=httpx.Response( - status_code=200, - json=attachment_data, - ) - ) - files = {"attachment": ("test.txt", io.BytesIO(b"Hello"), "text/plain")} - new_attachment = attachment_service.create({"name": "Upload test"}, files=files) - - request: httpx.Request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="_attachment_data"\r\n' - b"Content-Type: application/json\r\n\r\n" - b'{"name":"Upload test"}\r\n' in request.content - ) - assert ( - b'Content-Disposition: form-data; name="attachment"; filename="test.txt"\r\n' - b"Content-Type: text/plain\r\n\r\n" - b"Hello\r\n" in request.content - ) - assert new_attachment.to_dict() == attachment_data - - -def test_create_no_data(attachment_service): - attachment_data = {"id": "ATT-133"} - with respx.mock: - mock_route = respx.post( - "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments" - ).mock( - return_value=httpx.Response( - status_code=200, - json=attachment_data, - ) - ) - files = {"attachment": ("test.txt", io.BytesIO(b"Hello"), "text/plain")} - new_attachment = attachment_service.create(files=files) - - request: httpx.Request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="attachment"; filename="test.txt"\r\n' - b"Content-Type: text/plain\r\n\r\n" - b"Hello\r\n" in request.content - ) - assert new_attachment.to_dict() == attachment_data +def test_async_endpoint(async_attachment_service) -> None: + assert async_attachment_service.endpoint == "/public/v1/commerce/agreements/AGR-123/attachments" @pytest.mark.parametrize("method", ["get", "create", "delete", "download"]) -def test_mixins_present(attachment_service, method): +def test_methods_present(attachment_service, method: str) -> None: assert hasattr(attachment_service, method) @pytest.mark.parametrize("method", ["get", "create", "delete", "download"]) -def test_async_mixins_present(async_attachment_service, method): +def test_async_methods_present(async_attachment_service, method: str) -> None: assert hasattr(async_attachment_service, method)