From 92149de3503dd65c15c49828efa2feef7a7e36b0 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Mon, 2 Feb 2026 10:44:42 +0000 Subject: [PATCH] MPT-16437 Update http/mixins file structure --- mpt_api_client/http/mixins.py | 665 -------- mpt_api_client/http/mixins/__init__.py | 63 + .../http/mixins/collection_mixin.py | 145 ++ .../http/mixins/create_file_mixin.py | 66 + mpt_api_client/http/mixins/create_mixin.py | 29 + mpt_api_client/http/mixins/delete_mixin.py | 26 + mpt_api_client/http/mixins/disable_mixin.py | 22 + .../http/mixins/download_file_mixin.py | 53 + mpt_api_client/http/mixins/enable_mixin.py | 22 + .../http/mixins/file_operations_mixin.py | 73 + mpt_api_client/http/mixins/get_mixin.py | 35 + mpt_api_client/http/mixins/queryable_mixin.py | 75 + mpt_api_client/http/mixins/resource_mixins.py | 24 + .../http/mixins/update_file_mixin.py | 84 + mpt_api_client/http/mixins/update_mixin.py | 35 + pyproject.toml | 3 +- tests/unit/http/mixins/__init__.py | 0 .../unit/http/mixins/test_collection_mixin.py | 509 ++++++ .../http/mixins/test_create_file_mixin.py | 134 ++ tests/unit/http/mixins/test_create_mixin.py | 46 + tests/unit/http/mixins/test_delete_mixin.py | 30 + tests/unit/http/mixins/test_disable_mixin.py | 97 ++ .../http/mixins/test_download_file_mixin.py | 167 ++ tests/unit/http/mixins/test_enable_mixin.py | 97 ++ .../http/mixins/test_file_operations_mixin.py | 87 + tests/unit/http/mixins/test_get_mixin.py | 60 + .../unit/http/mixins/test_queryable_mixin.py | 80 + .../unit/http/mixins/test_resource_mixins.py | 95 ++ .../http/mixins/test_update_file_mixin.py | 151 ++ tests/unit/http/mixins/test_update_mixin.py | 38 + tests/unit/http/test_mixins.py | 1435 ----------------- 31 files changed, 2344 insertions(+), 2102 deletions(-) delete mode 100644 mpt_api_client/http/mixins.py create mode 100644 mpt_api_client/http/mixins/__init__.py create mode 100644 mpt_api_client/http/mixins/collection_mixin.py create mode 100644 mpt_api_client/http/mixins/create_file_mixin.py create mode 100644 mpt_api_client/http/mixins/create_mixin.py create mode 100644 mpt_api_client/http/mixins/delete_mixin.py create mode 100644 mpt_api_client/http/mixins/disable_mixin.py create mode 100644 mpt_api_client/http/mixins/download_file_mixin.py create mode 100644 mpt_api_client/http/mixins/enable_mixin.py create mode 100644 mpt_api_client/http/mixins/file_operations_mixin.py create mode 100644 mpt_api_client/http/mixins/get_mixin.py create mode 100644 mpt_api_client/http/mixins/queryable_mixin.py create mode 100644 mpt_api_client/http/mixins/resource_mixins.py create mode 100644 mpt_api_client/http/mixins/update_file_mixin.py create mode 100644 mpt_api_client/http/mixins/update_mixin.py create mode 100644 tests/unit/http/mixins/__init__.py create mode 100644 tests/unit/http/mixins/test_collection_mixin.py create mode 100644 tests/unit/http/mixins/test_create_file_mixin.py create mode 100644 tests/unit/http/mixins/test_create_mixin.py create mode 100644 tests/unit/http/mixins/test_delete_mixin.py create mode 100644 tests/unit/http/mixins/test_disable_mixin.py create mode 100644 tests/unit/http/mixins/test_download_file_mixin.py create mode 100644 tests/unit/http/mixins/test_enable_mixin.py create mode 100644 tests/unit/http/mixins/test_file_operations_mixin.py create mode 100644 tests/unit/http/mixins/test_get_mixin.py create mode 100644 tests/unit/http/mixins/test_queryable_mixin.py create mode 100644 tests/unit/http/mixins/test_resource_mixins.py create mode 100644 tests/unit/http/mixins/test_update_file_mixin.py create mode 100644 tests/unit/http/mixins/test_update_mixin.py delete mode 100644 tests/unit/http/test_mixins.py diff --git a/mpt_api_client/http/mixins.py b/mpt_api_client/http/mixins.py deleted file mode 100644 index 66793711..00000000 --- a/mpt_api_client/http/mixins.py +++ /dev/null @@ -1,665 +0,0 @@ -from collections.abc import AsyncIterator, Iterator -from typing import Self -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.client import json_to_file_payload -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 -from mpt_api_client.models import Model as BaseModel -from mpt_api_client.rql import RQLQuery - - -class CreateMixin[Model]: - """Create resource mixin.""" - - def create(self, resource_data: ResourceData) -> Model: - """Create a new resource using `POST /endpoint`. - - Returns: - New resource created. - """ - response = self.http_client.request("post", self.path, json=resource_data) # type: ignore[attr-defined] - - return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] - - -class DeleteMixin: - """Delete resource mixin.""" - - def delete(self, resource_id: str) -> None: - """Delete resource using `DELETE /endpoint/{resource_id}`. - - Args: - resource_id: Resource ID. - """ - self._resource_do_request(resource_id, "DELETE") # type: ignore[attr-defined] - - -class UpdateMixin[Model]: - """Update resource mixin.""" - - def update(self, resource_id: str, resource_data: ResourceData) -> Model: - """Update a resource using `PUT /endpoint/{resource_id}`. - - Args: - resource_id: Resource ID. - resource_data: Resource data. - - Returns: - Resource object. - - """ - return self._resource_action(resource_id, "PUT", json=resource_data) # type: ignore[attr-defined, no-any-return] - - -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( - 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.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 CreateFileMixin[Model]: - """Create file mixin.""" - - def create(self, resource_data: ResourceData, file: FileTypes | None = None) -> Model: # noqa: WPS110 - """Create logo. - - Create a file resource by specifying a file image. - - Args: - resource_data: Resource data. - file: File image. - - Returns: - Model: Created resource. - """ - files = {} - - if file: - files[self._upload_file_key] = file # type: ignore[attr-defined] - - response = self.http_client.request( # type: ignore[attr-defined] - "post", - self.path, # type: ignore[attr-defined] - json=resource_data, - files=files, - json_file_key=self._upload_data_key, # type: ignore[attr-defined] - force_multipart=True, - ) - - return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] - - -class UpdateFileMixin[Model]: - """Update file mixin.""" - - def update( - self, - resource_id: str, - resource_data: ResourceData, - file: FileTypes | None = None, # noqa: WPS110 - ) -> Model: - """Update file. - - Update a file resource by specifying a file. - - Args: - resource_id: Resource ID. - resource_data: Resource data. - file: File image. - - Returns: - Model: Updated resource. - """ - files = {} - - url = urljoin(f"{self.path}/", resource_id) # type: ignore[attr-defined] - - if file: - files[self._upload_file_key] = file # type: ignore[attr-defined] - - response = self.http_client.request( # type: ignore[attr-defined] - "put", - url, - json=resource_data, - files=files, - json_file_key=self._upload_data_key, # type: ignore[attr-defined] - force_multipart=True, - ) - - return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] - - -class AsyncCreateFileMixin[Model]: - """Asynchronous Create file mixin.""" - - async def create(self, resource_data: ResourceData, file: FileTypes | None = None) -> Model: # noqa: WPS110 - """Create file. - - Create a file resource by specifying a file. - - Args: - resource_data: Resource data. - file: File image. - - Returns: - Model: Created resource. - """ - files = {} - - if file: - files[self._upload_file_key] = file # type: ignore[attr-defined] - - response = await self.http_client.request( # type: ignore[attr-defined] - "post", - self.path, # type: ignore[attr-defined] - json=resource_data, - files=files, - json_file_key=self._upload_data_key, # type: ignore[attr-defined] - force_multipart=True, - ) - - return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] - - -class AsyncUpdateFileMixin[Model]: - """Asynchronous Update file mixin.""" - - async def update( - self, - resource_id: str, - resource_data: ResourceData, - file: FileTypes | None = None, # noqa: WPS110 - ) -> Model: - """Update file. - - Update a file resource by specifying a file. - - Args: - resource_id: Resource ID. - resource_data: Resource data. - file: File image. - - Returns: - Model: Updated resource. - """ - files = {} - - url = urljoin(f"{self.path}/", resource_id) # type: ignore[attr-defined] - - if file: - files[self._upload_file_key] = file # type: ignore[attr-defined] - - response = await self.http_client.request( # type: ignore[attr-defined] - "put", - url, - json=resource_data, - files=files, - json_file_key=self._upload_data_key, # type: ignore[attr-defined] - force_multipart=True, - ) - - return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] - - -class AsyncCreateMixin[Model]: - """Create resource mixin.""" - - async def create(self, resource_data: ResourceData) -> Model: - """Create a new resource using `POST /endpoint`. - - Returns: - New resource created. - """ - response = await self.http_client.request("post", self.path, json=resource_data) # type: ignore[attr-defined] - - return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] - - -class AsyncDeleteMixin: - """Delete resource mixin.""" - - async def delete(self, resource_id: str) -> None: - """Delete resource using `DELETE /endpoint/{resource_id}`. - - Args: - resource_id: Resource ID. - """ - url = urljoin(f"{self.path}/", resource_id) # type: ignore[attr-defined] - await self.http_client.request("delete", url) # type: ignore[attr-defined] - - -class AsyncUpdateMixin[Model]: - """Update resource mixin.""" - - async def update(self, resource_id: str, resource_data: ResourceData) -> Model: - """Update a resource using `PUT /endpoint/{resource_id}`. - - Args: - resource_id: Resource ID. - resource_data: Resource data. - - Returns: - Resource object. - - """ - return await self._resource_action(resource_id, "PUT", json=resource_data) # type: ignore[attr-defined, no-any-return] - - -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( - 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.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 GetMixin[Model]: - """Get resource mixin.""" - - def get(self, resource_id: str, select: list[str] | str | None = None) -> Model: - """Fetch a specific resource using `GET /endpoint/{resource_id}`. - - Args: - resource_id: Resource ID. - select: List of fields to select. - - Returns: - Resource object. - """ - if isinstance(select, list): - select = ",".join(select) if select else None - - return self._resource_action(resource_id=resource_id, query_params={"select": select}) # type: ignore[attr-defined, no-any-return] - - -class AsyncGetMixin[Model]: - """Async get resource mixin.""" - - async def get(self, resource_id: str, select: list[str] | str | None = None) -> Model: - """Fetch a specific resource using `GET /endpoint/{resource_id}`. - - Args: - resource_id: Resource ID. - select: List of fields to select. - - Returns: - Resource object. - """ - if isinstance(select, list): - select = ",".join(select) if select else None - return await self._resource_action(resource_id=resource_id, query_params={"select": select}) # type: ignore[attr-defined, no-any-return] - - -class AsyncEnableMixin[Model: BaseModel]: - """Enable resource mixin.""" - - async def enable(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Enable a specific resource.""" - return await self._resource_action( # type: ignore[attr-defined, no-any-return] - resource_id=resource_id, method="POST", action="enable", json=resource_data - ) - - -class EnableMixin[Model: BaseModel]: - """Enable resource mixin.""" - - def enable(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Enable a specific resource.""" - return self._resource_action( # type: ignore[attr-defined, no-any-return] - resource_id=resource_id, method="POST", action="enable", json=resource_data - ) - - -class AsyncDisableMixin[Model: BaseModel]: - """Disable resource mixin.""" - - async def disable(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Disable a specific resource.""" - return await self._resource_action( # type: ignore[attr-defined, no-any-return] - resource_id=resource_id, method="POST", action="disable", json=resource_data - ) - - -class DisableMixin[Model: BaseModel]: - """Disable resource mixin.""" - - def disable(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: - """Disable a specific resource .""" - return self._resource_action( # type: ignore[attr-defined, no-any-return] - resource_id=resource_id, method="POST", action="disable", json=resource_data - ) - - -class QueryableMixin: - """Mixin providing query functionality for filtering, ordering, and selecting fields.""" - - def order_by(self, *fields: str) -> Self: - """Returns new collection with ordering setup. - - Returns: - New collection with ordering setup. - - Raises: - ValueError: If ordering has already been set. - """ - if self.query_state.order_by is not None: # type: ignore[attr-defined] - raise ValueError("Ordering is already set. Cannot set ordering multiple times.") - return self._create_new_instance( - query_state=QueryState( - rql=self.query_state.filter, # type: ignore[attr-defined] - order_by=list(fields), - select=self.query_state.select, # type: ignore[attr-defined] - ) - ) - - def filter(self, rql: RQLQuery) -> Self: - """Creates a new collection with the filter added to the filter collection. - - Returns: - New copy of the collection with the filter added. - """ - existing_filter = self.query_state.filter # type: ignore[attr-defined] - combined_filter = existing_filter & rql if existing_filter else rql - return self._create_new_instance( - QueryState( - rql=combined_filter, - order_by=self.query_state.order_by, # type: ignore[attr-defined] - select=self.query_state.select, # type: ignore[attr-defined] - ) - ) - - def select(self, *fields: str) -> Self: - """Set select fields. Raises ValueError if select fields are already set. - - Returns: - New copy of the collection with the select fields set. - - Raises: - ValueError: If select fields are already set. - """ - if self.query_state.select is not None: # type: ignore[attr-defined] - raise ValueError( - "Select fields are already set. Cannot set select fields multiple times." - ) - return self._create_new_instance( - QueryState( - rql=self.query_state.filter, # type: ignore[attr-defined] - order_by=self.query_state.order_by, # type: ignore[attr-defined] - select=list(fields), - ), - ) - - def _create_new_instance( - self, - query_state: QueryState, - ) -> Self: - """Create a new instance with the given parameters.""" - return self.__class__( - http_client=self.http_client, # type: ignore[call-arg,attr-defined] - query_state=query_state, - endpoint_params=self.endpoint_params, # type: ignore[attr-defined] - ) - - -class CollectionMixin[Model: BaseModel](QueryableMixin): - """Mixin providing collection functionality.""" - - def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]: - """Fetch one page of resources. - - Returns: - Collection of resources. - """ - response = self._fetch_page_as_response(limit=limit, offset=offset) - return self.make_collection(response) # type: ignore[attr-defined, no-any-return] - - def fetch_one(self) -> Model: - """Fetch one resource, expect exactly one result. - - Returns: - One resource. - - Raises: - ValueError: If the total matching records are not exactly one. - """ - response = self._fetch_page_as_response(limit=1, offset=0) - resource_list = self.make_collection(response) # type: ignore[attr-defined] - total_records = len(resource_list) - if resource_list.meta: - total_records = resource_list.meta.pagination.total - if total_records == 0: - raise ValueError("Expected one result, but got zero results") - if total_records > 1: - raise ValueError(f"Expected one result, but got {total_records} results") - - return resource_list[0] # type: ignore[no-any-return] - - def iterate(self, batch_size: int = 100) -> Iterator[Model]: - """Iterate over all resources, yielding GenericResource objects. - - Args: - batch_size: Number of resources to fetch per request - - Returns: - Iterator of resources. - """ - offset = 0 - limit = batch_size # Default page size - - while True: - response = self._fetch_page_as_response(limit=limit, offset=offset) - items_collection = self.make_collection(response) # type: ignore[attr-defined] - yield from items_collection - - if not items_collection.meta: - break - if not items_collection.meta.pagination.has_next(): - break - offset = items_collection.meta.pagination.next_offset() - - def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Response: - """Fetch one page of resources. - - Returns: - Response object. - - Raises: - HTTPStatusError: if the response status code is not 200. - """ - pagination_params: dict[str, int] = {"limit": limit, "offset": offset} - return self.http_client.request("get", self.build_path(pagination_params)) # type: ignore[attr-defined, no-any-return] - - -class AsyncCollectionMixin[Model: BaseModel](QueryableMixin): - """Async mixin providing collection functionality.""" - - async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]: - """Fetch one page of resources. - - Returns: - Collection of resources. - """ - response = await self._fetch_page_as_response(limit=limit, offset=offset) - return self.make_collection(response) # type: ignore[no-any-return,attr-defined] - - async def fetch_one(self) -> Model: - """Fetch one resource, expect exactly one result. - - Returns: - One resource. - - Raises: - ValueError: If the total matching records are not exactly one. - """ - response = await self._fetch_page_as_response(limit=1, offset=0) - resource_list = self.make_collection(response) # type: ignore[attr-defined] - total_records = len(resource_list) - if resource_list.meta: - total_records = resource_list.meta.pagination.total - if total_records == 0: - raise ValueError("Expected one result, but got zero results") - if total_records > 1: - raise ValueError(f"Expected one result, but got {total_records} results") - - return resource_list[0] # type: ignore[no-any-return] - - async def iterate(self, batch_size: int = 100) -> AsyncIterator[Model]: - """Iterate over all resources, yielding GenericResource objects. - - Args: - batch_size: Number of resources to fetch per request - - Returns: - Iterator of resources. - """ - offset = 0 - limit = batch_size # Default page size - - while True: - response = await self._fetch_page_as_response(limit=limit, offset=offset) - items_collection = self.make_collection(response) # type: ignore[attr-defined] - for resource in items_collection: - yield resource - - if not items_collection.meta: - break - if not items_collection.meta.pagination.has_next(): - break - offset = items_collection.meta.pagination.next_offset() - - async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Response: - """Fetch one page of resources. - - Returns: - Response object. - - Raises: - HTTPStatusError: if the response status code is not 200. - """ - pagination_params: dict[str, int] = {"limit": limit, "offset": offset} - return await self.http_client.request("get", self.build_path(pagination_params)) # type: ignore[attr-defined,no-any-return] - - -class ModifiableResourceMixin[Model](GetMixin[Model], UpdateMixin[Model], DeleteMixin): - """Editable resource mixin allows to read and update a resource resources.""" - - -class AsyncModifiableResourceMixin[Model]( - AsyncGetMixin[Model], AsyncUpdateMixin[Model], AsyncDeleteMixin -): - """Editable resource mixin allows to read and update a resource resources.""" - - -class ManagedResourceMixin[Model](CreateMixin[Model], ModifiableResourceMixin[Model]): - """Managed resource mixin allows to read, create, update and delete a resource resources.""" - - -class AsyncManagedResourceMixin[Model]( - AsyncCreateMixin[Model], AsyncModifiableResourceMixin[Model] -): - """Managed resource mixin allows to read, create, update and delete a resource resources.""" diff --git a/mpt_api_client/http/mixins/__init__.py b/mpt_api_client/http/mixins/__init__.py new file mode 100644 index 00000000..84af78a1 --- /dev/null +++ b/mpt_api_client/http/mixins/__init__.py @@ -0,0 +1,63 @@ +from mpt_api_client.http.mixins.collection_mixin import ( + AsyncCollectionMixin, + CollectionMixin, +) +from mpt_api_client.http.mixins.create_file_mixin import ( + AsyncCreateFileMixin, + CreateFileMixin, +) +from mpt_api_client.http.mixins.create_mixin import AsyncCreateMixin, CreateMixin +from mpt_api_client.http.mixins.delete_mixin import AsyncDeleteMixin, DeleteMixin +from mpt_api_client.http.mixins.disable_mixin import AsyncDisableMixin, DisableMixin +from mpt_api_client.http.mixins.download_file_mixin import ( + AsyncDownloadFileMixin, + DownloadFileMixin, +) +from mpt_api_client.http.mixins.enable_mixin import AsyncEnableMixin, EnableMixin +from mpt_api_client.http.mixins.file_operations_mixin import ( + AsyncFilesOperationsMixin, + FilesOperationsMixin, +) +from mpt_api_client.http.mixins.get_mixin import AsyncGetMixin, GetMixin +from mpt_api_client.http.mixins.queryable_mixin import QueryableMixin +from mpt_api_client.http.mixins.resource_mixins import ( + AsyncManagedResourceMixin, + AsyncModifiableResourceMixin, + ManagedResourceMixin, + ModifiableResourceMixin, +) +from mpt_api_client.http.mixins.update_file_mixin import ( + AsyncUpdateFileMixin, + UpdateFileMixin, +) +from mpt_api_client.http.mixins.update_mixin import AsyncUpdateMixin, UpdateMixin + +__all__ = [ # noqa: WPS410 + "AsyncCollectionMixin", + "AsyncCreateFileMixin", + "AsyncCreateMixin", + "AsyncDeleteMixin", + "AsyncDisableMixin", + "AsyncDownloadFileMixin", + "AsyncEnableMixin", + "AsyncFilesOperationsMixin", + "AsyncGetMixin", + "AsyncManagedResourceMixin", + "AsyncModifiableResourceMixin", + "AsyncUpdateFileMixin", + "AsyncUpdateMixin", + "CollectionMixin", + "CreateFileMixin", + "CreateMixin", + "DeleteMixin", + "DisableMixin", + "DownloadFileMixin", + "EnableMixin", + "FilesOperationsMixin", + "GetMixin", + "ManagedResourceMixin", + "ModifiableResourceMixin", + "QueryableMixin", + "UpdateFileMixin", + "UpdateMixin", +] diff --git a/mpt_api_client/http/mixins/collection_mixin.py b/mpt_api_client/http/mixins/collection_mixin.py new file mode 100644 index 00000000..8f28fe82 --- /dev/null +++ b/mpt_api_client/http/mixins/collection_mixin.py @@ -0,0 +1,145 @@ +from collections.abc import AsyncIterator, Iterator + +from mpt_api_client.http.mixins.queryable_mixin import QueryableMixin +from mpt_api_client.http.types import Response +from mpt_api_client.models import Collection +from mpt_api_client.models import Model as BaseModel + + +class CollectionMixin[Model: BaseModel](QueryableMixin): + """Mixin providing collection functionality.""" + + def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]: + """Fetch one page of resources. + + Returns: + Collection of resources. + """ + response = self._fetch_page_as_response(limit=limit, offset=offset) + return self.make_collection(response) # type: ignore[attr-defined, no-any-return] + + def fetch_one(self) -> Model: + """Fetch one resource, expect exactly one result. + + Returns: + One resource. + + Raises: + ValueError: If the total matching records are not exactly one. + """ + response = self._fetch_page_as_response(limit=1, offset=0) + resource_list = self.make_collection(response) # type: ignore[attr-defined] + total_records = len(resource_list) + if resource_list.meta: + total_records = resource_list.meta.pagination.total + if total_records == 0: + raise ValueError("Expected one result, but got zero results") + if total_records > 1: + raise ValueError(f"Expected one result, but got {total_records} results") + + return resource_list[0] # type: ignore[no-any-return] + + def iterate(self, batch_size: int = 100) -> Iterator[Model]: + """Iterate over all resources, yielding GenericResource objects. + + Args: + batch_size: Number of resources to fetch per request + + Returns: + Iterator of resources. + """ + offset = 0 + limit = batch_size # Default page size + + while True: + response = self._fetch_page_as_response(limit=limit, offset=offset) + items_collection = self.make_collection(response) # type: ignore[attr-defined] + yield from items_collection + + if not items_collection.meta: + break + if not items_collection.meta.pagination.has_next(): + break + offset = items_collection.meta.pagination.next_offset() + + def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Response: + """Fetch one page of resources. + + Returns: + Response object. + + Raises: + HTTPStatusError: if the response status code is not 200. + """ + pagination_params: dict[str, int] = {"limit": limit, "offset": offset} + return self.http_client.request("get", self.build_path(pagination_params)) # type: ignore[attr-defined, no-any-return] + + +class AsyncCollectionMixin[Model: BaseModel](QueryableMixin): + """Async mixin providing collection functionality.""" + + async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[Model]: + """Fetch one page of resources. + + Returns: + Collection of resources. + """ + response = await self._fetch_page_as_response(limit=limit, offset=offset) + return self.make_collection(response) # type: ignore[no-any-return,attr-defined] + + async def fetch_one(self) -> Model: + """Fetch one resource, expect exactly one result. + + Returns: + One resource. + + Raises: + ValueError: If the total matching records are not exactly one. + """ + response = await self._fetch_page_as_response(limit=1, offset=0) + resource_list = self.make_collection(response) # type: ignore[attr-defined] + total_records = len(resource_list) + if resource_list.meta: + total_records = resource_list.meta.pagination.total + if total_records == 0: + raise ValueError("Expected one result, but got zero results") + if total_records > 1: + raise ValueError(f"Expected one result, but got {total_records} results") + + return resource_list[0] # type: ignore[no-any-return] + + async def iterate(self, batch_size: int = 100) -> AsyncIterator[Model]: + """Iterate over all resources, yielding GenericResource objects. + + Args: + batch_size: Number of resources to fetch per request + + Returns: + Iterator of resources. + """ + offset = 0 + limit = batch_size # Default page size + + while True: + response = await self._fetch_page_as_response(limit=limit, offset=offset) + items_collection = self.make_collection(response) # type: ignore[attr-defined] + for resource in items_collection: + yield resource + + if not items_collection.meta: + break + if not items_collection.meta.pagination.has_next(): + break + offset = items_collection.meta.pagination.next_offset() + + async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> Response: + """Fetch one page of resources. + + Returns: + Response object. + + Raises: + HTTPStatusError: if the response status code is not 200. + """ + pagination_params: dict[str, int] = {"limit": limit, "offset": offset} + return await self.http_client.request("get", self.build_path(pagination_params)) # type: ignore[attr-defined,no-any-return] diff --git a/mpt_api_client/http/mixins/create_file_mixin.py b/mpt_api_client/http/mixins/create_file_mixin.py new file mode 100644 index 00000000..4bae327a --- /dev/null +++ b/mpt_api_client/http/mixins/create_file_mixin.py @@ -0,0 +1,66 @@ +from mpt_api_client.http.types import FileTypes +from mpt_api_client.models import ResourceData + + +class CreateFileMixin[Model]: + """Create file mixin.""" + + def create(self, resource_data: ResourceData, file: FileTypes | None = None) -> Model: # noqa: WPS110 + """Create logo. + + Create a file resource by specifying a file image. + + Args: + resource_data: Resource data. + file: File image. + + Returns: + Model: Created resource. + """ + files = {} + + if file: + files[self._upload_file_key] = file # type: ignore[attr-defined] + + response = self.http_client.request( # type: ignore[attr-defined] + "post", + self.path, # type: ignore[attr-defined] + json=resource_data, + files=files, + json_file_key=self._upload_data_key, # type: ignore[attr-defined] + force_multipart=True, + ) + + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] + + +class AsyncCreateFileMixin[Model]: + """Asynchronous Create file mixin.""" + + async def create(self, resource_data: ResourceData, file: FileTypes | None = None) -> Model: # noqa: WPS110 + """Create file. + + Create a file resource by specifying a file. + + Args: + resource_data: Resource data. + file: File image. + + Returns: + Model: Created resource. + """ + files = {} + + if file: + files[self._upload_file_key] = file # type: ignore[attr-defined] + + response = await self.http_client.request( # type: ignore[attr-defined] + "post", + self.path, # type: ignore[attr-defined] + json=resource_data, + files=files, + json_file_key=self._upload_data_key, # type: ignore[attr-defined] + force_multipart=True, + ) + + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] diff --git a/mpt_api_client/http/mixins/create_mixin.py b/mpt_api_client/http/mixins/create_mixin.py new file mode 100644 index 00000000..2df0886d --- /dev/null +++ b/mpt_api_client/http/mixins/create_mixin.py @@ -0,0 +1,29 @@ +from mpt_api_client.models import ResourceData + + +class CreateMixin[Model]: + """Create resource mixin.""" + + def create(self, resource_data: ResourceData) -> Model: + """Create a new resource using `POST /endpoint`. + + Returns: + New resource created. + """ + response = self.http_client.request("post", self.path, json=resource_data) # type: ignore[attr-defined] + + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] + + +class AsyncCreateMixin[Model]: + """Create resource mixin.""" + + async def create(self, resource_data: ResourceData) -> Model: + """Create a new resource using `POST /endpoint`. + + Returns: + New resource created. + """ + response = await self.http_client.request("post", self.path, json=resource_data) # type: ignore[attr-defined] + + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] diff --git a/mpt_api_client/http/mixins/delete_mixin.py b/mpt_api_client/http/mixins/delete_mixin.py new file mode 100644 index 00000000..edcaa2e0 --- /dev/null +++ b/mpt_api_client/http/mixins/delete_mixin.py @@ -0,0 +1,26 @@ +from urllib.parse import urljoin + + +class DeleteMixin: + """Delete resource mixin.""" + + def delete(self, resource_id: str) -> None: + """Delete resource using `DELETE /endpoint/{resource_id}`. + + Args: + resource_id: Resource ID. + """ + self._resource_do_request(resource_id, "DELETE") # type: ignore[attr-defined] + + +class AsyncDeleteMixin: + """Delete resource mixin.""" + + async def delete(self, resource_id: str) -> None: + """Delete resource using `DELETE /endpoint/{resource_id}`. + + Args: + resource_id: Resource ID. + """ + url = urljoin(f"{self.path}/", resource_id) # type: ignore[attr-defined] + await self.http_client.request("delete", url) # type: ignore[attr-defined] diff --git a/mpt_api_client/http/mixins/disable_mixin.py b/mpt_api_client/http/mixins/disable_mixin.py new file mode 100644 index 00000000..1577f0e4 --- /dev/null +++ b/mpt_api_client/http/mixins/disable_mixin.py @@ -0,0 +1,22 @@ +from mpt_api_client.models import Model as BaseModel +from mpt_api_client.models import ResourceData + + +class AsyncDisableMixin[Model: BaseModel]: + """Disable resource mixin.""" + + async def disable(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Disable a specific resource.""" + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id=resource_id, method="POST", action="disable", json=resource_data + ) + + +class DisableMixin[Model: BaseModel]: + """Disable resource mixin.""" + + def disable(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Disable a specific resource.""" + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id=resource_id, method="POST", action="disable", json=resource_data + ) diff --git a/mpt_api_client/http/mixins/download_file_mixin.py b/mpt_api_client/http/mixins/download_file_mixin.py new file mode 100644 index 00000000..be2e85b5 --- /dev/null +++ b/mpt_api_client/http/mixins/download_file_mixin.py @@ -0,0 +1,53 @@ +from mpt_api_client.exceptions import MPTError +from mpt_api_client.http.types import Response +from mpt_api_client.models import FileModel + + +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 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) diff --git a/mpt_api_client/http/mixins/enable_mixin.py b/mpt_api_client/http/mixins/enable_mixin.py new file mode 100644 index 00000000..9f4346fa --- /dev/null +++ b/mpt_api_client/http/mixins/enable_mixin.py @@ -0,0 +1,22 @@ +from mpt_api_client.models import Model as BaseModel +from mpt_api_client.models import ResourceData + + +class AsyncEnableMixin[Model: BaseModel]: + """Enable resource mixin.""" + + async def enable(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Enable a specific resource.""" + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id=resource_id, method="POST", action="enable", json=resource_data + ) + + +class EnableMixin[Model: BaseModel]: + """Enable resource mixin.""" + + def enable(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Enable a specific resource.""" + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id=resource_id, method="POST", action="enable", json=resource_data + ) diff --git a/mpt_api_client/http/mixins/file_operations_mixin.py b/mpt_api_client/http/mixins/file_operations_mixin.py new file mode 100644 index 00000000..f894f437 --- /dev/null +++ b/mpt_api_client/http/mixins/file_operations_mixin.py @@ -0,0 +1,73 @@ +from mpt_api_client.constants import APPLICATION_JSON +from mpt_api_client.http.client import json_to_file_payload +from mpt_api_client.http.mixins.download_file_mixin import ( + AsyncDownloadFileMixin, + DownloadFileMixin, +) +from mpt_api_client.http.types import FileTypes +from mpt_api_client.models import ResourceData + + +class FilesOperationsMixin[Model](DownloadFileMixin[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.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 AsyncFilesOperationsMixin[Model](AsyncDownloadFileMixin[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.request("post", self.path, files=files) # type: ignore[attr-defined] + + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] diff --git a/mpt_api_client/http/mixins/get_mixin.py b/mpt_api_client/http/mixins/get_mixin.py new file mode 100644 index 00000000..69640a21 --- /dev/null +++ b/mpt_api_client/http/mixins/get_mixin.py @@ -0,0 +1,35 @@ +class GetMixin[Model]: + """Get resource mixin.""" + + def get(self, resource_id: str, select: list[str] | str | None = None) -> Model: + """Fetch a specific resource using `GET /endpoint/{resource_id}`. + + Args: + resource_id: Resource ID. + select: List of fields to select. + + Returns: + Resource object. + """ + if isinstance(select, list): + select = ",".join(select) if select else None + + return self._resource_action(resource_id=resource_id, query_params={"select": select}) # type: ignore[attr-defined, no-any-return] + + +class AsyncGetMixin[Model]: + """Async get resource mixin.""" + + async def get(self, resource_id: str, select: list[str] | str | None = None) -> Model: + """Fetch a specific resource using `GET /endpoint/{resource_id}`. + + Args: + resource_id: Resource ID. + select: List of fields to select. + + Returns: + Resource object. + """ + if isinstance(select, list): + select = ",".join(select) if select else None + return await self._resource_action(resource_id=resource_id, query_params={"select": select}) # type: ignore[attr-defined, no-any-return] diff --git a/mpt_api_client/http/mixins/queryable_mixin.py b/mpt_api_client/http/mixins/queryable_mixin.py new file mode 100644 index 00000000..fae7b4cc --- /dev/null +++ b/mpt_api_client/http/mixins/queryable_mixin.py @@ -0,0 +1,75 @@ +from typing import Self + +from mpt_api_client.http.query_state import QueryState +from mpt_api_client.rql import RQLQuery + + +class QueryableMixin: + """Mixin providing query functionality for filtering, ordering, and selecting fields.""" + + def order_by(self, *fields: str) -> Self: + """Returns new collection with ordering setup. + + Returns: + New collection with ordering setup. + + Raises: + ValueError: If ordering has already been set. + """ + if self.query_state.order_by is not None: # type: ignore[attr-defined] + raise ValueError("Ordering is already set. Cannot set ordering multiple times.") + return self._create_new_instance( + query_state=QueryState( + rql=self.query_state.filter, # type: ignore[attr-defined] + order_by=list(fields), + select=self.query_state.select, # type: ignore[attr-defined] + ) + ) + + def filter(self, rql: RQLQuery) -> Self: + """Creates a new collection with the filter added to the filter collection. + + Returns: + New copy of the collection with the filter added. + """ + existing_filter = self.query_state.filter # type: ignore[attr-defined] + combined_filter = existing_filter & rql if existing_filter else rql + return self._create_new_instance( + QueryState( + rql=combined_filter, + order_by=self.query_state.order_by, # type: ignore[attr-defined] + select=self.query_state.select, # type: ignore[attr-defined] + ) + ) + + def select(self, *fields: str) -> Self: + """Set select fields. Raises ValueError if select fields are already set. + + Returns: + New copy of the collection with the select fields set. + + Raises: + ValueError: If select fields are already set. + """ + if self.query_state.select is not None: # type: ignore[attr-defined] + raise ValueError( + "Select fields are already set. Cannot set select fields multiple times." + ) + return self._create_new_instance( + QueryState( + rql=self.query_state.filter, # type: ignore[attr-defined] + order_by=self.query_state.order_by, # type: ignore[attr-defined] + select=list(fields), + ), + ) + + def _create_new_instance( + self, + query_state: QueryState, + ) -> Self: + """Create a new instance with the given parameters.""" + return self.__class__( + http_client=self.http_client, # type: ignore[call-arg,attr-defined] + query_state=query_state, + endpoint_params=self.endpoint_params, # type: ignore[attr-defined] + ) diff --git a/mpt_api_client/http/mixins/resource_mixins.py b/mpt_api_client/http/mixins/resource_mixins.py new file mode 100644 index 00000000..36e59d05 --- /dev/null +++ b/mpt_api_client/http/mixins/resource_mixins.py @@ -0,0 +1,24 @@ +from mpt_api_client.http.mixins.create_mixin import AsyncCreateMixin, CreateMixin +from mpt_api_client.http.mixins.delete_mixin import AsyncDeleteMixin, DeleteMixin +from mpt_api_client.http.mixins.get_mixin import AsyncGetMixin, GetMixin +from mpt_api_client.http.mixins.update_mixin import AsyncUpdateMixin, UpdateMixin + + +class ModifiableResourceMixin[Model](GetMixin[Model], UpdateMixin[Model], DeleteMixin): + """Editable resource mixin allows to read and update a resource resources.""" + + +class AsyncModifiableResourceMixin[Model]( + AsyncGetMixin[Model], AsyncUpdateMixin[Model], AsyncDeleteMixin +): + """Editable resource mixin allows to read and update a resource resources.""" + + +class ManagedResourceMixin[Model](CreateMixin[Model], ModifiableResourceMixin[Model]): + """Managed resource mixin allows to read, create, update and delete a resource resources.""" + + +class AsyncManagedResourceMixin[Model]( + AsyncCreateMixin[Model], AsyncModifiableResourceMixin[Model] +): + """Managed resource mixin allows to read, create, update and delete a resource resources.""" diff --git a/mpt_api_client/http/mixins/update_file_mixin.py b/mpt_api_client/http/mixins/update_file_mixin.py new file mode 100644 index 00000000..ce76df71 --- /dev/null +++ b/mpt_api_client/http/mixins/update_file_mixin.py @@ -0,0 +1,84 @@ +from urllib.parse import urljoin + +from mpt_api_client.http.types import FileTypes +from mpt_api_client.models import ResourceData + + +class UpdateFileMixin[Model]: + """Update file mixin.""" + + def update( + self, + resource_id: str, + resource_data: ResourceData, + file: FileTypes | None = None, # noqa: WPS110 + ) -> Model: + """Update file. + + Update a file resource by specifying a file. + + Args: + resource_id: Resource ID. + resource_data: Resource data. + file: File image. + + Returns: + Model: Updated resource. + """ + files = {} + + url = urljoin(f"{self.path}/", resource_id) # type: ignore[attr-defined] + + if file: + files[self._upload_file_key] = file # type: ignore[attr-defined] + + response = self.http_client.request( # type: ignore[attr-defined] + "put", + url, + json=resource_data, + files=files, + json_file_key=self._upload_data_key, # type: ignore[attr-defined] + force_multipart=True, + ) + + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] + + +class AsyncUpdateFileMixin[Model]: + """Asynchronous Update file mixin.""" + + async def update( + self, + resource_id: str, + resource_data: ResourceData, + file: FileTypes | None = None, # noqa: WPS110 + ) -> Model: + """Update file. + + Update a file resource by specifying a file. + + Args: + resource_id: Resource ID. + resource_data: Resource data. + file: File image. + + Returns: + Model: Updated resource. + """ + files = {} + + url = urljoin(f"{self.path}/", resource_id) # type: ignore[attr-defined] + + if file: + files[self._upload_file_key] = file # type: ignore[attr-defined] + + response = await self.http_client.request( # type: ignore[attr-defined] + "put", + url, + json=resource_data, + files=files, + json_file_key=self._upload_data_key, # type: ignore[attr-defined] + force_multipart=True, + ) + + return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] diff --git a/mpt_api_client/http/mixins/update_mixin.py b/mpt_api_client/http/mixins/update_mixin.py new file mode 100644 index 00000000..9d9a7c4a --- /dev/null +++ b/mpt_api_client/http/mixins/update_mixin.py @@ -0,0 +1,35 @@ +from mpt_api_client.models import ResourceData + + +class UpdateMixin[Model]: + """Update resource mixin.""" + + def update(self, resource_id: str, resource_data: ResourceData) -> Model: + """Update a resource using `PUT /endpoint/{resource_id}`. + + Args: + resource_id: Resource ID. + resource_data: Resource data. + + Returns: + Resource object. + + """ + return self._resource_action(resource_id, "PUT", json=resource_data) # type: ignore[attr-defined, no-any-return] + + +class AsyncUpdateMixin[Model]: + """Update resource mixin.""" + + async def update(self, resource_id: str, resource_data: ResourceData) -> Model: + """Update a resource using `PUT /endpoint/{resource_id}`. + + Args: + resource_id: Resource ID. + resource_data: Resource data. + + Returns: + Resource object. + + """ + return await self._resource_action(resource_id, "PUT", json=resource_data) # type: ignore[attr-defined, no-any-return] diff --git a/pyproject.toml b/pyproject.toml index abb90224..da3604ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,7 +115,6 @@ select = ["AAA", "E999", "WPS"] show-source = true statistics = false per-file-ignores = [ - "mpt_api_client/http/mixins.py: WPS202 WPS204 WPS235", "mpt_api_client/models/model.py: WPS215 WPS110", "mpt_api_client/mpt_client.py: WPS214 WPS235", "mpt_api_client/resources/*: WPS215", @@ -138,7 +137,7 @@ per-file-ignores = [ "tests/e2e/commerce/subscription/*.py: WPS202", "tests/unit/http/test_async_service.py: WPS204 WPS202", "tests/unit/http/test_service.py: WPS204 WPS202", - "tests/unit/http/test_mixins.py: WPS204 WPS202 WPS210", + "tests/unit/http/mixins/*: WPS204 WPS202 WPS210", "tests/unit/resources/accounts/*.py: WPS204 WPS202 WPS210", "tests/unit/resources/catalog/test_products.py: WPS202 WPS210", "tests/unit/resources/commerce/*.py: WPS202 WPS204", diff --git a/tests/unit/http/mixins/__init__.py b/tests/unit/http/mixins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/http/mixins/test_collection_mixin.py b/tests/unit/http/mixins/test_collection_mixin.py new file mode 100644 index 00000000..bafb5acc --- /dev/null +++ b/tests/unit/http/mixins/test_collection_mixin.py @@ -0,0 +1,509 @@ +import httpx +import pytest +import respx + +from mpt_api_client import RQLQuery +from mpt_api_client.exceptions import MPTAPIError +from tests.unit.http.conftest import AsyncDummyService, DummyService + + +def test_col_mx_fetch_one_success( + dummy_service: DummyService, single_result_response: httpx.Response +) -> None: + """Test fetching a single resource successfully.""" + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_result_response + ) + + result = dummy_service.fetch_one() + + assert result.id == "ID-1" + assert result.name == "Test Resource" + assert mock_route.called + first_request = mock_route.calls[0].request + assert "limit=1" in str(first_request.url) + assert "offset=0" in str(first_request.url) + + +def test_col_mx_fetch_one_no_results( + dummy_service: DummyService, no_results_response: httpx.Response +) -> None: + """Test fetching a single resource when no results are returned.""" + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock(return_value=no_results_response) + + with pytest.raises(ValueError, match="Expected one result, but got zero results"): + dummy_service.fetch_one() + + +def test_col_mx_fetch_one_multiple_results( + dummy_service: DummyService, multiple_results_response: httpx.Response +) -> None: + """Test fetching a single resource when multiple results are returned.""" + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock( + return_value=multiple_results_response + ) + + with pytest.raises(ValueError, match=r"Expected one result, but got 2 results"): + dummy_service.fetch_one() + + +def test_col_mx_fetch_one_with_filters( + dummy_service: DummyService, + single_result_response: httpx.Response, + filter_status_active: RQLQuery, +) -> None: + """Test fetching a single resource with filters applied.""" + filtered_collection = ( + dummy_service.filter(filter_status_active).select("id", "name").order_by("created") + ) + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_result_response + ) + + result = filtered_collection.fetch_one() + + assert result.id == "ID-1" + assert mock_route.called + first_request = mock_route.calls[0].request + assert first_request.method == "GET" + assert first_request.url == ( + "https://api.example.com/api/v1/test" + "?limit=1&offset=0&order=created" + "&select=id,name&eq(status,active)" + ) + + +def test_col_mx_fetch_page_with_filter( + dummy_service: DummyService, list_response: httpx.Response, filter_status_active: RQLQuery +) -> None: + """Test fetching a page of resources with filters applied.""" + custom_collection = ( + dummy_service + .filter(filter_status_active) + .select("-audit", "product.agreements", "-product.agreements.product") + .order_by("-created", "name") + ) + expected_url = ( + "https://api.example.com/api/v1/test?limit=10&offset=5" + "&order=-created,name" + "&select=-audit,product.agreements,-product.agreements.product" + "&eq(status,active)" + ) + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=list_response + ) + + result = custom_collection.fetch_page(limit=10, offset=5) + + assert result.to_list() == [{"id": "ID-1"}] + assert mock_route.called + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "GET" + assert request.url == expected_url + + +def test_col_mx_iterate_single_page( + dummy_service: DummyService, single_page_response: httpx.Response +) -> None: + """Test iterating over a single page of resources.""" + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_page_response + ) + + result = list(dummy_service.iterate()) + + request = mock_route.calls[0].request + assert len(result) == 2 + assert result[0].to_dict() == {"id": "ID-1", "name": "Resource 1"} + assert result[1].to_dict() == {"id": "ID-2", "name": "Resource 2"} + assert mock_route.call_count == 1 + assert request.url == "https://api.example.com/api/v1/test?limit=100&offset=0" + + +def test_col_mx_iterate_multiple_pages( + dummy_service: DummyService, + multi_page_response_page1: httpx.Response, + multi_page_response_page2: httpx.Response, +) -> None: + """Test iterating over multiple pages of resources.""" + with respx.mock: + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( + return_value=multi_page_response_page1 + ) + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 2}).mock( + return_value=multi_page_response_page2 + ) + + result = list(dummy_service.iterate(2)) + + assert len(result) == 4 + assert result[0].id == "ID-1" + assert result[1].id == "ID-2" + assert result[2].id == "ID-3" + assert result[3].id == "ID-4" + + +def test_col_mx_iterate_empty_results( + dummy_service: DummyService, empty_response: httpx.Response +) -> None: + """Test iterating over an empty set of resources.""" + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=empty_response + ) + + result = list(dummy_service.iterate(2)) + + assert len(result) == 0 + assert mock_route.call_count == 1 + + +def test_col_mx_iterate_no_meta( + dummy_service: DummyService, no_meta_response: httpx.Response +) -> None: + """Test iterating over resources when no metadata is provided.""" + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=no_meta_response + ) + + result = list(dummy_service.iterate()) + + assert len(result) == 2 + assert result[0].id == "ID-1" + assert result[1].id == "ID-2" + assert mock_route.call_count == 1 + + +def test_col_mx_iterate_with_filters( + dummy_service: DummyService, filter_status_active: RQLQuery +) -> None: + """Test iterating over resources with filters applied.""" + filtered_collection = ( + dummy_service.filter(filter_status_active).select("id", "name").order_by("created") + ) + response = httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Active Resource"}], + "$meta": { + "pagination": { + "total": 1, + "offset": 0, + "limit": 100, + } + }, + }, + ) + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) + + result = list(filtered_collection.iterate()) + + assert len(result) == 1 + assert result[0].id == "ID-1" + assert result[0].name == "Active Resource" + request = mock_route.calls[0].request + assert ( + str(request.url) == "https://api.example.com/api/v1/test" + "?limit=100&offset=0&order=created&select=id,name&eq(status,active)" + ) + + +def test_col_mx_iterate_lazy_evaluation(dummy_service: DummyService) -> None: + """Test lazy evaluation of iterating over resources.""" + response = httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Resource 1"}], + "$meta": { + "pagination": { + "total": 1, + "offset": 0, + "limit": 100, + } + }, + }, + ) + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) + + result = dummy_service.iterate() + + assert mock_route.call_count == 0 + first_resource = next(result) + assert mock_route.call_count == 1 + assert first_resource.id == "ID-1" + + +def test_col_mx_iterate_handles_api_errors(dummy_service: DummyService) -> None: + """Test that API errors are handled during iteration over resources.""" + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock( + return_value=httpx.Response( + httpx.codes.INTERNAL_SERVER_ERROR, json={"error": "Internal Server Error"} + ) + ) + iterator = dummy_service.iterate() + + with pytest.raises(MPTAPIError): + list(iterator) + + +async def test_async_col_mx_fetch_one_success( + async_dummy_service: AsyncDummyService, single_result_response: httpx.Response +) -> None: + """Test fetching a single resource successfully.""" + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_result_response + ) + + result = await async_dummy_service.fetch_one() + + assert result.id == "ID-1" + assert result.name == "Test Resource" + assert mock_route.called + first_request = mock_route.calls[0].request + assert "limit=1" in str(first_request.url) + assert "offset=0" in str(first_request.url) + + +async def test_async_col_mx_fetch_one_no_results( + async_dummy_service: AsyncDummyService, no_results_response: httpx.Response +) -> None: + """Test fetching a single resource when no results are returned.""" + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock(return_value=no_results_response) + + with pytest.raises(ValueError, match="Expected one result, but got zero results"): + await async_dummy_service.fetch_one() + + +async def test_async_col_mx_fetch_one_multiple_results( + async_dummy_service: AsyncDummyService, multiple_results_response: httpx.Response +) -> None: + """Test fetching a single resource when multiple results are returned.""" + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock( + return_value=multiple_results_response + ) + + with pytest.raises(ValueError, match=r"Expected one result, but got 2 results"): + await async_dummy_service.fetch_one() + + +async def test_async_col_mx_fetch_one_with_filters( + async_dummy_service: AsyncDummyService, + single_result_response: httpx.Response, + filter_status_active: RQLQuery, +) -> None: + """Test fetching a single resource with filters applied.""" + filtered_collection = ( + async_dummy_service.filter(filter_status_active).select("id", "name").order_by("created") + ) + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_result_response + ) + result = await filtered_collection.fetch_one() + + assert result.id == "ID-1" + assert mock_route.called + first_request = mock_route.calls[0].request + assert first_request.method == "GET" + assert first_request.url == ( + "https://api.example.com/api/v1/test" + "?limit=1&offset=0&order=created" + "&select=id,name&eq(status,active)" + ) + + +async def test_async_col_mx_fetch_page_with_filter( + async_dummy_service: AsyncDummyService, + list_response: httpx.Response, + filter_status_active: RQLQuery, +) -> None: + """Test fetching a page of resources with filters applied.""" + custom_collection = ( + async_dummy_service + .filter(filter_status_active) + .select("-audit", "product.agreements", "-product.agreements.product") + .order_by("-created", "name") + ) + expected_url = ( + "https://api.example.com/api/v1/test?limit=10&offset=5" + "&order=-created,name" + "&select=-audit,product.agreements,-product.agreements.product" + "&eq(status,active)" + ) + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=list_response + ) + + result = await custom_collection.fetch_page(limit=10, offset=5) + + assert result.to_list() == [{"id": "ID-1"}] + assert mock_route.called + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "GET" + assert request.url == expected_url + + +async def test_async_col_mx_iterate_single_page( + async_dummy_service: AsyncDummyService, single_page_response: httpx.Response +) -> None: + """Test iterating over a single page of resources.""" + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=single_page_response + ) + + result = [resource async for resource in async_dummy_service.iterate()] + + request = mock_route.calls[0].request + assert len(result) == 2 + assert result[0].to_dict() == {"id": "ID-1", "name": "Resource 1"} + assert result[1].to_dict() == {"id": "ID-2", "name": "Resource 2"} + assert mock_route.call_count == 1 + assert request.url == "https://api.example.com/api/v1/test?limit=100&offset=0" + + +async def test_async_col_mx_iterate_multiple_pages( + async_dummy_service: AsyncDummyService, + multi_page_response_page1: httpx.Response, + multi_page_response_page2: httpx.Response, +) -> None: + """Test iterating over multiple pages of resources.""" + with respx.mock: + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( + return_value=multi_page_response_page1 + ) + respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 2}).mock( + return_value=multi_page_response_page2 + ) + + result = [resource async for resource in async_dummy_service.iterate(2)] + + assert len(result) == 4 + assert result[0].id == "ID-1" + assert result[1].id == "ID-2" + assert result[2].id == "ID-3" + assert result[3].id == "ID-4" + + +async def test_async_col_mx_iterate_empty_results( + async_dummy_service: AsyncDummyService, empty_response: httpx.Response +) -> None: + """Test iterating over an empty set of resources.""" + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=empty_response + ) + + result = [resource async for resource in async_dummy_service.iterate()] + + assert len(result) == 0 + assert mock_route.call_count == 1 + + +async def test_async_col_mx_iterate_no_meta( + async_dummy_service: AsyncDummyService, no_meta_response: httpx.Response +) -> None: + """Test iterating over resources when no metadata is provided.""" + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock( + return_value=no_meta_response + ) + + result = [resource async for resource in async_dummy_service.iterate()] + + assert len(result) == 2 + assert result[0].id == "ID-1" + assert result[1].id == "ID-2" + assert mock_route.call_count == 1 + + +async def test_async_col_mx_iterate_with_filters( + async_dummy_service: AsyncDummyService, filter_status_active: RQLQuery +) -> None: + """Test iterating over resources with filters applied.""" + filtered_collection = ( + async_dummy_service.filter(filter_status_active).select("id", "name").order_by("created") + ) + response = httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Active Resource"}], + "$meta": { + "pagination": { + "total": 1, + "offset": 0, + "limit": 100, + } + }, + }, + ) + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) + + result = [resource async for resource in filtered_collection.iterate()] + + assert len(result) == 1 + assert result[0].id == "ID-1" + assert result[0].name == "Active Resource" + request = mock_route.calls[0].request + assert ( + str(request.url) == "https://api.example.com/api/v1/test" + "?limit=100&offset=0&order=created&select=id,name&eq(status,active)" + ) + + +async def test_async_col_mx_iterate_lazy_evaluation(async_dummy_service: AsyncDummyService) -> None: + """Test lazy evaluation of iterating over resources.""" + response = httpx.Response( + httpx.codes.OK, + json={ + "data": [{"id": "ID-1", "name": "Resource 1"}], + "$meta": { + "pagination": { + "total": 1, + "offset": 0, + "limit": 100, + } + }, + }, + ) + with respx.mock: + mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) + + result = async_dummy_service.iterate() + + assert mock_route.call_count == 0 + first_resource = await anext(result) + assert first_resource.id == "ID-1" + assert mock_route.call_count == 1 + + +async def test_async_col_mx_iterate_handles_api_errors( + async_dummy_service: AsyncDummyService, +) -> None: + """Test that API errors are handled during iteration over resources.""" + with respx.mock: + respx.get("https://api.example.com/api/v1/test").mock( + return_value=httpx.Response( + httpx.codes.INTERNAL_SERVER_ERROR, json={"error": "Internal Server Error"} + ) + ) + + with pytest.raises(MPTAPIError): + [resource async for resource in async_dummy_service.iterate()] diff --git a/tests/unit/http/mixins/test_create_file_mixin.py b/tests/unit/http/mixins/test_create_file_mixin.py new file mode 100644 index 00000000..51336341 --- /dev/null +++ b/tests/unit/http/mixins/test_create_file_mixin.py @@ -0,0 +1,134 @@ +import io + +import httpx +import pytest +import respx + +from mpt_api_client.resources.catalog.products_media import AsyncMediaService, MediaService + + +@pytest.fixture +def media_service(http_client) -> MediaService: + """Fixture for MediaService.""" + return MediaService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) + + +@pytest.fixture +def async_media_service(async_http_client) -> AsyncMediaService: + """Fixture for AsyncMediaService.""" + return AsyncMediaService( + http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} + ) + + +async def test_async_file_create_with_data(async_media_service: AsyncMediaService) -> None: + """Test creating a file resource asynchronously with additional 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, + ) + ) + media_image = ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg") + + result = await async_media_service.create({"name": "Product image"}, file=media_image) + + request = mock_route.calls[0].request + assert ( + b'Content-Disposition: form-data; name="media"\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="file"; filename="test.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"Image content\r\n" in request.content + ) + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == media_data + + +async def test_async_file_create_no_data(async_media_service: AsyncMediaService) -> None: + """Test creating a file resource asynchronously without additional 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, + ) + ) + new_media = await async_media_service.create( + {}, file=("test.jpg", io.BytesIO(b"Image content"), "image/jpeg") + ) + + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="file"; 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_with_data(media_service: MediaService) -> None: + """Test creating a file resource synchronously with additional 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, + ) + ) + image_file = ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg") + + result = media_service.create({"name": "Product image"}, image_file) + + request = mock_route.calls[0].request + assert ( + b'Content-Disposition: form-data; name="media"\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="file"; filename="test.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"Image content\r\n" in request.content + ) + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == media_data + + +def test_sync_file_create_no_data(media_service: MediaService) -> None: + """Test creating a file resource synchronously without additional 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, + ) + ) + image_file = ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg") + + result = media_service.create({}, image_file) + + request = mock_route.calls[0].request + assert ( + b'Content-Disposition: form-data; name="file"; filename="test.jpg"\r\n' + b"Content-Type: image/jpeg\r\n\r\n" + b"Image content\r\n" in request.content + ) + assert result.to_dict() == media_data diff --git a/tests/unit/http/mixins/test_create_mixin.py b/tests/unit/http/mixins/test_create_mixin.py new file mode 100644 index 00000000..23f573ae --- /dev/null +++ b/tests/unit/http/mixins/test_create_mixin.py @@ -0,0 +1,46 @@ +import json + +import httpx +import respx + +from tests.unit.http.conftest import AsyncDummyService, DummyService + + +async def test_async_create_mixin(async_dummy_service: AsyncDummyService) -> None: + """Test creating a resource asynchronously.""" + resource_data = {"name": "Test Resource", "status": "active"} + new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} + create_response = httpx.Response(httpx.codes.OK, json=new_resource_data) + with respx.mock: + mock_route = respx.post("https://api.example.com/api/v1/test").mock( + return_value=create_response + ) + + result = await async_dummy_service.create(resource_data) + + assert result.to_dict() == new_resource_data + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "POST" + assert request.url == "https://api.example.com/api/v1/test" + assert json.loads(request.content.decode()) == resource_data + + +def test_sync_create_mixin(dummy_service: DummyService) -> None: + """Test creating a resource synchronously.""" + resource_data = {"name": "Test Resource", "status": "active"} + new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} + create_response = httpx.Response(httpx.codes.OK, json=new_resource_data) + with respx.mock: + mock_route = respx.post("https://api.example.com/api/v1/test").mock( + return_value=create_response + ) + + result = dummy_service.create(resource_data) + + assert result.to_dict() == new_resource_data + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.method == "POST" + assert request.url == "https://api.example.com/api/v1/test" + assert json.loads(request.content.decode()) == resource_data diff --git a/tests/unit/http/mixins/test_delete_mixin.py b/tests/unit/http/mixins/test_delete_mixin.py new file mode 100644 index 00000000..5b137dcd --- /dev/null +++ b/tests/unit/http/mixins/test_delete_mixin.py @@ -0,0 +1,30 @@ +import httpx +import respx + +from tests.unit.http.conftest import AsyncDummyService, DummyService + + +async def test_async_delete_mixin(async_dummy_service: AsyncDummyService) -> None: + """Test deleting a resource asynchronously.""" + delete_response = httpx.Response(httpx.codes.NO_CONTENT, json=None) + with respx.mock: + mock_route = respx.delete("https://api.example.com/api/v1/test/RES-123").mock( + return_value=delete_response + ) + + await async_dummy_service.delete("RES-123") # act + + assert mock_route.call_count == 1 + + +def test_sync_delete_mixin(dummy_service: DummyService) -> None: + """Test deleting a resource synchronously.""" + delete_response = httpx.Response(httpx.codes.NO_CONTENT, json=None) + with respx.mock: + mock_route = respx.delete("https://api.example.com/api/v1/test/RES-123").mock( + return_value=delete_response + ) + + dummy_service.delete("RES-123") # act + + assert mock_route.call_count == 1 diff --git a/tests/unit/http/mixins/test_disable_mixin.py b/tests/unit/http/mixins/test_disable_mixin.py new file mode 100644 index 00000000..cf9a386b --- /dev/null +++ b/tests/unit/http/mixins/test_disable_mixin.py @@ -0,0 +1,97 @@ +import httpx +import pytest +import respx + +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import AsyncDisableMixin, DisableMixin +from tests.unit.conftest import DummyModel + + +class DisableService( + DisableMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/enablable/" + _model_class = DummyModel + + +class AsyncDisableService( + AsyncDisableMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/enablable/" + _model_class = DummyModel + + +@pytest.fixture +def disablable_service(http_client) -> DisableService: + return DisableService(http_client=http_client) + + +@pytest.fixture +def async_disablable_service(async_http_client) -> AsyncDisableService: + return AsyncDisableService(http_client=async_http_client) + + +@pytest.mark.parametrize( + ("input_status"), + [ + ({"id": "OBJ-0000-0001", "status": "update"}), + (None), + ], +) +def test_disable_resource_actions( + disablable_service: DisableService, input_status: dict | None +) -> None: + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' if input_status else b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/dummy/enablable/OBJ-0000-0001/disable" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = disablable_service.disable("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize( + ("input_status"), + [ + ({"id": "OBJ-0000-0001", "status": "update"}), + (None), + ], +) +async def test_async_disable_resource_actions( + async_disablable_service: AsyncDisableService, input_status: dict | None +) -> None: + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' if input_status else b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/dummy/enablable/OBJ-0000-0001/disable" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = await async_disablable_service.disable("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) diff --git a/tests/unit/http/mixins/test_download_file_mixin.py b/tests/unit/http/mixins/test_download_file_mixin.py new file mode 100644 index 00000000..ac89f5a8 --- /dev/null +++ b/tests/unit/http/mixins/test_download_file_mixin.py @@ -0,0 +1,167 @@ +import httpx +import pytest +import respx + +from mpt_api_client.resources.catalog.products_media import AsyncMediaService, MediaService + + +@pytest.fixture +def media_service(http_client) -> MediaService: + """Fixture for MediaService.""" + return MediaService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) + + +@pytest.fixture +def async_media_service(async_http_client) -> AsyncMediaService: + """Fixture for AsyncMediaService.""" + return AsyncMediaService( + http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} + ) + + +async def test_async_file_download_request_and_headers( + async_media_service: AsyncMediaService, +) -> None: + """Test request/response and headers for async file download.""" + media_content = b"Image file content or binary data" + with respx.mock: + 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": "image/jpg", + "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', + }, + content=media_content, + ) + ) + # Act + await async_media_service.download("MED-456") + + 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_download.call_count == 1 + + +async def test_async_file_download_content_and_metadata( + async_media_service: AsyncMediaService, +) -> None: + """Test file content and metadata for async file download.""" + media_content = b"Image file content or binary data" + with respx.mock: + 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"}, + ) + ) + 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, + ) + ) + result = await async_media_service.download("MED-456") + + assert result.file_contents == media_content + assert result.content_type == "image/jpg" + assert result.filename == "product_image.jpg" + + +def test_sync_file_download_request_and_headers(media_service: MediaService) -> None: + """Test request/response and headers for sync file download.""" + media_content = b"Image file content or binary data" + with respx.mock: + 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": "image/jpg", + "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', + }, + content=media_content, + ) + ) + media_service.download("MED-456") + assert mock_resource.call_count == 1 + + result = mock_download.calls[0].request + + # Assert + accept_header = (b"Accept", b"image/jpg") + assert accept_header in result.headers.raw + assert mock_download.call_count == 1 + + +def test_sync_file_download_content_and_metadata(media_service: MediaService) -> None: + """Test file content and metadata for sync file download.""" + media_content = b"Image file content or binary data" + with respx.mock: + 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"}, + ) + ) + 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, + ) + ) + + result = media_service.download("MED-456") + + assert result.file_contents == media_content + assert result.content_type == "image/jpg" + assert result.filename == "product_image.jpg" diff --git a/tests/unit/http/mixins/test_enable_mixin.py b/tests/unit/http/mixins/test_enable_mixin.py new file mode 100644 index 00000000..8722f7dc --- /dev/null +++ b/tests/unit/http/mixins/test_enable_mixin.py @@ -0,0 +1,97 @@ +import httpx +import pytest +import respx + +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import AsyncEnableMixin, EnableMixin +from tests.unit.conftest import DummyModel + + +class EnableService( + EnableMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/enablable/" + _model_class = DummyModel + + +class AsyncEnableService( + AsyncEnableMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/enablable/" + _model_class = DummyModel + + +@pytest.fixture +def enablable_service(http_client) -> EnableService: + return EnableService(http_client=http_client) + + +@pytest.fixture +def async_enablable_service(async_http_client) -> AsyncEnableService: + return AsyncEnableService(http_client=async_http_client) + + +@pytest.mark.parametrize( + ("input_status"), + [ + ({"id": "OBJ-0000-0001", "status": "update"}), + (None), + ], +) +def test_enable_resource_actions( + enablable_service: EnableService, input_status: dict | None +) -> None: + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' if input_status else b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/dummy/enablable/OBJ-0000-0001/enable" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = enablable_service.enable("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) + + +@pytest.mark.parametrize( + ("input_status"), + [ + ({"id": "OBJ-0000-0001", "status": "update"}), + (None), + ], +) +async def test_async_enable_resource_actions( + async_enablable_service: AsyncEnableService, input_status: dict | None +) -> None: + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' if input_status else b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/dummy/enablable/OBJ-0000-0001/enable" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + + result = await async_enablable_service.enable("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == request_expected_content + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) diff --git a/tests/unit/http/mixins/test_file_operations_mixin.py b/tests/unit/http/mixins/test_file_operations_mixin.py new file mode 100644 index 00000000..fa3349af --- /dev/null +++ b/tests/unit/http/mixins/test_file_operations_mixin.py @@ -0,0 +1,87 @@ +import io + +import httpx +import pytest +import respx + +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import AsyncFilesOperationsMixin, FilesOperationsMixin +from tests.unit.conftest import DummyModel + + +class DummyFileOperationsService( + FilesOperationsMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/file-ops/" + _model_class = DummyModel + _collection_key = "data" + + +class DummyAsyncFileOperationsService( + AsyncFilesOperationsMixin[DummyModel], + AsyncService[DummyModel], +): + """Dummy asynchronous file operations service for testing.""" + + _endpoint = "/public/v1/dummy/file-ops/" + _model_class = DummyModel + _collection_key = "data" + + +@pytest.fixture +def dummy_file_operations_service(http_client) -> DummyFileOperationsService: + """Fixture for DummyFileOperationsService.""" + return DummyFileOperationsService(http_client=http_client) + + +@pytest.fixture +def async_dummy_file_operations_service(async_http_client) -> DummyAsyncFileOperationsService: + """Fixture for DummyAsyncFileOperationsService.""" + return DummyAsyncFileOperationsService(http_client=async_http_client) + + +def test_sync_file_create_with_resource_data( + dummy_file_operations_service: DummyFileOperationsService, +) -> None: + """Test creating a file with resource data.""" + file_data = {"id": "FILE-123"} + with respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/dummy/file-ops/").mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=file_data, + ) + ) + files = {"file": ("document.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + resource_data = {"name": "Test Document"} + + result = dummy_file_operations_service.create(resource_data=resource_data, files=files) + + request = mock_route.calls[0].request + assert b'name="_attachment_data"' in request.content + assert b'"name":"Test Document"' in request.content + assert result.to_dict() == file_data + + +async def test_async_file_create_with_resource_data( + async_dummy_file_operations_service: DummyAsyncFileOperationsService, +) -> None: + """Test creating a file with resource data.""" + file_data = {"id": "FILE-123"} + with respx.mock: + mock_route = respx.post("https://api.example.com/public/v1/dummy/file-ops/").mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=file_data, + ) + ) + files = {"file": ("document.pdf", io.BytesIO(b"PDF content"), "application/pdf")} + resource_data = {"name": "Test Document"} + result = await async_dummy_file_operations_service.create( + resource_data=resource_data, files=files + ) + request = mock_route.calls[0].request + assert b'name="_attachment_data"' in request.content + assert b'"name":"Test Document"' in request.content + assert result.to_dict() == file_data diff --git a/tests/unit/http/mixins/test_get_mixin.py b/tests/unit/http/mixins/test_get_mixin.py new file mode 100644 index 00000000..f7ac27e5 --- /dev/null +++ b/tests/unit/http/mixins/test_get_mixin.py @@ -0,0 +1,60 @@ +import httpx +import pytest +import respx + +from tests.unit.http.conftest import AsyncDummyService, DummyService + + +@pytest.mark.parametrize( + "select_value", + [ + ["id", "name"], + "id,name", + ], +) +def test_sync_get_mixin(dummy_service: DummyService, select_value: str | list[str]) -> None: + """Test getting a resource synchronously with different select parameter formats.""" + resource_data = {"id": "RES-123", "name": "Test Resource"} + with respx.mock: + mock_route = respx.get( + "https://api.example.com/api/v1/test/RES-123", params={"select": "id,name"} + ).mock(return_value=httpx.Response(httpx.codes.OK, json=resource_data)) + + result = dummy_service.get("RES-123", select=select_value) + + request = mock_route.calls[0].request + accept_header = (b"Accept", b"application/json") + assert accept_header in request.headers.raw + assert result.to_dict() == resource_data + + +async def test_async_get(async_dummy_service: AsyncDummyService) -> None: + """Test getting a resource asynchronously with a list select parameter.""" + resource_data = {"id": "RES-123", "name": "Test Resource"} + with respx.mock: + mock_route = respx.get( + "https://api.example.com/api/v1/test/RES-123", params={"select": "id,name"} + ).mock(return_value=httpx.Response(httpx.codes.OK, json=resource_data)) + + result = await async_dummy_service.get("RES-123", select=["id", "name"]) + + request = mock_route.calls[0].request + accept_header = (b"Accept", b"application/json") + assert accept_header in request.headers.raw + assert result.to_dict() == resource_data + + +async def test_async_get_select_str(async_dummy_service: AsyncDummyService) -> None: + """Test getting a resource asynchronously with a string select parameter.""" + resource_data = {"id": "RES-123", "name": "Test Resource"} + with respx.mock: + mock_route = respx.get( + "https://api.example.com/api/v1/test/RES-123", params={"select": "id,name"} + ).mock(return_value=httpx.Response(httpx.codes.OK, json=resource_data)) + + result = await async_dummy_service.get("RES-123", select="id,name") + + request = mock_route.calls[0].request + accept_header = (b"Accept", b"application/json") + assert accept_header in request.headers.raw + assert result.to_dict() == resource_data diff --git a/tests/unit/http/mixins/test_queryable_mixin.py b/tests/unit/http/mixins/test_queryable_mixin.py new file mode 100644 index 00000000..117a4b0f --- /dev/null +++ b/tests/unit/http/mixins/test_queryable_mixin.py @@ -0,0 +1,80 @@ +import pytest + +from mpt_api_client import RQLQuery +from tests.unit.http.conftest import DummyService + + +def test_queryable_mixin_order_by(dummy_service: DummyService) -> None: + result = dummy_service.order_by("created", "-name") + + assert result != dummy_service + assert dummy_service.query_state.order_by is None + assert result.query_state.order_by == ["created", "-name"] + assert result.http_client is dummy_service.http_client + assert result.endpoint_params == dummy_service.endpoint_params + + +def test_queryable_mixin_order_by_exception(dummy_service: DummyService) -> None: + """Test that setting order_by multiple times raises an exception.""" + ordered_service = dummy_service.order_by("created") + + with pytest.raises( + ValueError, match=r"Ordering is already set. Cannot set ordering multiple times." + ): + ordered_service.order_by("name") + + +def test_queryable_mixin_filter( + dummy_service: DummyService, filter_status_active: RQLQuery +) -> None: + result = dummy_service.filter(filter_status_active) + + assert result != dummy_service + assert dummy_service.query_state.filter is None + assert result.query_state.filter == filter_status_active + assert result.http_client is dummy_service.http_client + assert result.endpoint_params == dummy_service.endpoint_params + + +def test_queryable_mixin_filters(dummy_service: DummyService) -> None: + """Test applying multiple filters to a queryable service.""" + filter1 = RQLQuery(status="active") + filter2 = RQLQuery(name="test") + + result = dummy_service.filter(filter1).filter(filter2) + + assert dummy_service.query_state.filter is None + assert result.query_state.filter == filter1 & filter2 + + +def test_queryable_mixin_select(dummy_service: DummyService) -> None: + result = dummy_service.select("id", "name", "-audit") + + assert result != dummy_service + assert dummy_service.query_state.select is None + assert result.query_state.select == ["id", "name", "-audit"] + assert result.http_client is dummy_service.http_client + assert result.endpoint_params == dummy_service.endpoint_params + + +def test_queryable_mixin_select_exception(dummy_service: DummyService) -> None: + """Test that setting select fields multiple times raises an exception.""" + selected_service = dummy_service.select("id", "name") + + with pytest.raises( + ValueError, match=r"Select fields are already set. Cannot set select fields multiple times." + ): + selected_service.select("other_field") + + +def test_queryable_mixin_method_chaining( + dummy_service: DummyService, filter_status_active: RQLQuery +) -> None: + result = ( + dummy_service.filter(filter_status_active).order_by("created", "-name").select("id", "name") + ) + + assert result != dummy_service + assert result.query_state.filter == filter_status_active + assert result.query_state.order_by == ["created", "-name"] + assert result.query_state.select == ["id", "name"] diff --git a/tests/unit/http/mixins/test_resource_mixins.py b/tests/unit/http/mixins/test_resource_mixins.py new file mode 100644 index 00000000..a061e6c5 --- /dev/null +++ b/tests/unit/http/mixins/test_resource_mixins.py @@ -0,0 +1,95 @@ +import pytest + +from mpt_api_client.http.mixins import ( + AsyncManagedResourceMixin, + AsyncModifiableResourceMixin, + ManagedResourceMixin, + ModifiableResourceMixin, +) +from tests.unit.conftest import DummyModel + + +class _ModifiableResourceService(ModifiableResourceMixin[DummyModel]): + """Dummy service class for testing required methods.""" + + +class _AsyncModifiableResourceService(AsyncModifiableResourceMixin[DummyModel]): + """Dummy service class for testing required methods.""" + + +class _ManagedService(ManagedResourceMixin[DummyModel]): + """Dummy service class for testing required methods.""" + + +class _AsyncManagedService(AsyncManagedResourceMixin[DummyModel]): + """Dummy service class for testing required methods.""" + + +@pytest.mark.parametrize( + "method_name", + [ + "update", + "delete", + "get", + ], +) +def test_modifieable_resource_mixin(method_name: str) -> None: + """Test that ModifiableResourceMixin has the required methods.""" + result = _ModifiableResourceService() + + assert hasattr(result, method_name), f"ModifiableResourceMixin should have {method_name} method" + assert callable(getattr(result, method_name)), f"{method_name} should be callable" + + +@pytest.mark.parametrize( + "method_name", + [ + "update", + "delete", + "get", + ], +) +def test_async_modifiable_resource_mixin(method_name: str) -> None: + """Test that AsyncModifiableResourceMixin has the required methods.""" + result = _AsyncModifiableResourceService() + + assert hasattr(result, method_name), ( + f"AsyncModifiableResourceMixin should have {method_name} method" + ) + assert callable(getattr(result, method_name)), f"{method_name} should be callable" + + +@pytest.mark.parametrize( + "method_name", + [ + "create", + "update", + "delete", + "get", + ], +) +def test_managed_resource_mixin(method_name: str) -> None: + """Test that ManagedResourceMixin has the required methods.""" + result = _ManagedService() + + assert hasattr(result, method_name), f"ManagedResourceMixin should have {method_name} method" + assert callable(getattr(result, method_name)), f"{method_name} should be callable" + + +@pytest.mark.parametrize( + "method_name", + [ + "create", + "update", + "delete", + "get", + ], +) +def test_async_managed_resource_mixin(method_name: str) -> None: + """Test that AsyncManagedResourceMixin has the required methods.""" + result = _AsyncManagedService() + + assert hasattr(result, method_name), ( + f"AsyncManagedResourceMixin should have {method_name} method" + ) + assert callable(getattr(result, method_name)), f"{method_name} should be callable" diff --git a/tests/unit/http/mixins/test_update_file_mixin.py b/tests/unit/http/mixins/test_update_file_mixin.py new file mode 100644 index 00000000..95b284fa --- /dev/null +++ b/tests/unit/http/mixins/test_update_file_mixin.py @@ -0,0 +1,151 @@ +import io + +import httpx +import pytest +import respx + +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import AsyncUpdateFileMixin, UpdateFileMixin +from tests.unit.conftest import DummyModel + + +class DummyUpdateFileService( + UpdateFileMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/update-file" + _model_class = DummyModel + _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "document" + + +class DummyAsyncUpdateFileService( + AsyncUpdateFileMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/update-file" + _model_class = DummyModel + _collection_key = "data" + _upload_file_key = "file" + _upload_data_key = "document" + + +@pytest.fixture +def update_file_service(http_client) -> DummyUpdateFileService: + return DummyUpdateFileService(http_client=http_client) + + +@pytest.fixture +def async_update_file_service(async_http_client) -> DummyAsyncUpdateFileService: + return DummyAsyncUpdateFileService(http_client=async_http_client) + + +def test_sync_update_file(update_file_service: DummyUpdateFileService) -> None: + resource_id = "ICON-1234" + response_expected_data = {"id": resource_id, "name": "Updated Icon Object"} + file_tuple = ("icon.png", io.BytesIO(b"PNG DATA"), "image/png") + resource_data = {"name": "Updated Icon Object"} + with respx.mock: + mock_route = respx.put( + f"https://api.example.com/public/v1/dummy/update-file/{resource_id}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=response_expected_data, + ) + ) + + result = update_file_service.update(resource_id, resource_data, file=file_tuple) + + request = mock_route.calls[0].request + assert mock_route.call_count == 1 + assert ( + b'Content-Disposition: form-data; name="file"; filename="icon.png"\r\n' + b"Content-Type: image/png\r\n\r\n" + b"PNG DATA\r\n" in request.content + ) + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) + + +def test_update_file_no_file(update_file_service: DummyUpdateFileService) -> None: + resource_id = "ICON-1234" + response_expected_data = {"id": resource_id, "name": "Updated Icon Object"} + resource_data = {"name": "Updated Icon Object"} + with respx.mock: + mock_route = respx.put( + f"https://api.example.com/public/v1/dummy/update-file/{resource_id}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=response_expected_data, + ) + ) + + result = update_file_service.update(resource_id, resource_data) + + request = mock_route.calls[0].request + assert mock_route.call_count == 1 + assert b'Content-Disposition: form-data; name="file"' not in request.content + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) + + +async def test_async_update_file(async_update_file_service: DummyAsyncUpdateFileService) -> None: + resource_id = "ICON-1234" + response_expected_data = {"id": resource_id, "name": "Updated Icon Object"} + file_tuple = ("icon.png", io.BytesIO(b"PNG DATA"), "image/png") + resource_data = {"name": "Updated Icon Object"} + + with respx.mock: + mock_route = respx.put( + f"https://api.example.com/public/v1/dummy/update-file/{resource_id}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=response_expected_data, + ) + ) + # Act + result = await async_update_file_service.update(resource_id, resource_data, file=file_tuple) + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="file"; filename="icon.png"\r\n' + b"Content-Type: image/png\r\n\r\n" + b"PNG DATA\r\n" in request.content + ) + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) + + +async def test_async_update_file_no_file( + async_update_file_service: DummyAsyncUpdateFileService, +) -> None: + resource_id = "ICON-1234" + response_expected_data = {"id": resource_id, "name": "Updated Icon Object"} + resource_data = {"name": "Updated Icon Object"} + + with respx.mock: + mock_route = respx.put( + f"https://api.example.com/public/v1/dummy/update-file/{resource_id}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=response_expected_data, + ) + ) + # Act + result = await async_update_file_service.update(resource_id, resource_data) + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert b'Content-Disposition: form-data; name="file"' not in request.content + assert "multipart/form-data" in request.headers["Content-Type"] + assert result.to_dict() == response_expected_data + assert isinstance(result, DummyModel) diff --git a/tests/unit/http/mixins/test_update_mixin.py b/tests/unit/http/mixins/test_update_mixin.py new file mode 100644 index 00000000..20ca71fa --- /dev/null +++ b/tests/unit/http/mixins/test_update_mixin.py @@ -0,0 +1,38 @@ +import json + +import httpx +import respx + +from tests.unit.http.conftest import AsyncDummyService, DummyService + + +async def test_async_update_resource(async_dummy_service: AsyncDummyService) -> None: + """Test updating a resource asynchronously.""" + resource_data = {"name": "Test Resource", "status": "active"} + update_response = httpx.Response(httpx.codes.OK, json=resource_data) + with respx.mock: + mock_route = respx.put("https://api.example.com/api/v1/test/RES-123").mock( + return_value=update_response + ) + + await async_dummy_service.update("RES-123", resource_data) # act + + request = mock_route.calls[0].request + assert mock_route.call_count == 1 + assert json.loads(request.content.decode()) == resource_data + + +def test_sync_update_resource(dummy_service: DummyService) -> None: + """Test updating a resource synchronously.""" + resource_data = {"name": "Test Resource", "status": "active"} + update_response = httpx.Response(httpx.codes.OK, json=resource_data) + with respx.mock: + mock_route = respx.put("https://api.example.com/api/v1/test/RES-123").mock( + return_value=update_response + ) + + dummy_service.update("RES-123", resource_data) # act + + request = mock_route.calls[0].request + assert mock_route.call_count == 1 + assert json.loads(request.content.decode()) == resource_data diff --git a/tests/unit/http/test_mixins.py b/tests/unit/http/test_mixins.py deleted file mode 100644 index 70f40ab1..00000000 --- a/tests/unit/http/test_mixins.py +++ /dev/null @@ -1,1435 +0,0 @@ -import io -import json - -import httpx -import pytest -import respx - -from mpt_api_client import RQLQuery -from mpt_api_client.exceptions import MPTAPIError -from mpt_api_client.http import AsyncService, Service -from mpt_api_client.http.mixins import ( # noqa: WPS235 - AsyncDisableMixin, - AsyncEnableMixin, - AsyncFilesOperationsMixin, - AsyncManagedResourceMixin, - AsyncModifiableResourceMixin, - AsyncUpdateFileMixin, - DisableMixin, - EnableMixin, - FilesOperationsMixin, - ManagedResourceMixin, - ModifiableResourceMixin, - UpdateFileMixin, -) -from mpt_api_client.resources.catalog.products_media import ( - AsyncMediaService, - MediaService, -) -from tests.unit.conftest import DummyModel - - -class DummyFileOperationsService( - FilesOperationsMixin[DummyModel], - Service[DummyModel], -): - _endpoint = "/public/v1/dummy/file-ops/" - _model_class = DummyModel - _collection_key = "data" - - -class DummyAsyncFileOperationsService( - AsyncFilesOperationsMixin[DummyModel], - AsyncService[DummyModel], -): - """Dummy asynchronous file operations service for testing.""" - - _endpoint = "/public/v1/dummy/file-ops/" - _model_class = DummyModel - _collection_key = "data" - - -class DummyUpdateFileService( - UpdateFileMixin[DummyModel], - Service[DummyModel], -): - _endpoint = "/public/v1/dummy/update-file" - _model_class = DummyModel - _collection_key = "data" - _upload_file_key = "file" - _upload_data_key = "document" - - -class DummyAsyncUpdateFileService( - AsyncUpdateFileMixin[DummyModel], - AsyncService[DummyModel], -): - _endpoint = "/public/v1/dummy/update-file" - _model_class = DummyModel - _collection_key = "data" - _upload_file_key = "file" - _upload_data_key = "document" - - -class EnableDisableService( - EnableMixin[DummyModel], - DisableMixin[DummyModel], - Service[DummyModel], -): - _endpoint = "/public/v1/dummy/enablable/" - _model_class = DummyModel - - -class AsyncEnableDisableService( - AsyncEnableMixin[DummyModel], - AsyncDisableMixin[DummyModel], - AsyncService[DummyModel], -): - _endpoint = "/public/v1/dummy/enablable/" - _model_class = DummyModel - - -@pytest.fixture -def dummy_file_operations_service(http_client): - """Fixture for DummyFileOperationsService.""" - return DummyFileOperationsService(http_client=http_client) - - -@pytest.fixture -def async_dummy_file_operations_service(async_http_client): - """Fixture for DummyAsyncFileOperationsService.""" - return DummyAsyncFileOperationsService(http_client=async_http_client) - - -@pytest.fixture -def media_service(http_client): - """Fixture for MediaService.""" - return MediaService(http_client=http_client, endpoint_params={"product_id": "PRD-001"}) - - -@pytest.fixture -def async_media_service(async_http_client): - """Fixture for AsyncMediaService.""" - return AsyncMediaService( - http_client=async_http_client, endpoint_params={"product_id": "PRD-001"} - ) - - -@pytest.fixture -def update_file_service(http_client): - return DummyUpdateFileService(http_client=http_client) - - -@pytest.fixture -def async_update_file_service(async_http_client): - return DummyAsyncUpdateFileService(http_client=async_http_client) - - -async def test_async_create_mixin(async_dummy_service): - """Test creating a resource asynchronously.""" - resource_data = {"name": "Test Resource", "status": "active"} - new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} - create_response = httpx.Response(httpx.codes.OK, json=new_resource_data) - with respx.mock: - mock_route = respx.post("https://api.example.com/api/v1/test").mock( - return_value=create_response - ) - - result = await async_dummy_service.create(resource_data) - - assert result.to_dict() == new_resource_data - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.method == "POST" - assert request.url == "https://api.example.com/api/v1/test" - assert json.loads(request.content.decode()) == resource_data - - -async def test_async_delete_mixin(async_dummy_service): - """Test deleting a resource asynchronously.""" - delete_response = httpx.Response(httpx.codes.NO_CONTENT, json=None) - with respx.mock: - mock_route = respx.delete("https://api.example.com/api/v1/test/RES-123").mock( - return_value=delete_response - ) - - await async_dummy_service.delete("RES-123") # act - - assert mock_route.call_count == 1 - - -async def test_async_update_resource(async_dummy_service): - """Test updating a resource asynchronously.""" - resource_data = {"name": "Test Resource", "status": "active"} - update_response = httpx.Response(httpx.codes.OK, json=resource_data) - with respx.mock: - mock_route = respx.put("https://api.example.com/api/v1/test/RES-123").mock( - return_value=update_response - ) - - await async_dummy_service.update("RES-123", resource_data) # act - - request = mock_route.calls[0].request - assert mock_route.call_count == 1 - assert json.loads(request.content.decode()) == resource_data - - -def test_sync_create_mixin(dummy_service): - """Test creating a resource synchronously.""" - resource_data = {"name": "Test Resource", "status": "active"} - new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} - create_response = httpx.Response(httpx.codes.OK, json=new_resource_data) - with respx.mock: - mock_route = respx.post("https://api.example.com/api/v1/test").mock( - return_value=create_response - ) - - result = dummy_service.create(resource_data) - - assert result.to_dict() == new_resource_data - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.method == "POST" - assert request.url == "https://api.example.com/api/v1/test" - assert json.loads(request.content.decode()) == resource_data - - -def test_sync_delete_mixin(dummy_service): - """Test deleting a resource synchronously.""" - delete_response = httpx.Response(httpx.codes.NO_CONTENT, json=None) - with respx.mock: - mock_route = respx.delete("https://api.example.com/api/v1/test/RES-123").mock( - return_value=delete_response - ) - - dummy_service.delete("RES-123") # act - - assert mock_route.call_count == 1 - - -def test_sync_update_resource(dummy_service): - """Test updating a resource synchronously.""" - resource_data = {"name": "Test Resource", "status": "active"} - update_response = httpx.Response(httpx.codes.OK, json=resource_data) - with respx.mock: - mock_route = respx.put("https://api.example.com/api/v1/test/RES-123").mock( - return_value=update_response - ) - - dummy_service.update("RES-123", resource_data) # act - - request = mock_route.calls[0].request - assert mock_route.call_count == 1 - assert json.loads(request.content.decode()) == resource_data - - -async def test_async_file_create_with_data(async_media_service): - """Test creating a file resource asynchronously with additional 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, - ) - ) - media_image = ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg") - - result = await async_media_service.create({"name": "Product image"}, file=media_image) - - request = mock_route.calls[0].request - assert ( - b'Content-Disposition: form-data; name="media"\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="file"; filename="test.jpg"\r\n' - b"Content-Type: image/jpeg\r\n\r\n" - b"Image content\r\n" in request.content - ) - assert "multipart/form-data" in request.headers["Content-Type"] - assert result.to_dict() == media_data - - -async def test_async_file_create_no_data(async_media_service): - """Test creating a file resource asynchronously without additional 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, - ) - ) - new_media = await async_media_service.create( - {}, file=("test.jpg", io.BytesIO(b"Image content"), "image/jpeg") - ) - - request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="file"; 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_request_and_headers(async_media_service): - """Test request/response and headers for async file download.""" - media_content = b"Image file content or binary data" - with respx.mock: - 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": "image/jpg", - "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', - }, - content=media_content, - ) - ) - # Act - await async_media_service.download("MED-456") - - 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_download.call_count == 1 - - -async def test_async_file_download_content_and_metadata(async_media_service): - """Test file content and metadata for async file download.""" - media_content = b"Image file content or binary data" - with respx.mock: - 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"}, - ) - ) - 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, - ) - ) - result = await async_media_service.download("MED-456") - - assert result.file_contents == media_content - assert result.content_type == "image/jpg" - assert result.filename == "product_image.jpg" - - -def test_sync_file_download_request_and_headers(media_service): - """Test request/response and headers for sync file download.""" - media_content = b"Image file content or binary data" - with respx.mock: - 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": "image/jpg", - "content-disposition": 'form-data; name="file"; filename="product_image.jpg"', - }, - content=media_content, - ) - ) - media_service.download("MED-456") - assert mock_resource.call_count == 1 - - result = mock_download.calls[0].request - - # Assert - accept_header = (b"Accept", b"image/jpg") - assert accept_header in result.headers.raw - assert mock_download.call_count == 1 - - -def test_sync_file_download_content_and_metadata(media_service): - """Test file content and metadata for sync file download.""" - media_content = b"Image file content or binary data" - with respx.mock: - 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"}, - ) - ) - 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, - ) - ) - - result = media_service.download("MED-456") - - assert result.file_contents == media_content - assert result.content_type == "image/jpg" - assert result.filename == "product_image.jpg" - - -def test_sync_file_create_with_data(media_service): - """Test creating a file resource synchronously with additional 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, - ) - ) - image_file = ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg") - - result = media_service.create({"name": "Product image"}, image_file) - - request = mock_route.calls[0].request - assert ( - b'Content-Disposition: form-data; name="media"\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="file"; filename="test.jpg"\r\n' - b"Content-Type: image/jpeg\r\n\r\n" - b"Image content\r\n" in request.content - ) - assert "multipart/form-data" in request.headers["Content-Type"] - assert result.to_dict() == media_data - - -def test_sync_file_create_no_data(media_service): - """Test creating a file resource synchronously without additional 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, - ) - ) - image_file = ("test.jpg", io.BytesIO(b"Image content"), "image/jpeg") - - result = media_service.create({}, image_file) - - request = mock_route.calls[0].request - assert ( - b'Content-Disposition: form-data; name="file"; filename="test.jpg"\r\n' - b"Content-Type: image/jpeg\r\n\r\n" - b"Image content\r\n" in request.content - ) - assert result.to_dict() == media_data - - -@pytest.mark.parametrize( - "select_value", - [ - ["id", "name"], - "id,name", - ], -) -def test_sync_get_mixin(dummy_service, select_value): - """Test getting a resource synchronously with different select parameter formats.""" - resource_data = {"id": "RES-123", "name": "Test Resource"} - with respx.mock: - mock_route = respx.get( - "https://api.example.com/api/v1/test/RES-123", params={"select": "id,name"} - ).mock(return_value=httpx.Response(httpx.codes.OK, json=resource_data)) - - result = dummy_service.get("RES-123", select=select_value) - - request = mock_route.calls[0].request - accept_header = (b"Accept", b"application/json") - assert accept_header in request.headers.raw - assert result.to_dict() == resource_data - - -async def test_async_get(async_dummy_service): - """Test getting a resource asynchronously with a list select parameter.""" - resource_data = {"id": "RES-123", "name": "Test Resource"} - with respx.mock: - mock_route = respx.get( - "https://api.example.com/api/v1/test/RES-123", params={"select": "id,name"} - ).mock(return_value=httpx.Response(httpx.codes.OK, json=resource_data)) - - result = await async_dummy_service.get("RES-123", select=["id", "name"]) - - request = mock_route.calls[0].request - accept_header = (b"Accept", b"application/json") - assert accept_header in request.headers.raw - assert result.to_dict() == resource_data - - -async def test_async_get_select_str(async_dummy_service): - """Test getting a resource asynchronously with a string select parameter.""" - resource_data = {"id": "RES-123", "name": "Test Resource"} - with respx.mock: - mock_route = respx.get( - "https://api.example.com/api/v1/test/RES-123", params={"select": "id,name"} - ).mock(return_value=httpx.Response(httpx.codes.OK, json=resource_data)) - - result = await async_dummy_service.get("RES-123", select="id,name") - - request = mock_route.calls[0].request - accept_header = (b"Accept", b"application/json") - assert accept_header in request.headers.raw - assert result.to_dict() == resource_data - - -def test_queryable_mixin_order_by(dummy_service): - result = dummy_service.order_by("created", "-name") - - assert result != dummy_service - assert dummy_service.query_state.order_by is None - assert result.query_state.order_by == ["created", "-name"] - assert result.http_client is dummy_service.http_client - assert result.endpoint_params == dummy_service.endpoint_params - - -def test_queryable_mixin_order_by_exception(dummy_service): - """Test that setting order_by multiple times raises an exception.""" - ordered_service = dummy_service.order_by("created") - - with pytest.raises( - ValueError, match=r"Ordering is already set. Cannot set ordering multiple times." - ): - ordered_service.order_by("name") - - -def test_queryable_mixin_filter(dummy_service, filter_status_active): - result = dummy_service.filter(filter_status_active) - - assert result != dummy_service - assert dummy_service.query_state.filter is None - assert result.query_state.filter == filter_status_active - assert result.http_client is dummy_service.http_client - assert result.endpoint_params == dummy_service.endpoint_params - - -def test_queryable_mixin_filters(dummy_service): - """Test applying multiple filters to a queryable service.""" - filter1 = RQLQuery(status="active") - filter2 = RQLQuery(name="test") - - result = dummy_service.filter(filter1).filter(filter2) - - assert dummy_service.query_state.filter is None - assert result.query_state.filter == filter1 & filter2 - - -def test_queryable_mixin_select(dummy_service): - result = dummy_service.select("id", "name", "-audit") - - assert result != dummy_service - assert dummy_service.query_state.select is None - assert result.query_state.select == ["id", "name", "-audit"] - assert result.http_client is dummy_service.http_client - assert result.endpoint_params == dummy_service.endpoint_params - - -def test_queryable_mixin_select_exception(dummy_service): - """Test that setting select fields multiple times raises an exception.""" - selected_service = dummy_service.select("id", "name") - - with pytest.raises( - ValueError, match=r"Select fields are already set. Cannot set select fields multiple times." - ): - selected_service.select("other_field") - - -def test_queryable_mixin_method_chaining(dummy_service, filter_status_active): - result = ( - dummy_service.filter(filter_status_active).order_by("created", "-name").select("id", "name") - ) - - assert result != dummy_service - assert result.query_state.filter == filter_status_active - assert result.query_state.order_by == ["created", "-name"] - assert result.query_state.select == ["id", "name"] - - -def test_col_mx_fetch_one_success(dummy_service, single_result_response): - """Test fetching a single resource successfully.""" - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_result_response - ) - - result = dummy_service.fetch_one() - - assert result.id == "ID-1" - assert result.name == "Test Resource" - assert mock_route.called - first_request = mock_route.calls[0].request - assert "limit=1" in str(first_request.url) - assert "offset=0" in str(first_request.url) - - -def test_col_mx_fetch_one_no_results(dummy_service, no_results_response): - """Test fetching a single resource when no results are returned.""" - with respx.mock: - respx.get("https://api.example.com/api/v1/test").mock(return_value=no_results_response) - - with pytest.raises(ValueError, match="Expected one result, but got zero results"): - dummy_service.fetch_one() - - -def test_col_mx_fetch_one_multiple_results(dummy_service, multiple_results_response): - """Test fetching a single resource when multiple results are returned.""" - with respx.mock: - respx.get("https://api.example.com/api/v1/test").mock( - return_value=multiple_results_response - ) - - with pytest.raises(ValueError, match=r"Expected one result, but got 2 results"): - dummy_service.fetch_one() - - -def test_col_mx_fetch_one_with_filters(dummy_service, single_result_response, filter_status_active): - """Test fetching a single resource with filters applied.""" - filtered_collection = ( - dummy_service.filter(filter_status_active).select("id", "name").order_by("created") - ) - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_result_response - ) - - result = filtered_collection.fetch_one() - - assert result.id == "ID-1" - assert mock_route.called - first_request = mock_route.calls[0].request - assert first_request.method == "GET" - assert first_request.url == ( - "https://api.example.com/api/v1/test" - "?limit=1&offset=0&order=created" - "&select=id,name&eq(status,active)" - ) - - -def test_col_mx_fetch_page_with_filter(dummy_service, list_response, filter_status_active): - """Test fetching a page of resources with filters applied.""" - custom_collection = ( - dummy_service - .filter(filter_status_active) - .select("-audit", "product.agreements", "-product.agreements.product") - .order_by("-created", "name") - ) - expected_url = ( - "https://api.example.com/api/v1/test?limit=10&offset=5" - "&order=-created,name" - "&select=-audit,product.agreements,-product.agreements.product" - "&eq(status,active)" - ) - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=list_response - ) - - result = custom_collection.fetch_page(limit=10, offset=5) - - assert result.to_list() == [{"id": "ID-1"}] - assert mock_route.called - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.method == "GET" - assert request.url == expected_url - - -def test_col_mx_iterate_single_page(dummy_service, single_page_response): - """Test iterating over a single page of resources.""" - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_page_response - ) - - result = list(dummy_service.iterate()) - - request = mock_route.calls[0].request - assert len(result) == 2 - assert result[0].to_dict() == {"id": "ID-1", "name": "Resource 1"} - assert result[1].to_dict() == {"id": "ID-2", "name": "Resource 2"} - assert mock_route.call_count == 1 - assert request.url == "https://api.example.com/api/v1/test?limit=100&offset=0" - - -def test_col_mx_iterate_multiple_pages( - dummy_service, multi_page_response_page1, multi_page_response_page2 -): - """Test iterating over multiple pages of resources.""" - with respx.mock: - respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( - return_value=multi_page_response_page1 - ) - respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 2}).mock( - return_value=multi_page_response_page2 - ) - - result = list(dummy_service.iterate(2)) - - assert len(result) == 4 - assert result[0].id == "ID-1" - assert result[1].id == "ID-2" - assert result[2].id == "ID-3" - assert result[3].id == "ID-4" - - -def test_col_mx_iterate_empty_results(dummy_service, empty_response): - """Test iterating over an empty set of resources.""" - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=empty_response - ) - - result = list(dummy_service.iterate(2)) - - assert len(result) == 0 - assert mock_route.call_count == 1 - - -def test_col_mx_iterate_no_meta(dummy_service, no_meta_response): - """Test iterating over resources when no metadata is provided.""" - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=no_meta_response - ) - - result = list(dummy_service.iterate()) - - assert len(result) == 2 - assert result[0].id == "ID-1" - assert result[1].id == "ID-2" - assert mock_route.call_count == 1 - - -def test_col_mx_iterate_with_filters(dummy_service, filter_status_active): - """Test iterating over resources with filters applied.""" - filtered_collection = ( - dummy_service.filter(filter_status_active).select("id", "name").order_by("created") - ) - response = httpx.Response( - httpx.codes.OK, - json={ - "data": [{"id": "ID-1", "name": "Active Resource"}], - "$meta": { - "pagination": { - "total": 1, - "offset": 0, - "limit": 100, - } - }, - }, - ) - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) - - result = list(filtered_collection.iterate()) - - assert len(result) == 1 - assert result[0].id == "ID-1" - assert result[0].name == "Active Resource" - request = mock_route.calls[0].request - assert ( - str(request.url) == "https://api.example.com/api/v1/test" - "?limit=100&offset=0&order=created&select=id,name&eq(status,active)" - ) - - -def test_col_mx_iterate_lazy_evaluation(dummy_service): - """Test lazy evaluation of iterating over resources.""" - response = httpx.Response( - httpx.codes.OK, - json={ - "data": [{"id": "ID-1", "name": "Resource 1"}], - "$meta": { - "pagination": { - "total": 1, - "offset": 0, - "limit": 100, - } - }, - }, - ) - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) - - result = dummy_service.iterate() - - assert mock_route.call_count == 0 - first_resource = next(result) - assert mock_route.call_count == 1 - assert first_resource.id == "ID-1" - - -def test_col_mx_iterate_handles_api_errors(dummy_service): - """Test that API errors are handled during iteration over resources.""" - with respx.mock: - respx.get("https://api.example.com/api/v1/test").mock( - return_value=httpx.Response( - httpx.codes.INTERNAL_SERVER_ERROR, json={"error": "Internal Server Error"} - ) - ) - iterator = dummy_service.iterate() - - with pytest.raises(MPTAPIError): - list(iterator) - - -async def test_async_col_mx_fetch_one_success(async_dummy_service, single_result_response): - """Test fetching a single resource successfully.""" - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_result_response - ) - - result = await async_dummy_service.fetch_one() - - assert result.id == "ID-1" - assert result.name == "Test Resource" - assert mock_route.called - first_request = mock_route.calls[0].request - assert "limit=1" in str(first_request.url) - assert "offset=0" in str(first_request.url) - - -async def test_async_col_mx_fetch_one_no_results(async_dummy_service, no_results_response): - """Test fetching a single resource when no results are returned.""" - with respx.mock: - respx.get("https://api.example.com/api/v1/test").mock(return_value=no_results_response) - - with pytest.raises(ValueError, match="Expected one result, but got zero results"): - await async_dummy_service.fetch_one() - - -async def test_async_col_mx_fetch_one_multiple_results( - async_dummy_service, multiple_results_response -): - """Test fetching a single resource when multiple results are returned.""" - with respx.mock: - respx.get("https://api.example.com/api/v1/test").mock( - return_value=multiple_results_response - ) - - with pytest.raises(ValueError, match=r"Expected one result, but got 2 results"): - await async_dummy_service.fetch_one() - - -async def test_async_col_mx_fetch_one_with_filters( - async_dummy_service, single_result_response, filter_status_active -): - """Test fetching a single resource with filters applied.""" - filtered_collection = ( - async_dummy_service.filter(filter_status_active).select("id", "name").order_by("created") - ) - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_result_response - ) - result = await filtered_collection.fetch_one() - - assert result.id == "ID-1" - assert mock_route.called - first_request = mock_route.calls[0].request - assert first_request.method == "GET" - assert first_request.url == ( - "https://api.example.com/api/v1/test" - "?limit=1&offset=0&order=created" - "&select=id,name&eq(status,active)" - ) - - -async def test_async_col_mx_fetch_page_with_filter( - async_dummy_service, list_response, filter_status_active -) -> None: - """Test fetching a page of resources with filters applied.""" - custom_collection = ( - async_dummy_service - .filter(filter_status_active) - .select("-audit", "product.agreements", "-product.agreements.product") - .order_by("-created", "name") - ) - expected_url = ( - "https://api.example.com/api/v1/test?limit=10&offset=5" - "&order=-created,name" - "&select=-audit,product.agreements,-product.agreements.product" - "&eq(status,active)" - ) - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=list_response - ) - - result = await custom_collection.fetch_page(limit=10, offset=5) - - assert result.to_list() == [{"id": "ID-1"}] - assert mock_route.called - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.method == "GET" - assert request.url == expected_url - - -async def test_async_col_mx_iterate_single_page(async_dummy_service, single_page_response): - """Test iterating over a single page of resources.""" - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=single_page_response - ) - - result = [resource async for resource in async_dummy_service.iterate()] - - request = mock_route.calls[0].request - assert len(result) == 2 - assert result[0].to_dict() == {"id": "ID-1", "name": "Resource 1"} - assert result[1].to_dict() == {"id": "ID-2", "name": "Resource 2"} - assert mock_route.call_count == 1 - assert request.url == "https://api.example.com/api/v1/test?limit=100&offset=0" - - -async def test_async_col_mx_iterate_multiple_pages( - async_dummy_service, multi_page_response_page1, multi_page_response_page2 -): - """Test iterating over multiple pages of resources.""" - with respx.mock: - respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( - return_value=multi_page_response_page1 - ) - respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 2}).mock( - return_value=multi_page_response_page2 - ) - - result = [resource async for resource in async_dummy_service.iterate(2)] - - assert len(result) == 4 - assert result[0].id == "ID-1" - assert result[1].id == "ID-2" - assert result[2].id == "ID-3" - assert result[3].id == "ID-4" - - -async def test_async_col_mx_iterate_empty_results(async_dummy_service, empty_response): - """Test iterating over an empty set of resources.""" - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=empty_response - ) - - result = [resource async for resource in async_dummy_service.iterate()] - - assert len(result) == 0 - assert mock_route.call_count == 1 - - -async def test_async_col_mx_iterate_no_meta(async_dummy_service, no_meta_response): - """Test iterating over resources when no metadata is provided.""" - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock( - return_value=no_meta_response - ) - - result = [resource async for resource in async_dummy_service.iterate()] - - assert len(result) == 2 - assert result[0].id == "ID-1" - assert result[1].id == "ID-2" - assert mock_route.call_count == 1 - - -async def test_async_col_mx_iterate_with_filters(async_dummy_service, filter_status_active): - """Test iterating over resources with filters applied.""" - filtered_collection = ( - async_dummy_service.filter(filter_status_active).select("id", "name").order_by("created") - ) - response = httpx.Response( - httpx.codes.OK, - json={ - "data": [{"id": "ID-1", "name": "Active Resource"}], - "$meta": { - "pagination": { - "total": 1, - "offset": 0, - "limit": 100, - } - }, - }, - ) - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) - - result = [resource async for resource in filtered_collection.iterate()] - - assert len(result) == 1 - assert result[0].id == "ID-1" - assert result[0].name == "Active Resource" - request = mock_route.calls[0].request - assert ( - str(request.url) == "https://api.example.com/api/v1/test" - "?limit=100&offset=0&order=created&select=id,name&eq(status,active)" - ) - - -async def test_async_col_mx_iterate_lazy_evaluation(async_dummy_service): - """Test lazy evaluation of iterating over resources.""" - response = httpx.Response( - httpx.codes.OK, - json={ - "data": [{"id": "ID-1", "name": "Resource 1"}], - "$meta": { - "pagination": { - "total": 1, - "offset": 0, - "limit": 100, - } - }, - }, - ) - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) - - result = async_dummy_service.iterate() - - assert mock_route.call_count == 0 - first_resource = await anext(result) - assert first_resource.id == "ID-1" - assert mock_route.call_count == 1 - - -async def test_async_col_mx_iterate_handles_api_errors(async_dummy_service): - """Test that API errors are handled during iteration over resources.""" - with respx.mock: - respx.get("https://api.example.com/api/v1/test").mock( - return_value=httpx.Response( - httpx.codes.INTERNAL_SERVER_ERROR, json={"error": "Internal Server Error"} - ) - ) - - with pytest.raises(MPTAPIError): - [resource async for resource in async_dummy_service.iterate()] - - -@pytest.mark.parametrize( - "method_name", - [ - "update", - "delete", - "get", - ], -) -def test_modifieable_resource_mixin(method_name): - """Test that ModifiableResourceMixin has the required methods.""" - result = _ModifiableResourceService() - - assert hasattr(result, method_name), f"ManagedResourceMixin should have {method_name} method" - assert callable(getattr(result, method_name)), f"{method_name} should be callable" - - -@pytest.mark.parametrize( - "method_name", - [ - "update", - "delete", - "get", - ], -) -def test_async_modifiable_resource_mixin(method_name): - """Test that AsyncModifiableResourceMixin has the required methods.""" - result = _AsyncModifiableResourceService() - - assert hasattr(result, method_name), ( - f"AsyncManagedResourceMixin should have {method_name} method" - ) - assert callable(getattr(result, method_name)), f"{method_name} should be callable" - - -@pytest.mark.parametrize( - "method_name", - [ - "create", - "update", - "delete", - "get", - ], -) -def test_managed_resource_mixin(method_name): - """Test that ManagedResourceMixin has the required methods.""" - result = _ManagedService() - - assert hasattr(result, method_name), f"ManagedResourceMixin should have {method_name} method" - assert callable(getattr(result, method_name)), f"{method_name} should be callable" - - -@pytest.mark.parametrize( - "method_name", - [ - "create", - "update", - "delete", - "get", - ], -) -def test_async_managed_resource_mixin(method_name): - """Test that AsyncManagedResourceMixin has the required methods.""" - result = _AsyncManagedService() - - assert hasattr(result, method_name), ( - f"AsyncManagedResourceMixin should have {method_name} method" - ) - assert callable(getattr(result, method_name)), f"{method_name} should be callable" - - -class _ModifiableResourceService(ModifiableResourceMixin[DummyModel]): - """Dummy service class for testing required methods.""" - - -class _AsyncModifiableResourceService(AsyncModifiableResourceMixin[DummyModel]): - """Dummy service class for testing required methods.""" - - -class _ManagedService(ManagedResourceMixin[DummyModel]): - """Dummy service class for testing required methods.""" - - -class _AsyncManagedService(AsyncManagedResourceMixin[DummyModel]): - """Dummy service class for testing required methods.""" - - -def test_sync_file_create_with_resource_data(dummy_file_operations_service): - """Test creating a file with resource data.""" - file_data = {"id": "FILE-123"} - with respx.mock: - mock_route = respx.post("https://api.example.com/public/v1/dummy/file-ops/").mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - json=file_data, - ) - ) - files = {"file": ("document.pdf", io.BytesIO(b"PDF content"), "application/pdf")} - resource_data = {"name": "Test Document"} - - result = dummy_file_operations_service.create(resource_data=resource_data, files=files) - - request = mock_route.calls[0].request - assert b'name="_attachment_data"' in request.content - assert b'"name":"Test Document"' in request.content - assert result.to_dict() == file_data - - -async def test_async_file_create_with_resource_data(async_dummy_file_operations_service): - """Test creating a file with resource data.""" - file_data = {"id": "FILE-123"} - with respx.mock: - mock_route = respx.post("https://api.example.com/public/v1/dummy/file-ops/").mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - json=file_data, - ) - ) - files = {"file": ("document.pdf", io.BytesIO(b"PDF content"), "application/pdf")} - resource_data = {"name": "Test Document"} - result = await async_dummy_file_operations_service.create( - resource_data=resource_data, files=files - ) - request = mock_route.calls[0].request - assert b'name="_attachment_data"' in request.content - assert b'"name":"Test Document"' in request.content - assert result.to_dict() == file_data - - -def test_sync_update_file(update_file_service): # noqa: WPS210 - resource_id = "ICON-1234" - response_expected_data = {"id": resource_id, "name": "Updated Icon Object"} - file_tuple = ("icon.png", io.BytesIO(b"PNG DATA"), "image/png") - resource_data = {"name": "Updated Icon Object"} - with respx.mock: - mock_route = respx.put( - f"https://api.example.com/public/v1/dummy/update-file/{resource_id}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - json=response_expected_data, - ) - ) - - result = update_file_service.update(resource_id, resource_data, file=file_tuple) - - request = mock_route.calls[0].request - assert mock_route.call_count == 1 - assert ( - b'Content-Disposition: form-data; name="file"; filename="icon.png"\r\n' - b"Content-Type: image/png\r\n\r\n" - b"PNG DATA\r\n" in request.content - ) - assert "multipart/form-data" in request.headers["Content-Type"] - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -def test_update_file_no_file(update_file_service): # noqa: WPS210 - resource_id = "ICON-1234" - response_expected_data = {"id": resource_id, "name": "Updated Icon Object"} - resource_data = {"name": "Updated Icon Object"} - with respx.mock: - mock_route = respx.put( - f"https://api.example.com/public/v1/dummy/update-file/{resource_id}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - json=response_expected_data, - ) - ) - - result = update_file_service.update(resource_id, resource_data) - - request = mock_route.calls[0].request - assert mock_route.call_count == 1 - assert b'Content-Disposition: form-data; name="file"' not in request.content - assert "multipart/form-data" in request.headers["Content-Type"] - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -async def test_async_update_file(async_update_file_service): # noqa: WPS210 - resource_id = "ICON-1234" - response_expected_data = {"id": resource_id, "name": "Updated Icon Object"} - file_tuple = ("icon.png", io.BytesIO(b"PNG DATA"), "image/png") - resource_data = {"name": "Updated Icon Object"} - - with respx.mock: - mock_route = respx.put( - f"https://api.example.com/public/v1/dummy/update-file/{resource_id}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - json=response_expected_data, - ) - ) - # Act - result = await async_update_file_service.update(resource_id, resource_data, file=file_tuple) - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - - assert ( - b'Content-Disposition: form-data; name="file"; filename="icon.png"\r\n' - b"Content-Type: image/png\r\n\r\n" - b"PNG DATA\r\n" in request.content - ) - assert "multipart/form-data" in request.headers["Content-Type"] - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -async def test_async_update_file_no_file(async_update_file_service): # noqa: WPS210 - resource_id = "ICON-1234" - response_expected_data = {"id": resource_id, "name": "Updated Icon Object"} - resource_data = {"name": "Updated Icon Object"} - - with respx.mock: - mock_route = respx.put( - f"https://api.example.com/public/v1/dummy/update-file/{resource_id}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - json=response_expected_data, - ) - ) - # Act - result = await async_update_file_service.update(resource_id, resource_data) - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - - assert b'Content-Disposition: form-data; name="file"' not in request.content - assert "multipart/form-data" in request.headers["Content-Type"] - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.fixture -def async_enablable_service(async_http_client): - return AsyncEnableDisableService(http_client=async_http_client) - - -@pytest.fixture -def enablable_service(http_client): - return EnableDisableService(http_client=http_client) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("enable", {"id": "OBJ-0000-0001", "status": "update"}), - ("disable", {"id": "OBJ-0000-0001", "status": "update"}), - ], -) -def test_enablable_resource_actions(enablable_service, action, input_status): - request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' - response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/enablable/OBJ-0000-0001/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = getattr(enablable_service, action)("OBJ-0000-0001", input_status) - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("enable", None), - ("disable", None), - ], -) -def test_enablable_resource_actions_no_data(enablable_service, action, input_status): - request_expected_content = b"" - response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/enablable/OBJ-0000-0001/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = getattr(enablable_service, action)("OBJ-0000-0001", input_status) - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("enable", {"id": "OBJ-0000-0001", "status": "update"}), - ("disable", {"id": "OBJ-0000-0001", "status": "update"}), - ], -) -async def test_async_enablable_resource_actions(async_enablable_service, action, input_status): - request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' - response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/enablable/OBJ-0000-0001/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = await getattr(async_enablable_service, action)("OBJ-0000-0001", input_status) - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel) - - -@pytest.mark.parametrize( - ("action", "input_status"), - [ - ("enable", None), - ("disable", None), - ], -) -async def test_async_enablable_resource_actions_no_data( - async_enablable_service, action, input_status -): - request_expected_content = b"" - response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} - with respx.mock: - mock_route = respx.post( - f"https://api.example.com/public/v1/dummy/enablable/OBJ-0000-0001/{action}" - ).mock( - return_value=httpx.Response( - status_code=httpx.codes.OK, - headers={"content-type": "application/json"}, - json=response_expected_data, - ) - ) - - result = await getattr(async_enablable_service, action)("OBJ-0000-0001", input_status) - - assert mock_route.call_count == 1 - request = mock_route.calls[0].request - assert request.content == request_expected_content - assert result.to_dict() == response_expected_data - assert isinstance(result, DummyModel)