From 2c3600cda070bed9c3c8d1129dff6023e0e98976 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Thu, 4 Sep 2025 11:31:00 +0100 Subject: [PATCH] MPT-12840 Add agreements attachments --- mpt_api_client/http/async_client.py | 1 + mpt_api_client/http/async_service.py | 23 +-- mpt_api_client/http/base_service.py | 39 ++-- mpt_api_client/http/client.py | 1 + mpt_api_client/http/mixins.py | 40 +++- mpt_api_client/http/service.py | 32 ++- mpt_api_client/models/__init__.py | 3 +- mpt_api_client/models/file_model.py | 55 +++++ .../resources/commerce/agreements.py | 56 ++++- .../commerce/agreements_attachments.py | 149 ++++++++++++++ mpt_api_client/resources/commerce/orders.py | 3 + setup.cfg | 1 - tests/http/conftest.py | 15 +- tests/http/test_async_service.py | 28 +-- tests/http/test_base_service.py | 101 +++++++++ tests/http/test_mixins.py | 31 +++ tests/http/test_service.py | 106 +--------- tests/models/test_file_model.py | 94 +++++++++ tests/resources/commerce/test_agreements.py | 35 ++++ .../commerce/test_agreements_attachments.py | 194 ++++++++++++++++++ tests/resources/commerce/test_orders.py | 10 + 21 files changed, 841 insertions(+), 176 deletions(-) create mode 100644 mpt_api_client/models/file_model.py create mode 100644 mpt_api_client/resources/commerce/agreements_attachments.py create mode 100644 tests/http/test_base_service.py create mode 100644 tests/models/test_file_model.py create mode 100644 tests/resources/commerce/test_agreements_attachments.py diff --git a/mpt_api_client/http/async_client.py b/mpt_api_client/http/async_client.py index 8f113070..a9b82478 100644 --- a/mpt_api_client/http/async_client.py +++ b/mpt_api_client/http/async_client.py @@ -32,6 +32,7 @@ def __init__( base_headers = { "User-Agent": "swo-marketplace-client/1.0", "Authorization": f"Bearer {api_token}", + "Accept": "application/json", } super().__init__( base_url=base_url, diff --git a/mpt_api_client/http/async_service.py b/mpt_api_client/http/async_service.py index 765cdefd..37886735 100644 --- a/mpt_api_client/http/async_service.py +++ b/mpt_api_client/http/async_service.py @@ -91,19 +91,6 @@ async def get(self, resource_id: str, select: list[str] | str | None = None) -> select = ",".join(select) if select else None return await self._resource_action(resource_id=resource_id, query_params={"select": select}) - 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) - async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: """Fetch one page of resources. @@ -119,13 +106,14 @@ async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> ht return response - async def _resource_do_request( + async def _resource_do_request( # noqa: WPS211 self, resource_id: str, method: str = "GET", action: str | None = None, json: ResourceData | ResourceList | None = None, query_params: QueryParam | None = None, + headers: dict[str, str] | None = None, ) -> httpx.Response: """Perform an action on a specific resource using. @@ -138,13 +126,16 @@ async def _resource_do_request( action: The action name to use. json: The updated resource data. query_params: Additional query parameters. + headers: Additional headers. Raises: HTTPError: If the action fails. """ - resource_url = urljoin(f"{self._endpoint}/", resource_id) + resource_url = urljoin(f"{self.endpoint}/", resource_id) url = urljoin(f"{resource_url}/", action) if action else resource_url - response = await self.http_client.request(method, url, json=json, params=query_params) + response = await self.http_client.request( + method, url, json=json, params=query_params, headers=headers + ) response.raise_for_status() return response diff --git a/mpt_api_client/http/base_service.py b/mpt_api_client/http/base_service.py index daa90ae2..c8ee2a07 100644 --- a/mpt_api_client/http/base_service.py +++ b/mpt_api_client/http/base_service.py @@ -8,7 +8,7 @@ from mpt_api_client.rql import RQLQuery -class ServiceBase[Client, Model: BaseModel]: +class ServiceBase[Client, Model: BaseModel]: # noqa: WPS214 """Service base with agnostic HTTP client.""" _endpoint: str @@ -22,11 +22,13 @@ def __init__( query_rql: RQLQuery | None = None, query_order_by: list[str] | None = None, query_select: list[str] | None = None, + endpoint_params: dict[str, str] | None = None, ) -> None: self.http_client = http_client self.query_rql: RQLQuery | None = query_rql self.query_order_by = query_order_by self.query_select = query_select + self.endpoint_params = endpoint_params or {} def clone(self) -> Self: """Create a copy of collection client for immutable operations. @@ -39,30 +41,40 @@ def clone(self) -> Self: query_rql=self.query_rql, query_order_by=copy.copy(self.query_order_by) if self.query_order_by else None, query_select=copy.copy(self.query_select) if self.query_select else None, + endpoint_params=self.endpoint_params, ) - def build_url(self, query_params: dict[str, Any] | None = None) -> str: # noqa: WPS210 + @property + def endpoint(self) -> str: + """Service endpoint URL.""" + return self._endpoint.format(**self.endpoint_params) + + def build_url( + self, + query_params: dict[str, Any] | None = None, + ) -> str: # noqa: WPS210 """Builds the endpoint URL with all the query parameters. Returns: Partial URL with query parameters. """ query_params = query_params or {} + if self.query_order_by: + query_params.update({"order": ",".join(self.query_order_by)}) + if self.query_select: + query_params.update({"select": ",".join(self.query_select)}) + query_parts = [ f"{param_key}={param_value}" for param_key, param_value in query_params.items() ] - if self.query_order_by: - str_order_by = ",".join(self.query_order_by) - query_parts.append(f"order={str_order_by}") - if self.query_select: - str_query_select = ",".join(self.query_select) - query_parts.append(f"select={str_query_select}") + if self.query_rql: query_parts.append(str(self.query_rql)) + if query_parts: query = "&".join(query_parts) - return f"{self._endpoint}?{query}" - return self._endpoint + return f"{self.endpoint}?{query}" + return self.endpoint def order_by(self, *fields: str) -> Self: """Returns new collection with ordering setup. @@ -109,12 +121,13 @@ def select(self, *fields: str) -> Self: new_client.query_select = list(fields) return new_client - def _create_collection(self, response: httpx.Response) -> Collection[Model]: + @classmethod + def _create_collection(cls, response: httpx.Response) -> Collection[Model]: meta = Meta.from_response(response) return Collection( resources=[ - self._model_class.new(resource, meta) - for resource in response.json().get(self._collection_key) + cls._model_class.new(resource, meta) + for resource in response.json().get(cls._collection_key) ], meta=meta, ) diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index 62089150..5e587b1b 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -32,6 +32,7 @@ def __init__( base_headers = { "User-Agent": "swo-marketplace-client/1.0", "Authorization": f"Bearer {api_token}", + "content-type": "application/json", } super().__init__( base_url=base_url, diff --git a/mpt_api_client/http/mixins.py b/mpt_api_client/http/mixins.py index 5c32baff..645e4d8b 100644 --- a/mpt_api_client/http/mixins.py +++ b/mpt_api_client/http/mixins.py @@ -12,7 +12,7 @@ def create(self, resource_data: ResourceData) -> Model: Returns: New resource created. """ - response = self.http_client.post(self._endpoint, json=resource_data) # type: ignore[attr-defined] + response = self.http_client.post(self.endpoint, json=resource_data) # type: ignore[attr-defined] response.raise_for_status() return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] @@ -31,6 +31,23 @@ def delete(self, resource_id: str) -> None: response.raise_for_status() +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 AsyncCreateMixin[Model]: """Create resource mixin.""" @@ -40,7 +57,7 @@ async def create(self, resource_data: ResourceData) -> Model: Returns: New resource created. """ - response = await self.http_client.post(self._endpoint, json=resource_data) # type: ignore[attr-defined] + response = await self.http_client.post(self.endpoint, json=resource_data) # type: ignore[attr-defined] response.raise_for_status() return self._model_class.from_response(response) # type: ignore[attr-defined, no-any-return] @@ -55,6 +72,23 @@ async def delete(self, resource_id: str) -> None: Args: resource_id: Resource ID. """ - url = urljoin(f"{self._endpoint}/", resource_id) # type: ignore[attr-defined] + url = urljoin(f"{self.endpoint}/", resource_id) # type: ignore[attr-defined] response = await self.http_client.delete(url) # type: ignore[attr-defined] response.raise_for_status() + + +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/mpt_api_client/http/service.py b/mpt_api_client/http/service.py index febdaf31..7dfe2c16 100644 --- a/mpt_api_client/http/service.py +++ b/mpt_api_client/http/service.py @@ -11,7 +11,7 @@ from mpt_api_client.models.collection import ResourceList -class Service[Model: BaseModel](ServiceBase[HTTPClient, Model]): +class Service[Model: BaseModel](ServiceBase[HTTPClient, Model]): # noqa: WPS214 """Immutable service for RESTful resource collections. Examples: @@ -91,19 +91,6 @@ def get(self, resource_id: str, select: list[str] | str | None = None) -> Model: return self._resource_action(resource_id=resource_id, query_params={"select": select}) - 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) - def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: """Fetch one page of resources. @@ -119,13 +106,14 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re return response - def _resource_do_request( + def _resource_do_request( # noqa: WPS211 self, resource_id: str, method: str = "GET", action: str | None = None, json: ResourceData | ResourceList | None = None, query_params: QueryParam | None = None, + headers: dict[str, str] | None = None, ) -> httpx.Response: """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`. @@ -135,6 +123,7 @@ def _resource_do_request( action: The action name to use. json: The updated resource data. query_params: Additional query parameters. + headers: Additional headers. Returns: HTTP response object. @@ -142,9 +131,11 @@ def _resource_do_request( Raises: HTTPError: If the action fails. """ - resource_url = urljoin(f"{self._endpoint}/", resource_id) + resource_url = urljoin(f"{self.endpoint}/", resource_id) url = urljoin(f"{resource_url}/", action) if action else resource_url - response = self.http_client.request(method, url, json=json, params=query_params) + response = self.http_client.request( + method, url, json=json, params=query_params, headers=headers + ) response.raise_for_status() return response @@ -166,6 +157,11 @@ def _resource_action( query_params: Additional query parameters. """ response = self._resource_do_request( - resource_id, method, action, json=json, query_params=query_params + resource_id, + method, + action, + json=json, + query_params=query_params, + headers={"Accept": "application/json"}, ) return self._model_class.from_response(response) diff --git a/mpt_api_client/models/__init__.py b/mpt_api_client/models/__init__.py index 6b526260..23e01e49 100644 --- a/mpt_api_client/models/__init__.py +++ b/mpt_api_client/models/__init__.py @@ -1,5 +1,6 @@ from mpt_api_client.models.collection import Collection +from mpt_api_client.models.file_model import FileModel from mpt_api_client.models.meta import Meta, Pagination from mpt_api_client.models.model import Model, ResourceData -__all__ = ["Collection", "Meta", "Model", "Pagination", "ResourceData"] # noqa: WPS410 +__all__ = ["Collection", "FileModel", "Meta", "Model", "Pagination", "ResourceData"] # noqa: WPS410 diff --git a/mpt_api_client/models/file_model.py b/mpt_api_client/models/file_model.py new file mode 100644 index 00000000..da29d4de --- /dev/null +++ b/mpt_api_client/models/file_model.py @@ -0,0 +1,55 @@ +import re + +from httpx import Response + + +class FileModel: + """File resource.""" + + def __init__(self, response: Response): + self.response = response + + @property + def filename(self) -> str | None: + """Filename from Content-Disposition header. + + Returns: + The filename if found in the Content-Disposition header, None otherwise. + """ + content_disposition = self.response.headers.get("content-disposition") + if not content_disposition: + return None + + filename_match = re.search( + r'filename\*=(?:UTF-8\'\')?([^;]+)|filename=(?:"([^"]+)"|([^;]+))', + content_disposition, + re.IGNORECASE, + ) + + if filename_match: + return filename_match.group(1) or filename_match.group(2) or filename_match.group(3) + + return None + + @property + def file_contents(self) -> bytes: + """Returns the content of the attachment. + + Returns: + The content of the attachment in bytes + + Raises: + ResponseNotRead() + + """ + return self.response.content + + @property + def content_type(self) -> str | None: + """Returns the content type of the attachment. + + Returns: + The content type of the attachment. + """ + ctype = self.response.headers.get("content-type", "") + return str(ctype) diff --git a/mpt_api_client/resources/commerce/agreements.py b/mpt_api_client/resources/commerce/agreements.py index 3fbe8c3f..071e3497 100644 --- a/mpt_api_client/resources/commerce/agreements.py +++ b/mpt_api_client/resources/commerce/agreements.py @@ -1,5 +1,17 @@ from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCreateMixin, + AsyncDeleteMixin, + AsyncUpdateMixin, + CreateMixin, + DeleteMixin, + UpdateMixin, +) from mpt_api_client.models import Model +from mpt_api_client.resources.commerce.agreements_attachments import ( + AgreementsAttachmentService, + AsyncAgreementsAttachmentService, +) class Agreement(Model): @@ -14,7 +26,13 @@ class AgreementsServiceConfig: _collection_key = "data" -class AgreementsService(Service[Agreement], AgreementsServiceConfig): +class AgreementsService( # noqa: WPS215 + CreateMixin[Agreement], + UpdateMixin[Agreement], + DeleteMixin, + Service[Agreement], + AgreementsServiceConfig, +): """Agreements service.""" def template(self, agreement_id: str) -> str: @@ -29,8 +47,28 @@ def template(self, agreement_id: str) -> str: response = self._resource_do_request(agreement_id, action="template") return response.text + def attachments(self, agreement_id: str) -> AgreementsAttachmentService: + """Get the attachments service for the given Agreement id. -class AsyncAgreementsService(AsyncService[Agreement], AgreementsServiceConfig): + Args: + agreement_id: Agreement ID. + + Returns: + Agreements Attachment service. + """ + return AgreementsAttachmentService( + http_client=self.http_client, + endpoint_params={"agreement_id": agreement_id}, + ) + + +class AsyncAgreementsService( # noqa: WPS215 + AsyncCreateMixin[Agreement], + AsyncUpdateMixin[Agreement], + AsyncDeleteMixin, + AsyncService[Agreement], + AgreementsServiceConfig, +): """Agreements service.""" async def template(self, agreement_id: str) -> str: @@ -44,3 +82,17 @@ async def template(self, agreement_id: str) -> str: """ response = await self._resource_do_request(agreement_id, action="template") return response.text + + def attachments(self, agreement_id: str) -> AsyncAgreementsAttachmentService: + """Get the attachments service for the given Agreement id. + + Args: + agreement_id: Agreement ID. + + Returns: + Agreements Attachment service. + """ + return AsyncAgreementsAttachmentService( + http_client=self.http_client, + endpoint_params={"agreement_id": agreement_id}, + ) diff --git a/mpt_api_client/resources/commerce/agreements_attachments.py b/mpt_api_client/resources/commerce/agreements_attachments.py new file mode 100644 index 00000000..e388c0fc --- /dev/null +++ b/mpt_api_client/resources/commerce/agreements_attachments.py @@ -0,0 +1,149 @@ +import json + +from httpx import Response +from httpx._types import FileTypes + +from mpt_api_client.http import AsyncDeleteMixin, AsyncService, DeleteMixin, Service +from mpt_api_client.models import FileModel, Model, ResourceData + + +def _json_to_file_payload(resource_data: ResourceData) -> bytes: + return json.dumps( + resource_data, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + + +class AgreementAttachment(Model): + """Agreement attachment resource.""" + + +class AgreementsAttachmentServiceConfig: + """Orders service config.""" + + _endpoint = "/public/v1/commerce/agreements/{agreement_id}/attachments" + _model_class = AgreementAttachment + _collection_key = "data" + + +class AgreementsAttachmentService( + DeleteMixin, Service[AgreementAttachment], AgreementsAttachmentServiceConfig +): + """Attachments service.""" + + def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + ) -> AgreementAttachment: + """Create AgreementAttachment resource. + + Args: + resource_data: Resource data. + files: Files data. + + Returns: + AgreementAttachment resource. + """ + files = files or {} + + # Note: This is a workaround to fulfill MPT API request format + # + # HTTPx does not support sending json and files in the same call + # currently only supports sending form-data and files in the same call. + # https://www.python-httpx.org/quickstart/#sending-multipart-file-uploads + # + # MPT API expects files and data to be submitted in a multipart form-data upload. + # https://softwareone.atlassian.net/wiki/spaces/mpt/pages/5212079859/Commerce+API#Create-Agreement-Attachment + # + # Current workaround is to send the json data as an unnamed file. + # This ends adding the json as payload multipart data. + # + # json.dumps is setup using the same params of httpx json encoder to produce the same + # encodings. + + if resource_data: + files["_attachment_data"] = ( + None, + _json_to_file_payload(resource_data), + "application/json", + ) + + response = self.http_client.post(self.endpoint, files=files) + response.raise_for_status() + return AgreementAttachment.from_response(response) + + def download(self, agreement_id: str) -> FileModel: + """Renders the template for the given Agreement id. + + Args: + agreement_id: Agreement ID. + + Returns: + Agreement template. + """ + response: Response = self._resource_do_request( + agreement_id, method="GET", headers={"Accept": "*"} + ) + return FileModel(response) + + +class AsyncAgreementsAttachmentService( + AsyncDeleteMixin, AsyncService[AgreementAttachment], AgreementsAttachmentServiceConfig +): + """Attachments service.""" + + async def create( + self, + resource_data: ResourceData | None = None, + files: dict[str, FileTypes] | None = None, # noqa: WPS221 + ) -> AgreementAttachment: + """Create AgreementAttachment resource. + + Args: + resource_data: Resource data. + files: Files data. + + Returns: + AgreementAttachment resource. + """ + files = files or {} + + # Note: This is a workaround to fulfill MPT API request format + # + # HTTPx does not support sending json and files in the same call + # currently only supports sending form-data and files in the same call. + # https://www.python-httpx.org/quickstart/#sending-multipart-file-uploads + # + # MPT API expects files and data to be submitted in a multipart form-data upload. + # https://softwareone.atlassian.net/wiki/spaces/mpt/pages/5212079859/Commerce+API#Create-Agreement-Attachment + # + # Current workaround is to send the json data as an unnamed file. + # This ends adding the json as payload multipart data. + # + # json.dumps is setup using the same params of httpx json encoder to produce the same + # encodings. + + if resource_data: + files["_attachment_data"] = ( + None, + _json_to_file_payload(resource_data), + "application/json", + ) + + response = await self.http_client.post(self.endpoint, files=files) + response.raise_for_status() + return AgreementAttachment.from_response(response) + + async def download(self, agreement_id: str) -> FileModel: + """Renders the template for the given Agreement id. + + Args: + agreement_id: Agreement ID. + + Returns: + Agreement template. + """ + response = await self._resource_do_request( + agreement_id, method="GET", headers={"Accept": "*"} + ) + return FileModel(response) diff --git a/mpt_api_client/resources/commerce/orders.py b/mpt_api_client/resources/commerce/orders.py index 7878bff4..106884aa 100644 --- a/mpt_api_client/resources/commerce/orders.py +++ b/mpt_api_client/resources/commerce/orders.py @@ -6,6 +6,7 @@ DeleteMixin, Service, ) +from mpt_api_client.http.mixins import AsyncUpdateMixin, UpdateMixin from mpt_api_client.models import Model, ResourceData @@ -24,6 +25,7 @@ class OrdersServiceConfig: class OrdersService( # noqa: WPS215 CreateMixin[Order], DeleteMixin, + UpdateMixin[Order], Service[Order], OrdersServiceConfig, ): @@ -99,6 +101,7 @@ def template(self, resource_id: str) -> str: class AsyncOrdersService( # noqa: WPS215 AsyncCreateMixin[Order], AsyncDeleteMixin, + AsyncUpdateMixin[Order], AsyncService[Order], OrdersServiceConfig, ): diff --git a/setup.cfg b/setup.cfg index 8874bad0..f4133b87 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,7 +33,6 @@ extend-ignore = per-file-ignores = mpt_api_client/rql/query_builder.py: WPS110 WPS115 WPS210 WPS214 - mpt_api_client/http/service.py: WPS214 tests/http/test_async_service.py: WPS204 WPS202 tests/http/test_service.py: WPS204 WPS202 tests/*: diff --git a/tests/http/conftest.py b/tests/http/conftest.py index ced339ed..21e1c5c8 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -10,15 +10,26 @@ DeleteMixin, Service, ) +from mpt_api_client.http.mixins import AsyncUpdateMixin, UpdateMixin from tests.conftest import DummyModel -class DummyService(CreateMixin[DummyModel], DeleteMixin, Service[DummyModel]): +class DummyService( # noqa: WPS215 + CreateMixin[DummyModel], + DeleteMixin, + UpdateMixin[DummyModel], + Service[DummyModel], +): _endpoint = "/api/v1/test" _model_class = DummyModel -class AsyncDummyService(AsyncCreateMixin[DummyModel], AsyncDeleteMixin, AsyncService[DummyModel]): +class AsyncDummyService( # noqa: WPS215 + AsyncCreateMixin[DummyModel], + AsyncDeleteMixin, + AsyncUpdateMixin[DummyModel], + AsyncService[DummyModel], +): _endpoint = "/api/v1/test" _model_class = DummyModel diff --git a/tests/http/test_async_service.py b/tests/http/test_async_service.py index 43379d50..e7478051 100644 --- a/tests/http/test_async_service.py +++ b/tests/http/test_async_service.py @@ -1,5 +1,3 @@ -import json - import httpx import pytest import respx @@ -244,29 +242,17 @@ async def test_async_iterate_lazy_evaluation(async_dummy_service): assert mock_route.call_count == 1 -async def test_async_update_resource(async_dummy_service): # noqa: WPS210 - 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) - - request = mock_route.calls[0].request - assert mock_route.call_count == 1 - assert json.loads(request.content.decode()) == resource_data - - async def test_async_get(async_dummy_service): resource_data = {"id": "RES-123", "name": "Test Resource"} with respx.mock: - respx.get("https://api.example.com/api/v1/test/RES-123").mock( - return_value=httpx.Response(httpx.codes.OK, json=resource_data) - ) + 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)) resource = 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 isinstance(resource, DummyModel) assert resource.to_dict() == resource_data diff --git a/tests/http/test_base_service.py b/tests/http/test_base_service.py new file mode 100644 index 00000000..de863aff --- /dev/null +++ b/tests/http/test_base_service.py @@ -0,0 +1,101 @@ +import pytest + +from mpt_api_client import RQLQuery +from mpt_api_client.http import Service +from tests.conftest import DummyModel + + +class EndpointDummyService(Service[DummyModel]): + _endpoint = "/api/{version}/test" + _model_class = DummyModel + + +@pytest.fixture +def base_dummy_service(http_client): + return EndpointDummyService(http_client=http_client, endpoint_params={"version": "v1"}) + + +def test_endpoint(http_client): + service = EndpointDummyService(http_client=http_client, endpoint_params={"version": "vLatest"}) + + assert service.endpoint_params == {"version": "vLatest"} + assert service.endpoint == "/api/vLatest/test" + + +def test_filter(base_dummy_service, filter_status_active): + new_collection = base_dummy_service.filter(filter_status_active) + + assert base_dummy_service.query_rql is None + assert new_collection != base_dummy_service + assert new_collection.query_rql == filter_status_active + + +def test_multiple_filters(base_dummy_service) -> None: + filter_query = RQLQuery(status="active") + filter_query2 = RQLQuery(name="test") + + new_collection = base_dummy_service.filter(filter_query).filter(filter_query2) + + assert base_dummy_service.query_rql is None + assert new_collection.query_rql == filter_query & filter_query2 + + +def test_select(base_dummy_service) -> None: + new_collection = base_dummy_service.select("agreement", "-product") + + assert base_dummy_service.query_select is None + assert new_collection != base_dummy_service + assert new_collection.query_select == ["agreement", "-product"] + + +def test_select_exception(base_dummy_service) -> None: + with pytest.raises(ValueError): + base_dummy_service.select("agreement").select("product") + + +def test_order_by(base_dummy_service): + new_collection = base_dummy_service.order_by("created", "-name") + + assert base_dummy_service.query_order_by is None + assert new_collection != base_dummy_service + assert new_collection.query_order_by == ["created", "-name"] + + +def test_order_by_exception(base_dummy_service): + with pytest.raises( + ValueError, match=r"Ordering is already set. Cannot set ordering multiple times." + ): + base_dummy_service.order_by("created").order_by("name") + + +def test_url(base_dummy_service, filter_status_active) -> None: + custom_collection = ( + base_dummy_service.filter(filter_status_active) + .select("-audit", "product.agreements", "-product.agreements.product") + .order_by("-created", "name") + ) + + url = custom_collection.build_url() + + assert custom_collection != base_dummy_service + assert url == ( + "/api/v1/test?order=-created,name" + "&select=-audit,product.agreements,-product.agreements.product" + "&eq(status,active)" + ) + + +def test_clone(base_dummy_service, filter_status_active) -> None: + configured = ( + base_dummy_service.filter(filter_status_active) + .order_by("created", "-name") + .select("agreement", "-product") + ) + + cloned = configured.clone() + + assert cloned is not configured + assert isinstance(cloned, configured.__class__) + assert cloned.http_client is configured.http_client + assert str(cloned.query_rql) == str(configured.query_rql) + assert cloned.endpoint_params == configured.endpoint_params diff --git a/tests/http/test_mixins.py b/tests/http/test_mixins.py index e3fe705d..0a06e1d5 100644 --- a/tests/http/test_mixins.py +++ b/tests/http/test_mixins.py @@ -37,6 +37,22 @@ async def test_async_delete_mixin(async_dummy_service): # noqa: WPS210 assert mock_route.call_count == 1 +async def test_async_update_resource(async_dummy_service): # noqa: WPS210 + 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) + + request: httpx.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): # noqa: WPS210 resource_data = {"name": "Test Resource", "status": "active"} new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} @@ -67,3 +83,18 @@ def test_sync_delete_mixin(dummy_service): dummy_service.delete("RES-123") assert mock_route.call_count == 1 + + +def test_sync_update_resource(dummy_service): + 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) + + request = mock_route.calls[0].request + assert mock_route.call_count == 1 + assert json.loads(request.content.decode()) == resource_data diff --git a/tests/http/test_service.py b/tests/http/test_service.py index d93c2192..3768f778 100644 --- a/tests/http/test_service.py +++ b/tests/http/test_service.py @@ -1,10 +1,7 @@ -import json - import httpx import pytest import respx -from mpt_api_client.rql import RQLQuery from tests.conftest import DummyModel from tests.http.conftest import DummyService @@ -97,11 +94,15 @@ def test_sync_fetch_page_with_filter(dummy_service, list_response, filter_status def test_sync_get(dummy_service): resource_data = {"id": "RES-123", "name": "Test Resource"} with respx.mock: - respx.get("https://api.example.com/api/v1/test/RES-123").mock( - return_value=httpx.Response(httpx.codes.OK, json=resource_data) - ) + 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)) resource = 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 isinstance(resource, DummyModel) assert resource.to_dict() == resource_data @@ -264,96 +265,3 @@ def test_sync_iterate_handles_api_errors(dummy_service): with pytest.raises(httpx.HTTPStatusError): list(iterator) - - -def test_sync_update_resource(dummy_service): - 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) - - request = mock_route.calls[0].request - assert mock_route.call_count == 1 - assert json.loads(request.content.decode()) == resource_data - - -def test_sync_filter(dummy_service, filter_status_active): - new_collection = dummy_service.filter(filter_status_active) - - assert dummy_service.query_rql is None - assert new_collection != dummy_service - assert new_collection.query_rql == filter_status_active - - -def test_sync_multiple_filters(dummy_service) -> None: - filter_query = RQLQuery(status="active") - filter_query2 = RQLQuery(name="test") - - new_collection = dummy_service.filter(filter_query).filter(filter_query2) - - assert dummy_service.query_rql is None - assert new_collection.query_rql == filter_query & filter_query2 - - -def test_sync_select(dummy_service) -> None: - new_collection = dummy_service.select("agreement", "-product") - - assert dummy_service.query_select is None - assert new_collection != dummy_service - assert new_collection.query_select == ["agreement", "-product"] - - -def test_sync_select_exception(dummy_service) -> None: - with pytest.raises(ValueError): - dummy_service.select("agreement").select("product") - - -def test_sync_order_by(dummy_service): - new_collection = dummy_service.order_by("created", "-name") - - assert dummy_service.query_order_by is None - assert new_collection != dummy_service - assert new_collection.query_order_by == ["created", "-name"] - - -def test_sync_order_by_exception(dummy_service): - with pytest.raises( - ValueError, match=r"Ordering is already set. Cannot set ordering multiple times." - ): - dummy_service.order_by("created").order_by("name") - - -def test_sync_url(dummy_service, filter_status_active) -> None: - custom_collection = ( - dummy_service.filter(filter_status_active) - .select("-audit", "product.agreements", "-product.agreements.product") - .order_by("-created", "name") - ) - - url = custom_collection.build_url() - - assert custom_collection != dummy_service - assert url == ( - "/api/v1/test?order=-created,name" - "&select=-audit,product.agreements,-product.agreements.product" - "&eq(status,active)" - ) - - -def test_sync_clone(dummy_service, filter_status_active) -> None: - configured = ( - dummy_service.filter(filter_status_active) - .order_by("created", "-name") - .select("agreement", "-product") - ) - - cloned = configured.clone() - - assert cloned is not configured - assert isinstance(cloned, configured.__class__) - assert cloned.http_client is configured.http_client - assert str(cloned.query_rql) == str(configured.query_rql) diff --git a/tests/models/test_file_model.py b/tests/models/test_file_model.py new file mode 100644 index 00000000..8ed23a9d --- /dev/null +++ b/tests/models/test_file_model.py @@ -0,0 +1,94 @@ +import pytest +from httpx import Response + +from mpt_api_client.models import FileModel + + +@pytest.fixture +def empty_file(): + return FileModel(Response(200)) + + +def test_download_file_init(): + response = Response(200) + download_file = FileModel(response) + + assert download_file.response == response + + +def test_filename(empty_file): + empty_file.response.headers["content-disposition"] = 'attachment; filename="test.pdf"' + + filename = empty_file.filename + + assert filename == "test.pdf" + + +def test_filename_with_utf8_format(empty_file): + empty_file.response.headers["content-disposition"] = ( + "attachment; filename*=UTF-8''test%20file.pdf" + ) + + filename = empty_file.filename + + assert filename == "test%20file.pdf" + + +def test_filename_without_quotes(): + response = Response(200) + response.headers["content-disposition"] = "attachment; filename=test.pdf" + download_file = FileModel(response) + + filename = download_file.filename + + assert filename == "test.pdf" + + +def test_filename_case_insensitive(empty_file): + empty_file.response.headers["content-disposition"] = 'ATTACHMENT; FILENAME="test.pdf"' + + assert empty_file.filename == "test.pdf" + + +def test_filename_no_content_disposition(empty_file): + filename = empty_file.filename + + assert filename is None + + +def test_filename_empty_content_disposition(empty_file): + empty_file.response.headers["content-disposition"] = "" + + assert empty_file.filename is None + + +def test_filename_no_filename_in_header(): + response = Response(200) + response.headers["content-disposition"] = "attachment" + download_file = FileModel(response) + + filename = download_file.filename + + assert filename is None + + +def test_file_contents(): + response = Response(200, content=b"test content") + download_file = FileModel(response) + + assert download_file.file_contents == b"test content" + + +def test_content_type(): + response = Response(200) + response.headers["content-type"] = "application/pdf" + download_file = FileModel(response) + + assert download_file.content_type == "application/pdf" + + +def test_content_type_none(): + response = Response(200) + download_file = FileModel(response) + + assert not download_file.content_type diff --git a/tests/resources/commerce/test_agreements.py b/tests/resources/commerce/test_agreements.py index bd9e1db2..0d4726f0 100644 --- a/tests/resources/commerce/test_agreements.py +++ b/tests/resources/commerce/test_agreements.py @@ -1,7 +1,12 @@ import httpx +import pytest import respx from mpt_api_client.resources.commerce.agreements import AgreementsService, AsyncAgreementsService +from mpt_api_client.resources.commerce.agreements_attachments import ( + AgreementsAttachmentService, + AsyncAgreementsAttachmentService, +) async def test_async_template(async_http_client): @@ -39,3 +44,33 @@ def test_template(http_client): assert mock_route.called assert mock_route.call_count == 1 assert markdown_template == "# Order Template\n\nThis is a markdown template." + + +def test_attachments_service(http_client): + agreements_service = AgreementsService(http_client=http_client) + + attachments = agreements_service.attachments("AGR-123") + + assert isinstance(attachments, AgreementsAttachmentService) + assert attachments.endpoint_params == {"agreement_id": "AGR-123"} + + +def test_async_attachments_service(http_client): + agreements_service = AsyncAgreementsService(http_client=http_client) + + attachments = agreements_service.attachments("AGR-123") + + assert isinstance(attachments, AsyncAgreementsAttachmentService) + assert attachments.endpoint_params == {"agreement_id": "AGR-123"} + + +@pytest.mark.parametrize("method", ["create", "update", "get"]) +def test_mixins_present(http_client, method): + service = AgreementsService(http_client=http_client) + assert hasattr(service, method) + + +@pytest.mark.parametrize("method", ["create", "update", "get"]) +def test_async_mixins_present(async_http_client, method): + service = AgreementsService(http_client=async_http_client) + assert hasattr(service, method) diff --git a/tests/resources/commerce/test_agreements_attachments.py b/tests/resources/commerce/test_agreements_attachments.py new file mode 100644 index 00000000..ffa57411 --- /dev/null +++ b/tests/resources/commerce/test_agreements_attachments.py @@ -0,0 +1,194 @@ +import io + +import httpx +import pytest +import respx + +from mpt_api_client.resources.commerce.agreements_attachments import ( + AgreementsAttachmentService, + AsyncAgreementsAttachmentService, +) + + +@pytest.fixture +def attachment_service(http_client): + return AgreementsAttachmentService( + http_client=http_client, endpoint_params={"agreement_id": "AGR-123"} + ) + + +@pytest.fixture +def async_attachment_service(async_http_client): + return AsyncAgreementsAttachmentService( + http_client=async_http_client, endpoint_params={"agreement_id": "AGR-123"} + ) + + +async def test_async_create(async_attachment_service): + attachment_data = {"id": "ATT-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments" + ).mock( + return_value=httpx.Response( + status_code=200, + json=attachment_data, + ) + ) + files = {"attachment": ("test.txt", io.BytesIO(b"Hello"), "text/plain")} + new_attachment = await async_attachment_service.create({"name": "Upload test"}, files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_attachment_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Upload test"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="attachment"; filename="test.txt"\r\n' + b"Content-Type: text/plain\r\n\r\n" + b"Hello\r\n" in request.content + ) + assert new_attachment.to_dict() == attachment_data + + +async def test_async_create_no_data(async_attachment_service): + attachment_data = {"id": "ATT-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments" + ).mock( + return_value=httpx.Response( + status_code=200, + json=attachment_data, + ) + ) + files = {"attachment": ("test.txt", io.BytesIO(b"Hello"), "text/plain")} + new_attachment = await async_attachment_service.create(files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="attachment"; filename="test.txt"\r\n' + b"Content-Type: text/plain\r\n\r\n" + b"Hello\r\n" in request.content + ) + assert new_attachment.to_dict() == attachment_data + + +async def test_async_download(async_attachment_service): + attachment_content = b"PDF file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments/ATT-456" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; filename="test.txt.pdf"', + }, + content=attachment_content, + ) + ) + + downloaded_file = await async_attachment_service.download("ATT-456") + request = mock_route.calls[0].request + accept_header = (b"Accept", b"*") + assert accept_header in request.headers.raw + assert mock_route.call_count == 1 + assert downloaded_file.file_contents == attachment_content + assert downloaded_file.content_type == "application/octet-stream" + assert downloaded_file.filename == "test.txt.pdf" + + +def test_download(attachment_service): + attachment_content = b"PDF file content or binary data" + + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments/ATT-456" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={ + "content-type": "application/octet-stream", + "content-disposition": 'form-data; name="file"; filename="test.txt.pdf"', + }, + content=attachment_content, + ) + ) + + downloaded_file = attachment_service.download("ATT-456") + request = mock_route.calls[0].request + accept_header = (b"Accept", b"*") + assert accept_header in request.headers.raw + assert mock_route.call_count == 1 + assert downloaded_file.file_contents == attachment_content + assert downloaded_file.content_type == "application/octet-stream" + assert downloaded_file.filename == "test.txt.pdf" + + +def test_create(attachment_service): + attachment_data = {"id": "ATT-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments" + ).mock( + return_value=httpx.Response( + status_code=200, + json=attachment_data, + ) + ) + files = {"attachment": ("test.txt", io.BytesIO(b"Hello"), "text/plain")} + new_attachment = attachment_service.create({"name": "Upload test"}, files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="_attachment_data"\r\n' + b"Content-Type: application/json\r\n\r\n" + b'{"name":"Upload test"}\r\n' in request.content + ) + assert ( + b'Content-Disposition: form-data; name="attachment"; filename="test.txt"\r\n' + b"Content-Type: text/plain\r\n\r\n" + b"Hello\r\n" in request.content + ) + assert new_attachment.to_dict() == attachment_data + + +def test_create_no_data(attachment_service): + attachment_data = {"id": "ATT-133"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/commerce/agreements/AGR-123/attachments" + ).mock( + return_value=httpx.Response( + status_code=200, + json=attachment_data, + ) + ) + files = {"attachment": ("test.txt", io.BytesIO(b"Hello"), "text/plain")} + new_attachment = attachment_service.create(files=files) + + request: httpx.Request = mock_route.calls[0].request + + assert ( + b'Content-Disposition: form-data; name="attachment"; filename="test.txt"\r\n' + b"Content-Type: text/plain\r\n\r\n" + b"Hello\r\n" in request.content + ) + assert new_attachment.to_dict() == attachment_data + + +@pytest.mark.parametrize("method", ["get", "create", "delete", "download"]) +def test_mixins_present(attachment_service, method): + assert hasattr(attachment_service, method) + + +@pytest.mark.parametrize("method", ["get", "create", "delete", "download"]) +def test_async_mixins_present(async_attachment_service, method): + assert hasattr(async_attachment_service, method) diff --git a/tests/resources/commerce/test_orders.py b/tests/resources/commerce/test_orders.py index 683a3e5a..c09b9e82 100644 --- a/tests/resources/commerce/test_orders.py +++ b/tests/resources/commerce/test_orders.py @@ -219,3 +219,13 @@ async def test_async_template(async_orders_service): template = await async_orders_service.template("ORD-123") assert template == template_content + + +@pytest.mark.parametrize("method", ["get", "create", "update", "delete"]) +def test_mixins_present(orders_service, method): + assert hasattr(orders_service, method) + + +@pytest.mark.parametrize("method", ["get", "create", "update", "delete"]) +def test_async_mixins_present(async_orders_service, method): + assert hasattr(async_orders_service, method)