diff --git a/e2e_config.test.json b/e2e_config.test.json index a93317cb..6850dc32 100644 --- a/e2e_config.test.json +++ b/e2e_config.test.json @@ -3,6 +3,7 @@ "accounts.seller.id": "SEL-7310-3075", "catalog.product.parameter_group.id": "PGR-7255-3950-0001", "catalog.product.parameter.id": "PAR-7255-3950-0016", + "catalog.product.document.id": "PDC-7255-3950-0001", "accounts.account.id": "ACC-9042-0088", "accounts.buyer.account.id": "ACC-1086-6867", "accounts.buyer.id": "BUY-1591-2112", diff --git a/mpt_api_client/http/async_client.py b/mpt_api_client/http/async_client.py index 1de2f23d..d0f74b71 100644 --- a/mpt_api_client/http/async_client.py +++ b/mpt_api_client/http/async_client.py @@ -53,6 +53,7 @@ def __init__( headers=base_headers, timeout=timeout, transport=AsyncHTTPTransport(retries=retries), + follow_redirects=True, ) async def request( # noqa: WPS211 diff --git a/mpt_api_client/http/async_service.py b/mpt_api_client/http/async_service.py index 5749b81e..585b674d 100644 --- a/mpt_api_client/http/async_service.py +++ b/mpt_api_client/http/async_service.py @@ -1,5 +1,6 @@ from urllib.parse import urljoin +from mpt_api_client.constants import APPLICATION_JSON from mpt_api_client.http.async_client import AsyncHTTPClient from mpt_api_client.http.base_service import ServiceBase from mpt_api_client.http.types import QueryParam, Response @@ -69,6 +70,11 @@ async def _resource_action( query_params: Additional query parameters. """ response = await self._resource_do_request( - resource_id, method, action, json=json, query_params=query_params + resource_id, + method, + action, + json=json, + query_params=query_params, + headers={"Accept": APPLICATION_JSON}, ) return self._model_class.from_response(response) diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index ee8aa05e..f623a613 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -55,6 +55,7 @@ def __init__( headers=base_headers, timeout=timeout, transport=HTTPTransport(retries=retries), + follow_redirects=True, ) def request( # noqa: WPS211 diff --git a/mpt_api_client/http/mixins.py b/mpt_api_client/http/mixins.py index 2ea73b84..66d22dad 100644 --- a/mpt_api_client/http/mixins.py +++ b/mpt_api_client/http/mixins.py @@ -4,6 +4,7 @@ from urllib.parse import urljoin from mpt_api_client.constants import APPLICATION_JSON +from mpt_api_client.exceptions import MPTError from mpt_api_client.http.query_state import QueryState from mpt_api_client.http.types import FileTypes, Response from mpt_api_client.models import Collection, FileModel, ResourceData @@ -60,7 +61,32 @@ 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]: +class DownloadFileMixin[Model]: + """Download file mixin.""" + + def download(self, resource_id: str, accept: str | None = None) -> FileModel: + """Download the file for the given resource ID. + + Args: + resource_id: Resource ID. + accept: The content type expected for the file. + If not provided, the content type will be fetched from the resource. + + Returns: + File model containing the downloaded file. + """ + if not accept: + resource: Model = self._resource_action(resource_id, method="GET") # type: ignore[attr-defined] + accept = resource.content_type # type: ignore[attr-defined] + if not accept: + raise MPTError("Unable to download file. Content type not found in resource") + response: Response = self._resource_do_request( # type: ignore[attr-defined] + resource_id, method="GET", headers={"Accept": accept} + ) + return FileModel(response) + + +class FilesOperationsMixin[Model](DownloadFileMixin[Model]): """Mixin that provides create and download methods for file-based resources.""" def create( @@ -91,20 +117,6 @@ def create( 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 CreateWithIconMixin[Model]: """Create resource with icon mixin.""" @@ -221,7 +233,32 @@ 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]: +class AsyncDownloadFileMixin[Model]: + """Download file mixin.""" + + async def download(self, resource_id: str, accept: str | None = None) -> FileModel: + """Download the file for the given resource ID. + + Args: + resource_id: Resource ID. + accept: The content type expected for the file. + If not provided, the content type will be fetched from the resource. + + Returns: + File model containing the downloaded file. + """ + if not accept: + resource: Model = await self._resource_action(resource_id, method="GET") # type: ignore[attr-defined] + accept = resource.content_type # type: ignore[attr-defined] + if not accept: + raise MPTError("Unable to download file. Content type not found in resource") + response = await self._resource_do_request( # type: ignore[attr-defined] + resource_id, method="GET", headers={"Accept": accept} + ) + return FileModel(response) + + +class AsyncFilesOperationsMixin[Model](AsyncDownloadFileMixin[Model]): """Async mixin that provides create and download methods for file-based resources.""" async def create( @@ -253,20 +290,6 @@ async def create( 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) - class AsyncCreateWithIconMixin[Model]: """Create resource with icon mixin.""" diff --git a/mpt_api_client/http/service.py b/mpt_api_client/http/service.py index 00162823..45ae6ac8 100644 --- a/mpt_api_client/http/service.py +++ b/mpt_api_client/http/service.py @@ -1,5 +1,6 @@ from urllib.parse import urljoin +from mpt_api_client.constants import APPLICATION_JSON from mpt_api_client.http.base_service import ServiceBase from mpt_api_client.http.client import HTTPClient from mpt_api_client.http.types import QueryParam, Response @@ -74,6 +75,6 @@ def _resource_action( action, json=json, query_params=query_params, - headers={"Accept": "application/json"}, + headers={"Accept": APPLICATION_JSON}, ) return self._model_class.from_response(response) diff --git a/mpt_api_client/resources/billing/credit_memo_attachments.py b/mpt_api_client/resources/billing/credit_memo_attachments.py index 65e020fa..3805efe2 100644 --- a/mpt_api_client/resources/billing/credit_memo_attachments.py +++ b/mpt_api_client/resources/billing/credit_memo_attachments.py @@ -1,10 +1,10 @@ from mpt_api_client.http import AsyncService, Service from mpt_api_client.http.mixins import ( AsyncCollectionMixin, - AsyncFileOperationsMixin, + AsyncFilesOperationsMixin, AsyncModifiableResourceMixin, CollectionMixin, - FileOperationsMixin, + FilesOperationsMixin, ModifiableResourceMixin, ) from mpt_api_client.models import Model @@ -23,7 +23,7 @@ class CreditMemoAttachmentsServiceConfig: class CreditMemoAttachmentsService( - FileOperationsMixin[CreditMemoAttachment], + FilesOperationsMixin[CreditMemoAttachment], ModifiableResourceMixin[CreditMemoAttachment], CollectionMixin[CreditMemoAttachment], Service[CreditMemoAttachment], @@ -33,7 +33,7 @@ class CreditMemoAttachmentsService( class AsyncCreditMemoAttachmentsService( - AsyncFileOperationsMixin[CreditMemoAttachment], + AsyncFilesOperationsMixin[CreditMemoAttachment], AsyncModifiableResourceMixin[CreditMemoAttachment], AsyncCollectionMixin[CreditMemoAttachment], AsyncService[CreditMemoAttachment], diff --git a/mpt_api_client/resources/billing/custom_ledger_attachments.py b/mpt_api_client/resources/billing/custom_ledger_attachments.py index 5fda68da..45fb3918 100644 --- a/mpt_api_client/resources/billing/custom_ledger_attachments.py +++ b/mpt_api_client/resources/billing/custom_ledger_attachments.py @@ -1,10 +1,10 @@ from mpt_api_client.http import AsyncService, Service from mpt_api_client.http.mixins import ( AsyncCollectionMixin, - AsyncFileOperationsMixin, + AsyncFilesOperationsMixin, AsyncModifiableResourceMixin, CollectionMixin, - FileOperationsMixin, + FilesOperationsMixin, ModifiableResourceMixin, ) from mpt_api_client.models import Model @@ -23,7 +23,7 @@ class CustomLedgerAttachmentsServiceConfig: class CustomLedgerAttachmentsService( - FileOperationsMixin[CustomLedgerAttachment], + FilesOperationsMixin[CustomLedgerAttachment], ModifiableResourceMixin[CustomLedgerAttachment], CollectionMixin[CustomLedgerAttachment], Service[CustomLedgerAttachment], @@ -33,7 +33,7 @@ class CustomLedgerAttachmentsService( class AsyncCustomLedgerAttachmentsService( - AsyncFileOperationsMixin[CustomLedgerAttachment], + AsyncFilesOperationsMixin[CustomLedgerAttachment], AsyncModifiableResourceMixin[CustomLedgerAttachment], AsyncCollectionMixin[CustomLedgerAttachment], AsyncService[CustomLedgerAttachment], diff --git a/mpt_api_client/resources/billing/custom_ledger_upload.py b/mpt_api_client/resources/billing/custom_ledger_upload.py index 2c445b3a..47351df2 100644 --- a/mpt_api_client/resources/billing/custom_ledger_upload.py +++ b/mpt_api_client/resources/billing/custom_ledger_upload.py @@ -1,5 +1,5 @@ from mpt_api_client.http import AsyncService, Service -from mpt_api_client.http.mixins import AsyncFileOperationsMixin, FileOperationsMixin +from mpt_api_client.http.mixins import AsyncFilesOperationsMixin, FilesOperationsMixin from mpt_api_client.models import Model @@ -16,7 +16,7 @@ class CustomLedgerUploadServiceConfig: class CustomLedgerUploadService( - FileOperationsMixin[CustomLedgerUpload], + FilesOperationsMixin[CustomLedgerUpload], Service[CustomLedgerUpload], CustomLedgerUploadServiceConfig, ): @@ -24,7 +24,7 @@ class CustomLedgerUploadService( class AsyncCustomLedgerUploadService( - AsyncFileOperationsMixin[CustomLedgerUpload], + AsyncFilesOperationsMixin[CustomLedgerUpload], AsyncService[CustomLedgerUpload], CustomLedgerUploadServiceConfig, ): diff --git a/mpt_api_client/resources/billing/invoice_attachments.py b/mpt_api_client/resources/billing/invoice_attachments.py index ba9cd800..7966f731 100644 --- a/mpt_api_client/resources/billing/invoice_attachments.py +++ b/mpt_api_client/resources/billing/invoice_attachments.py @@ -1,10 +1,10 @@ from mpt_api_client.http import AsyncService, Service from mpt_api_client.http.mixins import ( AsyncCollectionMixin, - AsyncFileOperationsMixin, + AsyncFilesOperationsMixin, AsyncModifiableResourceMixin, CollectionMixin, - FileOperationsMixin, + FilesOperationsMixin, ModifiableResourceMixin, ) from mpt_api_client.models import Model @@ -23,7 +23,7 @@ class InvoiceAttachmentsServiceConfig: class InvoiceAttachmentsService( - FileOperationsMixin[InvoiceAttachment], + FilesOperationsMixin[InvoiceAttachment], ModifiableResourceMixin[InvoiceAttachment], CollectionMixin[InvoiceAttachment], Service[InvoiceAttachment], @@ -33,7 +33,7 @@ class InvoiceAttachmentsService( class AsyncInvoiceAttachmentsService( - AsyncFileOperationsMixin[InvoiceAttachment], + AsyncFilesOperationsMixin[InvoiceAttachment], AsyncModifiableResourceMixin[InvoiceAttachment], AsyncCollectionMixin[InvoiceAttachment], AsyncService[InvoiceAttachment], diff --git a/mpt_api_client/resources/billing/journal_attachments.py b/mpt_api_client/resources/billing/journal_attachments.py index 6fc50ba3..fcf81a1d 100644 --- a/mpt_api_client/resources/billing/journal_attachments.py +++ b/mpt_api_client/resources/billing/journal_attachments.py @@ -1,10 +1,10 @@ from mpt_api_client.http import AsyncService, Service from mpt_api_client.http.mixins import ( AsyncCollectionMixin, - AsyncFileOperationsMixin, + AsyncFilesOperationsMixin, AsyncModifiableResourceMixin, CollectionMixin, - FileOperationsMixin, + FilesOperationsMixin, ModifiableResourceMixin, ) from mpt_api_client.models import Model @@ -23,7 +23,7 @@ class JournalAttachmentsServiceConfig: class JournalAttachmentsService( - FileOperationsMixin[JournalAttachment], + FilesOperationsMixin[JournalAttachment], ModifiableResourceMixin[JournalAttachment], CollectionMixin[JournalAttachment], Service[JournalAttachment], @@ -33,7 +33,7 @@ class JournalAttachmentsService( class AsyncJournalAttachmentsService( - AsyncFileOperationsMixin[JournalAttachment], + AsyncFilesOperationsMixin[JournalAttachment], AsyncModifiableResourceMixin[JournalAttachment], AsyncCollectionMixin[JournalAttachment], AsyncService[JournalAttachment], diff --git a/mpt_api_client/resources/billing/journal_upload.py b/mpt_api_client/resources/billing/journal_upload.py index da661985..dd9e9774 100644 --- a/mpt_api_client/resources/billing/journal_upload.py +++ b/mpt_api_client/resources/billing/journal_upload.py @@ -1,9 +1,9 @@ from mpt_api_client.http import AsyncService, Service from mpt_api_client.http.mixins import ( AsyncCollectionMixin, - AsyncFileOperationsMixin, + AsyncFilesOperationsMixin, CollectionMixin, - FileOperationsMixin, + FilesOperationsMixin, ) from mpt_api_client.models import Model @@ -21,7 +21,7 @@ class JournalUploadServiceConfig: class JournalUploadService( - FileOperationsMixin[JournalUpload], + FilesOperationsMixin[JournalUpload], CollectionMixin[JournalUpload], Service[JournalUpload], JournalUploadServiceConfig, @@ -30,7 +30,7 @@ class JournalUploadService( class AsyncJournalUploadService( - AsyncFileOperationsMixin[JournalUpload], + AsyncFilesOperationsMixin[JournalUpload], AsyncCollectionMixin[JournalUpload], AsyncService[JournalUpload], JournalUploadServiceConfig, diff --git a/mpt_api_client/resources/billing/ledger_attachments.py b/mpt_api_client/resources/billing/ledger_attachments.py index 6f195868..836ac718 100644 --- a/mpt_api_client/resources/billing/ledger_attachments.py +++ b/mpt_api_client/resources/billing/ledger_attachments.py @@ -1,10 +1,10 @@ from mpt_api_client.http import AsyncService, Service from mpt_api_client.http.mixins import ( AsyncCollectionMixin, - AsyncFileOperationsMixin, + AsyncFilesOperationsMixin, AsyncModifiableResourceMixin, CollectionMixin, - FileOperationsMixin, + FilesOperationsMixin, ModifiableResourceMixin, ) from mpt_api_client.models import Model @@ -23,7 +23,7 @@ class LedgerAttachmentsServiceConfig: class LedgerAttachmentsService( - FileOperationsMixin[LedgerAttachment], + FilesOperationsMixin[LedgerAttachment], ModifiableResourceMixin[LedgerAttachment], CollectionMixin[LedgerAttachment], Service[LedgerAttachment], @@ -33,7 +33,7 @@ class LedgerAttachmentsService( class AsyncLedgerAttachmentsService( - AsyncFileOperationsMixin[LedgerAttachment], + AsyncFilesOperationsMixin[LedgerAttachment], AsyncModifiableResourceMixin[LedgerAttachment], AsyncCollectionMixin[LedgerAttachment], AsyncService[LedgerAttachment], diff --git a/mpt_api_client/resources/catalog/mixins.py b/mpt_api_client/resources/catalog/mixins.py index 639f1b99..5e5bb789 100644 --- a/mpt_api_client/resources/catalog/mixins.py +++ b/mpt_api_client/resources/catalog/mixins.py @@ -1,3 +1,10 @@ +from mpt_api_client.constants import APPLICATION_JSON +from mpt_api_client.http.mixins import ( + AsyncDownloadFileMixin, + DownloadFileMixin, + _json_to_file_payload, +) +from mpt_api_client.http.types import FileTypes from mpt_api_client.models import ResourceData @@ -76,6 +83,71 @@ async def unpublish(self, resource_id: str, resource_data: ResourceData | None = ) +class AsyncDocumentMixin[Model]( + AsyncDownloadFileMixin[Model], + AsyncPublishableMixin[Model], +): + """Async document mixin.""" + + async def create(self, resource_data: ResourceData, file: FileTypes | None = None) -> Model: + """Creates document resource. + + Creates a document resource by specifying a `file` or an `url`. + + Args: + resource_data: Resource data. + file: File to upload. + + Returns: + Created resource. + + """ + files = {} + + if resource_data: + files["document"] = ( + None, + _json_to_file_payload(resource_data), + APPLICATION_JSON, + ) + if file: + files["file"] = file # type: ignore[assignment] + response = await self.http_client.request("post", self.path, files=files) # type: ignore[attr-defined] + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] + + +class DocumentMixin[Model]( + DownloadFileMixin[Model], + PublishableMixin[Model], +): + """Document mixin.""" + + def create(self, resource_data: ResourceData, file: FileTypes | None = None) -> Model: + """Create document. + + Creates a document resource by specifying a `file` or an `url`. + + Args: + resource_data: Resource data. + file: File to upload. + + Returns: + Created resource. + """ + files = {} + + if resource_data: + files["document"] = ( + None, + _json_to_file_payload(resource_data), + APPLICATION_JSON, + ) + if file: + files["file"] = file # type: ignore[assignment] + response = self.http_client.request("post", self.path, files=files) # type: ignore[attr-defined] + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] + + class ActivatableMixin[Model]: """Activatable mixin adds the ability to activate and deactivate.""" diff --git a/mpt_api_client/resources/catalog/pricing_policy_attachments.py b/mpt_api_client/resources/catalog/pricing_policy_attachments.py index 72042bea..f237697a 100644 --- a/mpt_api_client/resources/catalog/pricing_policy_attachments.py +++ b/mpt_api_client/resources/catalog/pricing_policy_attachments.py @@ -1,10 +1,10 @@ from mpt_api_client.http import AsyncService, Service from mpt_api_client.http.mixins import ( AsyncCollectionMixin, - AsyncFileOperationsMixin, + AsyncFilesOperationsMixin, AsyncModifiableResourceMixin, CollectionMixin, - FileOperationsMixin, + FilesOperationsMixin, ModifiableResourceMixin, ) from mpt_api_client.models import Model @@ -24,7 +24,7 @@ class PricingPolicyAttachmentsServiceConfig: class PricingPolicyAttachmentsService( - FileOperationsMixin[PricingPolicyAttachment], + FilesOperationsMixin[PricingPolicyAttachment], ActivatableMixin[PricingPolicyAttachment], ModifiableResourceMixin[PricingPolicyAttachment], CollectionMixin[PricingPolicyAttachment], @@ -35,7 +35,7 @@ class PricingPolicyAttachmentsService( class AsyncPricingPolicyAttachmentsService( - AsyncFileOperationsMixin[PricingPolicyAttachment], + AsyncFilesOperationsMixin[PricingPolicyAttachment], AsyncActivatableMixin[PricingPolicyAttachment], AsyncModifiableResourceMixin[PricingPolicyAttachment], AsyncCollectionMixin[PricingPolicyAttachment], diff --git a/mpt_api_client/resources/catalog/product_term_variants.py b/mpt_api_client/resources/catalog/product_term_variants.py index 12ef4e5f..2edae1ab 100644 --- a/mpt_api_client/resources/catalog/product_term_variants.py +++ b/mpt_api_client/resources/catalog/product_term_variants.py @@ -1,10 +1,10 @@ from mpt_api_client.http import AsyncService, Service from mpt_api_client.http.mixins import ( AsyncCollectionMixin, - AsyncFileOperationsMixin, + AsyncFilesOperationsMixin, AsyncModifiableResourceMixin, CollectionMixin, - FileOperationsMixin, + FilesOperationsMixin, ModifiableResourceMixin, ) from mpt_api_client.models import Model @@ -27,7 +27,7 @@ class TermVariantServiceConfig: class TermVariantService( - FileOperationsMixin[TermVariant], + FilesOperationsMixin[TermVariant], ModifiableResourceMixin[TermVariant], PublishableMixin[TermVariant], CollectionMixin[TermVariant], @@ -38,7 +38,7 @@ class TermVariantService( class AsyncTermVariantService( - AsyncFileOperationsMixin[TermVariant], + AsyncFilesOperationsMixin[TermVariant], AsyncModifiableResourceMixin[TermVariant], AsyncPublishableMixin[TermVariant], AsyncCollectionMixin[TermVariant], diff --git a/mpt_api_client/resources/catalog/products_documents.py b/mpt_api_client/resources/catalog/products_documents.py index f903fc8f..01ce52c2 100644 --- a/mpt_api_client/resources/catalog/products_documents.py +++ b/mpt_api_client/resources/catalog/products_documents.py @@ -1,14 +1,15 @@ from mpt_api_client.http import AsyncService, Service from mpt_api_client.http.mixins import ( AsyncCollectionMixin, - AsyncFileOperationsMixin, AsyncModifiableResourceMixin, CollectionMixin, - FileOperationsMixin, ModifiableResourceMixin, ) from mpt_api_client.models import Model -from mpt_api_client.resources.catalog.mixins import AsyncPublishableMixin, PublishableMixin +from mpt_api_client.resources.catalog.mixins import ( + AsyncDocumentMixin, + DocumentMixin, +) class Document(Model): @@ -24,8 +25,7 @@ class DocumentServiceConfig: class DocumentService( - FileOperationsMixin[Document], - PublishableMixin[Document], + DocumentMixin[Document], ModifiableResourceMixin[Document], CollectionMixin[Document], Service[Document], @@ -35,8 +35,7 @@ class DocumentService( class AsyncDocumentService( - AsyncFileOperationsMixin[Document], - AsyncPublishableMixin[Document], + AsyncDocumentMixin[Document], AsyncModifiableResourceMixin[Document], AsyncCollectionMixin[Document], AsyncService[Document], diff --git a/mpt_api_client/resources/catalog/products_media.py b/mpt_api_client/resources/catalog/products_media.py index 21ddf5f0..36cb7b27 100644 --- a/mpt_api_client/resources/catalog/products_media.py +++ b/mpt_api_client/resources/catalog/products_media.py @@ -5,10 +5,10 @@ from mpt_api_client.http import AsyncService, Service from mpt_api_client.http.mixins import ( AsyncCollectionMixin, - AsyncFileOperationsMixin, + AsyncFilesOperationsMixin, AsyncModifiableResourceMixin, CollectionMixin, - FileOperationsMixin, + FilesOperationsMixin, ModifiableResourceMixin, ) from mpt_api_client.models import Model, ResourceData @@ -31,7 +31,7 @@ class MediaServiceConfig: class MediaService( - FileOperationsMixin[Media], + FilesOperationsMixin[Media], PublishableMixin[Media], ModifiableResourceMixin[Media], CollectionMixin[Media], @@ -84,7 +84,7 @@ def create( class AsyncMediaService( - AsyncFileOperationsMixin[Media], + AsyncFilesOperationsMixin[Media], AsyncPublishableMixin[Media], AsyncModifiableResourceMixin[Media], AsyncCollectionMixin[Media], diff --git a/mpt_api_client/resources/commerce/agreements_attachments.py b/mpt_api_client/resources/commerce/agreements_attachments.py index 5042b88d..55d3e675 100644 --- a/mpt_api_client/resources/commerce/agreements_attachments.py +++ b/mpt_api_client/resources/commerce/agreements_attachments.py @@ -2,11 +2,11 @@ from mpt_api_client.http.mixins import ( AsyncCollectionMixin, AsyncDeleteMixin, - AsyncFileOperationsMixin, + AsyncFilesOperationsMixin, AsyncGetMixin, CollectionMixin, DeleteMixin, - FileOperationsMixin, + FilesOperationsMixin, GetMixin, ) from mpt_api_client.models import Model @@ -25,7 +25,7 @@ class AgreementsAttachmentServiceConfig: class AgreementsAttachmentService( - FileOperationsMixin[AgreementAttachment], + FilesOperationsMixin[AgreementAttachment], DeleteMixin, GetMixin[AgreementAttachment], CollectionMixin[AgreementAttachment], @@ -36,7 +36,7 @@ class AgreementsAttachmentService( class AsyncAgreementsAttachmentService( - AsyncFileOperationsMixin[AgreementAttachment], + AsyncFilesOperationsMixin[AgreementAttachment], AsyncDeleteMixin, AsyncGetMixin[AgreementAttachment], AsyncCollectionMixin[AgreementAttachment], diff --git a/setup.cfg b/setup.cfg index 06e1231e..96f96f7b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,6 +50,7 @@ per-file-ignores = tests/unit/test_mpt_client.py: WPS235 tests/e2e/accounts/*.py: WPS430 WPS202 tests/e2e/catalog/*.py: WPS421 + tests/e2e/catalog/product/documents/*.py: WPS202 WPS421 tests/*: # Allow magic strings. diff --git a/tests/e2e/catalog/product/documents/__init__.py b/tests/e2e/catalog/product/documents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/catalog/product/documents/conftest.py b/tests/e2e/catalog/product/documents/conftest.py new file mode 100644 index 00000000..dd6f1bc8 --- /dev/null +++ b/tests/e2e/catalog/product/documents/conftest.py @@ -0,0 +1,33 @@ +import pathlib + +import pytest + + +@pytest.fixture +def document_id(e2e_config): + return e2e_config["catalog.product.document.id"] + + +@pytest.fixture +def document_data(): + return { + "name": "e2e test document - please delete", + "description": "E2E test document for automated testing", + "language": "en-gb", + } + + +@pytest.fixture +def test_file(): + file_path = pathlib.Path(__file__).parents[3] / "logo.png" + return pathlib.Path.open(file_path, "rb") + + +@pytest.fixture +def vendor_document_service(mpt_vendor, product_id): + return mpt_vendor.catalog.products.documents(product_id) + + +@pytest.fixture +def async_vendor_document_service(async_mpt_vendor, product_id): + return async_mpt_vendor.catalog.products.documents(product_id) diff --git a/tests/e2e/catalog/product/documents/test_async_document.py b/tests/e2e/catalog/product/documents/test_async_document.py new file mode 100644 index 00000000..30fff07f --- /dev/null +++ b/tests/e2e/catalog/product/documents/test_async_document.py @@ -0,0 +1,102 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from mpt_api_client.rql.query_builder import RQLQuery + +pytestmark = [pytest.mark.flaky, pytest.mark.asyncio] + + +@pytest.fixture +def async_document_service(async_mpt_vendor, product_id): + return async_mpt_vendor.catalog.products.documents(product_id) + + +@pytest.fixture +async def created_document_from_file_async(logger, async_document_service, document_data, pdf_fd): + document_data["documenttype"] = "File" + document = await async_document_service.create(document_data, file=pdf_fd) + yield document + try: + await async_document_service.delete(document.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete document {document.id}: {error.title}") + + +@pytest.fixture +async def created_document_from_link_async(logger, async_document_service, document_data, pdf_url): + document_data["url"] = pdf_url + document = await async_document_service.create(document_data) + yield document + try: + await async_document_service.delete(document.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete document {document.id}: {error.title}") + + +def test_create_document_async(created_document_from_file_async, document_data): + assert created_document_from_file_async.name == document_data["name"] + assert created_document_from_file_async.description == document_data["description"] + + +def test_create_from_link_async(created_document_from_link_async, pdf_url, document_data): + assert created_document_from_link_async.name == document_data["name"] + assert created_document_from_link_async.description == document_data["description"] + + +async def test_update_document_async(async_document_service, created_document_from_file_async): + update_data = {"name": "Updated e2e test document - please delete"} + document = await async_document_service.update(created_document_from_file_async.id, update_data) + assert document.name == update_data["name"] + + +async def test_get_document_async(async_document_service, document_id): + document = await async_document_service.get(document_id) + assert document.id == document_id + + +async def test_download_document_async(async_document_service, document_id): + file_response = await async_document_service.download(document_id) + assert file_response.file_contents is not None + assert file_response.filename == "pdf - empty.pdf" + + +async def test_iterate_documents_async(async_document_service, created_document_from_file_async): + documents = [doc async for doc in async_document_service.iterate()] + assert any(doc.id == created_document_from_file_async.id for doc in documents) + + +async def test_filter_documents_async(async_document_service, created_document_from_file_async): + filtered_service = async_document_service.filter( + RQLQuery(id=created_document_from_file_async.id) + ) + documents = [doc async for doc in filtered_service.iterate()] + assert len(documents) == 1 + assert documents[0].id == created_document_from_file_async.id + + +@pytest.mark.skip(reason="Leaves test documents in published state") +async def test_review_and_publish_document_async( + async_mpt_vendor, async_mpt_ops, created_document_from_file_async, product_id +): + vendor_service = async_mpt_vendor.catalog.products.documents(product_id) + ops_service = async_mpt_ops.catalog.products.documents(product_id) + + document = await vendor_service.review(created_document_from_file_async.id) + assert document.status == "Pending" + + document = await ops_service.publish(created_document_from_file_async.id) + assert document.status == "Published" + + document = await ops_service.unpublish(created_document_from_file_async.id) + assert document.status == "Unpublished" + + +async def test_not_found_async(async_document_service): + with pytest.raises(MPTAPIError): + await async_document_service.get("DOC-000-000-000") + + +async def test_delete_document_async(async_document_service, created_document_from_file_async): + await async_document_service.delete(created_document_from_file_async.id) + with pytest.raises(MPTAPIError): + await async_document_service.get(created_document_from_file_async.id) diff --git a/tests/e2e/catalog/product/documents/test_sync_document.py b/tests/e2e/catalog/product/documents/test_sync_document.py new file mode 100644 index 00000000..828a8994 --- /dev/null +++ b/tests/e2e/catalog/product/documents/test_sync_document.py @@ -0,0 +1,94 @@ +import pytest + +from mpt_api_client.exceptions import MPTAPIError +from mpt_api_client.rql.query_builder import RQLQuery + +pytestmark = [pytest.mark.flaky] + + +@pytest.fixture +def created_document_from_file(logger, vendor_document_service, document_data, pdf_fd): + document_data["documenttype"] = "File" + document = vendor_document_service.create(document_data, pdf_fd) + yield document + try: + vendor_document_service.delete(document.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete document {document.id}: {error.title}") + + +@pytest.fixture +def created_document_from_link(logger, vendor_document_service, document_data, pdf_url): + document_data["url"] = pdf_url + document = vendor_document_service.create(document_data) + yield document + try: + vendor_document_service.delete(document.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete document {document.id}: {error.title}") + + +def test_create_document(created_document_from_file, document_data): + assert created_document_from_file.name == document_data["name"] + assert created_document_from_file.description == document_data["description"] + + +def test_create_from_link(created_document_from_link, pdf_url, document_data): + assert created_document_from_link.name == document_data["name"] + assert created_document_from_link.description == document_data["description"] + + +def test_update_document(vendor_document_service, created_document_from_file): + update_data = {"name": "Updated e2e test document - please delete"} + document = vendor_document_service.update(created_document_from_file.id, update_data) + assert document.name == update_data["name"] + + +def test_get_document(vendor_document_service, document_id): + document = vendor_document_service.get(document_id) + assert document.id == document_id + + +def test_download_document(vendor_document_service, document_id): + file_response = vendor_document_service.download(document_id) + assert file_response.file_contents is not None + assert file_response.filename == "pdf - empty.pdf" + + +def test_iterate_documents(vendor_document_service, created_document_from_file): + documents = list(vendor_document_service.iterate()) + assert any(doc.id == created_document_from_file.id for doc in documents) + + +def test_filter_documents(vendor_document_service, created_document_from_file): + documents = list( + vendor_document_service.filter(RQLQuery(id=created_document_from_file.id)).iterate() + ) + assert len(documents) == 1 + assert documents[0].id == created_document_from_file.id + + +@pytest.mark.skip(reason="Leaves test documents in published state") +def test_review_and_publish_document(mpt_vendor, mpt_ops, created_document_from_file, product_id): + vendor_service = mpt_vendor.catalog.products.documents(product_id) + ops_service = mpt_ops.catalog.products.documents(product_id) + + document = vendor_service.review(created_document_from_file.id) + assert document.status == "Pending" + + document = ops_service.publish(created_document_from_file.id) + assert document.status == "Published" + + document = ops_service.unpublish(created_document_from_file.id) + assert document.status == "Unpublished" + + +def test_not_found(vendor_document_service): + with pytest.raises(MPTAPIError): + vendor_document_service.get("DOC-000-000-000") + + +def test_delete_document(vendor_document_service, created_document_from_file): + vendor_document_service.delete(created_document_from_file.id) + with pytest.raises(MPTAPIError): + vendor_document_service.get(created_document_from_file.id) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index a6461c4f..ded433dd 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -68,6 +68,17 @@ def project_root_path(): return pathlib.Path(__file__).parent.parent.parent +@pytest.fixture +def pdf_fd(): + icon_path = pathlib.Path(__file__).parent / "empty.pdf" + return pathlib.Path.open(icon_path, "rb") + + +@pytest.fixture +def pdf_url(): + return "https://sample-files.com/downloads/documents/pdf/basic-text.pdf" + + @pytest.fixture def e2e_config(project_root_path): filename = os.getenv("TEST_CONFIG_FILE", "e2e_config.test.json") diff --git a/tests/e2e/empty.pdf b/tests/e2e/empty.pdf new file mode 100644 index 00000000..5abbd77a Binary files /dev/null and b/tests/e2e/empty.pdf differ diff --git a/tests/unit/http/test_async_client.py b/tests/unit/http/test_async_client.py index c19888b5..f37f9ba7 100644 --- a/tests/unit/http/test_async_client.py +++ b/tests/unit/http/test_async_client.py @@ -26,6 +26,7 @@ def test_async_http_initialization(mocker): mock_async_client.assert_called_once_with( base_url=API_URL, + follow_redirects=True, headers={ "User-Agent": "swo-marketplace-client/1.0", "Authorization": "Bearer test-token", @@ -45,6 +46,7 @@ def test_async_env_initialization(monkeypatch, mocker): mock_async_client.assert_called_once_with( base_url=API_URL, + follow_redirects=True, headers={ "User-Agent": "swo-marketplace-client/1.0", "Authorization": f"Bearer {API_TOKEN}", diff --git a/tests/unit/http/test_client.py b/tests/unit/http/test_client.py index 026b1844..6b4613f0 100644 --- a/tests/unit/http/test_client.py +++ b/tests/unit/http/test_client.py @@ -16,6 +16,7 @@ def test_http_initialization(mocker): mock_client.assert_called_once_with( base_url=API_URL, + follow_redirects=True, headers={ "User-Agent": "swo-marketplace-client/1.0", "Authorization": "Bearer test-token", @@ -34,6 +35,7 @@ def test_env_initialization(monkeypatch, mocker): mock_client.assert_called_once_with( base_url=API_URL, + follow_redirects=True, headers={ "User-Agent": "swo-marketplace-client/1.0", "Authorization": f"Bearer {API_TOKEN}", diff --git a/tests/unit/http/test_mixins.py b/tests/unit/http/test_mixins.py index 0d350357..5e801dbd 100644 --- a/tests/unit/http/test_mixins.py +++ b/tests/unit/http/test_mixins.py @@ -278,17 +278,30 @@ async def test_async_file_create_no_data(async_media_service): assert new_media.to_dict() == media_data -async def test_async_file_download(async_media_service): +async def test_async_file_download(async_media_service): # noqa: WPS218 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_resource = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/media/MED-456", + headers={"Accept": "application/json"}, + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={ + "content-type": "application/json", + }, + json={"id": "MED-456", "name": "Product image", "content_type": "image/jpg"}, + ) + ) + mock_download = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/media/MED-456", + headers={"Accept": "image/jpg"}, ).mock( return_value=httpx.Response( status_code=httpx.codes.OK, headers={ - "content-type": "application/octet-stream", + "content-type": "image/jpg", "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', }, content=media_content, @@ -296,26 +309,42 @@ async def test_async_file_download(async_media_service): ) downloaded_file = await async_media_service.download("MED-456") - request = mock_route.calls[0].request - accept_header = (b"Accept", b"*") + + assert mock_resource.call_count == 1 + + request = mock_download.calls[0].request + accept_header = (b"Accept", b"image/jpg") assert accept_header in request.headers.raw - assert mock_route.call_count == 1 + assert mock_download.call_count == 1 assert downloaded_file.file_contents == media_content - assert downloaded_file.content_type == "application/octet-stream" + assert downloaded_file.content_type == "image/jpg" assert downloaded_file.filename == "product_image.jpg" -def test_sync_file_download(media_service): +def test_sync_file_download(media_service): # noqa: WPS218 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_resource = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/media/MED-456", + headers={"Accept": "application/json"}, ).mock( return_value=httpx.Response( status_code=httpx.codes.OK, headers={ - "content-type": "application/octet-stream", + "content-type": "application/json", + }, + json={"id": "MED-456", "name": "Product image", "content_type": "image/jpg"}, + ) + ) + mock_download = respx.get( + "https://api.example.com/public/v1/catalog/products/PRD-001/media/MED-456", + headers={"Accept": "image/jpg"}, + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={ + "content-type": "image/jpg", "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', }, content=media_content, @@ -323,12 +352,14 @@ def test_sync_file_download(media_service): ) downloaded_file = media_service.download("MED-456") - request = mock_route.calls[0].request - accept_header = (b"Accept", b"*") + assert mock_resource.call_count == 1 + + request = mock_download.calls[0].request + accept_header = (b"Accept", b"image/jpg") assert accept_header in request.headers.raw - assert mock_route.call_count == 1 + assert mock_download.call_count == 1 assert downloaded_file.file_contents == media_content - assert downloaded_file.content_type == "application/octet-stream" + assert downloaded_file.content_type == "image/jpg" assert downloaded_file.filename == "product_image.jpg" diff --git a/tests/unit/resources/catalog/test_mixins.py b/tests/unit/resources/catalog/test_mixins.py index 1310e3f0..d015d554 100644 --- a/tests/unit/resources/catalog/test_mixins.py +++ b/tests/unit/resources/catalog/test_mixins.py @@ -1,3 +1,5 @@ +import io + import httpx import pytest import respx @@ -7,7 +9,9 @@ from mpt_api_client.resources.catalog.mixins import ( ActivatableMixin, AsyncActivatableMixin, + AsyncDocumentMixin, AsyncPublishableMixin, + DocumentMixin, PublishableMixin, ) from tests.unit.conftest import DummyModel @@ -49,6 +53,24 @@ class DummyAsyncActivatableService( # noqa: WPS215 _collection_key = "data" +class DummyDocumentService( # noqa: WPS215 + DocumentMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/documents" + _model_class = DummyModel + _collection_key = "data" + + +class DummyAsyncDocumentService( # noqa: WPS215 + AsyncDocumentMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/documents" + _model_class = DummyModel + _collection_key = "data" + + @pytest.fixture def publishable_service(http_client): return DummyPublishableService(http_client=http_client) @@ -317,3 +339,131 @@ async def test_async_custom_resource_activatable_actions_no_data( assert request.content == request_expected_content assert attachment.to_dict() == response_expected_data assert isinstance(attachment, 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) + + 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 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) + ) + new_doc = 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 new_doc.to_dict() == response_data + assert isinstance(new_doc, 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, + ) + ) + new_doc = 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 new_doc.to_dict() == resource_data + assert isinstance(new_doc, 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) + ) + new_doc = 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 new_doc.to_dict() == response_data + assert isinstance(new_doc, DummyModel)