11import pytest
2- from unittest .mock import Mock
2+ from unittest .mock import Mock , call
33from sinch .core .enums import HTTPAuthentication
44from sinch .core .exceptions import ValidationException
55from sinch .core .models .http_request import HttpRequest
66from sinch .core .endpoint import HTTPEndpoint
77from sinch .core .models .http_response import HTTPResponse
88from sinch .core .ports .http_transport import HTTPTransport
9+ from sinch .core .token_manager import TokenState
910
1011
1112# Mock classes and fixtures
12- class MockEndpoint (HTTPEndpoint ):
13- def __init__ (self , auth_type ):
14- self .HTTP_AUTHENTICATION = auth_type
15- self .HTTP_METHOD = "GET"
13+ def _make_mock_endpoint (auth_type , error_on_4xx = False ):
14+ """Create a MockEndpoint that satisfies the abstract property contract."""
1615
17- def build_url (self , sinch ):
18- return "api.sinch.com/test"
16+ class _Endpoint (HTTPEndpoint ):
17+ HTTP_AUTHENTICATION = auth_type
18+ HTTP_METHOD = "GET"
1919
20- def get_url_without_origin (self , sinch ):
21- return "/test"
20+ def __init__ (self ):
21+ # Skip super().__init__ — we don't need project_id / request_data
22+ pass
2223
23- def request_body (self ):
24- return {}
24+ def build_url (self , sinch ):
25+ return "api.sinch.com/test"
2526
26- def build_query_params (self ):
27- return {}
27+ def get_url_without_origin (self , sinch ):
28+ return "/test"
2829
29- def handle_response (self , response : HTTPResponse ):
30- return response
30+ def request_body (self ):
31+ return {}
32+
33+ def build_query_params (self ):
34+ return {}
35+
36+ def handle_response (self , response : HTTPResponse ):
37+ if error_on_4xx and response .status_code >= 400 :
38+ raise ValidationException (
39+ message = f"HTTP { response .status_code } " ,
40+ is_from_server = True ,
41+ response = response ,
42+ )
43+ return response
44+
45+ return _Endpoint ()
3146
3247
3348@pytest .fixture
@@ -46,7 +61,6 @@ def mock_sinch():
4661def base_request ():
4762 return HttpRequest (
4863 headers = {},
49- protocol = "https://" ,
5064 url = "https://api.sinch.com/test" ,
5165 http_method = "GET" ,
5266 request_body = {},
@@ -56,9 +70,24 @@ def base_request():
5670
5771
5872class MockHTTPTransport (HTTPTransport ):
59- def request (self , endpoint : HTTPEndpoint ) -> HTTPResponse :
60- # Simple mock implementation that just returns a dummy response
61- return HTTPResponse (status_code = 200 , body = {}, headers = {})
73+ """Transport whose send() returns from a pre-configured list of responses."""
74+
75+ def __init__ (self , sinch , responses = None ):
76+ super ().__init__ (sinch )
77+ self ._responses = list (responses or [])
78+ self ._call_count = 0
79+
80+ def send (self , endpoint : HTTPEndpoint ) -> HTTPResponse :
81+ if self ._call_count < len (self ._responses ):
82+ resp = self ._responses [self ._call_count ]
83+ else :
84+ resp = HTTPResponse (status_code = 200 , body = {}, headers = {})
85+ self ._call_count += 1
86+ return resp
87+
88+ @property
89+ def call_count (self ):
90+ return self ._call_count
6291
6392
6493# Synchronous Transport Tests
@@ -70,7 +99,7 @@ class TestHTTPTransport:
7099 ])
71100 def test_authenticate (self , mock_sinch , base_request , auth_type ):
72101 transport = MockHTTPTransport (mock_sinch )
73- endpoint = MockEndpoint (auth_type )
102+ endpoint = _make_mock_endpoint (auth_type )
74103
75104 if auth_type == HTTPAuthentication .BASIC .value :
76105 result = transport .authenticate (endpoint , base_request )
@@ -94,10 +123,131 @@ def test_authenticate(self, mock_sinch, base_request, auth_type):
94123 ])
95124 def test_authenticate_missing_credentials (self , mock_sinch , base_request , auth_type , missing_creds ):
96125 transport = MockHTTPTransport (mock_sinch )
97- endpoint = MockEndpoint (auth_type )
126+ endpoint = _make_mock_endpoint (auth_type )
98127
99128 for cred , value in missing_creds .items ():
100129 setattr (mock_sinch .configuration , cred , value )
101130
102131 with pytest .raises (ValidationException ):
103132 transport .authenticate (endpoint , base_request )
133+
134+
135+ class TestTokenRefreshRetry :
136+ """Tests for the automatic token refresh on 401 expired responses."""
137+
138+ @staticmethod
139+ def _expired_401 ():
140+ return HTTPResponse (
141+ status_code = 401 ,
142+ body = {"error" : "token expired" },
143+ headers = {"www-authenticate" : "Bearer error=\" expired\" " },
144+ )
145+
146+ @staticmethod
147+ def _non_expired_401 ():
148+ return HTTPResponse (
149+ status_code = 401 ,
150+ body = {"error" : "unauthorized" },
151+ headers = {"www-authenticate" : "Bearer error=\" invalid_token\" " },
152+ )
153+
154+ @staticmethod
155+ def _ok_200 ():
156+ return HTTPResponse (status_code = 200 , body = {"ok" : True }, headers = {})
157+
158+ def test_retry_succeeds_after_expired_token (self , mock_sinch ):
159+ """A single 401-expired followed by a 200 should retry once and succeed."""
160+ from sinch .core .token_manager import TokenManager
161+
162+ token_manager = Mock (spec = TokenManager )
163+ token_manager .token_state = TokenState .VALID
164+
165+ def mark_expired (http_response ):
166+ token_manager .token_state = TokenState .EXPIRED
167+
168+ token_manager .handle_invalid_token .side_effect = mark_expired
169+ mock_sinch .configuration .token_manager = token_manager
170+
171+ transport = MockHTTPTransport (
172+ mock_sinch ,
173+ responses = [self ._expired_401 (), self ._ok_200 ()],
174+ )
175+ endpoint = _make_mock_endpoint (HTTPAuthentication .OAUTH .value )
176+
177+ result = transport .request (endpoint )
178+
179+ assert result .status_code == 200
180+ assert transport .call_count == 2
181+ token_manager .handle_invalid_token .assert_called_once ()
182+
183+ def test_no_infinite_loop_on_persistent_401 (self , mock_sinch ):
184+ """Two consecutive 401-expired must NOT cause infinite retries.
185+
186+ The second 401 should be handed to the endpoint's error handler
187+ and send() should be called at most twice.
188+ """
189+ from sinch .core .token_manager import TokenManager
190+
191+ token_manager = Mock (spec = TokenManager )
192+ token_manager .token_state = TokenState .VALID
193+
194+ def mark_expired (http_response ):
195+ token_manager .token_state = TokenState .EXPIRED
196+
197+ token_manager .handle_invalid_token .side_effect = mark_expired
198+ mock_sinch .configuration .token_manager = token_manager
199+
200+ transport = MockHTTPTransport (
201+ mock_sinch ,
202+ responses = [self ._expired_401 (), self ._expired_401 ()],
203+ )
204+ endpoint = _make_mock_endpoint (HTTPAuthentication .OAUTH .value , error_on_4xx = True )
205+
206+ with pytest .raises (ValidationException , match = "401" ):
207+ transport .request (endpoint )
208+
209+ # send() must have been called exactly twice: initial + one retry
210+ assert transport .call_count == 2
211+
212+ def test_no_retry_when_401_is_not_expired (self , mock_sinch ):
213+ """A 401 without 'expired' in WWW-Authenticate should NOT trigger a retry."""
214+ from sinch .core .token_manager import TokenManager
215+
216+ token_manager = Mock (spec = TokenManager )
217+ token_manager .token_state = TokenState .VALID
218+
219+ # handle_invalid_token inspects the header but does NOT set EXPIRED
220+ # because the header says "invalid_token", not "expired"
221+ token_manager .handle_invalid_token .side_effect = lambda r : None
222+ mock_sinch .configuration .token_manager = token_manager
223+
224+ transport = MockHTTPTransport (
225+ mock_sinch ,
226+ responses = [self ._non_expired_401 ()],
227+ )
228+ endpoint = _make_mock_endpoint (HTTPAuthentication .OAUTH .value , error_on_4xx = True )
229+
230+ with pytest .raises (ValidationException , match = "401" ):
231+ transport .request (endpoint )
232+
233+ # send() called only once — no retry
234+ assert transport .call_count == 1
235+
236+ def test_no_retry_for_non_oauth_endpoint (self , mock_sinch ):
237+ """A 401 on a BASIC-auth endpoint should NOT trigger token refresh."""
238+ from sinch .core .token_manager import TokenManager
239+
240+ token_manager = Mock (spec = TokenManager )
241+ mock_sinch .configuration .token_manager = token_manager
242+
243+ transport = MockHTTPTransport (
244+ mock_sinch ,
245+ responses = [self ._expired_401 ()],
246+ )
247+ endpoint = _make_mock_endpoint (HTTPAuthentication .BASIC .value , error_on_4xx = True )
248+
249+ with pytest .raises (ValidationException , match = "401" ):
250+ transport .request (endpoint )
251+
252+ assert transport .call_count == 1
253+ token_manager .handle_invalid_token .assert_not_called ()
0 commit comments