diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py index 04671812..62c0a3cb 100644 --- a/sinch/core/adapters/requests_http_transport.py +++ b/sinch/core/adapters/requests_http_transport.py @@ -9,7 +9,7 @@ def __init__(self, sinch): super().__init__(sinch) self.http_session = requests.Session() - def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: + def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: request_data: HttpRequest = self.prepare_request(endpoint) request_data: HttpRequest = self.authenticate(endpoint, request_data) @@ -34,11 +34,8 @@ def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: f"and body: {response_body} from URL: {request_data.url}" ) - return self.handle_response( - endpoint=endpoint, - http_response=HTTPResponse( - status_code=response.status_code, - body=response_body, - headers=response.headers - ) + return HTTPResponse( + status_code=response.status_code, + body=response_body, + headers=response.headers ) diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index 0bba7730..78678397 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -3,7 +3,6 @@ from sinch.core.ports.http_transport import HTTPTransport from sinch.core.token_manager import TokenManager -from sinch.core.enums import HTTPAuthentication class Configuration: @@ -43,15 +42,12 @@ def __init__( self._sms_region_with_service_plan_id = sms_region self._sms_domain = "https://zt.{}.sms.api.sinch.com" self._sms_domain_with_service_plan_id = "https://{}.sms.api.sinch.com" - self._templates_region = "eu" - self._templates_domain = ".template.api.sinch.com" self.token_manager = token_manager self.transport: HTTPTransport = transport self._set_conversation_origin() self._set_sms_origin() self._set_sms_origin_with_service_plan_id() - self._set_templates_origin() if logger_name: self.logger = logging.getLogger(logger_name) @@ -158,35 +154,6 @@ def _get_conversation_domain(self): doc="ConversationAPI Domain" ) - def _set_templates_origin(self): - self.templates_origin = self._templates_region + self._templates_domain - - def _set_templates_region(self, region): - self._templates_region = region - self._set_templates_origin() - - def _get_templates_region(self): - return self._templates_region - - templates_region = property( - _get_templates_region, - _set_templates_region, - doc="Conversation API Templates Region" - ) - - def _set_templates_domain(self, domain): - self._templates_domain = domain - self._set_templates_origin() - - def _get_templates_domain(self): - return self._templates_domain - - templates_domain = property( - _get_templates_domain, - _set_templates_domain, - doc="Conversation API Templates Domain" - ) - def _determine_authentication_method(self): """ Determines the authentication method based on provided parameters. diff --git a/sinch/core/models/utils.py b/sinch/core/models/utils.py index aef322e2..566ae3d8 100644 --- a/sinch/core/models/utils.py +++ b/sinch/core/models/utils.py @@ -29,22 +29,29 @@ def serialize_datetime_in_dict(value: Optional[Dict[str, Any]]) -> Optional[Dict return serialized -def model_dump_for_query_params(model, exclude_none=True, by_alias=True): +def model_dump_for_query_params( + model, exclude_none=True, by_alias=True, exclude=None +): """ Serializes a Pydantic model for use as query parameters. Converts list values to comma-separated strings for APIs that expect this format. Filters out empty values (empty strings and empty lists). - + :param model: A Pydantic BaseModel instance :type model: BaseModel :param exclude_none: Whether to exclude None values (default: True) :type exclude_none: bool :param by_alias: Whether to use field aliases (default: True) :type by_alias: bool + :param exclude: Field names to omit (e.g. URL path params), passed to model_dump + :type exclude: set[str] | None :returns: Serialized model data with lists converted to comma-separated strings :rtype: dict """ - data = model.model_dump(exclude_none=exclude_none, by_alias=by_alias) + dump_kwargs = {"exclude_none": exclude_none, "by_alias": by_alias} + if exclude is not None: + dump_kwargs["exclude"] = exclude + data = model.model_dump(**dump_kwargs) filtered_data = {} for key, value in data.items(): # Filter out empty strings diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index f4e30dcd..ec0edafc 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -10,12 +10,59 @@ class HTTPTransport(ABC): + """Base class for HTTP transports. + + Subclasses implement ``send`` to perform the raw HTTP call. + The public ``request`` method adds cross-cutting concerns on top: + authentication, logging hooks, and automatic token refresh on 401. + """ + def __init__(self, sinch): self.sinch = sinch + # ------------------------------------------------------------------ + # Subclass contract + # ------------------------------------------------------------------ + @abstractmethod + def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: + """Execute a single HTTP round-trip and return the response. + + Implementations must prepare the request, authenticate, perform the + HTTP call, deserialize the response, and return an ``HTTPResponse``. + They should **not** handle token refresh — that is done by + ``request``. + """ + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: - pass + """Send a request with automatic OAuth token refresh on 401. + + If the server responds with 401 *and* the token is detected as + expired, the token is invalidated and **one** retry is attempted + with a fresh token. A second consecutive 401 is handed straight + to the endpoint's error handler — no further retries. + """ + http_response = self.send(endpoint) + + if self._should_refresh_token(endpoint, http_response): + self.sinch.configuration.token_manager.handle_invalid_token( + http_response + ) + if ( + self.sinch.configuration.token_manager.token_state + == TokenState.EXPIRED + ): + http_response = self.send(endpoint) + + return endpoint.handle_response(http_response) + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ def authenticate(self, endpoint, request_data): if endpoint.HTTP_AUTHENTICATION in (HTTPAuthentication.BASIC.value, HTTPAuthentication.OAUTH.value): @@ -83,10 +130,7 @@ def deserialize_json_response(response): response_body = response.json() except ValueError as err: raise SinchException( - message=( - "Error while parsing json response.", - err.msg - ), + message=f"Error while parsing json response. {err}", is_from_server=True, response=response ) @@ -95,10 +139,11 @@ def deserialize_json_response(response): return response_body - def handle_response(self, endpoint: HTTPEndpoint, http_response: HTTPResponse): - if http_response.status_code == 401 and endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.OAUTH.value: - self.sinch.configuration.token_manager.handle_invalid_token(http_response) - if self.sinch.configuration.token_manager.token_state == TokenState.EXPIRED: - return self.request(endpoint=endpoint) - - return endpoint.handle_response(http_response) + @staticmethod + def _should_refresh_token(endpoint, http_response): + """Return True when a 401 response should trigger a token refresh.""" + return ( + http_response.status_code == 401 + and endpoint.HTTP_AUTHENTICATION + == HTTPAuthentication.OAUTH.value + ) diff --git a/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py b/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py index 2cd6f35f..4dac4692 100644 --- a/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py +++ b/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py @@ -107,8 +107,15 @@ def process_response_model( def handle_response(self, response: HTTPResponse): if response.status_code >= 400: + error = (response.body or {}).get("error", {}) + message = error.get("message", "") + status = error.get("status", "") + error_message = ( + f"{message} {status}".strip() + or f"Error {response.status_code}" + ) raise ConversationException( - message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}", + message=error_message, response=response, is_from_server=True, ) diff --git a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py index 9b31a217..6e309763 100644 --- a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py @@ -124,8 +124,9 @@ def __init__( self.request_data = request_data def request_body(self): + path_params = self._get_path_params_from_url() request_data = self.request_data.model_dump( - by_alias=True, exclude_none=True + by_alias=True, exclude_none=True, exclude=path_params ) return json.dumps(request_data) diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index b9321256..1ad32eaa 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -33,8 +33,9 @@ def __init__(self, project_id: str, request_data: RentNumberRequest): def request_body(self) -> str: # Convert the request data to a dictionary and remove None values + path_params = self._get_path_params_from_url() request_data = self.request_data.model_dump( - by_alias=True, exclude_none=True + by_alias=True, exclude_none=True, exclude=path_params ) return json.dumps(request_data) diff --git a/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py b/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py index 43b24862..5bc463f7 100644 --- a/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py +++ b/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py @@ -1,3 +1,4 @@ +import re from abc import ABC from typing import Type from sinch.core.models.http_response import HTTPResponse @@ -23,6 +24,23 @@ def build_url(self, sinch) -> str: **vars(self.request_data), ) + def _get_path_params_from_url(self) -> set: + """ + Extracts path parameters from ENDPOINT_URL template. + + Returns: + set: Set of path parameter names that should be excluded + from request body and query params. + """ + if not self.ENDPOINT_URL: + return set() + + path_params = set(re.findall(r"\{(\w+)\}", self.ENDPOINT_URL)) + path_params.discard("origin") + path_params.discard("project_id") + + return path_params + def build_query_params(self) -> dict: """ Constructs the query parameters for the endpoint. @@ -60,15 +78,26 @@ def process_response_model( raise ValueError(f"Invalid response structure: {e}") from e def handle_response(self, response: HTTPResponse): + error_data = (response.body or {}).get("error", {}) + if response.status_code == 404: - error = NotFoundError(**response.body["error"]) + try: + error = NotFoundError(**error_data) + except TypeError: + error = f"Not found: {error_data}" raise NumbersException( message=error, response=response, is_from_server=True ) if response.status_code >= 400: + message = error_data.get("message", "") + status = error_data.get("status", "") + error_message = ( + f"{message} {status}".strip() + or f"Error {response.status_code}" + ) raise NumbersException( - message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}", + message=error_message, response=response, is_from_server=True, ) diff --git a/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py b/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py index ca4aa6e5..b80dd8a8 100644 --- a/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py @@ -32,7 +32,10 @@ def __init__( self.request_data = request_data def build_query_params(self) -> dict: - return model_dump_for_query_params(self.request_data) + path_params = self._get_path_params_from_url() + return model_dump_for_query_params( + self.request_data, exclude=path_params + ) def handle_response(self, response: HTTPResponse) -> BatchDeliveryReport: try: diff --git a/tests/conftest.py b/tests/conftest.py index 3cae4d3c..d879c2d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,6 @@ def configure_origin( sinch_client, numbers_origin, conversation_origin, - templates_origin, auth_origin, sms_origin ): @@ -56,9 +55,6 @@ def configure_origin( if conversation_origin: sinch_client.configuration.conversation_origin = conversation_origin - if templates_origin: - sinch_client.configuration.templates_origin = templates_origin - if sms_origin: sinch_client.configuration.sms_origin = sms_origin sinch_client.configuration.sms_origin_with_service_plan_id = sms_origin @@ -101,11 +97,6 @@ def sms_origin(): return os.getenv("SMS_ORIGIN") -@pytest.fixture -def templates_origin(): - return os.getenv("TEMPLATES_ORIGIN") - - @pytest.fixture def disable_ssl(): return os.getenv("DISABLE_SSL") @@ -181,7 +172,6 @@ def sinch_client_sync( key_secret, numbers_origin, conversation_origin, - templates_origin, auth_origin, sms_origin, project_id @@ -194,7 +184,6 @@ def sinch_client_sync( ), numbers_origin, conversation_origin, - templates_origin, auth_origin, sms_origin ) diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py index 34af95e3..21efe049 100644 --- a/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py @@ -51,7 +51,6 @@ def mock_response(): @pytest.fixture def mock_response_body(): expected_body = { - "phoneNumber": "+1234567890", "displayName": "Display Name", "smsConfiguration": { "servicePlanId": "Service Plan Id" diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_number_endpoint.py index edd8efb4..bc2c3800 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_number_endpoint.py @@ -40,7 +40,6 @@ def mock_response(): @pytest.fixture def mock_response_body(): expected_body = { - "phoneNumber": "+1234567890", "smsConfiguration": { "servicePlanId": "YOUR_SMS_servicePlanId" }, diff --git a/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_batch_delivery_report_endpoint.py b/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_batch_delivery_report_endpoint.py index 255845a7..b7d4dbb3 100644 --- a/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_batch_delivery_report_endpoint.py +++ b/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_batch_delivery_report_endpoint.py @@ -60,7 +60,6 @@ def test_build_url(endpoint, mock_sinch_client_sms): def test_build_query_params(endpoint): query_params = endpoint.build_query_params() expected_params = { - "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", "type": "summary", "status": "DELIVERED", "code": "400", @@ -79,7 +78,6 @@ def test_build_query_params_with_multiple_status_and_code(): endpoint = GetBatchDeliveryReportEndpoint("test_project_id", request_data) query_params = endpoint.build_query_params() expected_params = { - "batch_id": "01W4FFL35P4NC4K35SMSBATCH1", "status": "DELIVERED,FAILED,QUEUED", "code": "400,401,402", } diff --git a/tests/unit/http_transport_tests.py b/tests/unit/http_transport_tests.py index bee82710..c567dcb9 100644 --- a/tests/unit/http_transport_tests.py +++ b/tests/unit/http_transport_tests.py @@ -1,33 +1,48 @@ import pytest -from unittest.mock import Mock +from unittest.mock import Mock, call from sinch.core.enums import HTTPAuthentication from sinch.core.exceptions import ValidationException from sinch.core.models.http_request import HttpRequest from sinch.core.endpoint import HTTPEndpoint from sinch.core.models.http_response import HTTPResponse from sinch.core.ports.http_transport import HTTPTransport +from sinch.core.token_manager import TokenState # Mock classes and fixtures -class MockEndpoint(HTTPEndpoint): - def __init__(self, auth_type): - self.HTTP_AUTHENTICATION = auth_type - self.HTTP_METHOD = "GET" +def _make_mock_endpoint(auth_type, error_on_4xx=False): + """Create a MockEndpoint that satisfies the abstract property contract.""" - def build_url(self, sinch): - return "api.sinch.com/test" + class _Endpoint(HTTPEndpoint): + HTTP_AUTHENTICATION = auth_type + HTTP_METHOD = "GET" - def get_url_without_origin(self, sinch): - return "/test" + def __init__(self): + # Skip super().__init__ — we don't need project_id / request_data + pass - def request_body(self): - return {} + def build_url(self, sinch): + return "api.sinch.com/test" - def build_query_params(self): - return {} + def get_url_without_origin(self, sinch): + return "/test" - def handle_response(self, response: HTTPResponse): - return response + def request_body(self): + return {} + + def build_query_params(self): + return {} + + def handle_response(self, response: HTTPResponse): + if error_on_4xx and response.status_code >= 400: + raise ValidationException( + message=f"HTTP {response.status_code}", + is_from_server=True, + response=response, + ) + return response + + return _Endpoint() @pytest.fixture @@ -46,7 +61,6 @@ def mock_sinch(): def base_request(): return HttpRequest( headers={}, - protocol="https://", url="https://api.sinch.com/test", http_method="GET", request_body={}, @@ -56,9 +70,24 @@ def base_request(): class MockHTTPTransport(HTTPTransport): - def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: - # Simple mock implementation that just returns a dummy response - return HTTPResponse(status_code=200, body={}, headers={}) + """Transport whose send() returns from a pre-configured list of responses.""" + + def __init__(self, sinch, responses=None): + super().__init__(sinch) + self._responses = list(responses or []) + self._call_count = 0 + + def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: + if self._call_count < len(self._responses): + resp = self._responses[self._call_count] + else: + resp = HTTPResponse(status_code=200, body={}, headers={}) + self._call_count += 1 + return resp + + @property + def call_count(self): + return self._call_count # Synchronous Transport Tests @@ -70,7 +99,7 @@ class TestHTTPTransport: ]) def test_authenticate(self, mock_sinch, base_request, auth_type): transport = MockHTTPTransport(mock_sinch) - endpoint = MockEndpoint(auth_type) + endpoint = _make_mock_endpoint(auth_type) if auth_type == HTTPAuthentication.BASIC.value: result = transport.authenticate(endpoint, base_request) @@ -94,10 +123,131 @@ def test_authenticate(self, mock_sinch, base_request, auth_type): ]) def test_authenticate_missing_credentials(self, mock_sinch, base_request, auth_type, missing_creds): transport = MockHTTPTransport(mock_sinch) - endpoint = MockEndpoint(auth_type) + endpoint = _make_mock_endpoint(auth_type) for cred, value in missing_creds.items(): setattr(mock_sinch.configuration, cred, value) with pytest.raises(ValidationException): transport.authenticate(endpoint, base_request) + + +class TestTokenRefreshRetry: + """Tests for the automatic token refresh on 401 expired responses.""" + + @staticmethod + def _expired_401(): + return HTTPResponse( + status_code=401, + body={"error": "token expired"}, + headers={"www-authenticate": "Bearer error=\"expired\""}, + ) + + @staticmethod + def _non_expired_401(): + return HTTPResponse( + status_code=401, + body={"error": "unauthorized"}, + headers={"www-authenticate": "Bearer error=\"invalid_token\""}, + ) + + @staticmethod + def _ok_200(): + return HTTPResponse(status_code=200, body={"ok": True}, headers={}) + + def test_retry_succeeds_after_expired_token(self, mock_sinch): + """A single 401-expired followed by a 200 should retry once and succeed.""" + from sinch.core.token_manager import TokenManager + + token_manager = Mock(spec=TokenManager) + token_manager.token_state = TokenState.VALID + + def mark_expired(http_response): + token_manager.token_state = TokenState.EXPIRED + + token_manager.handle_invalid_token.side_effect = mark_expired + mock_sinch.configuration.token_manager = token_manager + + transport = MockHTTPTransport( + mock_sinch, + responses=[self._expired_401(), self._ok_200()], + ) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + assert transport.call_count == 2 + token_manager.handle_invalid_token.assert_called_once() + + def test_no_infinite_loop_on_persistent_401(self, mock_sinch): + """Two consecutive 401-expired must NOT cause infinite retries. + + The second 401 should be handed to the endpoint's error handler + and send() should be called at most twice. + """ + from sinch.core.token_manager import TokenManager + + token_manager = Mock(spec=TokenManager) + token_manager.token_state = TokenState.VALID + + def mark_expired(http_response): + token_manager.token_state = TokenState.EXPIRED + + token_manager.handle_invalid_token.side_effect = mark_expired + mock_sinch.configuration.token_manager = token_manager + + transport = MockHTTPTransport( + mock_sinch, + responses=[self._expired_401(), self._expired_401()], + ) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) + + with pytest.raises(ValidationException, match="401"): + transport.request(endpoint) + + # send() must have been called exactly twice: initial + one retry + assert transport.call_count == 2 + + def test_no_retry_when_401_is_not_expired(self, mock_sinch): + """A 401 without 'expired' in WWW-Authenticate should NOT trigger a retry.""" + from sinch.core.token_manager import TokenManager + + token_manager = Mock(spec=TokenManager) + token_manager.token_state = TokenState.VALID + + # handle_invalid_token inspects the header but does NOT set EXPIRED + # because the header says "invalid_token", not "expired" + token_manager.handle_invalid_token.side_effect = lambda r: None + mock_sinch.configuration.token_manager = token_manager + + transport = MockHTTPTransport( + mock_sinch, + responses=[self._non_expired_401()], + ) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) + + with pytest.raises(ValidationException, match="401"): + transport.request(endpoint) + + # send() called only once — no retry + assert transport.call_count == 1 + + def test_no_retry_for_non_oauth_endpoint(self, mock_sinch): + """A 401 on a BASIC-auth endpoint should NOT trigger token refresh.""" + from sinch.core.token_manager import TokenManager + + token_manager = Mock(spec=TokenManager) + mock_sinch.configuration.token_manager = token_manager + + transport = MockHTTPTransport( + mock_sinch, + responses=[self._expired_401()], + ) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, error_on_4xx=True) + + with pytest.raises(ValidationException, match="401"): + transport.request(endpoint) + + assert transport.call_count == 1 + token_manager.handle_invalid_token.assert_not_called() diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index db790b67..8d1a9632 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -76,18 +76,6 @@ def test_if_logger_name_was_preserved_correctly(sinch_client_sync): assert client_configuration.logger.name == clever_monty_python_quote -def test_set_templates_region_property_and_check_that_templates_origin_was_updated(sinch_client_sync): - sinch_client_sync.configuration.templates_region = "Are_you_suggesting_that_coconuts_migrate?" - assert "coconuts" in sinch_client_sync.configuration.templates_origin - assert "migrate" in sinch_client_sync.configuration.templates_origin - - -def test_set_templates_domain_property_and_check_that_templates_origin_was_updated(sinch_client_sync): - sinch_client_sync.configuration.templates_domain = "Are_you_suggesting_that_coconuts_migrate?" - assert "coconuts" in sinch_client_sync.configuration.templates_origin - assert "migrate" in sinch_client_sync.configuration.templates_origin - - def test_configuration_expects_authentication_method_determination_sms_auth_priority(sinch_client_sync): """ Test that SMS authentication takes priority over project authentication """ client_configuration = Configuration(