@@ -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
68163class 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