diff --git a/mpt_api_client/__init__.py b/mpt_api_client/__init__.py index 2afb1505..5f3cf009 100644 --- a/mpt_api_client/__init__.py +++ b/mpt_api_client/__init__.py @@ -1,4 +1,4 @@ -from mpt_api_client.mptclient import MPTClient +from mpt_api_client.mpt_client import AsyncMPTClient, MPTClient from mpt_api_client.rql import RQLQuery -__all__ = ["MPTClient", "RQLQuery"] # noqa: WPS410 +__all__ = ["AsyncMPTClient", "MPTClient", "RQLQuery"] # noqa: WPS410 diff --git a/mpt_api_client/http/__init__.py b/mpt_api_client/http/__init__.py new file mode 100644 index 00000000..5dc5f427 --- /dev/null +++ b/mpt_api_client/http/__init__.py @@ -0,0 +1,4 @@ +from mpt_api_client.http.client import AsyncHTTPClient, HTTPClient +from mpt_api_client.http.service import AsyncServiceBase, SyncServiceBase + +__all__ = ["AsyncHTTPClient", "AsyncServiceBase", "HTTPClient", "SyncServiceBase"] # noqa: WPS410 diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index 234b70d9..7440a9e3 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -33,8 +33,7 @@ def __init__( "User-Agent": "swo-marketplace-client/1.0", "Authorization": f"Bearer {api_token}", } - Client.__init__( - self, + super().__init__( base_url=base_url, headers=base_headers, timeout=timeout, @@ -42,7 +41,7 @@ def __init__( ) -class HTTPClientAsync(AsyncClient): +class AsyncHTTPClient(AsyncClient): """Async HTTP client for interacting with SoftwareOne Marketplace Platform API.""" def __init__( @@ -72,8 +71,7 @@ def __init__( "User-Agent": "swo-marketplace-client/1.0", "Authorization": f"Bearer {api_token}", } - AsyncClient.__init__( - self, + super().__init__( base_url=base_url, headers=base_headers, timeout=timeout, diff --git a/mpt_api_client/http/resource.py b/mpt_api_client/http/resource.py deleted file mode 100644 index bc5fb486..00000000 --- a/mpt_api_client/http/resource.py +++ /dev/null @@ -1,135 +0,0 @@ -from abc import ABC -from typing import Any, ClassVar, Self, override - -from httpx import Response - -from mpt_api_client.http.client import HTTPClient -from mpt_api_client.models import Resource - - -class ResourceBaseClient[ResourceModel: Resource](ABC): # noqa: WPS214 - """Client for RESTful resources.""" - - _endpoint: str - _resource_class: type[ResourceModel] - _safe_attributes: ClassVar[set[str]] = {"http_client_", "resource_id_", "resource_"} - - def __init__(self, http_client: HTTPClient, resource_id: str) -> None: - self.http_client_ = http_client # noqa: WPS120 - self.resource_id_ = resource_id # noqa: WPS120 - self.resource_: Resource | None = None # noqa: WPS120 - - def __getattr__(self, attribute: str) -> Any: - """Returns the resource data.""" - self._ensure_resource_is_fetched() - return self.resource_.__getattr__(attribute) # type: ignore[union-attr] - - @property - def resource_url(self) -> str: - """Returns the resource URL.""" - return f"{self._endpoint}/{self.resource_id_}" - - @override - def __setattr__(self, attribute: str, attribute_value: Any) -> None: - if attribute in self._safe_attributes: - object.__setattr__(self, attribute, attribute_value) - return - self._ensure_resource_is_fetched() - self.resource_.__setattr__(attribute, attribute_value) - - def fetch(self) -> ResourceModel: - """Fetch a specific resource using `GET /endpoint/{resource_id}`. - - It fetches and caches the resource. - - Returns: - The fetched resource. - """ - response = self.do_action("GET") - - self.resource_ = self._resource_class.from_response(response) # noqa: WPS120 - return self.resource_ - - def resource_action( - self, - method: str = "GET", - url: str | None = None, - json: dict[str, Any] | list[Any] | None = None, # noqa: WPS221 - ) -> ResourceModel: - """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.""" - response = self.do_action(method, url, json=json) - self.resource_ = self._resource_class.from_response(response) # noqa: WPS120 - return self.resource_ - - def do_action( - self, - method: str = "GET", - url: str | None = None, - json: dict[str, Any] | list[Any] | None = None, # noqa: WPS221 - ) -> Response: - """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`. - - Args: - method: The HTTP method to use. - url: The action name to use. - json: The updated resource data. - - Raises: - HTTPError: If the action fails. - """ - url = f"{self.resource_url}/{url}" if url else self.resource_url - response = self.http_client_.request(method, url, json=json) - response.raise_for_status() - return response - - def update(self, resource_data: dict[str, Any]) -> ResourceModel: - """Update a specific in the API and catches the result as a current resource. - - Args: - resource_data: The updated resource data. - - Returns: - The updated resource. - - Examples: - updated_contact = contact.update({"name": "New Name"}) - - - """ - response = self.do_action("PUT", json=resource_data) - self.resource_ = self._resource_class.from_response(response) # noqa: WPS120 - return self.resource_ - - def save(self) -> Self: - """Save the current state of the resource to the api using the update method. - - Raises: - ValueError: If the resource has not been set. - - Examples: - contact.name = "New Name" - contact.save() - - """ - if not self.resource_: - raise ValueError("Unable to save resource that has not been set.") - self.update(self.resource_.to_dict()) - return self - - def delete(self) -> None: - """Delete the resource using `DELETE /endpoint/{resource_id}`. - - Raises: - HTTPStatusError: If the deletion fails. - - Examples: - contact.delete() - """ - response = self.do_action("DELETE") - response.raise_for_status() - - self.resource_ = None # noqa: WPS120 - - def _ensure_resource_is_fetched(self) -> None: - if not self.resource_: - self.fetch() diff --git a/mpt_api_client/http/collection.py b/mpt_api_client/http/service.py similarity index 54% rename from mpt_api_client/http/collection.py rename to mpt_api_client/http/service.py index 17a6f124..3f105fc0 100644 --- a/mpt_api_client/http/collection.py +++ b/mpt_api_client/http/service.py @@ -1,27 +1,26 @@ import copy -from abc import ABC from collections.abc import AsyncIterator, Iterator from typing import Any, Self import httpx -from mpt_api_client.http.client import HTTPClient, HTTPClientAsync -from mpt_api_client.http.resource import ResourceBaseClient -from mpt_api_client.models import Collection, Resource +from mpt_api_client.http.client import AsyncHTTPClient, HTTPClient +from mpt_api_client.models import Collection, Meta, Model, ResourceData +from mpt_api_client.models.collection import ResourceList from mpt_api_client.rql.query_builder import RQLQuery -class CollectionMixin: - """Mixin for collection clients.""" +class ServiceSharedBase[HTTP, ModelClass: Model]: + """Client base.""" _endpoint: str - _resource_class: type[Any] - _resource_client_class: type[Any] - _collection_class: type[Collection[Any]] + _model_class: type[ModelClass] + _collection_key = "data" def __init__( self, - http_client: HTTPClient | HTTPClientAsync, + *, + http_client: HTTP, query_rql: RQLQuery | None = None, ) -> None: self.http_client = http_client @@ -29,28 +28,20 @@ def __init__( self.query_order_by: list[str] | None = None self.query_select: list[str] | None = None - @classmethod - def clone(cls, collection_client: "CollectionMixin") -> Self: + def clone(self) -> Self: """Create a copy of collection client for immutable operations. Returns: New collection client with same settings. """ - new_collection = cls( - http_client=collection_client.http_client, - query_rql=collection_client.query_rql, - ) + new_collection = type(self)(http_client=self.http_client, query_rql=self.query_rql) new_collection.query_order_by = ( - copy.copy(collection_client.query_order_by) - if collection_client.query_order_by - else None - ) - new_collection.query_select = ( - copy.copy(collection_client.query_select) if collection_client.query_select else None + copy.copy(self.query_order_by) if self.query_order_by else None ) + new_collection.query_select = copy.copy(self.query_select) if self.query_select else None return new_collection - def build_url(self, query_params: dict[str, Any] | None = None) -> str: + def build_url(self, query_params: dict[str, Any] | None = None) -> str: # noqa: WPS210 """Builds the endpoint URL with all the query parameters. Returns: @@ -59,15 +50,18 @@ def build_url(self, query_params: dict[str, Any] | None = None) -> str: query_params = query_params or {} query_parts = [ f"{param_key}={param_value}" for param_key, param_value in query_params.items() - ] # noqa: WPS237 + ] if self.query_order_by: - query_parts.append(f"order={','.join(self.query_order_by)}") # noqa: WPS237 + str_order_by = ",".join(self.query_order_by) + query_parts.append(f"order={str_order_by}") if self.query_select: - query_parts.append(f"select={','.join(self.query_select)}") # noqa: WPS237 + 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: - return f"{self._endpoint}?{'&'.join(query_parts)}" # noqa: WPS237 + query = "&".join(query_parts) + return f"{self._endpoint}?{query}" return self._endpoint def order_by(self, *fields: str) -> Self: @@ -81,7 +75,7 @@ def order_by(self, *fields: str) -> Self: """ if self.query_order_by is not None: raise ValueError("Ordering is already set. Cannot set ordering multiple times.") - new_collection = self.clone(self) + new_collection = self.clone() new_collection.query_order_by = list(fields) return new_collection @@ -93,7 +87,7 @@ def filter(self, rql: RQLQuery) -> Self: """ if self.query_rql: rql = self.query_rql & rql - new_collection = self.clone(self) + new_collection = self.clone() new_collection.query_rql = rql return new_collection @@ -111,14 +105,22 @@ def select(self, *fields: str) -> Self: "Select fields are already set. Cannot set select fields multiple times." ) - new_client = self.clone(self) + new_client = self.clone() new_client.query_select = list(fields) return new_client + def _create_collection(self, response: httpx.Response) -> Collection[ModelClass]: + meta = Meta.from_response(response) + return Collection( + items=[ + self._model_class.new(resource, meta) + for resource in response.json().get(self._collection_key) + ], + meta=meta, + ) -class CollectionClientBase[ResourceModel: Resource, ResourceClient: ResourceBaseClient[Resource]]( # noqa: WPS214 - ABC, CollectionMixin -): + +class SyncServiceBase[ModelClass: Model](ServiceSharedBase[HTTPClient, ModelClass]): """Immutable Base client for RESTful resource collections. Examples: @@ -130,28 +132,16 @@ class CollectionClientBase[ResourceModel: Resource, ResourceClient: ResourceBase """ - _resource_class: type[ResourceModel] - _resource_client_class: type[ResourceClient] - _collection_class: type[Collection[ResourceModel]] - - def __init__( - self, - query_rql: RQLQuery | None = None, - http_client: HTTPClient | None = None, - ) -> None: - self.http_client: HTTPClient = http_client or HTTPClient() # type: ignore[mutable-override] - CollectionMixin.__init__(self, http_client=self.http_client, query_rql=query_rql) - - def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceModel]: + def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ModelClass]: """Fetch one page of resources. Returns: Collection of resources. """ response = self._fetch_page_as_response(limit=limit, offset=offset) - return Collection.from_response(response) + return self._create_collection(response) - def fetch_one(self) -> ResourceModel: + def fetch_one(self) -> ModelClass: """Fetch one page, expect exactly one result. Returns: @@ -161,7 +151,7 @@ def fetch_one(self) -> ResourceModel: ValueError: If the total matching records are not exactly one. """ response = self._fetch_page_as_response(limit=1, offset=0) - resource_list: Collection[ResourceModel] = Collection.from_response(response) + resource_list = self._create_collection(response) total_records = len(resource_list) if resource_list.meta: total_records = resource_list.meta.pagination.total @@ -172,7 +162,7 @@ def fetch_one(self) -> ResourceModel: return resource_list[0] - def iterate(self, batch_size: int = 100) -> Iterator[ResourceModel]: + def iterate(self, batch_size: int = 100) -> Iterator[ModelClass]: """Iterate over all resources, yielding GenericResource objects. Args: @@ -186,9 +176,7 @@ def iterate(self, batch_size: int = 100) -> Iterator[ResourceModel]: while True: response = self._fetch_page_as_response(limit=limit, offset=offset) - items_collection: Collection[ResourceModel] = self._collection_class.from_response( - response - ) + items_collection = self._create_collection(response) yield from items_collection if not items_collection.meta: @@ -197,11 +185,7 @@ def iterate(self, batch_size: int = 100) -> Iterator[ResourceModel]: break offset = items_collection.meta.pagination.next_offset() - def get(self, resource_id: str) -> ResourceClient: - """Get resource by resource_id.""" - return self._resource_client_class(http_client=self.http_client, resource_id=resource_id) - - def create(self, resource_data: dict[str, Any]) -> ResourceModel: + def create(self, resource_data: ResourceData) -> Model: """Create a new resource using `POST /endpoint`. Returns: @@ -210,7 +194,20 @@ def create(self, resource_data: dict[str, Any]) -> ResourceModel: response = self.http_client.post(self._endpoint, json=resource_data) response.raise_for_status() - return self._resource_class.from_response(response) + return self._model_class.from_response(response) + + def get(self, resource_id: str) -> ModelClass: + """Fetch a specific resource using `GET /endpoint/{resource_id}`.""" + return self._resource_action(resource_id=resource_id) + + def update(self, resource_id: str, resource_data: ResourceData) -> ModelClass: + """Update a resource using `PUT /endpoint/{resource_id}`.""" + return self._resource_action(resource_id, "PUT", json=resource_data) + + def delete(self, resource_id: str) -> None: + """Delete the resoruce using `DELETE /endpoint/{resource_id}`.""" + response = self._resource_do_request(resource_id, "DELETE") + response.raise_for_status() def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: """Fetch one page of resources. @@ -227,11 +224,50 @@ def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Re return response + def _resource_do_request( + self, + resource_id: str, + method: str = "GET", + action: str | None = None, + json: ResourceData | ResourceList | None = None, + ) -> httpx.Response: + """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`. + + Args: + resource_id: The resource ID to operate on. + method: The HTTP method to use. + action: The action name to use. + json: The updated resource data. + + Returns: + HTTP response object. + + Raises: + HTTPError: If the action fails. + """ + resource_url = f"{self._endpoint}/{resource_id}" + url = f"{resource_url}/{action}" if action else resource_url + response = self.http_client.request(method, url, json=json) + response.raise_for_status() + return response -class AsyncCollectionClientBase[ - ResourceModel: Resource, - ResourceClient: ResourceBaseClient[Resource], -](ABC, CollectionMixin): + def _resource_action( + self, + resource_id: str, + method: str = "GET", + action: str | None = None, + json: ResourceData | ResourceList | None = None, + ) -> ModelClass: + """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.""" + response = self._resource_do_request(resource_id, method, action, json=json) + return self._model_class.from_response(response) + + +class AsyncServiceBase[ # noqa: WPS214 + ModelClass: Model, +]( # noqa: WPS214 + ServiceSharedBase[AsyncHTTPClient, ModelClass] +): """Immutable Base client for RESTful resource collections. Examples: @@ -243,28 +279,12 @@ class AsyncCollectionClientBase[ """ - _resource_class: type[ResourceModel] - _resource_client_class: type[ResourceClient] - _collection_class: type[Collection[ResourceModel]] - - def __init__( - self, - query_rql: RQLQuery | None = None, - http_client: HTTPClientAsync | None = None, - ) -> None: - self.http_client: HTTPClientAsync = http_client or HTTPClientAsync() # type: ignore[mutable-override] - CollectionMixin.__init__(self, http_client=self.http_client, query_rql=query_rql) - - async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ResourceModel]: - """Fetch one page of resources. - - Returns: - Collection of resources. - """ + async def fetch_page(self, limit: int = 100, offset: int = 0) -> Collection[ModelClass]: + """Fetch one page of resources.""" response = await self._fetch_page_as_response(limit=limit, offset=offset) - return Collection.from_response(response) + return self._create_collection(response) - async def fetch_one(self) -> ResourceModel: + async def fetch_one(self) -> Model: """Fetch one page, expect exactly one result. Returns: @@ -274,7 +294,7 @@ async def fetch_one(self) -> ResourceModel: ValueError: If the total matching records are not exactly one. """ response = await self._fetch_page_as_response(limit=1, offset=0) - resource_list: Collection[ResourceModel] = Collection.from_response(response) + resource_list = self._create_collection(response) total_records = len(resource_list) if resource_list.meta: total_records = resource_list.meta.pagination.total @@ -285,7 +305,7 @@ async def fetch_one(self) -> ResourceModel: return resource_list[0] - async def iterate(self, batch_size: int = 100) -> AsyncIterator[ResourceModel]: + async def iterate(self, batch_size: int = 100) -> AsyncIterator[Model]: """Iterate over all resources, yielding GenericResource objects. Args: @@ -299,9 +319,7 @@ async def iterate(self, batch_size: int = 100) -> AsyncIterator[ResourceModel]: while True: response = await self._fetch_page_as_response(limit=limit, offset=offset) - items_collection: Collection[ResourceModel] = self._collection_class.from_response( - response - ) + items_collection = self._create_collection(response) for resource in items_collection: yield resource @@ -311,11 +329,7 @@ async def iterate(self, batch_size: int = 100) -> AsyncIterator[ResourceModel]: break offset = items_collection.meta.pagination.next_offset() - async def get(self, resource_id: str) -> ResourceClient: - """Get resource by resource_id.""" - return self._resource_client_class(http_client=self.http_client, resource_id=resource_id) # type: ignore[arg-type] - - async def create(self, resource_data: dict[str, Any]) -> ResourceModel: + async def create(self, resource_data: dict[str, Any]) -> Model: """Create a new resource using `POST /endpoint`. Returns: @@ -324,7 +338,25 @@ async def create(self, resource_data: dict[str, Any]) -> ResourceModel: response = await self.http_client.post(self._endpoint, json=resource_data) response.raise_for_status() - return self._resource_class.from_response(response) + return self._model_class.from_response(response) + + async def get(self, resource_id: str) -> ModelClass: + """Fetch a specific resource using `GET /endpoint/{resource_id}`.""" + return await self._resource_action(resource_id=resource_id) + + async def update(self, resource_id: str, resource_data: ResourceData) -> ModelClass: + """Update a resource using `PUT /endpoint/{resource_id}`.""" + return await self._resource_action(resource_id, "PUT", json=resource_data) + + async def delete(self, resource_id: str) -> None: + """Create a new resource using `POST /endpoint`. + + Returns: + New resource created. + """ + url = f"{self._endpoint}/{resource_id}" + response = await self.http_client.delete(url) + response.raise_for_status() async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> httpx.Response: """Fetch one page of resources. @@ -340,3 +372,41 @@ async def _fetch_page_as_response(self, limit: int = 100, offset: int = 0) -> ht response.raise_for_status() return response + + async def _resource_do_request( + self, + resource_id: str, + method: str = "GET", + action: str | None = None, + json: ResourceData | ResourceList | None = None, + ) -> httpx.Response: + """Perform an action on a specific resource using. + + Request with action: `HTTP_METHOD /endpoint/{resource_id}/{action}`. + Request without action: `HTTP_METHOD /endpoint/{resource_id}`. + + Args: + resource_id: The resource ID to operate on. + method: The HTTP method to use. + action: The action name to use. + json: The updated resource data. + + Raises: + HTTPError: If the action fails. + """ + resource_url = f"{self._endpoint}/{resource_id}" + url = f"{resource_url}/{action}" if action else resource_url + response = await self.http_client.request(method, url, json=json) + response.raise_for_status() + return response + + async def _resource_action( + self, + resource_id: str, + method: str = "GET", + action: str | None = None, + json: ResourceData | ResourceList | None = None, + ) -> ModelClass: + """Perform an action on a specific resource using `HTTP_METHOD /endpoint/{resource_id}`.""" + response = await self._resource_do_request(resource_id, method, action, json=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 0e026579..6b526260 100644 --- a/mpt_api_client/models/__init__.py +++ b/mpt_api_client/models/__init__.py @@ -1,5 +1,5 @@ from mpt_api_client.models.collection import Collection from mpt_api_client.models.meta import Meta, Pagination -from mpt_api_client.models.resource import Resource +from mpt_api_client.models.model import Model, ResourceData -__all__ = ["Collection", "Meta", "Pagination", "Resource"] # noqa: WPS410 +__all__ = ["Collection", "Meta", "Model", "Pagination", "ResourceData"] # noqa: WPS410 diff --git a/mpt_api_client/models/base.py b/mpt_api_client/models/base.py deleted file mode 100644 index af22be3d..00000000 --- a/mpt_api_client/models/base.py +++ /dev/null @@ -1,52 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Self - -from httpx import Response - -from mpt_api_client.models.meta import Meta - -ResourceData = dict[str, Any] - - -class BaseResource(ABC): - """Provides a base resource to interact with api data using fluent interfaces.""" - - @classmethod - @abstractmethod - def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: - """Creates a new resource from ResourceData and Meta.""" - raise NotImplementedError - - @classmethod - @abstractmethod - def from_response(cls, response: Response) -> Self: - """Creates a collection from a response. - - Args: - response: The httpx response object. - """ - raise NotImplementedError - - @abstractmethod - def to_dict(self) -> dict[str, Any]: - """Returns the resource as a dictionary.""" - raise NotImplementedError - - -class BaseCollection(ABC): - """Provides a base collection to interact with api collection data using fluent interfaces.""" - - @classmethod - @abstractmethod - def from_response(cls, response: Response) -> Self: - """Creates a collection from a response. - - Args: - response: The httpx response object. - """ - raise NotImplementedError - - @abstractmethod - def to_list(self) -> list[dict[str, Any]]: - """Returns the collection as a list of dictionaries.""" - raise NotImplementedError diff --git a/mpt_api_client/models/collection.py b/mpt_api_client/models/collection.py index 1bb1d048..6df20928 100644 --- a/mpt_api_client/models/collection.py +++ b/mpt_api_client/models/collection.py @@ -1,54 +1,34 @@ from collections.abc import Iterator -from typing import Any, ClassVar, Self, override -from httpx import Response - -from mpt_api_client.models.base import BaseCollection, ResourceData from mpt_api_client.models.meta import Meta -from mpt_api_client.models.resource import Resource +from mpt_api_client.models.model import Model, ResourceData +ResourceList = list[ResourceData] -class Collection[ResourceType](BaseCollection): - """Provides a base collection to interact with api collection data using fluent interfaces.""" - _data_key: ClassVar[str] = "data" - _resource_model: type[Resource] = Resource +class Collection[ItemType: Model]: + """Provides a collection to interact with api collection data using fluent interfaces.""" - def __init__( - self, collection_data: list[ResourceData] | None = None, meta: Meta | None = None - ) -> None: + def __init__(self, items: list[ItemType] | None = None, meta: Meta | None = None) -> None: # noqa: WPS110 self.meta = meta - collection_data = collection_data or [] - self._resource_collection = [ - self._resource_model.new(resource_data, meta) for resource_data in collection_data - ] + self.items = items or [] # noqa: WPS110 - def __getitem__(self, index: int) -> ResourceType: + def __getitem__(self, index: int) -> ItemType: """Returns the collection item at the given index.""" - return self._resource_collection[index] # type: ignore[return-value] + return self.items[index] - def __iter__(self) -> Iterator[ResourceType]: + def __iter__(self) -> Iterator[ItemType]: """Make GenericCollection iterable.""" - return iter(self._resource_collection) # type: ignore[arg-type] + return iter(self.items) def __len__(self) -> int: """Return the number of items in the collection.""" - return len(self._resource_collection) + return len(self.items) def __bool__(self) -> bool: """Returns True if collection has items.""" - return len(self._resource_collection) > 0 - - @override - @classmethod - def from_response(cls, response: Response) -> Self: - response_data = response.json().get(cls._data_key) - meta = Meta.from_response(response) - if not isinstance(response_data, list): - raise TypeError(f"Response `{cls._data_key}` must be a list for collection endpoints.") - - return cls(response_data, meta) + return len(self.items) > 0 - @override - def to_list(self) -> list[dict[str, Any]]: - return [resource.to_dict() for resource in self._resource_collection] + def to_list(self) -> ResourceList: + """Returns the collection as a list of dictionaries.""" + return [resource.to_dict() for resource in self.items] diff --git a/mpt_api_client/models/resource.py b/mpt_api_client/models/model.py similarity index 86% rename from mpt_api_client/models/resource.py rename to mpt_api_client/models/model.py index 89a02ef7..758809b0 100644 --- a/mpt_api_client/models/resource.py +++ b/mpt_api_client/models/model.py @@ -3,11 +3,12 @@ from box import Box from httpx import Response -from mpt_api_client.models.base import BaseResource, ResourceData from mpt_api_client.models.meta import Meta +ResourceData = dict[str, Any] -class Resource(BaseResource): + +class Model: """Provides a resource to interact with api data using fluent interfaces.""" _data_key: ClassVar[str | None] = None @@ -18,8 +19,8 @@ def __init__(self, resource_data: ResourceData | None = None, meta: Meta | None self._resource_data = Box(resource_data or {}, camel_killer_box=True, default_box=False) @classmethod - @override def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None) -> Self: + """Creates a new resource from ResourceData and Meta.""" return cls(resource_data, meta) def __getattr__(self, attribute: str) -> Box | Any: @@ -35,8 +36,12 @@ def __setattr__(self, attribute: str, attribute_value: Any) -> None: self._resource_data.__setattr__(attribute, attribute_value) # type: ignore[no-untyped-call] @classmethod - @override def from_response(cls, response: Response) -> Self: + """Creates a collection from a response. + + Args: + response: The httpx response object. + """ response_data = response.json() if isinstance(response_data, dict): response_data.pop("$meta", None) @@ -47,6 +52,6 @@ def from_response(cls, response: Response) -> Self: meta = Meta.from_response(response) return cls.new(response_data, meta) - @override def to_dict(self) -> dict[str, Any]: + """Returns the resource as a dictionary.""" return self._resource_data.to_dict() diff --git a/mpt_api_client/mpt_client.py b/mpt_api_client/mpt_client.py new file mode 100644 index 00000000..5ae4342a --- /dev/null +++ b/mpt_api_client/mpt_client.py @@ -0,0 +1,49 @@ +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.resources import AsyncCommerce, Commerce + + +class AsyncMPTClientBase: + """MPT API Client Base.""" + + def __init__( + self, + base_url: str | None = None, + api_key: str | None = None, + http_client: AsyncHTTPClient | None = None, + ): + self.http_client = http_client or AsyncHTTPClient(base_url=base_url, api_token=api_key) + + +class AsyncMPTClient(AsyncMPTClientBase): + """MPT API Client.""" + + @property + def commerce(self) -> "AsyncCommerce": + """Commerce MPT API Client.""" + return AsyncCommerce(http_client=self.http_client) + + +class MPTClientBase: + """MPT API Client Base.""" + + def __init__( + self, + base_url: str | None = None, + api_key: str | None = None, + http_client: HTTPClient | None = None, + ): + self.http_client = http_client or HTTPClient(base_url=base_url, api_token=api_key) + + +class MPTClient(MPTClientBase): + """MPT API Client.""" + + @property + def commerce(self) -> "Commerce": + """Commerce MPT API Client. + + The Commerce API provides a comprehensive set of endpoints + for managing agreements, requests, subscriptions, and orders + within a vendor-client-ops ecosystem. + """ + return Commerce(http_client=self.http_client) diff --git a/mpt_api_client/mptclient.py b/mpt_api_client/mptclient.py deleted file mode 100644 index e5a1617b..00000000 --- a/mpt_api_client/mptclient.py +++ /dev/null @@ -1,57 +0,0 @@ -from mpt_api_client.http.client import HTTPClient -from mpt_api_client.registry import Registry, commerce -from mpt_api_client.resources import OrderCollectionClientBase - - -class MPTClientBase: - """MPT API Client Base.""" - - def __init__( - self, - base_url: str | None = None, - api_key: str | None = None, - registry: Registry | None = None, - http_client: HTTPClient | None = None, - ): - self.http_client = http_client or HTTPClient(base_url=base_url, api_token=api_key) - self.registry: Registry = registry or Registry() - - def __getattr__(self, name): # type: ignore[no-untyped-def] - return self.registry.get(name)(http_client=self.http_client) - - -class MPTClient(MPTClientBase): - """MPT API Client.""" - - @property - def commerce(self) -> "CommerceMpt": - """Commerce MPT API Client. - - The Commerce API provides a comprehensive set of endpoints - for managing agreements, requests, subscriptions, and orders - within a vendor-client-ops ecosystem. - """ - return CommerceMpt(http_client=self.http_client, registry=commerce) - - -class CommerceMpt(MPTClientBase): - """Commerce MPT API Client.""" - - @property - def orders(self) -> OrderCollectionClientBase: - """Orders MPT API collection. - - The Orders API provides a comprehensive set of endpoints - for creating, updating, and retrieving orders. - - - - Returns: Order collection - - Examples: - active=RQLQuery("status=active") - for order in mpt.orders.filter(active).iterate(): - [...] - - """ - return self.registry.get("orders")(http_client=self.http_client) # type: ignore[return-value] diff --git a/mpt_api_client/registry.py b/mpt_api_client/registry.py deleted file mode 100644 index f7a3acf5..00000000 --- a/mpt_api_client/registry.py +++ /dev/null @@ -1,74 +0,0 @@ -from collections.abc import Callable -from typing import Any - -from mpt_api_client.http.collection import CollectionClientBase - -ItemType = type[CollectionClientBase[Any, Any]] - - -class Registry: - """Registry for MPT collection clients.""" - - def __init__(self) -> None: - self.items: dict[str, ItemType] = {} # noqa: WPS110 - - def __call__(self, keyname: str) -> Callable[[ItemType], ItemType]: - """Decorator to register a CollectionBaseClient class. - - Args: - keyname: The key to register the class under - - Returns: - The decorator function - - Examples: - registry = Registry() - @registry("orders") - class OrderCollectionClient(CollectionBaseClient): - _endpoint = "/api/v1/orders" - _resource_class = Order - - registry.get("orders") == OrderCollectionClient - """ - - def decorator(cls: ItemType) -> ItemType: - self.register(keyname, cls) - return cls - - return decorator - - def register(self, keyname: str, item: ItemType) -> None: # noqa: WPS110 - """Register a collection client class with a keyname. - - Args: - keyname: The key to register the client under - item: The collection client class to register - """ - self.items[keyname] = item - - def get(self, keyname: str) -> ItemType: - """Get a registered collection client class by keyname. - - Args: - keyname: The key to look up - - Returns: - The registered collection client class - - Raises: - KeyError: If keyname is not registered - """ - if keyname not in self.items: - raise KeyError(f"No collection client registered with keyname: {keyname}") - return self.items[keyname] - - def list_keys(self) -> list[str]: - """Get all registered keynames. - - Returns: - List of all registered keynames - """ - return list(self.items.keys()) - - -commerce = Registry() diff --git a/mpt_api_client/resources/__init__.py b/mpt_api_client/resources/__init__.py index 97ace29a..9d0d49e6 100644 --- a/mpt_api_client/resources/__init__.py +++ b/mpt_api_client/resources/__init__.py @@ -1,3 +1,3 @@ -from mpt_api_client.resources.order import Order, OrderCollectionClientBase, OrderResourceClient +from mpt_api_client.resources.commerce import AsyncCommerce, Commerce -__all__ = ["Order", "OrderCollectionClientBase", "OrderResourceClient"] # noqa: WPS410 +__all__ = ["AsyncCommerce", "Commerce"] # noqa: WPS410 diff --git a/mpt_api_client/resources/commerce/__init__.py b/mpt_api_client/resources/commerce/__init__.py new file mode 100644 index 00000000..d9922d60 --- /dev/null +++ b/mpt_api_client/resources/commerce/__init__.py @@ -0,0 +1,4 @@ +from mpt_api_client.resources.commerce.commerce import AsyncCommerce, Commerce +from mpt_api_client.resources.commerce.orders import AsyncOrdersService, OrdersService + +__all__ = ["AsyncCommerce", "AsyncOrdersService", "Commerce", "OrdersService"] # noqa: WPS410 diff --git a/mpt_api_client/resources/commerce/commerce.py b/mpt_api_client/resources/commerce/commerce.py new file mode 100644 index 00000000..fc56c6e6 --- /dev/null +++ b/mpt_api_client/resources/commerce/commerce.py @@ -0,0 +1,26 @@ +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.resources.commerce.orders import AsyncOrdersService, OrdersService + + +class Commerce: + """Commerce MPT API Module.""" + + def __init__(self, http_client: HTTPClient): + self.http_client = http_client + + @property + def orders(self) -> OrdersService: + """Order service.""" + return OrdersService(http_client=self.http_client) + + +class AsyncCommerce: + """Commerce MPT API Module.""" + + def __init__(self, http_client: AsyncHTTPClient): + self.http_client = http_client + + @property + def orders(self) -> AsyncOrdersService: + """Order service.""" + return AsyncOrdersService(http_client=self.http_client) diff --git a/mpt_api_client/resources/commerce/orders.py b/mpt_api_client/resources/commerce/orders.py new file mode 100644 index 00000000..a7cfd136 --- /dev/null +++ b/mpt_api_client/resources/commerce/orders.py @@ -0,0 +1,169 @@ +from mpt_api_client.http.service import AsyncServiceBase, SyncServiceBase +from mpt_api_client.models import Model, ResourceData + + +class Order(Model): + """Order resource.""" + + +class OrdersServiceConfig: + """Orders service config.""" + + _endpoint = "/public/v1/commerce/orders" + _model_class = Order + _collection_key = "data" + + +class OrdersService(SyncServiceBase[Order], OrdersServiceConfig): + """Orders client.""" + + def validate(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to validate state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + """ + return self._resource_action(resource_id, "POST", "validate", json=resource_data) + + def process(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to process state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + """ + return self._resource_action(resource_id, "POST", "process", json=resource_data) + + def query(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to query state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + """ + return self._resource_action(resource_id, "POST", "query", json=resource_data) + + def complete(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to complete state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + """ + return self._resource_action(resource_id, "POST", "complete", json=resource_data) + + def fail(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to fail state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + """ + return self._resource_action(resource_id, "POST", "fail", json=resource_data) + + def notify(self, resource_id: str, user: ResourceData) -> None: + """Notify user about order status. + + Args: + resource_id: Order resource ID + user: User data + """ + self._resource_do_request(resource_id, "POST", "notify", json=user) + + def template(self, resource_id: str) -> str: + """Render order template. + + Args: + resource_id: Order resource ID + + Returns: + Order template text in markdown format. + """ + response = self._resource_do_request(resource_id, "GET", "template") + return response.text + + +class AsyncOrdersService(AsyncServiceBase[Order], OrdersServiceConfig): + """Async Orders client.""" + + async def validate(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to validate state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + + Returns: + Updated order resource + """ + return await self._resource_action(resource_id, "POST", "validate", json=resource_data) + + async def process(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to process state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + + Returns: + Updated order resource + """ + return await self._resource_action(resource_id, "POST", "process", json=resource_data) + + async def query(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to query state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + + Returns: + Updated order resource + """ + return await self._resource_action(resource_id, "POST", "query", json=resource_data) + + async def complete(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to complete state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + + Returns: + Updated order resource + """ + return await self._resource_action(resource_id, "POST", "complete", json=resource_data) + + async def fail(self, resource_id: str, resource_data: ResourceData | None = None) -> Order: + """Switch order to fail state. + + Args: + resource_id: Order resource ID + resource_data: Order data will be updated + + Returns: + Updated order resource + """ + return await self._resource_action(resource_id, "POST", "fail", json=resource_data) + + async def notify(self, resource_id: str, resource_data: ResourceData) -> None: + """Notify user about order status. + + Args: + resource_id: Order resource ID + resource_data: User data to notify + """ + await self._resource_do_request(resource_id, "POST", "notify", json=resource_data) + + async def template(self, resource_id: str) -> str: + """Render order template. + + Args: + resource_id: Order resource ID + + Returns: + Order template text in markdown format. + """ + response = await self._resource_do_request(resource_id, "GET", "template") + return response.text diff --git a/mpt_api_client/resources/order.py b/mpt_api_client/resources/order.py deleted file mode 100644 index 79ba72f2..00000000 --- a/mpt_api_client/resources/order.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Any - -from mpt_api_client.http.collection import CollectionClientBase -from mpt_api_client.http.resource import ResourceBaseClient -from mpt_api_client.models import Collection, Resource -from mpt_api_client.registry import commerce - - -class Order(Resource): - """Order resource.""" - - -class OrderResourceClient(ResourceBaseClient[Order]): - """Order resource client.""" - - _endpoint = "/public/v1/commerce/orders" - _resource_class = Order - - def validate(self, order: dict[str, Any] | None = None) -> Order: - """Switch order to validate state. - - Args: - order: Order data will be updated - """ - response = self.do_action("POST", "validate", json=order) - return self._resource_class.from_response(response) - - def process(self, order: dict[str, Any] | None = None) -> Order: - """Switch order to process state. - - Args: - order: Order data will be updated - """ - return self.resource_action("POST", "process", json=order) - - def query(self, order: dict[str, Any] | None = None) -> Order: - """Switch order to query state. - - Args: - order: Order data will be updated - """ - return self.resource_action("POST", "query", json=order) - - def complete(self, order: dict[str, Any] | None = None) -> Order: - """Switch order to complete state. - - Args: - order: Order data will be updated - """ - return self.resource_action("POST", "complete", json=order) - - def fail(self, order: dict[str, Any] | None = None) -> Order: - """Switch order to fail state. - - Args: - order: Order data will be updated - """ - return self.resource_action("POST", "fail", json=order) - - def notify(self, user: dict[str, Any]) -> None: - """Notify user about order status. - - Args: - user: User data - """ - self.do_action("POST", "notify", json=user) - - def template(self) -> str: - """Render order template. - - Returns: - Order template text in markdown format. - """ - response = self.do_action("GET", "template") - return response.text - - -@commerce("orders") -class OrderCollectionClientBase(CollectionClientBase[Order, OrderResourceClient]): - """Orders client.""" - - _endpoint = "/public/v1/commerce/orders" - _resource_class = Order - _resource_client_class = OrderResourceClient - _collection_class = Collection[Order] diff --git a/setup.cfg b/setup.cfg index 9e8f2fd8..e4123612 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,21 +33,27 @@ extend-ignore = per-file-ignores = mpt_api_client/rql/query_builder.py: - # Forbid blacklisted variable names + # Forbid blacklisted variable names. WPS110 - # Found `noqa` comments overuse + # Found `noqa` comments overuse. WPS402 tests/http/collection/test_collection_client_iterate.py: # Found too many module members WPS202 tests/http/collection/test_collection_client_fetch.py: - # Found too many module members + # Found too many module members. WPS202 - # Found magic number + # Found magic number. WPS432 + mpt_api_client/http/service.py: + WPS214 + # Found too many methods + mpt_api_client/resources/commerce/order.py: + WPS110 + # Found wrong variable name tests/*: - # Allow magic strings + # Allow magic strings. WPS432 - # Found too many modules members + # Found too many modules members. WPS202 diff --git a/tests/conftest.py b/tests/conftest.py index 6d7c6c7e..8f507ee5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,23 @@ import pytest -from mpt_api_client.http.client import HTTPClient -from mpt_api_client.models import Resource +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.models import Model API_TOKEN = "test-token" API_URL = "https://api.example.com" -class DummyResource(Resource): +class DummyModel(Model): """Dummy resource for testing.""" - _data_key = "data" + _data_key = None @pytest.fixture -def mpt_client(): +def http_client(): return HTTPClient(base_url=API_URL, api_token=API_TOKEN) + + +@pytest.fixture +def async_http_client(): + return AsyncHTTPClient(base_url=API_URL, api_token=API_TOKEN) diff --git a/tests/http/collection/test_async_collection_client_create.py b/tests/http/collection/test_async_collection_client_create.py deleted file mode 100644 index 04273499..00000000 --- a/tests/http/collection/test_async_collection_client_create.py +++ /dev/null @@ -1,26 +0,0 @@ -import json - -import httpx -import pytest -import respx - - -@pytest.mark.asyncio -async def test_create_resource(async_collection_client): # noqa: WPS210 - resource_data = {"name": "Test Resource", "status": "active"} - new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} - create_response = httpx.Response(201, json={"data": new_resource_data}) - - with respx.mock: - mock_route = respx.post("https://api.example.com/api/v1/test").mock( - return_value=create_response - ) - - created_resource = await async_collection_client.create(resource_data) - - assert created_resource.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/http/collection/test_async_collection_client_init.py b/tests/http/collection/test_async_collection_client_init.py deleted file mode 100644 index 81e90ce5..00000000 --- a/tests/http/collection/test_async_collection_client_init.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest - -from mpt_api_client.http.client import HTTPClientAsync -from mpt_api_client.rql.query_builder import RQLQuery -from tests.http.conftest import DummyAsyncCollectionClientBase - - -@pytest.fixture -def mock_mpt_client_async(api_url, api_token): - return HTTPClientAsync(base_url=api_url, api_token=api_token) - - -@pytest.fixture -def sample_rql_query(): - return RQLQuery(status="active") - - -def test_init_defaults(async_collection_client): - assert async_collection_client.query_rql is None - assert async_collection_client.query_order_by is None - assert async_collection_client.query_select is None - assert async_collection_client.build_url() == "/api/v1/test" - - -def test_init_with_filter(http_client_async, sample_rql_query): - collection_client = DummyAsyncCollectionClientBase( - http_client=http_client_async, - query_rql=sample_rql_query, - ) - - assert collection_client.query_rql == sample_rql_query - assert collection_client.query_order_by is None - assert collection_client.query_select is None - assert collection_client.build_url() == "/api/v1/test?eq(status,active)" diff --git a/tests/http/collection/test_collection_client_create.py b/tests/http/collection/test_collection_client_create.py deleted file mode 100644 index ca207e2a..00000000 --- a/tests/http/collection/test_collection_client_create.py +++ /dev/null @@ -1,24 +0,0 @@ -import json - -import httpx -import respx - - -def test_create_resource(collection_client): # noqa: WPS210 - resource_data = {"name": "Test Resource", "status": "active"} - new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} - create_response = httpx.Response(201, json={"data": new_resource_data}) - - with respx.mock: - mock_route = respx.post("https://api.example.com/api/v1/test").mock( - return_value=create_response - ) - - created_resource = collection_client.create(resource_data) - - assert created_resource.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/http/collection/test_collection_get.py b/tests/http/collection/test_collection_get.py deleted file mode 100644 index db5076be..00000000 --- a/tests/http/collection/test_collection_get.py +++ /dev/null @@ -1,4 +0,0 @@ -def test_get(collection_client): - resource = collection_client.get("RES-123") - assert resource.resource_id_ == "RES-123" - assert isinstance(resource, collection_client._resource_client_class) # noqa: SLF001 diff --git a/tests/http/conftest.py b/tests/http/conftest.py index 58178f1c..94fb7812 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -1,61 +1,24 @@ import pytest -from mpt_api_client.http.client import HTTPClient, HTTPClientAsync -from mpt_api_client.http.collection import AsyncCollectionClientBase, CollectionClientBase -from mpt_api_client.http.resource import ResourceBaseClient -from mpt_api_client.models import Collection -from tests.conftest import DummyResource +from mpt_api_client.http.service import AsyncServiceBase, SyncServiceBase +from tests.conftest import DummyModel -class DummyResourceClient(ResourceBaseClient[DummyResource]): - _endpoint = "/api/v1/test-resource" - _resource_class = DummyResource - - -class DummyCollectionClientBase(CollectionClientBase[DummyResource, DummyResourceClient]): +class DummyService(SyncServiceBase[DummyModel]): _endpoint = "/api/v1/test" - _resource_class = DummyResource - _resource_client_class = DummyResourceClient - _collection_class = Collection[DummyResource] + _model_class = DummyModel -class DummyAsyncCollectionClientBase(AsyncCollectionClientBase[DummyResource, DummyResourceClient]): +class AsyncDummyService(AsyncServiceBase[DummyModel]): _endpoint = "/api/v1/test" - _resource_class = DummyResource - _resource_client_class = DummyResourceClient - _collection_class = Collection[DummyResource] - - -@pytest.fixture -def api_url(): - return "https://api.example.com" - - -@pytest.fixture -def api_token(): - return "test-token" - - -@pytest.fixture -def http_client(api_url, api_token): - return HTTPClient(base_url=api_url, api_token=api_token) - - -@pytest.fixture -def http_client_async(api_url, api_token): - return HTTPClientAsync(base_url=api_url, api_token=api_token) - - -@pytest.fixture -def resource_client(http_client): - return DummyResourceClient(http_client=http_client, resource_id="RES-123") + _model_class = DummyModel @pytest.fixture -def collection_client(http_client) -> DummyCollectionClientBase: - return DummyCollectionClientBase(http_client=http_client) +def dummy_service(http_client) -> DummyService: + return DummyService(http_client=http_client) @pytest.fixture -def async_collection_client(http_client_async) -> DummyAsyncCollectionClientBase: - return DummyAsyncCollectionClientBase(http_client=http_client_async) +def async_dummy_service(async_http_client) -> AsyncDummyService: + return AsyncDummyService(http_client=async_http_client) diff --git a/tests/http/resource/test_resource_client_fetch.py b/tests/http/resource/test_resource_client_fetch.py deleted file mode 100644 index 646dbbde..00000000 --- a/tests/http/resource/test_resource_client_fetch.py +++ /dev/null @@ -1,114 +0,0 @@ -import httpx -import pytest -import respx - - -def test_fetch_success(resource_client): - expected_response = httpx.Response( - httpx.codes.OK, - json={"data": {"id": "RES-123", "name": "Test Resource", "status": "active"}}, - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - resource = resource_client.fetch() - - assert resource.to_dict() == {"id": "RES-123", "name": "Test Resource", "status": "active"} - assert mock_route.called - assert mock_route.call_count == 1 - assert resource_client.resource_ is not None - - -def test_get_attribute(resource_client): - expected_response = httpx.Response( - httpx.codes.OK, - json={"data": {"id": "RES-123", "contact": {"name": "Albert"}, "status": "active"}}, - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - assert resource_client.id == "RES-123" - assert resource_client.contact.name == "Albert" - assert mock_route.call_count == 1 - - -def test_set_attribute(resource_client): - expected_response = httpx.Response( - httpx.codes.OK, - json={"data": {"id": "RES-123", "contact": {"name": "Albert"}, "status": "active"}}, - ) - - with respx.mock: - respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - resource_client.status = "disabled" - resource_client.contact.name = "Alice" - - assert resource_client.status == "disabled" - assert resource_client.contact.name == "Alice" - - -def test_fetch_not_found(resource_client): - error_response = httpx.Response(httpx.codes.NOT_FOUND, json={"error": "Resource not found"}) - - with respx.mock: - respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=error_response - ) - - with pytest.raises(httpx.HTTPStatusError): - resource_client.fetch() - - -def test_fetch_server_error(resource_client): - error_response = httpx.Response( - httpx.codes.INTERNAL_SERVER_ERROR, json={"error": "Internal server error"} - ) - - with respx.mock: - respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=error_response - ) - - with pytest.raises(httpx.HTTPStatusError): - resource_client.fetch() - - -def test_fetch_with_special_characters_in_id(resource_client): - expected_response = httpx.Response( - httpx.codes.OK, json={"data": {"id": "RES-123", "name": "Special Resource"}} - ) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - resource = resource_client.fetch() - - assert resource.to_dict() == {"id": "RES-123", "name": "Special Resource"} - assert mock_route.called - - -def test_fetch_verifies_correct_url_construction(resource_client): - expected_response = httpx.Response(httpx.codes.OK, json={"data": {"id": "RES-123"}}) - - with respx.mock: - mock_route = respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - resource_client.fetch() - - request = mock_route.calls[0].request - - assert request.method == "GET" - assert str(request.url) == "https://api.example.com/api/v1/test-resource/RES-123" diff --git a/tests/http/resource/test_resource_client_update.py b/tests/http/resource/test_resource_client_update.py deleted file mode 100644 index 97524926..00000000 --- a/tests/http/resource/test_resource_client_update.py +++ /dev/null @@ -1,85 +0,0 @@ -import httpx -import pytest -import respx - - -def test_update_resource_successfully(resource_client): - update_data = {"name": "Updated Resource Name", "status": "modified", "version": 2} - expected_response = httpx.Response( - httpx.codes.OK, - json={ - "data": { - "id": "RES-123", - "name": "Updated Resource Name", - "status": "modified", - "version": 2, - } - }, - ) - - with respx.mock: - mock_route = respx.put("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=expected_response - ) - - resource = resource_client.update(update_data) - - assert resource.to_dict() == { - "id": "RES-123", - "name": "Updated Resource Name", - "status": "modified", - "version": 2, - } - assert mock_route.called - assert mock_route.call_count == 1 - - -def test_save_resource_successfully(resource_client): - fetch_response = httpx.Response( - httpx.codes.OK, - json={"data": {"id": "RES-123", "name": "Original Name", "status": "active"}}, - ) - save_response = httpx.Response( - httpx.codes.OK, - json={"data": {"id": "RES-123", "name": "Modified Name", "status": "active"}}, - ) - - with respx.mock: - respx.get("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=fetch_response - ) - mock_put_route = respx.put("https://api.example.com/api/v1/test-resource/RES-123").mock( - return_value=save_response - ) - - resource_client.fetch() - resource_client.name = "Modified Name" - resource_client.save() - - assert resource_client.resource_.to_dict() == { - "id": "RES-123", - "name": "Modified Name", - "status": "active", - } - assert mock_put_route.called - assert mock_put_route.call_count == 1 - - -def test_save_raises_error_when_resource_not_set(resource_client): - with pytest.raises(ValueError, match="Unable to save resource that has not been set"): - resource_client.save() - - -def test_delete_resource_successfully(resource_client): - delete_response = httpx.Response(httpx.codes.NO_CONTENT) - - with respx.mock: - mock_delete_route = respx.delete( - "https://api.example.com/api/v1/test-resource/RES-123" - ).mock(return_value=delete_response) - - resource_client.delete() - - assert resource_client.resource_ is None - assert mock_delete_route.called - assert mock_delete_route.call_count == 1 diff --git a/tests/http/collection/conftest.py b/tests/http/service/conftest.py similarity index 100% rename from tests/http/collection/conftest.py rename to tests/http/service/conftest.py diff --git a/tests/http/collection/test_async_collection_client_fetch.py b/tests/http/service/test_async_service_fetch.py similarity index 80% rename from tests/http/collection/test_async_collection_client_fetch.py rename to tests/http/service/test_async_service_fetch.py index 3bcce0d7..60e01853 100644 --- a/tests/http/collection/test_async_collection_client_fetch.py +++ b/tests/http/service/test_async_service_fetch.py @@ -40,24 +40,19 @@ def multiple_results_response(): ) -@pytest.fixture -def no_meta_response(): - return httpx.Response(httpx.codes.OK, json={"data": [{"id": "ID-1"}]}) - - @pytest.fixture def filter_status_active(): return RQLQuery(status="active") @pytest.mark.asyncio -async def test_fetch_one_success(async_collection_client, single_result_response): +async def test_fetch_one_success(async_dummy_service, single_result_response): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( return_value=single_result_response ) - resource = await async_collection_client.fetch_one() + resource = await async_dummy_service.fetch_one() assert resource.id == "ID-1" assert resource.name == "Test Resource" @@ -69,33 +64,31 @@ async def test_fetch_one_success(async_collection_client, single_result_response @pytest.mark.asyncio -async def test_fetch_one_no_results(async_collection_client, no_results_response): +async def test_fetch_one_no_results(async_dummy_service, no_results_response): 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_collection_client.fetch_one() + await async_dummy_service.fetch_one() @pytest.mark.asyncio -async def test_fetch_one_multiple_results(async_collection_client, multiple_results_response): +async def test_fetch_one_multiple_results(async_dummy_service, multiple_results_response): 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_collection_client.fetch_one() + await async_dummy_service.fetch_one() @pytest.mark.asyncio async def test_fetch_one_with_filters( - async_collection_client, single_result_response, filter_status_active + async_dummy_service, single_result_response, filter_status_active ): filtered_collection = ( - async_collection_client.filter(filter_status_active) - .select("id", "name") - .order_by("created") + async_dummy_service.filter(filter_status_active).select("id", "name").order_by("created") ) with respx.mock: @@ -118,10 +111,10 @@ async def test_fetch_one_with_filters( @pytest.mark.asyncio async def test_fetch_page_with_filter( - async_collection_client, list_response, filter_status_active + async_dummy_service, list_response, filter_status_active ) -> None: custom_collection = ( - async_collection_client.filter(filter_status_active) + async_dummy_service.filter(filter_status_active) .select("-audit", "product.agreements", "-product.agreements.product") .order_by("-created", "name") ) diff --git a/tests/http/service/test_async_service_init.py b/tests/http/service/test_async_service_init.py new file mode 100644 index 00000000..1995609f --- /dev/null +++ b/tests/http/service/test_async_service_init.py @@ -0,0 +1,28 @@ +import pytest + +from mpt_api_client.rql.query_builder import RQLQuery +from tests.http.conftest import AsyncDummyService + + +@pytest.fixture +def sample_rql_query(): + return RQLQuery(status="active") + + +def test_init_defaults(async_dummy_service): + assert async_dummy_service.query_rql is None + assert async_dummy_service.query_order_by is None + assert async_dummy_service.query_select is None + assert async_dummy_service.build_url() == "/api/v1/test" + + +def test_init_with_filter(async_http_client, sample_rql_query): + collection_client = AsyncDummyService( + http_client=async_http_client, + query_rql=sample_rql_query, + ) + + assert collection_client.query_rql == sample_rql_query + assert collection_client.query_order_by is None + assert collection_client.query_select is None + assert collection_client.build_url() == "/api/v1/test?eq(status,active)" diff --git a/tests/http/collection/test_async_collection_client_iterate.py b/tests/http/service/test_async_service_iterate.py similarity index 79% rename from tests/http/collection/test_async_collection_client_iterate.py rename to tests/http/service/test_async_service_iterate.py index 88024bf9..658fd64b 100644 --- a/tests/http/collection/test_async_collection_client_iterate.py +++ b/tests/http/service/test_async_service_iterate.py @@ -6,13 +6,13 @@ @pytest.mark.asyncio -async def test_iterate_single_page(async_collection_client, single_page_response): +async def test_iterate_single_page(async_dummy_service, single_page_response): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( return_value=single_page_response ) - resources = [resource async for resource in async_collection_client.iterate()] + resources = [resource async for resource in async_dummy_service.iterate()] request = mock_route.calls[0].request @@ -25,7 +25,7 @@ async def test_iterate_single_page(async_collection_client, single_page_response @pytest.mark.asyncio async def test_iterate_multiple_pages( - async_collection_client, multi_page_response_page1, multi_page_response_page2 + async_dummy_service, multi_page_response_page1, multi_page_response_page2 ): with respx.mock: respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( @@ -35,7 +35,7 @@ async def test_iterate_multiple_pages( return_value=multi_page_response_page2 ) - resources = [resource async for resource in async_collection_client.iterate(2)] + resources = [resource async for resource in async_dummy_service.iterate(2)] assert len(resources) == 4 assert resources[0].id == "ID-1" @@ -45,26 +45,26 @@ async def test_iterate_multiple_pages( @pytest.mark.asyncio -async def test_iterate_empty_results(async_collection_client, empty_response): +async def test_iterate_empty_results(async_dummy_service, empty_response): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( return_value=empty_response ) - resources = [resource async for resource in async_collection_client.iterate()] + resources = [resource async for resource in async_dummy_service.iterate()] assert len(resources) == 0 assert mock_route.call_count == 1 @pytest.mark.asyncio -async def test_iterate_no_meta(async_collection_client, no_meta_response): +async def test_iterate_no_meta(async_dummy_service, no_meta_response): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( return_value=no_meta_response ) - resources = [resource async for resource in async_collection_client.iterate()] + resources = [resource async for resource in async_dummy_service.iterate()] assert len(resources) == 2 assert resources[0].id == "ID-1" @@ -73,9 +73,9 @@ async def test_iterate_no_meta(async_collection_client, no_meta_response): @pytest.mark.asyncio -async def test_iterate_with_filters(async_collection_client): +async def test_iterate_with_filters(async_dummy_service): filtered_collection = ( - async_collection_client.filter(RQLQuery(status="active")) + async_dummy_service.filter(RQLQuery(status="active")) .select("id", "name") .order_by("created") ) @@ -111,7 +111,7 @@ async def test_iterate_with_filters(async_collection_client): @pytest.mark.asyncio -async def test_iterate_lazy_evaluation(async_collection_client): +async def test_iterate_lazy_evaluation(async_dummy_service): response = httpx.Response( httpx.codes.OK, json={ @@ -129,7 +129,7 @@ async def test_iterate_lazy_evaluation(async_collection_client): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) - iterator = async_collection_client.iterate() + iterator = async_dummy_service.iterate() # No requests should be made until we start iterating assert mock_route.call_count == 0 diff --git a/tests/http/service/test_async_service_resource.py b/tests/http/service/test_async_service_resource.py new file mode 100644 index 00000000..35c30dcb --- /dev/null +++ b/tests/http/service/test_async_service_resource.py @@ -0,0 +1,70 @@ +import json + +import httpx +import pytest +import respx + +from tests.conftest import DummyModel + + +@pytest.mark.asyncio +async def test_create_resource(async_dummy_service): # noqa: WPS210 + resource_data = {"name": "Test Resource", "status": "active"} + new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} + create_response = httpx.Response(201, json=new_resource_data) + + with respx.mock: + mock_route = respx.post("https://api.example.com/api/v1/test").mock( + return_value=create_response + ) + + created_resource = await async_dummy_service.create(resource_data) + + assert created_resource.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_delete_resource(async_dummy_service): # noqa: WPS210 + delete_response = httpx.Response(204, 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") + + assert mock_route.call_count == 1 + + +async def test_update_resource(async_dummy_service): # noqa: WPS210 + resource_data = {"name": "Test Resource", "status": "active"} + update_response = httpx.Response(200, 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 + + +@pytest.mark.asyncio +async def test_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) + ) + + resource = await async_dummy_service.get("RES-123") + assert isinstance(resource, DummyModel) + assert resource.to_dict() == resource_data diff --git a/tests/http/collection/test_collection_client_fetch.py b/tests/http/service/test_service_fetch.py similarity index 74% rename from tests/http/collection/test_collection_client_fetch.py rename to tests/http/service/test_service_fetch.py index 93d8e1aa..a93cf36c 100644 --- a/tests/http/collection/test_collection_client_fetch.py +++ b/tests/http/service/test_service_fetch.py @@ -3,6 +3,7 @@ import respx from mpt_api_client.rql import RQLQuery +from tests.conftest import DummyModel @pytest.fixture @@ -40,23 +41,18 @@ def multiple_results_response(): ) -@pytest.fixture -def no_meta_response(): - return httpx.Response(httpx.codes.OK, json={"data": [{"id": "ID-1"}]}) - - @pytest.fixture def filter_status_active(): return RQLQuery(status="active") -def test_fetch_one_success(collection_client, single_result_response): +def test_fetch_one_success(dummy_service, single_result_response): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( return_value=single_result_response ) - resource = collection_client.fetch_one() + resource = dummy_service.fetch_one() assert resource.id == "ID-1" assert resource.name == "Test Resource" @@ -67,27 +63,27 @@ def test_fetch_one_success(collection_client, single_result_response): assert "offset=0" in str(first_request.url) -def test_fetch_one_no_results(collection_client, no_results_response): +def test_fetch_one_no_results(dummy_service, no_results_response): 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"): - collection_client.fetch_one() + dummy_service.fetch_one() -def test_fetch_one_multiple_results(collection_client, multiple_results_response): +def test_fetch_one_multiple_results(dummy_service, multiple_results_response): 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"): - collection_client.fetch_one() + dummy_service.fetch_one() -def test_fetch_one_with_filters(collection_client, single_result_response, filter_status_active): +def test_fetch_one_with_filters(dummy_service, single_result_response, filter_status_active): filtered_collection = ( - collection_client.filter(filter_status_active).select("id", "name").order_by("created") + dummy_service.filter(filter_status_active).select("id", "name").order_by("created") ) with respx.mock: @@ -108,9 +104,9 @@ def test_fetch_one_with_filters(collection_client, single_result_response, filte ) -def test_fetch_page_with_filter(collection_client, list_response, filter_status_active) -> None: +def test_fetch_page_with_filter(dummy_service, list_response, filter_status_active) -> None: custom_collection = ( - collection_client.filter(filter_status_active) + dummy_service.filter(filter_status_active) .select("-audit", "product.agreements", "-product.agreements.product") .order_by("-created", "name") ) @@ -133,3 +129,15 @@ def test_fetch_page_with_filter(collection_client, list_response, filter_status_ request = mock_route.calls[0].request assert request.method == "GET" assert request.url == expected_url + + +def test_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) + ) + + resource = dummy_service.get("RES-123") + assert isinstance(resource, DummyModel) + assert resource.to_dict() == resource_data diff --git a/tests/http/collection/test_collection_client_init.py b/tests/http/service/test_service_init.py similarity index 68% rename from tests/http/collection/test_collection_client_init.py rename to tests/http/service/test_service_init.py index 5003a0c4..81220de2 100644 --- a/tests/http/collection/test_collection_client_init.py +++ b/tests/http/service/test_service_init.py @@ -1,13 +1,7 @@ import pytest -from mpt_api_client.http.client import HTTPClient from mpt_api_client.rql.query_builder import RQLQuery -from tests.http.conftest import DummyCollectionClientBase - - -@pytest.fixture -def mock_mpt_client(api_url, api_token): - return HTTPClient(base_url=api_url, api_token=api_token) +from tests.http.conftest import DummyService @pytest.fixture @@ -16,7 +10,7 @@ def sample_rql_query(): def test_init_defaults(http_client): - collection_client = DummyCollectionClientBase(http_client=http_client) + collection_client = DummyService(http_client=http_client) assert collection_client.query_rql is None assert collection_client.query_order_by is None @@ -25,7 +19,7 @@ def test_init_defaults(http_client): def test_init_with_filter(http_client, sample_rql_query): - collection_client = DummyCollectionClientBase( + collection_client = DummyService( http_client=http_client, query_rql=sample_rql_query, ) diff --git a/tests/http/collection/test_collection_client_iterate.py b/tests/http/service/test_service_iterate.py similarity index 80% rename from tests/http/collection/test_collection_client_iterate.py rename to tests/http/service/test_service_iterate.py index 2ce02117..9f3116ef 100644 --- a/tests/http/collection/test_collection_client_iterate.py +++ b/tests/http/service/test_service_iterate.py @@ -5,13 +5,13 @@ from mpt_api_client.rql import RQLQuery -def test_iterate_single_page(collection_client, single_page_response): +def test_iterate_single_page(dummy_service, single_page_response): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( return_value=single_page_response ) - resources = list(collection_client.iterate()) + resources = list(dummy_service.iterate()) request = mock_route.calls[0].request assert len(resources) == 2 @@ -22,7 +22,7 @@ def test_iterate_single_page(collection_client, single_page_response): def test_iterate_multiple_pages( - collection_client, multi_page_response_page1, multi_page_response_page2 + dummy_service, multi_page_response_page1, multi_page_response_page2 ): with respx.mock: respx.get("https://api.example.com/api/v1/test", params={"limit": 2, "offset": 0}).mock( @@ -32,7 +32,7 @@ def test_iterate_multiple_pages( return_value=multi_page_response_page2 ) - resources = list(collection_client.iterate(2)) + resources = list(dummy_service.iterate(2)) assert len(resources) == 4 assert resources[0].id == "ID-1" @@ -41,25 +41,25 @@ def test_iterate_multiple_pages( assert resources[3].id == "ID-4" -def test_iterate_empty_results(collection_client, empty_response): +def test_iterate_empty_results(dummy_service, empty_response): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( return_value=empty_response ) - resources = list(collection_client.iterate(2)) + resources = list(dummy_service.iterate(2)) assert len(resources) == 0 assert mock_route.call_count == 1 -def test_iterate_no_meta(collection_client, no_meta_response): +def test_iterate_no_meta(dummy_service, no_meta_response): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( return_value=no_meta_response ) - resources = list(collection_client.iterate()) + resources = list(dummy_service.iterate()) assert len(resources) == 2 assert resources[0].id == "ID-1" @@ -67,9 +67,9 @@ def test_iterate_no_meta(collection_client, no_meta_response): assert mock_route.call_count == 1 -def test_iterate_with_filters(collection_client): +def test_iterate_with_filters(dummy_service): filtered_collection = ( - collection_client.filter(RQLQuery(status="active")).select("id", "name").order_by("created") + dummy_service.filter(RQLQuery(status="active")).select("id", "name").order_by("created") ) response = httpx.Response( @@ -102,7 +102,7 @@ def test_iterate_with_filters(collection_client): ) -def test_iterate_lazy_evaluation(collection_client): +def test_iterate_lazy_evaluation(dummy_service): response = httpx.Response( httpx.codes.OK, json={ @@ -120,7 +120,7 @@ def test_iterate_lazy_evaluation(collection_client): with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock(return_value=response) - iterator = collection_client.iterate() + iterator = dummy_service.iterate() assert mock_route.call_count == 0 @@ -130,7 +130,7 @@ def test_iterate_lazy_evaluation(collection_client): assert first_resource.id == "ID-1" -def test_iterate_handles_api_errors(collection_client): +def test_iterate_handles_api_errors(dummy_service): with respx.mock: respx.get("https://api.example.com/api/v1/test").mock( return_value=httpx.Response( @@ -138,7 +138,7 @@ def test_iterate_handles_api_errors(collection_client): ) ) - iterator = collection_client.iterate() + iterator = dummy_service.iterate() with pytest.raises(httpx.HTTPStatusError): list(iterator) diff --git a/tests/http/service/test_service_resource.py b/tests/http/service/test_service_resource.py new file mode 100644 index 00000000..a6fc21d4 --- /dev/null +++ b/tests/http/service/test_service_resource.py @@ -0,0 +1,51 @@ +import json + +import httpx +import respx + + +def test_create_resource(dummy_service): # noqa: WPS210 + resource_data = {"name": "Test Resource", "status": "active"} + new_resource_data = {"id": "new-resource-id", "name": "Test Resource", "status": "active"} + create_response = httpx.Response(201, json=new_resource_data) + + with respx.mock: + mock_route = respx.post("https://api.example.com/api/v1/test").mock( + return_value=create_response + ) + + created_resource = dummy_service.create(resource_data) + + assert created_resource.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_delete_resource(dummy_service): + delete_response = httpx.Response(204, 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") + + assert mock_route.call_count == 1 + + +def test_update_resource(dummy_service): + resource_data = {"name": "Test Resource", "status": "active"} + update_response = httpx.Response(200, 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/collection/test_collection_mixin.py b/tests/http/service/test_service_shared.py similarity index 50% rename from tests/http/collection/test_collection_mixin.py rename to tests/http/service/test_service_shared.py index b9529de1..a713c886 100644 --- a/tests/http/collection/test_collection_mixin.py +++ b/tests/http/service/test_service_shared.py @@ -3,65 +3,65 @@ from mpt_api_client.rql.query_builder import RQLQuery -def test_filter(collection_client): +def test_filter(dummy_service): filter_query = RQLQuery(status="active") - new_collection = collection_client.filter(filter_query) + new_collection = dummy_service.filter(filter_query) - assert collection_client.query_rql is None - assert new_collection != collection_client + assert dummy_service.query_rql is None + assert new_collection != dummy_service assert new_collection.query_rql == filter_query -def test_multiple_filters(collection_client) -> None: +def test_multiple_filters(dummy_service) -> None: filter_query = RQLQuery(status="active") filter_query2 = RQLQuery(name="test") - new_collection = collection_client.filter(filter_query).filter(filter_query2) + new_collection = dummy_service.filter(filter_query).filter(filter_query2) - assert collection_client.query_rql is None + assert dummy_service.query_rql is None assert new_collection.query_rql == filter_query & filter_query2 -def test_select(collection_client) -> None: - new_collection = collection_client.select("agreement", "-product") +def test_select(dummy_service) -> None: + new_collection = dummy_service.select("agreement", "-product") - assert collection_client.query_select is None - assert new_collection != collection_client + assert dummy_service.query_select is None + assert new_collection != dummy_service assert new_collection.query_select == ["agreement", "-product"] -def test_select_exception(collection_client) -> None: +def test_select_exception(dummy_service) -> None: with pytest.raises(ValueError): - collection_client.select("agreement").select("product") + dummy_service.select("agreement").select("product") -def test_order_by(collection_client): - new_collection = collection_client.order_by("created", "-name") +def test_order_by(dummy_service): + new_collection = dummy_service.order_by("created", "-name") - assert collection_client.query_order_by is None - assert new_collection != collection_client + assert dummy_service.query_order_by is None + assert new_collection != dummy_service assert new_collection.query_order_by == ["created", "-name"] -def test_order_by_exception(collection_client): +def test_order_by_exception(dummy_service): with pytest.raises( ValueError, match=r"Ordering is already set. Cannot set ordering multiple times." ): - collection_client.order_by("created").order_by("name") + dummy_service.order_by("created").order_by("name") -def test_url(collection_client) -> None: +def test_url(dummy_service) -> None: filter_query = RQLQuery(status="active") custom_collection = ( - collection_client.filter(filter_query) + dummy_service.filter(filter_query) .select("-audit", "product.agreements", "-product.agreements.product") .order_by("-created", "name") ) url = custom_collection.build_url() - assert custom_collection != collection_client + assert custom_collection != dummy_service assert url == ( "/api/v1/test?order=-created,name" "&select=-audit,product.agreements,-product.agreements.product" @@ -69,14 +69,14 @@ def test_url(collection_client) -> None: ) -def test_clone(collection_client) -> None: +def test_clone(dummy_service) -> None: configured = ( - collection_client.filter(RQLQuery(status="active")) + dummy_service.filter(RQLQuery(status="active")) .order_by("created", "-name") .select("agreement", "-product") ) - cloned = configured.clone(configured) + cloned = configured.clone() assert cloned is not configured assert isinstance(cloned, configured.__class__) diff --git a/tests/http/test_async_client.py b/tests/http/test_async_client.py deleted file mode 100644 index c320e79b..00000000 --- a/tests/http/test_async_client.py +++ /dev/null @@ -1,57 +0,0 @@ -import pytest -import respx -from httpx import ConnectTimeout, Response, codes - -from mpt_api_client.http.client import HTTPClientAsync -from tests.conftest import API_TOKEN, API_URL - - -def test_mpt_client_initialization(): - client = HTTPClientAsync(base_url=API_URL, api_token=API_TOKEN) - - assert client.base_url == API_URL - assert client.headers["Authorization"] == "Bearer test-token" - assert client.headers["User-Agent"] == "swo-marketplace-client/1.0" - - -def test_env_initialization(monkeypatch): - monkeypatch.setenv("MPT_TOKEN", API_TOKEN) - monkeypatch.setenv("MPT_URL", API_URL) - - client = HTTPClientAsync() - - assert client.base_url == API_URL - assert client.headers["Authorization"] == f"Bearer {API_TOKEN}" - - -def test_mpt_client_without_token(): - with pytest.raises(ValueError): - HTTPClientAsync(base_url=API_URL) - - -def test_mpt_client_without_url(): - with pytest.raises(ValueError): - HTTPClientAsync(api_token=API_TOKEN) - - -@respx.mock -async def test_mock_call_success(http_client_async): - success_route = respx.get(f"{API_URL}/").mock( - return_value=Response(200, json={"message": "Hello, World!"}) - ) - - success_response = await http_client_async.get("/") - - assert success_response.status_code == codes.OK - assert success_response.json() == {"message": "Hello, World!"} - assert success_route.called - - -@respx.mock -async def test_mock_call_failure(http_client_async): - timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout")) - - with pytest.raises(ConnectTimeout): - await http_client_async.get("/timeout") - - assert timeout_route.called diff --git a/tests/http/test_client.py b/tests/http/test_client.py index d3885232..cd8d5dc3 100644 --- a/tests/http/test_client.py +++ b/tests/http/test_client.py @@ -2,11 +2,11 @@ import respx from httpx import ConnectTimeout, Response, codes -from mpt_api_client.http.client import HTTPClient +from mpt_api_client.http.client import AsyncHTTPClient, HTTPClient from tests.conftest import API_TOKEN, API_URL -def test_mpt_client_initialization(): +def test_http_initialization(): client = HTTPClient(base_url=API_URL, api_token=API_TOKEN) assert client.base_url == API_URL @@ -24,18 +24,18 @@ def test_env_initialization(monkeypatch): assert client.headers["Authorization"] == f"Bearer {API_TOKEN}" -def test_mpt_client_without_token(): +def test_http_without_token(): with pytest.raises(ValueError): HTTPClient(base_url=API_URL) -def test_mpt_client_without_url(): +def test_http_without_url(): with pytest.raises(ValueError): HTTPClient(api_token=API_TOKEN) @respx.mock -def test_mock_call_success(http_client): +def test_http_call_success(http_client): success_route = respx.get(f"{API_URL}/").mock( return_value=Response(200, json={"message": "Hello, World!"}) ) @@ -48,10 +48,61 @@ def test_mock_call_success(http_client): @respx.mock -def test_mock_call_failure(http_client): +def test_http_call_failure(http_client): timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout")) with pytest.raises(ConnectTimeout): http_client.get("/timeout") assert timeout_route.called + + +def test_async_http_initialization(): + client = AsyncHTTPClient(base_url=API_URL, api_token=API_TOKEN) + + assert client.base_url == API_URL + assert client.headers["Authorization"] == "Bearer test-token" + assert client.headers["User-Agent"] == "swo-marketplace-client/1.0" + + +def test_async_http_env_initialization(monkeypatch): + monkeypatch.setenv("MPT_TOKEN", API_TOKEN) + monkeypatch.setenv("MPT_URL", API_URL) + + client = AsyncHTTPClient() + + assert client.base_url == API_URL + assert client.headers["Authorization"] == f"Bearer {API_TOKEN}" + + +def test_async_http_without_token(): + with pytest.raises(ValueError): + AsyncHTTPClient(base_url=API_URL) + + +def test_async_http_without_url(): + with pytest.raises(ValueError): + AsyncHTTPClient(api_token=API_TOKEN) + + +@respx.mock +async def test_async_http_call_success(async_http_client): + success_route = respx.get(f"{API_URL}/").mock( + return_value=Response(200, json={"message": "Hello, World!"}) + ) + + success_response = await async_http_client.get("/") + + assert success_response.status_code == codes.OK + assert success_response.json() == {"message": "Hello, World!"} + assert success_route.called + + +@respx.mock +async def test_async_http_call_failure(async_http_client): + timeout_route = respx.get(f"{API_URL}/timeout").mock(side_effect=ConnectTimeout("Mock Timeout")) + + with pytest.raises(ConnectTimeout): + await async_http_client.get("/timeout") + + assert timeout_route.called diff --git a/tests/models/collection/conftest.py b/tests/models/collection/conftest.py index e2abbf61..2f17e812 100644 --- a/tests/models/collection/conftest.py +++ b/tests/models/collection/conftest.py @@ -1,11 +1,7 @@ import pytest -from mpt_api_client.models import Collection, Resource - - -@pytest.fixture -def meta_data(): - return {"pagination": {"limit": 10, "offset": 0, "total": 3}, "ignored": ["field1"]} +from mpt_api_client.models import Collection +from tests.conftest import DummyModel @pytest.fixture @@ -17,14 +13,16 @@ def response_collection_data(): ] -TestCollection = Collection[Resource] +@pytest.fixture +def empty_collection(): + return Collection() @pytest.fixture -def empty_collection(): - return TestCollection() +def collection_items(response_collection_data): + return [DummyModel(resource_data) for resource_data in response_collection_data] @pytest.fixture -def collection(response_collection_data): - return TestCollection(response_collection_data) +def collection(collection_items): + return Collection(collection_items) diff --git a/tests/models/collection/test_collection_custom_key.py b/tests/models/collection/test_collection_custom_key.py deleted file mode 100644 index 31aa2517..00000000 --- a/tests/models/collection/test_collection_custom_key.py +++ /dev/null @@ -1,21 +0,0 @@ -from httpx import Response - -from mpt_api_client.models.collection import Collection -from mpt_api_client.models.resource import Resource - - -class ChargeResourceMock(Collection[Resource]): - _data_key = "charge" - - -def charge(charge_id, amount) -> dict[str, int]: - return {"id": charge_id, "amount": amount} - - -def test_custom_data_key(): - payload = {"charge": [charge(1, 100), charge(2, 101)]} - response = Response(200, json=payload) - - resource = ChargeResourceMock.from_response(response) - - assert resource[0].to_dict() == charge(1, 100) diff --git a/tests/models/collection/test_collection_init.py b/tests/models/collection/test_collection_init.py index 8aecf8f3..9aa1f1c3 100644 --- a/tests/models/collection/test_collection_init.py +++ b/tests/models/collection/test_collection_init.py @@ -1,11 +1,3 @@ -import pytest -from httpx import Response - -from mpt_api_client.models.collection import Collection -from mpt_api_client.models.meta import Meta -from tests.models.collection.conftest import TestCollection - - def test_generic_collection_empty(empty_collection): assert empty_collection.meta is None assert len(empty_collection) == 0 @@ -16,33 +8,5 @@ def test_generic_collection_empty(empty_collection): def test_generic_collection_with_data(collection, response_collection_data): assert len(collection) == 3 assert bool(collection) is True - assert collection.to_list() == response_collection_data - - -def test_from_response(meta_data, response_collection_data): - response = Response(200, json={"data": response_collection_data, "$meta": meta_data}) - expected_meta = Meta.from_response(response) - - collection = TestCollection.from_response(response) - - assert collection.to_list() == response_collection_data - assert collection.meta == expected_meta - assert len(collection) == 3 - - -def test_wrong_data_type_from_response(): - response = Response(200, json={"data": {"not": "a list"}}) - - with pytest.raises( - TypeError, match=r"Response `data` must be a list for collection endpoints." - ): - Collection.from_response(response) - - -def test_collection_with_meta(meta_data, response_collection_data): - response = Response(200, json={"data": response_collection_data, "$meta": meta_data}) - meta = Meta.from_response(response) - - collection = TestCollection.from_response(response) - - assert collection.meta == meta + for resource in collection.to_list(): + assert isinstance(resource, dict) diff --git a/tests/models/collection/test_collection_iteration.py b/tests/models/collection/test_collection_iteration.py index 0c6f7162..df8c43bb 100644 --- a/tests/models/collection/test_collection_iteration.py +++ b/tests/models/collection/test_collection_iteration.py @@ -1,7 +1,5 @@ import pytest -from tests.models.collection.conftest import TestCollection - def test_iteration(collection): resources = list(collection) @@ -9,13 +7,11 @@ def test_iteration(collection): assert len(resources) == 3 -def test_iteration_next(response_collection_data): - collection = TestCollection(response_collection_data) - +def test_iteration_next(collection, response_collection_data): iterator = iter(collection) - assert next(iterator).id == response_collection_data[0]["id"] - assert next(iterator).id == response_collection_data[1]["id"] - assert next(iterator).id == response_collection_data[2]["id"] + assert next(iterator).to_dict() == response_collection_data[0] + assert next(iterator).to_dict() == response_collection_data[1] + assert next(iterator).to_dict() == response_collection_data[2] # Check that iterator is exhausted with pytest.raises(StopIteration): diff --git a/tests/models/collection/test_collection_list.py b/tests/models/collection/test_collection_list.py index 2cd715b4..ff0ea5f3 100644 --- a/tests/models/collection/test_collection_list.py +++ b/tests/models/collection/test_collection_list.py @@ -1,11 +1,7 @@ import pytest -from tests.models.collection.conftest import TestCollection - - -def test_getitem_access(response_collection_data): - collection = TestCollection(response_collection_data) +def test_getitem_access(collection, response_collection_data): assert collection[0].to_dict() == response_collection_data[0] assert collection[1].to_dict() == response_collection_data[1] assert collection[2].to_dict() == response_collection_data[2] @@ -16,22 +12,17 @@ def test_getitem_out_of_bounds(collection): collection[10] -def test_length(empty_collection, response_collection_data): - collection = TestCollection(response_collection_data) - +def test_length(empty_collection, collection): assert len(empty_collection) == 0 assert len(collection) == 3 -def test_bool_conversion(empty_collection, response_collection_data): - collection_with_data = TestCollection(response_collection_data) - +def test_bool_conversion(empty_collection, collection, response_collection_data): assert bool(empty_collection) is False - assert bool(collection_with_data) is True + assert bool(collection) is True -def test_to_list_method(response_collection_data): - collection = TestCollection(response_collection_data) +def test_to_list_method(collection, response_collection_data): resources = collection.to_list() assert resources == response_collection_data diff --git a/tests/models/resource/test_resource.py b/tests/models/resource/test_resource.py index dc0817bb..4ab2ae38 100644 --- a/tests/models/resource/test_resource.py +++ b/tests/models/resource/test_resource.py @@ -1,7 +1,7 @@ import pytest from httpx import Response -from mpt_api_client.models import Meta, Resource +from mpt_api_client.models import Meta, Model @pytest.fixture @@ -10,7 +10,7 @@ def meta_data(): def test_resource_empty(): - resource = Resource() + resource = Model() assert resource.meta is None assert resource.to_dict() == {} @@ -21,7 +21,7 @@ def test_from_response(meta_data): response = Response(200, json=record_data | {"$meta": meta_data}) expected_meta = Meta.from_response(response) - resource = Resource.from_response(response) + resource = Model.from_response(response) assert resource.to_dict() == record_data assert resource.meta == expected_meta @@ -33,7 +33,7 @@ def test_attribute_getter(mocker, meta_data): response = Response(200, json=response_data) - resource = Resource.from_response(response) + resource = Model.from_response(response) assert resource.id == 1 assert resource.name.given == "Albert" @@ -41,7 +41,7 @@ def test_attribute_getter(mocker, meta_data): def test_attribute_setter(): resource_data = {"id": 1, "name": {"given": "Albert", "family": "Einstein"}} - resource = Resource(resource_data) + resource = Model(resource_data) resource.id = 2 resource.name.given = "John" @@ -53,4 +53,4 @@ def test_attribute_setter(): def test_wrong_data_type(): response = Response(200, json=1) with pytest.raises(TypeError, match=r"Response data must be a dict."): - Resource.from_response(response) + Model.from_response(response) diff --git a/tests/models/resource/test_resource_custom_key.py b/tests/models/resource/test_resource_custom_key.py index 25a80cb3..f43aea22 100644 --- a/tests/models/resource/test_resource_custom_key.py +++ b/tests/models/resource/test_resource_custom_key.py @@ -1,9 +1,9 @@ from httpx import Response -from mpt_api_client.models import Resource +from mpt_api_client.models import Model -class ChargeResourceMock(Resource): +class ChargeResourceMock(Model): _data_key = "charge" diff --git a/tests/resources/commerce/test_commerce.py b/tests/resources/commerce/test_commerce.py new file mode 100644 index 00000000..0823bc2c --- /dev/null +++ b/tests/resources/commerce/test_commerce.py @@ -0,0 +1,57 @@ +from mpt_api_client.http import AsyncHTTPClient +from mpt_api_client.resources.commerce import AsyncCommerce, Commerce +from mpt_api_client.resources.commerce.orders import AsyncOrdersService, OrdersService + + +def test_commerce_init(http_client): + commerce = Commerce(http_client=http_client) + + assert isinstance(commerce, Commerce) + assert commerce.http_client is http_client + + +def test_commerce_orders_property(http_client): + commerce = Commerce(http_client=http_client) + + orders_service = commerce.orders + + assert isinstance(orders_service, OrdersService) + assert orders_service.http_client is http_client + + +def test_commerce_orders_multiple_calls(http_client): + commerce = Commerce(http_client=http_client) + + orders_service = commerce.orders + order_service_additional = commerce.orders + + assert orders_service is not order_service_additional + assert isinstance(orders_service, OrdersService) + assert isinstance(order_service_additional, OrdersService) + + +def test_async_commerce_init(async_http_client: AsyncHTTPClient): + commerce = AsyncCommerce(http_client=async_http_client) + + assert isinstance(commerce, AsyncCommerce) + assert commerce.http_client is async_http_client + + +def test_async_commerce_orders_property(async_http_client: AsyncHTTPClient): + commerce = AsyncCommerce(http_client=async_http_client) + + orders_service = commerce.orders + + assert isinstance(orders_service, AsyncOrdersService) + assert orders_service.http_client is async_http_client + + +def test_async_commerce_orders_multiple_calls(async_http_client: AsyncHTTPClient): + commerce = AsyncCommerce(http_client=async_http_client) + + orders_service = commerce.orders + orders_service_additional = commerce.orders + + assert orders_service is not orders_service_additional + assert isinstance(orders_service, AsyncOrdersService) + assert isinstance(orders_service_additional, AsyncOrdersService) diff --git a/tests/resources/commerce/test_orders.py b/tests/resources/commerce/test_orders.py new file mode 100644 index 00000000..cc9a8393 --- /dev/null +++ b/tests/resources/commerce/test_orders.py @@ -0,0 +1,185 @@ +import httpx +import pytest +import respx + +from mpt_api_client.resources.commerce.orders import AsyncOrdersService, Order, OrdersService + + +@pytest.fixture +def orders_service(http_client): + return OrdersService(http_client=http_client) + + +@pytest.fixture +def async_orders_service(async_http_client): + return AsyncOrdersService(http_client=async_http_client) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("validate", {"id": "ORD-123", "status": "update"}), + ("validate", None), + ("process", {"id": "ORD-123", "status": "update"}), + ("process", None), + ("query", {"id": "ORD-123", "status": "update"}), + ("query", None), + ("complete", {"id": "ORD-123", "status": "update"}), + ("complete", None), + ("fail", {"id": "ORD-123", "status": "update"}), + ("fail", None), + ], +) +def test_custom_resource_actions(orders_service, action, input_status): + if input_status is None: + request_expected_content = b"" + else: + request_expected_content = b'{"id":"ORD-123","status":"update"}' + response_expected_data = {"id": "ORD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/commerce/orders/ORD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + if input_status is None: + order = getattr(orders_service, action)("ORD-123") + else: + order = getattr(orders_service, action)("ORD-123", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + if input_status is None: + assert request.content == request_expected_content + else: + assert request.content == request_expected_content + assert order.to_dict() == response_expected_data + assert isinstance(order, Order) + + +def test_notify(orders_service): + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/commerce/orders/ORD-123/notify" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + content='{"status": "notified"}', + ) + ) + user_data = {"email": "user@example.com", "name": "John Doe"} + + orders_service.notify("ORD-123", user_data) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == b'{"email":"user@example.com","name":"John Doe"}' + + +def test_template(orders_service): + with respx.mock: + mock_route = respx.get( + "https://api.example.com/public/v1/commerce/orders/ORD-123/template" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "text/markdown"}, + content="# Order Template\n\nThis is a markdown template.", + ) + ) + + markdown_template = orders_service.template("ORD-123") + + assert mock_route.called + assert mock_route.call_count == 1 + assert markdown_template == "# Order Template\n\nThis is a markdown template." + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("validate", {"id": "ORD-123", "status": "update"}), + ("validate", None), + ("process", {"id": "ORD-123", "status": "update"}), + ("process", None), + ("query", {"id": "ORD-123", "status": "update"}), + ("query", None), + ("complete", {"id": "ORD-123", "status": "update"}), + ("complete", None), + ("fail", {"id": "ORD-123", "status": "update"}), + ("fail", None), + ], +) +@pytest.mark.asyncio +async def test_async_custom_resource_actions(async_orders_service, action, input_status): + if input_status is None: + request_expected_content = b"" + else: + request_expected_content = b'{"id":"ORD-123","status":"update"}' + response_expected_data = {"id": "ORD-123", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/commerce/orders/ORD-123/{action}" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + if input_status is None: + order = await getattr(async_orders_service, action)("ORD-123") + else: + order = await getattr(async_orders_service, action)("ORD-123", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + if input_status is None: + assert request.content == request_expected_content + else: + assert request.content == request_expected_content + assert order.to_dict() == response_expected_data + assert isinstance(order, Order) + + +@pytest.mark.asyncio +async def test_async_notify(async_orders_service): + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/commerce/orders/ORD-123/notify" + ).mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "application/json"}, + content='{"status": "notified"}', + ) + ) + user_data = {"email": "user@example.com", "name": "John Doe"} + + await async_orders_service.notify("ORD-123", user_data) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + assert request.content == b'{"email":"user@example.com","name":"John Doe"}' + + +@pytest.mark.asyncio +async def test_async_template(async_orders_service): + template_content = "# Order Template\n\nThis is a markdown template." + with respx.mock: + respx.get("https://api.example.com/public/v1/commerce/orders/ORD-123/template").mock( + return_value=httpx.Response( + status_code=200, + headers={"content-type": "text/markdown"}, + content=template_content, + ) + ) + + template = await async_orders_service.template("ORD-123") + + assert template == template_content diff --git a/tests/resources/orders/test_order_collection_client.py b/tests/resources/orders/test_order_collection_client.py deleted file mode 100644 index 08a2eaa3..00000000 --- a/tests/resources/orders/test_order_collection_client.py +++ /dev/null @@ -1,6 +0,0 @@ -from mpt_api_client.resources.order import OrderCollectionClientBase - - -def test_order_collection_client(mpt_client): - order_cc = OrderCollectionClientBase(http_client=mpt_client) - assert order_cc.query_rql is None diff --git a/tests/resources/orders/test_order_resource_client.py b/tests/resources/orders/test_order_resource_client.py deleted file mode 100644 index 0639d612..00000000 --- a/tests/resources/orders/test_order_resource_client.py +++ /dev/null @@ -1,97 +0,0 @@ -from unittest.mock import Mock, patch - -import httpx -import pytest - -from mpt_api_client.resources.order import Order, OrderResourceClient - - -@pytest.fixture -def order_response(): - return httpx.Response( - status_code=200, - headers={"content-type": "application/json"}, - content='{"id": "order123", "status": "completed", "$meta": {"total": 1}}', - request=httpx.Request("POST", "https://api.example.com/orders"), - ) - - -@pytest.fixture -def order_client(): - with patch.object(OrderResourceClient, "do_action"): - client = OrderResourceClient(Mock(), "order123") - yield client - - -def test_validate(order_client, order_response): - order_client.do_action.return_value = order_response - order_data = {"id": "order123", "status": "draft"} - - order = order_client.validate(order_data) - - order_client.do_action.assert_called_once_with("POST", "validate", json=order_data) - assert isinstance(order, Order) - - -def test_process(order_client, order_response): - order_client.do_action.return_value = order_response - order_data = {"id": "order123", "status": "validated"} - - order = order_client.process(order_data) - - order_client.do_action.assert_called_once_with("POST", "process", json=order_data) - assert isinstance(order, Order) - - -def test_query(order_client, order_response): - order_client.do_action.return_value = order_response - order_data = {"id": "order123", "status": "processing"} - - order = order_client.query(order_data) - - order_client.do_action.assert_called_once_with("POST", "query", json=order_data) - assert isinstance(order, Order) - - -def test_complete(order_client, order_response): - order_client.do_action.return_value = order_response - order_data = {"id": "order123", "status": "processing"} - - order = order_client.complete(order_data) - - order_client.do_action.assert_called_once_with("POST", "complete", json=order_data) - assert isinstance(order, Order) - - -def test_fail(order_client, order_response): - order_client.do_action.return_value = order_response - order_data = {"id": "order123", "status": "processing"} - - order = order_client.fail(order_data) - - order_client.do_action.assert_called_once_with("POST", "fail", json=order_data) - assert isinstance(order, Order) - - -def test_notify(order_client, order_response): - order_client.do_action.return_value = order_response - user_data = {"email": "user@example.com", "name": "John Doe"} - - order_client.notify(user_data) - - order_client.do_action.assert_called_once_with("POST", "notify", json=user_data) - - -def test_template(order_client): - template_response = httpx.Response( - status_code=200, - headers={"content-type": "text/markdown"}, - content="# Order Template\n\nThis is a markdown template.", - request=httpx.Request("GET", "https://api.example.com/orders/template"), - ) - order_client.do_action.return_value = template_response - - markdown_template = order_client.template() - - order_client.do_action.assert_called_once_with("GET", "template") - assert markdown_template == "# Order Template\n\nThis is a markdown template." diff --git a/tests/test_mpt.py b/tests/test_mpt.py index 4ecb9c4a..0a6d9182 100644 --- a/tests/test_mpt.py +++ b/tests/test_mpt.py @@ -1,31 +1,39 @@ -from unittest.mock import Mock +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.mpt_client import AsyncMPTClient, MPTClient +from mpt_api_client.resources import AsyncCommerce, Commerce -from mpt_api_client.mptclient import MPTClient -from mpt_api_client.resources import OrderCollectionClientBase +def test_mpt_client() -> None: + mpt = MPTClient(base_url="https://test.example.com", api_key="test-key") + commerce = mpt.commerce -def test_mapped_module() -> None: - mock_registry = Mock() - mpt = MPTClient(base_url="https://test.example.com", api_key="test-key", registry=mock_registry) + assert isinstance(mpt, MPTClient) + assert isinstance(commerce, Commerce) - mpt.orders # noqa: B018 - mock_registry.get.assert_called_once_with("orders") +def test_mpt_client_env(monkeypatch): + monkeypatch.setenv("MPT_URL", "https://test.example.com") + monkeypatch.setenv("MPT_TOKEN", "test-key") + mpt = MPTClient() -def test_not_mapped_module() -> None: - mock_registry = Mock() - mpt = MPTClient(base_url="https://test.example.com", api_key="test-key", registry=mock_registry) + assert isinstance(mpt, MPTClient) + assert isinstance(mpt.http_client, HTTPClient) - mpt.non_existing_module # noqa: B018 - mock_registry.get.assert_called_once_with("non_existing_module") +def test_async_mpt_client() -> None: + mpt = AsyncMPTClient(base_url="https://test.example.com", api_key="test-key") + commerce = mpt.commerce + assert isinstance(mpt, AsyncMPTClient) + assert isinstance(commerce, AsyncCommerce) -def test_subclient_orders_module(): - mpt = MPTClient(base_url="https://test.example.com", api_key="test-key") - orders_client = mpt.commerce.orders +def test_async_mpt_client_env(monkeypatch): + monkeypatch.setenv("MPT_URL", "https://test.example.com") + monkeypatch.setenv("MPT_TOKEN", "test-key") + + mpt = AsyncMPTClient() - assert isinstance(orders_client, OrderCollectionClientBase) - assert orders_client.http_client == mpt.http_client + assert isinstance(mpt, AsyncMPTClient) + assert isinstance(mpt.http_client, AsyncHTTPClient) diff --git a/tests/test_registry.py b/tests/test_registry.py deleted file mode 100644 index 3714380f..00000000 --- a/tests/test_registry.py +++ /dev/null @@ -1,75 +0,0 @@ -import pytest - -from mpt_api_client.http.collection import CollectionClientBase -from mpt_api_client.http.resource import ResourceBaseClient -from mpt_api_client.models import Resource -from mpt_api_client.registry import Registry - - -class DummyResource(Resource): - """Dummy resource for testing.""" - - -class DummyCollectionClientBase(CollectionClientBase): - _endpoint = "/api/v1/dummy" - _resource_class = DummyResource - - -def test_register_collection_client_successfully(): - registry = Registry() - keyname = "test_collection" - - registry.register(keyname, DummyCollectionClientBase) - - assert keyname in registry.items - assert registry.items[keyname] == DummyCollectionClientBase - assert registry.get(keyname) == DummyCollectionClientBase - - -def test_get_registered_client_successfully(): - registry = Registry() - keyname = "orders" - - registry.register(keyname, DummyCollectionClientBase) - - retrieved_client = registry.get(keyname) - - assert retrieved_client == DummyCollectionClientBase - - -def test_get_raise_exception(): - registry = Registry() - unregistered_keyname = "nonexistent_client" - - with pytest.raises( - KeyError, match="No collection client registered with keyname: nonexistent_client" - ): - registry.get(unregistered_keyname) - - -def test_list_keys(): - registry = Registry() - expected_keys = ["orders", "customers", "products"] - - for keyname in expected_keys: - registry.register(keyname, DummyCollectionClientBase) - - registry_keys = registry.list_keys() - - assert sorted(registry_keys) == sorted(expected_keys) - assert len(registry_keys) == 3 - - -def test_registry_as_decorator(): - registry = Registry() - - @registry("test_call") - class TestCallClientBase( # noqa: WPS431 - CollectionClientBase[DummyResource, ResourceBaseClient[DummyResource]] - ): - _endpoint = "/api/v1/test-call" - _resource_class = DummyResource - - registered_client = registry.get("test_call") - - assert registered_client == TestCallClientBase