python-mope 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {python_mope-0.3.0 → python_mope-0.3.1}/PKG-INFO +23 -1
  2. {python_mope-0.3.0 → python_mope-0.3.1}/README.md +22 -0
  3. {python_mope-0.3.0 → python_mope-0.3.1}/mope/__version__.py +1 -1
  4. {python_mope-0.3.0 → python_mope-0.3.1}/mope/client.py +35 -5
  5. python_mope-0.3.1/mope/errors.py +52 -0
  6. {python_mope-0.3.0 → python_mope-0.3.1}/mope/resources/shop.py +3 -2
  7. {python_mope-0.3.0 → python_mope-0.3.1}/python_mope.egg-info/PKG-INFO +23 -1
  8. {python_mope-0.3.0 → python_mope-0.3.1}/python_mope.egg-info/SOURCES.txt +1 -1
  9. python_mope-0.3.1/tests/test_mope.py +204 -0
  10. python_mope-0.3.0/tests/test_models.py +0 -167
  11. python_mope-0.3.0/tests/test_mope.py +0 -126
  12. {python_mope-0.3.0 → python_mope-0.3.1}/LICENSE +0 -0
  13. {python_mope-0.3.0 → python_mope-0.3.1}/mope/__init__.py +0 -0
  14. {python_mope-0.3.0 → python_mope-0.3.1}/mope/json.py +0 -0
  15. {python_mope-0.3.0 → python_mope-0.3.1}/mope/models/__init__.py +0 -0
  16. {python_mope-0.3.0 → python_mope-0.3.1}/mope/models/payments.py +0 -0
  17. {python_mope-0.3.0 → python_mope-0.3.1}/mope/py.typed +0 -0
  18. {python_mope-0.3.0 → python_mope-0.3.1}/mope/resources/__init__.py +0 -0
  19. {python_mope-0.3.0 → python_mope-0.3.1}/mope/transport.py +0 -0
  20. {python_mope-0.3.0 → python_mope-0.3.1}/pyproject.toml +0 -0
  21. {python_mope-0.3.0 → python_mope-0.3.1}/python_mope.egg-info/dependency_links.txt +0 -0
  22. {python_mope-0.3.0 → python_mope-0.3.1}/python_mope.egg-info/requires.txt +0 -0
  23. {python_mope-0.3.0 → python_mope-0.3.1}/python_mope.egg-info/top_level.txt +0 -0
  24. {python_mope-0.3.0 → python_mope-0.3.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mope
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A client library for interacting with the Mopé Web Shop API
5
5
  Author-email: Lemuel Boyce <lemuel@vokality.com>
6
6
  License-Expression: MIT
@@ -52,6 +52,28 @@ mope = Mope(token="test_123")
52
52
  mope.shop.get_payment_request(payment_id="123")
53
53
  ```
54
54
 
55
+ ### Timeouts and errors
56
+
57
+ Requests use a 5-second connect timeout and a 30-second read timeout by default.
58
+ Configure both with `Mope(token="test_123", timeout=(3.0, 15.0))`, or pass a
59
+ single number of seconds to apply to both. `mope.client.Client` accepts the same
60
+ keyword argument. Values must be finite and greater than zero. The read timeout
61
+ limits inactivity between received bytes, not the total duration of a request.
62
+
63
+ Shop methods raise `mope.errors.MopeAPIError` for HTTP errors. It is a subclass
64
+ of `requests.HTTPError`, so existing handlers keep working. Its `status_code`,
65
+ `code`, `message`, and `data` fields expose the HTTP status and validated Mopé
66
+ error details, such as code `1033` for account limits exceeded. If the error body
67
+ is missing or malformed, `code` and `data` are `None` and `message` retains the
68
+ HTTP error description. The original response and exception cause are preserved.
69
+
70
+ Connection failures and timeouts remain `requests.RequestException` subclasses.
71
+ Malformed successful responses still raise `TypeError`, `ValueError`, or
72
+ `KeyError`; invalid JSON raises `requests.exceptions.JSONDecodeError`, which is
73
+ also a `ValueError`. Applications should translate these errors at the API call
74
+ boundary and decide which failures are retryable. Requests are not automatically
75
+ retried, including payment creation after a timeout.
76
+
55
77
  ### Development
56
78
 
57
79
  Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then run:
@@ -26,6 +26,28 @@ mope = Mope(token="test_123")
26
26
  mope.shop.get_payment_request(payment_id="123")
27
27
  ```
28
28
 
29
+ ### Timeouts and errors
30
+
31
+ Requests use a 5-second connect timeout and a 30-second read timeout by default.
32
+ Configure both with `Mope(token="test_123", timeout=(3.0, 15.0))`, or pass a
33
+ single number of seconds to apply to both. `mope.client.Client` accepts the same
34
+ keyword argument. Values must be finite and greater than zero. The read timeout
35
+ limits inactivity between received bytes, not the total duration of a request.
36
+
37
+ Shop methods raise `mope.errors.MopeAPIError` for HTTP errors. It is a subclass
38
+ of `requests.HTTPError`, so existing handlers keep working. Its `status_code`,
39
+ `code`, `message`, and `data` fields expose the HTTP status and validated Mopé
40
+ error details, such as code `1033` for account limits exceeded. If the error body
41
+ is missing or malformed, `code` and `data` are `None` and `message` retains the
42
+ HTTP error description. The original response and exception cause are preserved.
43
+
44
+ Connection failures and timeouts remain `requests.RequestException` subclasses.
45
+ Malformed successful responses still raise `TypeError`, `ValueError`, or
46
+ `KeyError`; invalid JSON raises `requests.exceptions.JSONDecodeError`, which is
47
+ also a `ValueError`. Applications should translate these errors at the API call
48
+ boundary and decide which failures are retryable. Requests are not automatically
49
+ retried, including payment creation after a timeout.
50
+
29
51
  ### Development
30
52
 
31
53
  Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then run:
@@ -1,2 +1,2 @@
1
1
  __author__ = "lemuel@vokality.com"
2
- __version__ = "0.3.0"
2
+ __version__ = "0.3.1"
@@ -1,4 +1,5 @@
1
1
  from collections.abc import Iterable, Mapping, Set
2
+ from math import isfinite
2
3
  from typing import IO, Protocol, TypeAlias
3
4
  from urllib.parse import urljoin
4
5
 
@@ -29,19 +30,45 @@ RequestData: TypeAlias = (
29
30
  | str
30
31
  | bytes
31
32
  )
33
+ RequestTimeout: TypeAlias = float | tuple[float, float]
32
34
 
33
35
  _user_agent = f"Python Mope {__version__}"
36
+ _BASE_URL = "https://api.mope.sr/api/"
37
+ _DEFAULT_TIMEOUT = (5.0, 30.0)
38
+
39
+
40
+ def _timeout_seconds(value: object) -> float:
41
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
42
+ raise TypeError("timeout must contain numbers of seconds")
43
+ try:
44
+ seconds = float(value)
45
+ except OverflowError as error:
46
+ raise ValueError("timeout must be finite and greater than zero") from error
47
+ if not isfinite(seconds) or seconds <= 0:
48
+ raise ValueError("timeout must be finite and greater than zero")
49
+ return seconds
50
+
51
+
52
+ def _request_timeout(value: object) -> RequestTimeout:
53
+ if isinstance(value, tuple):
54
+ if len(value) != 2:
55
+ raise ValueError("timeout must be a (connect, read) pair")
56
+ return (_timeout_seconds(value[0]), _timeout_seconds(value[1]))
57
+ return _timeout_seconds(value)
34
58
 
35
59
 
36
60
  class Client:
37
- def __init__(self, token: str) -> None:
61
+ def __init__(
62
+ self, token: str, *, timeout: RequestTimeout = _DEFAULT_TIMEOUT
63
+ ) -> None:
64
+ self._timeout = _request_timeout(timeout)
38
65
  self._token = token
39
66
  self._headers = {
40
67
  "User-Agent": _user_agent,
41
68
  "Content-Type": "application/json",
42
69
  "Authorization": f"Bearer {token}",
43
70
  }
44
- self._base_url = "https://api.mope.sr/api"
71
+ self._base_url = _BASE_URL
45
72
 
46
73
  def call_api(
47
74
  self,
@@ -59,20 +86,23 @@ class Client:
59
86
  data=data,
60
87
  json=json_value(json, path="request"),
61
88
  headers=self._headers,
89
+ timeout=self._timeout,
62
90
  )
63
91
 
64
92
 
65
93
  class Mope:
66
- def __init__(self, token: str) -> None:
94
+ def __init__(
95
+ self, token: str, *, timeout: RequestTimeout = _DEFAULT_TIMEOUT
96
+ ) -> None:
67
97
  self._token = token
68
98
  self._headers = {
69
99
  "User-Agent": _user_agent,
70
100
  "Content-Type": "application/json",
71
101
  "Authorization": f"Bearer {token}",
72
102
  }
73
- self.base_url = "https://api.mope.sr/api"
103
+ self.base_url = _BASE_URL
74
104
 
75
- self._client = Client(token)
105
+ self._client = Client(token, timeout=timeout)
76
106
 
77
107
  self.shop = ShopResource(self._client)
78
108
 
@@ -0,0 +1,52 @@
1
+ """Typed details for HTTP errors returned by the Mopé API."""
2
+
3
+ from typing import Self
4
+
5
+ from requests import HTTPError, Response
6
+
7
+ from mope.json import JsonValue, json_integer, json_object, json_string
8
+
9
+
10
+ class MopeAPIError(HTTPError):
11
+ """An HTTP rejection, with validated Mopé error details when available."""
12
+
13
+ response: Response | None
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ *,
19
+ response: Response | None = None,
20
+ code: int | None = None,
21
+ data: JsonValue = None,
22
+ ) -> None:
23
+ super().__init__(message, response=response)
24
+ self.message = message
25
+ self.code = code
26
+ self.data = data
27
+
28
+ @property
29
+ def status_code(self) -> int | None:
30
+ return self.response.status_code if self.response is not None else None
31
+
32
+ @classmethod
33
+ def from_response(cls, response: Response, *, fallback_message: str) -> Self:
34
+ try:
35
+ payload = json_object(response.json())
36
+ # The schema wraps details in "error"; documented examples are unwrapped.
37
+ details = json_object(payload.get("error", payload), path="error")
38
+ code = json_integer(details["code"], path="error.code")
39
+ message = json_string(details["message"], path="error.message")
40
+ except (TypeError, ValueError, KeyError):
41
+ return cls(fallback_message, response=response)
42
+ return cls(message, response=response, code=code, data=details.get("data"))
43
+
44
+
45
+ def raise_for_status(response: Response) -> None:
46
+ """Preserve HTTPError compatibility without letting a bad error body mask it."""
47
+ try:
48
+ response.raise_for_status()
49
+ except HTTPError as error:
50
+ raise MopeAPIError.from_response(
51
+ response, fallback_message=str(error)
52
+ ) from error
@@ -1,3 +1,4 @@
1
+ from mope.errors import raise_for_status
1
2
  from mope.models.payments import CreatePaymentResponse, CurrencyCode, PaymentRequest
2
3
  from mope.transport import ApiTransport
3
4
 
@@ -30,7 +31,7 @@ class ShopResource:
30
31
  "redirect_url": redirect_url,
31
32
  },
32
33
  )
33
- response.raise_for_status()
34
+ raise_for_status(response)
34
35
  return CreatePaymentResponse.from_json(response.json())
35
36
 
36
37
  def get_payment_request(self, payment_id: str) -> PaymentRequest:
@@ -40,5 +41,5 @@ class ShopResource:
40
41
 
41
42
  endpoint = f"shop/payment_request/{payment_id}"
42
43
  response = self._client.call_api("GET", endpoint=endpoint)
43
- response.raise_for_status()
44
+ raise_for_status(response)
44
45
  return PaymentRequest.from_json(response.json())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mope
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A client library for interacting with the Mopé Web Shop API
5
5
  Author-email: Lemuel Boyce <lemuel@vokality.com>
6
6
  License-Expression: MIT
@@ -52,6 +52,28 @@ mope = Mope(token="test_123")
52
52
  mope.shop.get_payment_request(payment_id="123")
53
53
  ```
54
54
 
55
+ ### Timeouts and errors
56
+
57
+ Requests use a 5-second connect timeout and a 30-second read timeout by default.
58
+ Configure both with `Mope(token="test_123", timeout=(3.0, 15.0))`, or pass a
59
+ single number of seconds to apply to both. `mope.client.Client` accepts the same
60
+ keyword argument. Values must be finite and greater than zero. The read timeout
61
+ limits inactivity between received bytes, not the total duration of a request.
62
+
63
+ Shop methods raise `mope.errors.MopeAPIError` for HTTP errors. It is a subclass
64
+ of `requests.HTTPError`, so existing handlers keep working. Its `status_code`,
65
+ `code`, `message`, and `data` fields expose the HTTP status and validated Mopé
66
+ error details, such as code `1033` for account limits exceeded. If the error body
67
+ is missing or malformed, `code` and `data` are `None` and `message` retains the
68
+ HTTP error description. The original response and exception cause are preserved.
69
+
70
+ Connection failures and timeouts remain `requests.RequestException` subclasses.
71
+ Malformed successful responses still raise `TypeError`, `ValueError`, or
72
+ `KeyError`; invalid JSON raises `requests.exceptions.JSONDecodeError`, which is
73
+ also a `ValueError`. Applications should translate these errors at the API call
74
+ boundary and decide which failures are retryable. Requests are not automatically
75
+ retried, including payment creation after a timeout.
76
+
55
77
  ### Development
56
78
 
57
79
  Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then run:
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  mope/__init__.py
5
5
  mope/__version__.py
6
6
  mope/client.py
7
+ mope/errors.py
7
8
  mope/json.py
8
9
  mope/py.typed
9
10
  mope/transport.py
@@ -16,5 +17,4 @@ python_mope.egg-info/SOURCES.txt
16
17
  python_mope.egg-info/dependency_links.txt
17
18
  python_mope.egg-info/requires.txt
18
19
  python_mope.egg-info/top_level.txt
19
- tests/test_models.py
20
20
  tests/test_mope.py
@@ -0,0 +1,204 @@
1
+ import json
2
+ from collections.abc import Iterator
3
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
4
+ from threading import Event, Thread
5
+ from unittest.mock import Mock, patch
6
+
7
+ import pytest
8
+ from requests import HTTPError, ReadTimeout, Response, Timeout
9
+
10
+ from mope.client import Client, Mope, RequestTimeout
11
+ from mope.errors import MopeAPIError
12
+ from mope.json import JsonObject
13
+
14
+
15
+ @pytest.fixture
16
+ def payment() -> JsonObject:
17
+ return {
18
+ "id": "payment-123",
19
+ "amount": 35638,
20
+ "created_at": "2019-12-02T14:28:14+00:00",
21
+ "description": "Order",
22
+ "expires_at": "2019-12-03T14:28:14+00:00",
23
+ "currency": {"name": "Surinamese dollar", "code": "SRD", "symbol": "$"},
24
+ "status": "paid",
25
+ "meta_data": [{"key": "order_id", "value": "order-1"}],
26
+ }
27
+
28
+
29
+ @pytest.fixture
30
+ def http_request() -> Iterator[Mock]:
31
+ with patch("requests.request") as request:
32
+ yield request
33
+
34
+
35
+ def response_for(payload: object, status: int = 200) -> Response:
36
+ response = Response()
37
+ response.status_code = status
38
+ response._content = json.dumps(payload).encode()
39
+ return response
40
+
41
+
42
+ def test_create_payment_sends_correct_request(http_request: Mock) -> None:
43
+ created = {"id": "payment-123", "url": "https://mope.sr/p/payment-123"}
44
+ http_request.return_value = response_for(created)
45
+ result = Mope("token").shop.create_payment_request(
46
+ 100, "Order", "order-1", "SRD", "https://example.com/return"
47
+ )
48
+
49
+ assert result.as_dict() == created
50
+ http_request.assert_called_once()
51
+ sent = http_request.call_args.kwargs
52
+ assert sent["method"] == "POST"
53
+ assert sent["url"] == "https://api.mope.sr/api/shop/payment_request"
54
+ assert sent["headers"]["Authorization"] == "Bearer token"
55
+ assert sent["timeout"] == (5.0, 30.0)
56
+ assert sent["json"] == {
57
+ "amount": 100,
58
+ "description": "Order",
59
+ "order_id": "order-1",
60
+ "currency": "SRD",
61
+ "redirect_url": "https://example.com/return",
62
+ }
63
+
64
+
65
+ @pytest.mark.parametrize("timeout", [2.5, (1.0, 3.0)])
66
+ def test_fetch_payment_preserves_details_and_configured_timeout(
67
+ http_request: Mock, payment: JsonObject, timeout: RequestTimeout
68
+ ) -> None:
69
+ http_request.return_value = response_for(payment)
70
+ result = Mope("token", timeout=timeout).shop.get_payment_request("payment-123")
71
+
72
+ assert result.as_dict() == payment
73
+ assert result.currency.code == "SRD"
74
+ http_request.assert_called_once()
75
+ sent = http_request.call_args.kwargs
76
+ assert sent["method"] == "GET"
77
+ assert sent["url"] == "https://api.mope.sr/api/shop/payment_request/payment-123"
78
+ assert sent["timeout"] == timeout
79
+
80
+
81
+ @pytest.mark.parametrize("create", [True, False])
82
+ def test_shop_errors_preserve_api_details_and_http_error_compatibility(
83
+ http_request: Mock, create: bool
84
+ ) -> None:
85
+ code = 1033 if create else 1002
86
+ details: JsonObject = {
87
+ "code": code,
88
+ "message": "API rejected request",
89
+ "data": {"reason": "test"},
90
+ }
91
+ # Exercise both shapes shown in Mopé's schema and examples.
92
+ response = response_for({"error": details} if create else details, status=400)
93
+ http_request.return_value = response
94
+ client = Mope("token")
95
+
96
+ with pytest.raises(HTTPError) as raised:
97
+ if create:
98
+ client.shop.create_payment_request(
99
+ 100, "Order", "order-1", "SRD", "https://example.com"
100
+ )
101
+ else:
102
+ client.shop.get_payment_request("payment-123")
103
+
104
+ error = raised.value
105
+ assert isinstance(error, MopeAPIError)
106
+ assert error.status_code == 400
107
+ assert error.code == code
108
+ assert error.message == details["message"]
109
+ assert error.data == details["data"]
110
+ assert error.response is response
111
+ assert isinstance(error.__cause__, HTTPError)
112
+
113
+
114
+ @pytest.mark.parametrize(
115
+ "body",
116
+ [
117
+ b"<html>Service unavailable</html>",
118
+ b'{"error": {"code": "invalid", "message": "Error"}}',
119
+ ],
120
+ )
121
+ def test_malformed_error_body_does_not_mask_http_failure(
122
+ http_request: Mock, body: bytes
123
+ ) -> None:
124
+ response = response_for(None, status=503)
125
+ response._content = body
126
+ http_request.return_value = response
127
+
128
+ with pytest.raises(MopeAPIError) as raised:
129
+ Mope("token").shop.get_payment_request("payment-123")
130
+
131
+ assert raised.value.status_code == 503
132
+ assert raised.value.code is None
133
+ assert "503" in raised.value.message
134
+ assert raised.value.response is response
135
+
136
+
137
+ @pytest.mark.parametrize(
138
+ "invalid, expected",
139
+ [
140
+ ("json", ValueError),
141
+ ("missing", KeyError),
142
+ ("amount", TypeError),
143
+ ("status", ValueError),
144
+ ("meta_data", TypeError),
145
+ ],
146
+ )
147
+ def test_invalid_payment_response_is_rejected(
148
+ http_request: Mock, payment: JsonObject, invalid: str, expected: type[Exception]
149
+ ) -> None:
150
+ changes: JsonObject = {
151
+ "amount": True,
152
+ "status": "unknown",
153
+ "meta_data": [{"key": "order_id", "value": 1}],
154
+ }
155
+ if invalid in changes:
156
+ payment[invalid] = changes[invalid]
157
+ response = response_for({} if invalid == "missing" else payment)
158
+ if invalid == "json":
159
+ response._content = b"not JSON"
160
+ http_request.return_value = response
161
+
162
+ with pytest.raises(expected):
163
+ Mope("token").shop.get_payment_request("payment-123")
164
+
165
+
166
+ @pytest.mark.parametrize("timeout", [0.0, (1.0, float("inf"))])
167
+ def test_timeout_must_be_positive_and_finite(timeout: RequestTimeout) -> None:
168
+ with pytest.raises(ValueError, match="finite and greater than zero"):
169
+ Mope("token", timeout=timeout)
170
+
171
+
172
+ def test_payment_creation_timeout_is_not_retried(http_request: Mock) -> None:
173
+ failure = Timeout("Server stopped responding")
174
+ http_request.side_effect = failure
175
+ with pytest.raises(Timeout) as raised:
176
+ Mope("token").shop.create_payment_request(
177
+ 100, "Order", "order-1", "SRD", "https://example.com"
178
+ )
179
+ assert raised.value is failure
180
+ http_request.assert_called_once()
181
+
182
+
183
+ def test_read_timeout_interrupts_a_stalled_http_response() -> None:
184
+ received = Event()
185
+ release = Event()
186
+
187
+ class StalledHandler(BaseHTTPRequestHandler):
188
+ def do_GET(self) -> None:
189
+ received.set()
190
+ release.wait(timeout=5)
191
+
192
+ with ThreadingHTTPServer(("127.0.0.1", 0), StalledHandler) as server:
193
+ worker = Thread(target=server.serve_forever)
194
+ worker.start()
195
+ try:
196
+ with pytest.raises(ReadTimeout):
197
+ Client("token", timeout=(1.0, 0.1)).call_api(
198
+ "GET", f"http://127.0.0.1:{server.server_port}/stalled"
199
+ )
200
+ assert received.is_set()
201
+ finally:
202
+ release.set()
203
+ server.shutdown()
204
+ worker.join(timeout=5)
@@ -1,167 +0,0 @@
1
- import pytest
2
-
3
- from mope.json import JsonObject, json_object
4
- from mope.models.payments import (
5
- BaseModel,
6
- CreatePaymentResponse,
7
- Currency,
8
- PaymentRequest,
9
- PaymentStatus,
10
- )
11
-
12
-
13
- @pytest.fixture
14
- def payment_payload() -> JsonObject:
15
- return {
16
- "id": "payment-123",
17
- "amount": 35638,
18
- "created_at": "2019-12-02T14:28:14+00:00",
19
- "description": "Order for a lot of beer",
20
- "expires_at": "2019-12-03T14:28:14+00:00",
21
- "currency": {"name": "Surinamese dollar", "code": "SRD", "symbol": "$"},
22
- "status": "paid",
23
- "meta_data": [{"key": "order_id", "value": "beer-order-123"}],
24
- }
25
-
26
-
27
- @pytest.mark.parametrize("status", ["open", "scanned", "unconfirmed", "paid"])
28
- def test_payment_round_trip(payment_payload: JsonObject, status: PaymentStatus) -> None:
29
- payment_payload["status"] = status
30
- payment = PaymentRequest.from_json(payment_payload)
31
-
32
- assert payment.amount == 35638
33
- assert isinstance(payment.currency, Currency)
34
- assert payment.currency.code == "SRD"
35
- assert payment.status == status
36
- assert payment.meta_data == [{"key": "order_id", "value": "beer-order-123"}]
37
- assert payment.as_dict() == payment_payload
38
-
39
-
40
- @pytest.mark.parametrize("amount", [True, False, "100", 100.5, None])
41
- def test_amount_is_not_coerced(payment_payload: JsonObject, amount: object) -> None:
42
- payload: dict[str, object] = dict(payment_payload)
43
- payload["amount"] = amount
44
-
45
- with pytest.raises(TypeError, match="amount must be an integer"):
46
- PaymentRequest.from_json(payload)
47
-
48
-
49
- @pytest.mark.parametrize("field", ["id", "created_at", "description", "expires_at"])
50
- def test_payment_string_fields_are_validated(
51
- payment_payload: JsonObject, field: str
52
- ) -> None:
53
- payment_payload[field] = 123
54
-
55
- with pytest.raises(TypeError, match=f"{field} must be a string"):
56
- PaymentRequest.from_json(payment_payload)
57
-
58
-
59
- @pytest.mark.parametrize("status", ["expired", None, 1, True])
60
- def test_invalid_status_is_rejected(
61
- payment_payload: JsonObject, status: object
62
- ) -> None:
63
- payload: dict[str, object] = dict(payment_payload)
64
- payload["status"] = status
65
-
66
- with pytest.raises(ValueError, match="status must be"):
67
- PaymentRequest.from_json(payload)
68
-
69
-
70
- @pytest.mark.parametrize(
71
- "metadata",
72
- [None, {}, "metadata", [1], [{"key": 1, "value": "x"}], [{"key": "x", "value": 1}]],
73
- )
74
- def test_metadata_is_validated(payment_payload: JsonObject, metadata: object) -> None:
75
- payload: dict[str, object] = dict(payment_payload)
76
- payload["meta_data"] = metadata
77
-
78
- with pytest.raises(TypeError, match="meta_data"):
79
- PaymentRequest.from_json(payload)
80
-
81
-
82
- @pytest.mark.parametrize("field", ["key", "value"])
83
- def test_metadata_requires_both_fields(payment_payload: JsonObject, field: str) -> None:
84
- entry: JsonObject = {"key": "order_id", "value": "order-123"}
85
- del entry[field]
86
- payment_payload["meta_data"] = [entry]
87
-
88
- with pytest.raises(KeyError, match=field):
89
- PaymentRequest.from_json(payment_payload)
90
-
91
-
92
- @pytest.mark.parametrize(
93
- "field",
94
- [
95
- "id",
96
- "amount",
97
- "created_at",
98
- "description",
99
- "expires_at",
100
- "currency",
101
- "status",
102
- "meta_data",
103
- ],
104
- )
105
- def test_payment_requires_all_fields(payment_payload: JsonObject, field: str) -> None:
106
- del payment_payload[field]
107
-
108
- with pytest.raises(KeyError, match=field):
109
- PaymentRequest.from_json(payment_payload)
110
-
111
-
112
- @pytest.mark.parametrize("field", ["name", "code", "symbol"])
113
- def test_currency_fields_are_validated(field: str) -> None:
114
- payload: JsonObject = {"name": "Surinamese dollar", "code": "SRD", "symbol": "$"}
115
- payload[field] = None
116
-
117
- with pytest.raises(TypeError, match=f"currency.{field} must be a string"):
118
- Currency.from_json(payload)
119
-
120
-
121
- @pytest.mark.parametrize("field", ["id", "url"])
122
- def test_creation_fields_are_validated(field: str) -> None:
123
- payload: JsonObject = {"id": "payment-123", "url": "https://mope.sr/p/payment-123"}
124
- payload[field] = 123
125
-
126
- with pytest.raises(TypeError, match=f"{field} must be a string"):
127
- CreatePaymentResponse.from_json(payload)
128
-
129
-
130
- @pytest.mark.parametrize("model", [CreatePaymentResponse, Currency, PaymentRequest])
131
- @pytest.mark.parametrize("payload", [None, [], "response", 1])
132
- def test_models_require_objects(model: type[BaseModel], payload: object) -> None:
133
- with pytest.raises(TypeError, match="must be a JSON object"):
134
- model.from_json(payload)
135
-
136
-
137
- @pytest.mark.parametrize("payload", [{1: "value"}, {"nested": {1: "value"}}])
138
- def test_json_object_keys_must_be_strings(payload: object) -> None:
139
- with pytest.raises(TypeError, match="must have string keys"):
140
- json_object(payload)
141
-
142
-
143
- @pytest.mark.parametrize("value", [object(), {1, 2}, (1, 2)])
144
- def test_non_json_values_are_rejected(value: object) -> None:
145
- with pytest.raises(TypeError, match="must be a JSON value"):
146
- json_object({"nested": [value]})
147
-
148
-
149
- @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
150
- def test_non_finite_json_numbers_are_rejected(value: float) -> None:
151
- with pytest.raises(ValueError, match="finite JSON number"):
152
- json_object({"nested": [value]})
153
-
154
-
155
- def test_json_preserves_valid_nested_values() -> None:
156
- payload: JsonObject = {"nested": [None, True, 1, 1.5, "text", {"key": "value"}]}
157
- assert json_object(payload) == payload
158
-
159
-
160
- def test_serialization_does_not_share_mutable_metadata(
161
- payment_payload: JsonObject,
162
- ) -> None:
163
- payment = PaymentRequest.from_json(payment_payload)
164
- serialized = payment.as_dict()
165
- payment.meta_data[0]["value"] = "changed"
166
-
167
- assert serialized == payment_payload
@@ -1,126 +0,0 @@
1
- import unittest
2
- from unittest import mock
3
- from unittest.mock import Mock
4
-
5
- from requests import HTTPError, Response
6
-
7
- from mope import Mope
8
- from mope.__version__ import __version__
9
- from mope.json import JsonObject
10
- from mope.models.payments import CreatePaymentResponse, Currency, PaymentRequest
11
-
12
- mock_payment_request: JsonObject = {
13
- "id": "c43f24a3-df4f-43b7-9045-b454529369dc",
14
- "amount": 35638,
15
- "created_at": "2019-12-02T14:28:14+00:00",
16
- "description": "Order for a lot of beer",
17
- "expires_at": "2019-12-03T14:28:14+00:00",
18
- "currency": {"name": "Surinamese dollar", "code": "SRD", "symbol": "$"},
19
- "status": "paid",
20
- "meta_data": [],
21
- }
22
-
23
-
24
- class MopeTestCase(unittest.TestCase):
25
- def setUp(self) -> None:
26
- self.mope = Mope("fake-token")
27
- self.client = self.mope._client
28
-
29
- @mock.patch("requests.request")
30
- def test_call_api(self, mock_request: Mock) -> None:
31
- expected_response = Response()
32
- mock_request.return_value = expected_response
33
- response = self.client.call_api(
34
- "POST", "https://api.mope.sr/api", json={"amount": 1000}
35
- )
36
- self.assertIs(response, expected_response)
37
-
38
- mock_request.assert_called_once_with(
39
- url="https://api.mope.sr/api",
40
- json={"amount": 1000},
41
- data=None,
42
- method="POST",
43
- params=None,
44
- headers={
45
- "User-Agent": f"Python Mope {__version__}",
46
- "Content-Type": "application/json",
47
- "Authorization": "Bearer fake-token",
48
- },
49
- )
50
-
51
- @mock.patch("requests.request")
52
- def test_shop_create_payment_request(self, mock_request: Mock) -> None:
53
- payload = {"id": "payment-123", "url": "https://api.mope.sr/pay/payment-123"}
54
- mock_request.return_value.json.return_value = payload
55
-
56
- response = self.mope.shop.create_payment_request(
57
- amount=1000,
58
- description="test",
59
- order_id="test-1",
60
- currency="SRD",
61
- redirect_url="https://www.sup.com/products/1",
62
- )
63
- self.assertIsInstance(response, CreatePaymentResponse)
64
- self.assertEqual(response.as_dict(), payload)
65
- mock_request.assert_called_once_with(
66
- method="POST",
67
- url="https://api.mope.sr/shop/payment_request",
68
- params=None,
69
- data=None,
70
- json={
71
- "amount": 1000,
72
- "order_id": "test-1",
73
- "currency": "SRD",
74
- "description": "test",
75
- "redirect_url": "https://www.sup.com/products/1",
76
- },
77
- headers={
78
- "User-Agent": f"Python Mope {__version__}",
79
- "Content-Type": "application/json",
80
- "Authorization": "Bearer fake-token",
81
- },
82
- )
83
-
84
- @mock.patch("requests.request")
85
- def test_shop_get_payment_request(self, mock_request: Mock) -> None:
86
- mock_request.return_value = Mock(ok=True)
87
- mock_request.return_value.json.return_value = mock_payment_request
88
-
89
- mock_response = self.mope.shop.get_payment_request(
90
- payment_id="c43f24a3-df4f-43b7-9045-b454529369dc"
91
- )
92
-
93
- self.assertTrue(isinstance(mock_response, PaymentRequest))
94
- self.assertTrue(isinstance(mock_response.currency, Currency))
95
-
96
- self.assertEqual(mock_response.as_dict(), mock_payment_request)
97
-
98
- @mock.patch("requests.request")
99
- def test_invalid_response_is_rejected(self, mock_request: Mock) -> None:
100
- mock_request.return_value.json.return_value = {
101
- **mock_payment_request,
102
- "amount": True,
103
- }
104
-
105
- with self.assertRaisesRegex(TypeError, "amount must be an integer"):
106
- self.mope.shop.get_payment_request("payment-123")
107
-
108
- @mock.patch("requests.request")
109
- def test_invalid_request_json_is_rejected(self, mock_request: Mock) -> None:
110
- with self.assertRaisesRegex(TypeError, "request.amount must be a JSON value"):
111
- self.client.call_api("POST", "endpoint", json={"amount": object()})
112
-
113
- mock_request.assert_not_called()
114
-
115
- @mock.patch("requests.request")
116
- def test_http_errors_are_raised_before_parsing(self, mock_request: Mock) -> None:
117
- mock_request.return_value.raise_for_status.side_effect = HTTPError("Not found")
118
-
119
- with self.assertRaises(HTTPError):
120
- self.mope.shop.get_payment_request("payment-123")
121
- with self.assertRaises(HTTPError):
122
- self.mope.shop.create_payment_request(
123
- 100, "order", "order-1", "SRD", "https://example.com"
124
- )
125
-
126
- mock_request.return_value.json.assert_not_called()
File without changes
File without changes
File without changes
File without changes
File without changes