Skip to content

Commit bf0897e

Browse files
committed
Add tests
1 parent 12ba685 commit bf0897e

1 file changed

Lines changed: 171 additions & 21 deletions

File tree

tests/unit/http_transport_tests.py

Lines changed: 171 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,48 @@
11
import pytest
2-
from unittest.mock import Mock
2+
from unittest.mock import Mock, call
33
from sinch.core.enums import HTTPAuthentication
44
from sinch.core.exceptions import ValidationException
55
from sinch.core.models.http_request import HttpRequest
66
from sinch.core.endpoint import HTTPEndpoint
77
from sinch.core.models.http_response import HTTPResponse
88
from 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():
4661
def 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

5872
class 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

Comments
 (0)