Skip to content

Commit 96397b2

Browse files
committed
✅ test: improve coverage for http_client.py verbose mode and DescopeResponse
- Fix: Move verbose capture before error raising so failed responses are captured - Add: Tests for all DescopeResponse dict-like methods and properties - Add: Tests for verbose mode capturing GET, POST, PATCH, DELETE responses - Add: Tests for error handling paths (AuthException, RateLimitException) - Add: Tests for header parsing and retry-after edge cases - Coverage: Achieves 98% overall, 100% for http_client.py
1 parent 6a5a90b commit 96397b2

File tree

2 files changed

+239
-4
lines changed

2 files changed

+239
-4
lines changed

descope/http_client.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,9 @@ def get(
190190
verify=self.secure,
191191
timeout=self.timeout_seconds,
192192
)
193-
self._raise_from_response(response)
194193
if self.verbose:
195194
self._last_response = DescopeResponse(response)
195+
self._raise_from_response(response)
196196
return response
197197

198198
def post(
@@ -213,9 +213,9 @@ def post(
213213
params=params,
214214
timeout=self.timeout_seconds,
215215
)
216-
self._raise_from_response(response)
217216
if self.verbose:
218217
self._last_response = DescopeResponse(response)
218+
self._raise_from_response(response)
219219
return response
220220

221221
def patch(
@@ -235,9 +235,9 @@ def patch(
235235
params=params,
236236
timeout=self.timeout_seconds,
237237
)
238-
self._raise_from_response(response)
239238
if self.verbose:
240239
self._last_response = DescopeResponse(response)
240+
self._raise_from_response(response)
241241
return response
242242

243243
def delete(
@@ -255,9 +255,9 @@ def delete(
255255
verify=self.secure,
256256
timeout=self.timeout_seconds,
257257
)
258-
self._raise_from_response(response)
259258
if self.verbose:
260259
self._last_response = DescopeResponse(response)
260+
self._raise_from_response(response)
261261
return response
262262

263263
def get_last_response(self) -> DescopeResponse | None:

tests/test_http_client.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,101 @@ def test_json_caching(self):
6464
# json() should only be called once on the underlying response
6565
assert mock_response.json.call_count == 1
6666

67+
def test_dict_like_values_items(self):
68+
"""Test that values() and items() work correctly."""
69+
mock_response = Mock()
70+
mock_response.json.return_value = {"a": 1, "b": 2}
71+
resp = DescopeResponse(mock_response)
72+
73+
assert list(resp.values()) == [1, 2]
74+
assert list(resp.items()) == [("a", 1), ("b", 2)]
75+
76+
def test_string_representation(self):
77+
"""Test __str__ and __repr__ methods."""
78+
mock_response = Mock()
79+
mock_response.json.return_value = {"result": "success"}
80+
resp = DescopeResponse(mock_response)
81+
82+
assert str(resp) == "{'result': 'success'}"
83+
assert "DescopeResponse" in repr(resp)
84+
85+
def test_bool_and_len(self):
86+
"""Test __bool__ and __len__ methods."""
87+
mock_response = Mock()
88+
mock_response.json.return_value = {"data": "value"}
89+
resp = DescopeResponse(mock_response)
90+
91+
assert bool(resp) is True
92+
assert len(resp) == 1
93+
94+
def test_equality(self):
95+
"""Test __eq__ and __ne__ methods."""
96+
mock1 = Mock()
97+
mock1.json.return_value = {"data": "value"}
98+
mock2 = Mock()
99+
mock2.json.return_value = {"data": "value"}
100+
mock3 = Mock()
101+
mock3.json.return_value = {"different": "data"}
102+
103+
resp1 = DescopeResponse(mock1)
104+
resp2 = DescopeResponse(mock2)
105+
resp3 = DescopeResponse(mock3)
106+
107+
assert resp1 == resp2
108+
assert resp1 != resp3
109+
assert resp1 == {"data": "value"}
110+
111+
def test_iter(self):
112+
"""Test __iter__ method."""
113+
mock_response = Mock()
114+
mock_response.json.return_value = {"a": 1, "b": 2}
115+
resp = DescopeResponse(mock_response)
116+
117+
assert list(resp) == ["a", "b"]
118+
119+
def test_cookies_and_content(self):
120+
"""Test cookies and content properties."""
121+
mock_response = Mock()
122+
mock_response.json.return_value = {"data": "test"}
123+
mock_response.cookies = {"session": "abc123"}
124+
mock_response.content = b'{"data":"test"}'
125+
resp = DescopeResponse(mock_response)
126+
127+
assert resp.cookies.get("session") == "abc123"
128+
assert resp.content == b'{"data":"test"}'
129+
130+
@patch("requests.get")
131+
def test_verbose_mode_captures_response_before_error(self, mock_get):
132+
"""Test that verbose mode captures response even when errors are raised.
133+
134+
This is critical for debugging - the whole point of verbose mode is to
135+
capture headers (cf-ray) from failed requests to share with support.
136+
"""
137+
from descope.exceptions import AuthException
138+
139+
mock_response = Mock()
140+
mock_response.ok = False
141+
mock_response.status_code = 401
142+
mock_response.text = "Unauthorized"
143+
mock_response.headers = {"cf-ray": "error123"}
144+
mock_response.json.return_value = {"error": "Unauthorized"}
145+
mock_get.return_value = mock_response
146+
147+
client = HTTPClient(project_id="test123", verbose=True)
148+
try:
149+
client.get("/test")
150+
assert False, "Should have raised AuthException"
151+
except AuthException:
152+
pass
153+
154+
last_resp = client.get_last_response()
155+
assert (
156+
last_resp is not None
157+
), "Response should be captured even when error occurs"
158+
assert last_resp.status_code == 401
159+
assert last_resp.headers.get("cf-ray") == "error123"
160+
assert last_resp.text == "Unauthorized"
161+
67162

68163
class TestHTTPClient(unittest.TestCase):
69164
def test_base_url_for_project_id(self):
@@ -143,6 +238,146 @@ def test_verbose_mode_captures_post_response(self, mock_post):
143238
assert last_resp["created"] == "user1"
144239
assert last_resp.status_code == 201
145240

241+
@patch("requests.patch")
242+
def test_verbose_mode_captures_patch_response(self, mock_patch):
243+
"""Test that PATCH responses are captured in verbose mode."""
244+
mock_response = Mock()
245+
mock_response.ok = True
246+
mock_response.json.return_value = {"updated": "user1"}
247+
mock_response.headers = {"cf-ray": "patch123"}
248+
mock_response.status_code = 200
249+
mock_patch.return_value = mock_response
250+
251+
client = HTTPClient(project_id="test123", verbose=True)
252+
client.patch("/users/1", body={"name": "updated"})
253+
254+
last_resp = client.get_last_response()
255+
assert last_resp is not None
256+
assert last_resp["updated"] == "user1"
257+
assert last_resp.status_code == 200
258+
259+
@patch("requests.delete")
260+
def test_verbose_mode_captures_delete_response(self, mock_delete):
261+
"""Test that DELETE responses are captured in verbose mode."""
262+
mock_response = Mock()
263+
mock_response.ok = True
264+
mock_response.json.return_value = {"deleted": "user1"}
265+
mock_response.headers = {"cf-ray": "delete123"}
266+
mock_response.status_code = 204
267+
mock_delete.return_value = mock_response
268+
269+
client = HTTPClient(project_id="test123", verbose=True)
270+
client.delete("/users/1")
271+
272+
last_resp = client.get_last_response()
273+
assert last_resp is not None
274+
assert last_resp["deleted"] == "user1"
275+
assert last_resp.status_code == 204
276+
277+
def test_raises_auth_exception_with_empty_project_id(self):
278+
"""Test that HTTPClient raises AuthException when project_id is empty."""
279+
from descope.exceptions import AuthException
280+
281+
with self.assertRaises(AuthException) as cm:
282+
HTTPClient(project_id="")
283+
284+
assert cm.exception.status_code == 400
285+
286+
@patch("requests.get")
287+
def test_raises_rate_limit_exception(self, mock_get):
288+
"""Test that HTTPClient raises RateLimitException on 429."""
289+
from descope.exceptions import RateLimitException
290+
291+
mock_response = Mock()
292+
mock_response.ok = False
293+
mock_response.status_code = 429
294+
mock_response.json.return_value = {
295+
"errorCode": "E010",
296+
"errorDescription": "Rate limit exceeded",
297+
"errorMessage": "Too many requests",
298+
}
299+
mock_response.headers = {"Retry-After": "60"}
300+
mock_get.return_value = mock_response
301+
302+
client = HTTPClient(project_id="test123")
303+
304+
with self.assertRaises(RateLimitException) as cm:
305+
client.get("/test")
306+
307+
assert cm.exception.error_type == "API rate limit exceeded"
308+
309+
@patch("requests.get")
310+
def test_raises_rate_limit_exception_without_json_body(self, mock_get):
311+
"""Test that RateLimitException is raised even when JSON parsing fails."""
312+
from descope.exceptions import RateLimitException
313+
314+
mock_response = Mock()
315+
mock_response.ok = False
316+
mock_response.status_code = 429
317+
mock_response.json.side_effect = ValueError("Invalid JSON")
318+
mock_response.headers = {"Retry-After": "30"}
319+
mock_get.return_value = mock_response
320+
321+
client = HTTPClient(project_id="test123")
322+
323+
with self.assertRaises(RateLimitException) as cm:
324+
client.get("/test")
325+
326+
assert cm.exception.error_type == "API rate limit exceeded"
327+
328+
@patch("requests.get")
329+
def test_raises_auth_exception_on_server_error(self, mock_get):
330+
"""Test that HTTPClient raises AuthException on 500."""
331+
from descope.exceptions import AuthException
332+
333+
mock_response = Mock()
334+
mock_response.ok = False
335+
mock_response.status_code = 500
336+
mock_response.text = "Internal Server Error"
337+
mock_get.return_value = mock_response
338+
339+
client = HTTPClient(project_id="test123")
340+
341+
with self.assertRaises(AuthException) as cm:
342+
client.get("/test")
343+
344+
assert cm.exception.status_code == 500
345+
346+
def test_get_default_headers_with_password(self):
347+
"""Test get_default_headers with password."""
348+
client = HTTPClient(project_id="test123")
349+
headers = client.get_default_headers("mypassword")
350+
assert "Authorization" in headers
351+
assert "test123:mypassword" in headers["Authorization"]
352+
353+
def test_get_default_headers_with_management_key(self):
354+
"""Test get_default_headers with management key."""
355+
client = HTTPClient(project_id="test123", management_key="mgmt-key")
356+
headers = client.get_default_headers()
357+
assert "Authorization" in headers
358+
assert "test123:mgmt-key" in headers["Authorization"]
359+
360+
def test_parse_retry_after_with_valid_header(self):
361+
"""Test _parse_retry_after with valid header."""
362+
client = HTTPClient(project_id="test123")
363+
headers = {"Retry-After": "60"}
364+
result = client._parse_retry_after(headers)
365+
assert result == 60
366+
367+
def test_parse_retry_after_with_missing_header(self):
368+
"""Test _parse_retry_after with missing header."""
369+
client = HTTPClient(project_id="test123")
370+
headers = {}
371+
result = client._parse_retry_after(headers)
372+
assert result == 0
373+
374+
def test_parse_retry_after_with_invalid_header(self):
375+
"""Test _parse_retry_after with invalid header."""
376+
client = HTTPClient(project_id="test123")
377+
headers = {"Retry-After": "not-a-number"}
378+
result = client._parse_retry_after(headers)
379+
assert result == 0
380+
146381
@unittest.skipIf(
147382
importlib.util.find_spec("importlib.metadata") is not None,
148383
"Stdlib metadata available; skip fallback path test",

0 commit comments

Comments
 (0)