From cb368b242303a067574a964e970fc0343c8173e9 Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Thu, 2 Apr 2026 15:17:53 +0100 Subject: [PATCH] MPT-13307: implement exchange currencies resource - Add Currency model with all fields (name, code, precision, statistics, status, icon, revision, audit) - Add CurrenciesService and AsyncCurrenciesService with list, get, create, update, delete and download_icon operations - Add Exchange and AsyncExchange module classes - Register exchange module on MPTClient and AsyncMPTClient - Add per-file-ignores for exchange module in pyproject.toml - Add unit tests following AAA and single-assertion practices Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- mpt_api_client/mpt_client.py | 12 + mpt_api_client/resources/__init__.py | 3 + mpt_api_client/resources/exchange/__init__.py | 6 + .../resources/exchange/currencies.py | 97 +++++++ mpt_api_client/resources/exchange/exchange.py | 29 ++ pyproject.toml | 2 + tests/unit/resources/exchange/__init__.py | 0 .../resources/exchange/test_currencies.py | 270 ++++++++++++++++++ .../unit/resources/exchange/test_exchange.py | 43 +++ tests/unit/test_mpt_client.py | 4 + 10 files changed, 466 insertions(+) create mode 100644 mpt_api_client/resources/exchange/__init__.py create mode 100644 mpt_api_client/resources/exchange/currencies.py create mode 100644 mpt_api_client/resources/exchange/exchange.py create mode 100644 tests/unit/resources/exchange/__init__.py create mode 100644 tests/unit/resources/exchange/test_currencies.py create mode 100644 tests/unit/resources/exchange/test_exchange.py diff --git a/mpt_api_client/mpt_client.py b/mpt_api_client/mpt_client.py index a5eb358b..da465ef1 100644 --- a/mpt_api_client/mpt_client.py +++ b/mpt_api_client/mpt_client.py @@ -8,12 +8,14 @@ AsyncBilling, AsyncCatalog, AsyncCommerce, + AsyncExchange, AsyncHelpdesk, AsyncNotifications, Audit, Billing, Catalog, Commerce, + Exchange, Helpdesk, Notifications, ) @@ -83,6 +85,11 @@ def helpdesk(self) -> AsyncHelpdesk: """Helpdesk MPT API Client.""" return AsyncHelpdesk(http_client=self.http_client) + @property + def exchange(self) -> AsyncExchange: + """Exchange MPT API Client.""" + return AsyncExchange(http_client=self.http_client) + class MPTClient: """MPT API Client.""" @@ -152,3 +159,8 @@ def notifications(self) -> Notifications: def helpdesk(self) -> Helpdesk: """Helpdesk MPT API Client.""" return Helpdesk(http_client=self.http_client) + + @property + def exchange(self) -> Exchange: + """Exchange MPT API Client.""" + return Exchange(http_client=self.http_client) diff --git a/mpt_api_client/resources/__init__.py b/mpt_api_client/resources/__init__.py index 1f53f1c0..d66c9b09 100644 --- a/mpt_api_client/resources/__init__.py +++ b/mpt_api_client/resources/__init__.py @@ -3,6 +3,7 @@ from mpt_api_client.resources.billing import AsyncBilling, Billing from mpt_api_client.resources.catalog import AsyncCatalog, Catalog from mpt_api_client.resources.commerce import AsyncCommerce, Commerce +from mpt_api_client.resources.exchange import AsyncExchange, Exchange from mpt_api_client.resources.helpdesk import AsyncHelpdesk, Helpdesk from mpt_api_client.resources.notifications import AsyncNotifications, Notifications @@ -13,12 +14,14 @@ "AsyncBilling", "AsyncCatalog", "AsyncCommerce", + "AsyncExchange", "AsyncHelpdesk", "AsyncNotifications", "Audit", "Billing", "Catalog", "Commerce", + "Exchange", "Helpdesk", "Notifications", ] diff --git a/mpt_api_client/resources/exchange/__init__.py b/mpt_api_client/resources/exchange/__init__.py new file mode 100644 index 00000000..b43a22b3 --- /dev/null +++ b/mpt_api_client/resources/exchange/__init__.py @@ -0,0 +1,6 @@ +from mpt_api_client.resources.exchange.exchange import AsyncExchange, Exchange + +__all__ = [ # noqa: WPS410 + "AsyncExchange", + "Exchange", +] diff --git a/mpt_api_client/resources/exchange/currencies.py b/mpt_api_client/resources/exchange/currencies.py new file mode 100644 index 00000000..38c6575e --- /dev/null +++ b/mpt_api_client/resources/exchange/currencies.py @@ -0,0 +1,97 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncCollectionMixin, + AsyncCreateFileMixin, + AsyncDeleteMixin, + AsyncGetMixin, + AsyncUpdateFileMixin, + CollectionMixin, + CreateFileMixin, + DeleteMixin, + GetMixin, + UpdateFileMixin, +) +from mpt_api_client.models import FileModel, Model +from mpt_api_client.models.model import BaseModel + + +class Currency(Model): + """Currency resource. + + Attributes: + name: Currency name. + code: ISO code of the currency. + precision: Number of decimal places. + statistics: Currency statistics (seller count, pair count). + status: Current status of the currency. + icon: URL or identifier for the currency icon. + revision: Revision number. + audit: Audit information (created, updated events). + """ + + name: str | None + code: str | None + precision: int | None + statistics: BaseModel | None + status: str | None + icon: str | None + revision: int | None + audit: BaseModel | None + + +class CurrenciesServiceConfig: + """Currencies service configuration.""" + + _endpoint = "/public/v1/exchange/currencies" + _model_class = Currency + _collection_key = "data" + _upload_file_key = "icon" + _upload_data_key = "currency" + + +class CurrenciesService( + CreateFileMixin[Currency], + GetMixin[Currency], + UpdateFileMixin[Currency], + DeleteMixin, + CollectionMixin[Currency], + Service[Currency], + CurrenciesServiceConfig, +): + """Currencies service.""" + + def download_icon(self, resource_id: str) -> FileModel: + """Download the icon for the given currency. + + Args: + resource_id: Currency ID. + + Returns: + File model containing the downloaded icon. + """ + response = self._resource(resource_id).do_request("GET", "icon") + return FileModel(response) + + +class AsyncCurrenciesService( + AsyncCreateFileMixin[Currency], + AsyncGetMixin[Currency], + AsyncUpdateFileMixin[Currency], + AsyncDeleteMixin, + AsyncCollectionMixin[Currency], + AsyncService[Currency], + CurrenciesServiceConfig, +): + """Async currencies service.""" + + async def download_icon(self, resource_id: str) -> FileModel: + """Download the icon for the given currency. + + Args: + resource_id: Currency ID. + + Returns: + File model containing the downloaded icon. + """ + response = await self._resource(resource_id).do_request("GET", "icon") + return FileModel(response) diff --git a/mpt_api_client/resources/exchange/exchange.py b/mpt_api_client/resources/exchange/exchange.py new file mode 100644 index 00000000..fff7e46f --- /dev/null +++ b/mpt_api_client/resources/exchange/exchange.py @@ -0,0 +1,29 @@ +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.resources.exchange.currencies import ( + AsyncCurrenciesService, + CurrenciesService, +) + + +class Exchange: + """Exchange MPT API Module.""" + + def __init__(self, *, http_client: HTTPClient): + self.http_client = http_client + + @property + def currencies(self) -> CurrenciesService: + """Currencies service.""" + return CurrenciesService(http_client=self.http_client) + + +class AsyncExchange: + """Exchange MPT API Module.""" + + def __init__(self, *, http_client: AsyncHTTPClient): + self.http_client = http_client + + @property + def currencies(self) -> AsyncCurrenciesService: + """Currencies service.""" + return AsyncCurrenciesService(http_client=self.http_client) diff --git a/pyproject.toml b/pyproject.toml index c2a85aa1..fd18d3a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,7 @@ per-file-ignores = [ "mpt_api_client/resources/catalog/*.py: WPS110 WPS214 WPS215 WPS235", "mpt_api_client/resources/catalog/products.py: WPS204 WPS214 WPS215 WPS235", "mpt_api_client/resources/commerce/*.py: WPS235 WPS215", + "mpt_api_client/resources/exchange/*.py: WPS235 WPS215", "mpt_api_client/resources/helpdesk/*.py: WPS204 WPS215 WPS214", "mpt_api_client/rql/query_builder.py: WPS110 WPS115 WPS210 WPS214", "tests/e2e/accounts/*.py: WPS430 WPS202", @@ -144,6 +145,7 @@ per-file-ignores = [ "tests/unit/resources/commerce/*.py: WPS202 WPS204", "tests/unit/test_mpt_client.py: WPS235", "tests/*: WPS432 WPS202", + "tests/unit/resources/exchange/*.py: WPS202 WPS204 WPS210", ] [tool.ruff] diff --git a/tests/unit/resources/exchange/__init__.py b/tests/unit/resources/exchange/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/resources/exchange/test_currencies.py b/tests/unit/resources/exchange/test_currencies.py new file mode 100644 index 00000000..28b43fb2 --- /dev/null +++ b/tests/unit/resources/exchange/test_currencies.py @@ -0,0 +1,270 @@ +import httpx +import pytest +import respx + +from mpt_api_client.models import FileModel +from mpt_api_client.models.model import BaseModel +from mpt_api_client.resources.exchange.currencies import ( + AsyncCurrenciesService, + CurrenciesService, + Currency, +) + + +@pytest.fixture +def currencies_service(http_client): + return CurrenciesService(http_client=http_client) + + +@pytest.fixture +def async_currencies_service(async_http_client): + return AsyncCurrenciesService(http_client=async_http_client) + + +@pytest.fixture +def currency_data(): + return { + "id": "CUR-001", + "name": "US Dollar", + "code": "USD", + "precision": 2, + "statistics": {"sellerCount": 10, "pairCount": 5}, + "status": "Active", + "icon": "https://example.com/icons/usd.png", + "revision": 1, + "audit": {"created": {"at": "2024-01-01T00:00:00Z"}}, + } + + +@pytest.mark.parametrize( + "method", + ["get", "create", "update", "delete", "iterate", "download_icon"], +) +def test_mixins_present(currencies_service, method): + result = hasattr(currencies_service, method) + + assert result is True + + +@pytest.mark.parametrize( + "method", + ["get", "create", "update", "delete", "iterate", "download_icon"], +) +def test_async_mixins_present(async_currencies_service, method): + result = hasattr(async_currencies_service, method) + + assert result is True + + +def test_currency_primitive_fields(currency_data): + result = Currency(currency_data) + + assert result.to_dict() == currency_data + + +@pytest.mark.parametrize("field", ["statistics", "audit"]) +def test_currency_nested_model_fields(currency_data, field): + result = Currency(currency_data) + + assert isinstance(getattr(result, field), BaseModel) + + +@pytest.mark.parametrize("field", ["name", "code", "audit"]) +def test_currency_optional_fields_absent(field): + result = Currency({"id": "CUR-001"}) + + assert not hasattr(result, field) + + +def test_currency_id_present(): + result = Currency({"id": "CUR-001"}) + + assert result.id == "CUR-001" + + +def test_get_currency(currencies_service): + currency_id = "CUR-001" + expected_response = {"id": currency_id, "name": "US Dollar", "code": "USD", "precision": 2} + with respx.mock: + respx.get(f"https://api.example.com/public/v1/exchange/currencies/{currency_id}").mock( + return_value=httpx.Response(httpx.codes.OK, json=expected_response) + ) + + result = currencies_service.get(currency_id) + + assert result.to_dict() == expected_response + + +async def test_async_get_currency(async_currencies_service): + currency_id = "CUR-001" + expected_response = {"id": currency_id, "name": "US Dollar", "code": "USD", "precision": 2} + with respx.mock: + respx.get(f"https://api.example.com/public/v1/exchange/currencies/{currency_id}").mock( + return_value=httpx.Response(httpx.codes.OK, json=expected_response) + ) + + result = await async_currencies_service.get(currency_id) + + assert result.to_dict() == expected_response + + +def test_create_currency(currencies_service, tmp_path): + currency_data = {"name": "Euro", "code": "EUR", "precision": 2} + expected_response = {"id": "CUR-002", "name": "Euro", "code": "EUR", "precision": 2} + icon_path = tmp_path / "eur.png" + icon_path.write_bytes(b"fake icon data") + with icon_path.open("rb") as icon_file, respx.mock: + respx.post("https://api.example.com/public/v1/exchange/currencies").mock( + return_value=httpx.Response(httpx.codes.CREATED, json=expected_response) + ) + + result = currencies_service.create(currency_data, file=icon_file) + + assert result.to_dict() == expected_response + + +async def test_async_create_currency(async_currencies_service, tmp_path): + currency_data = {"name": "British Pound", "code": "GBP", "precision": 2} + expected_response = {"id": "CUR-003", "name": "British Pound", "code": "GBP", "precision": 2} + icon_path = tmp_path / "gbp.png" + icon_path.write_bytes(b"fake icon data") + with icon_path.open("rb") as icon_file, respx.mock: + respx.post("https://api.example.com/public/v1/exchange/currencies").mock( + return_value=httpx.Response(httpx.codes.CREATED, json=expected_response) + ) + + result = await async_currencies_service.create(currency_data, file=icon_file) + + assert result.to_dict() == expected_response + + +def test_update_currency(currencies_service, tmp_path): + currency_id = "CUR-001" + update_data = {"name": "US Dollar Updated", "code": "USD", "precision": 2} + expected_response = { + "id": currency_id, + "name": "US Dollar Updated", + "code": "USD", + "precision": 2, + } + icon_path = tmp_path / "usd.png" + icon_path.write_bytes(b"updated icon data") + with icon_path.open("rb") as icon_file, respx.mock: + respx.put(f"https://api.example.com/public/v1/exchange/currencies/{currency_id}").mock( + return_value=httpx.Response(httpx.codes.OK, json=expected_response) + ) + + result = currencies_service.update(currency_id, update_data, file=icon_file) + + assert result.to_dict() == expected_response + + +async def test_async_update_currency(async_currencies_service, tmp_path): + currency_id = "CUR-001" + update_data = {"name": "US Dollar Updated", "code": "USD", "precision": 2} + expected_response = { + "id": currency_id, + "name": "US Dollar Updated", + "code": "USD", + "precision": 2, + } + icon_path = tmp_path / "usd.png" + icon_path.write_bytes(b"updated icon data") + with icon_path.open("rb") as icon_file, respx.mock: + respx.put(f"https://api.example.com/public/v1/exchange/currencies/{currency_id}").mock( + return_value=httpx.Response(httpx.codes.OK, json=expected_response) + ) + + result = await async_currencies_service.update(currency_id, update_data, file=icon_file) + + assert result.to_dict() == expected_response + + +def test_delete_currency(currencies_service): + currency_id = "CUR-001" + with respx.mock: + mock_route = respx.delete( + f"https://api.example.com/public/v1/exchange/currencies/{currency_id}" + ).mock(return_value=httpx.Response(httpx.codes.NO_CONTENT)) + + currencies_service.delete(currency_id) # act + + assert mock_route.called is True + + +async def test_async_delete_currency(async_currencies_service): + currency_id = "CUR-001" + with respx.mock: + mock_route = respx.delete( + f"https://api.example.com/public/v1/exchange/currencies/{currency_id}" + ).mock(return_value=httpx.Response(httpx.codes.NO_CONTENT)) + + await async_currencies_service.delete(currency_id) # act + + assert mock_route.called is True + + +def test_download_icon_returns_file_model(currencies_service): + currency_id = "CUR-001" + with respx.mock: + respx.get(f"https://api.example.com/public/v1/exchange/currencies/{currency_id}/icon").mock( + return_value=httpx.Response( + httpx.codes.OK, + content=b"PNG icon bytes", + headers={"Content-Type": "image/png"}, + ) + ) + + result = currencies_service.download_icon(currency_id) + + assert isinstance(result, FileModel) + + +def test_download_icon_returns_file_contents(currencies_service): + currency_id = "CUR-001" + icon_content = b"PNG icon bytes" + with respx.mock: + respx.get(f"https://api.example.com/public/v1/exchange/currencies/{currency_id}/icon").mock( + return_value=httpx.Response( + httpx.codes.OK, + content=icon_content, + headers={"Content-Type": "image/png"}, + ) + ) + + result = currencies_service.download_icon(currency_id) + + assert result.file_contents == icon_content + + +async def test_async_download_icon_returns_file_model(async_currencies_service): + currency_id = "CUR-001" + with respx.mock: + respx.get(f"https://api.example.com/public/v1/exchange/currencies/{currency_id}/icon").mock( + return_value=httpx.Response( + httpx.codes.OK, + content=b"PNG icon bytes", + headers={"Content-Type": "image/png"}, + ) + ) + + result = await async_currencies_service.download_icon(currency_id) + + assert isinstance(result, FileModel) + + +async def test_async_download_icon_file_contents(async_currencies_service): + currency_id = "CUR-001" + icon_content = b"PNG icon bytes" + with respx.mock: + respx.get(f"https://api.example.com/public/v1/exchange/currencies/{currency_id}/icon").mock( + return_value=httpx.Response( + httpx.codes.OK, + content=icon_content, + headers={"Content-Type": "image/png"}, + ) + ) + + result = await async_currencies_service.download_icon(currency_id) + + assert result.file_contents == icon_content diff --git a/tests/unit/resources/exchange/test_exchange.py b/tests/unit/resources/exchange/test_exchange.py new file mode 100644 index 00000000..c77dbe79 --- /dev/null +++ b/tests/unit/resources/exchange/test_exchange.py @@ -0,0 +1,43 @@ +import pytest + +from mpt_api_client.resources.exchange.currencies import ( + AsyncCurrenciesService, + CurrenciesService, +) +from mpt_api_client.resources.exchange.exchange import AsyncExchange, Exchange + + +@pytest.fixture +def exchange(http_client): + return Exchange(http_client=http_client) + + +@pytest.fixture +def async_exchange(async_http_client): + return AsyncExchange(http_client=async_http_client) + + +def test_exchange_initialization(http_client): + result = Exchange(http_client=http_client) + + assert result.http_client is http_client + + +def test_async_exchange_initialization(async_http_client): + result = AsyncExchange(http_client=async_http_client) + + assert result.http_client is async_http_client + + +def test_exchange_currencies_property(exchange): + result = exchange.currencies + + assert isinstance(result, CurrenciesService) + assert result.http_client is exchange.http_client + + +def test_async_exchange_currencies_property(async_exchange): + result = async_exchange.currencies + + assert isinstance(result, AsyncCurrenciesService) + assert result.http_client is async_exchange.http_client diff --git a/tests/unit/test_mpt_client.py b/tests/unit/test_mpt_client.py index 4cccab67..684d5998 100644 --- a/tests/unit/test_mpt_client.py +++ b/tests/unit/test_mpt_client.py @@ -9,12 +9,14 @@ AsyncBilling, AsyncCatalog, AsyncCommerce, + AsyncExchange, AsyncHelpdesk, AsyncNotifications, Audit, Billing, Catalog, Commerce, + Exchange, Helpdesk, Notifications, ) @@ -35,6 +37,7 @@ def get_mpt_client(): ("accounts", Accounts), ("notifications", Notifications), ("helpdesk", Helpdesk), + ("exchange", Exchange), ], ) def test_mpt_client(resource_name: str, expected_type: type) -> None: @@ -66,6 +69,7 @@ def test_mpt_client_env(monkeypatch: pytest.MonkeyPatch) -> None: ("accounts", AsyncAccounts), ("notifications", AsyncNotifications), ("helpdesk", AsyncHelpdesk), + ("exchange", AsyncExchange), ], ) def test_async_mpt_client(resource_name: str, expected_type: type) -> None: