diff --git a/.gitignore b/.gitignore index e79cdc6f..8e3c77ce 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +tox.ini .nox/ .coverage .coverage.* @@ -129,4 +130,9 @@ cython_debug/ .idea/ # Poetry -poetry.lock \ No newline at end of file +poetry.lock + +# .DS_Store files +.DS_Store + +qodana.yaml \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 665599b0..2c9529c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ keywords = ["sinch", "sdk"] python = ">=3.9" requests = "*" httpx = "*" +pydantic = ">=2.0.0" [build-system] requires = ["poetry-core"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 56637da2..d4617425 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,7 @@ # Testing pytest pytest-asyncio +pytest-mock coverage # Code Quality @@ -8,4 +9,7 @@ flake8 # HTTP Libraries httpx -requests \ No newline at end of file +requests + +# Data Validation +pydantic >= 2.0.0 \ No newline at end of file diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index 9dc430d5..db72959f 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,11 +1,7 @@ from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator -from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint -from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint -from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint -from sinch.domains.numbers.endpoints.available.rent_any_number import RentAnyNumberEndpoint +from sinch.domains.numbers.available_numbers import AvailableNumbers from sinch.domains.numbers.endpoints.callbacks.get_configuration import GetNumbersCallbackConfigurationEndpoint from sinch.domains.numbers.endpoints.callbacks.update_configuration import UpdateNumbersCallbackConfigurationEndpoint - from sinch.domains.numbers.endpoints.active.list_active_numbers_for_project import ListActiveNumbersEndpoint from sinch.domains.numbers.endpoints.active.update_number_configuration import UpdateNumberConfigurationEndpoint from sinch.domains.numbers.endpoints.active.get_number_configuration import GetNumberConfigurationEndpoint @@ -17,15 +13,7 @@ ListActiveNumbersRequest, GetNumberConfigurationRequest, UpdateNumberConfigurationRequest, ReleaseNumberFromProjectRequest ) -from sinch.domains.numbers.models.available.requests import ( - ListAvailableNumbersRequest, ActivateNumberRequest, - CheckNumberAvailabilityRequest, RentAnyNumberRequest -) from sinch.domains.numbers.models.regions.responses import ListAvailableRegionsResponse -from sinch.domains.numbers.models.available.responses import ( - ListAvailableNumbersResponse, ActivateNumberResponse, - CheckNumberAvailabilityResponse -) from sinch.domains.numbers.models.active.responses import ( ListActiveNumbersResponse, UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse @@ -39,98 +27,6 @@ ) -class AvailableNumbers: - def __init__(self, sinch): - self._sinch = sinch - - def list( - self, - region_code: str, - number_type: str, - number_pattern: str = None, - number_search_pattern: str = None, - capabilities: list = None, - page_size: int = None - ) -> ListAvailableNumbersResponse: - """ - Search for available virtual numbers using a variety of parameters to filter results. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - AvailableNumbersEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListAvailableNumbersRequest( - region_code=region_code, - number_type=number_type, - page_size=page_size, - capabilities=capabilities, - number_search_pattern=number_search_pattern, - number_pattern=number_pattern - ) - ) - ) - - def activate( - self, - phone_number: str, - sms_configuration: dict = None, - voice_configuration: dict = None - ) -> ActivateNumberResponse: - """ - Activate a virtual number to use with SMS products, Voice products, or both. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - ActivateNumberEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ActivateNumberRequest( - phone_number=phone_number, - sms_configuration=sms_configuration, - voice_configuration=voice_configuration - ) - ) - ) - - def rent_any( - self, - region_code: str, - type_: str, - number_pattern: str = None, - capabilities: list = None, - sms_configuration: dict = None, - voice_configuration: dict = None, - callback_url: str = None - ) -> RentAnyNumberRequest: - return self._sinch.configuration.transport.request( - RentAnyNumberEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=RentAnyNumberRequest( - region_code=region_code, - type_=type_, - number_pattern=number_pattern, - capabilities=capabilities, - sms_configuration=sms_configuration, - voice_configuration=voice_configuration, - callback_url=callback_url - ) - ) - ) - - def check_availability(self, phone_number: str) -> CheckNumberAvailabilityResponse: - """ - Enter a specific phone number to check availability. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - SearchForNumberEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CheckNumberAvailabilityRequest( - phone_number=phone_number - ) - ) - ) - - class ActiveNumbers: def __init__(self, sinch): self._sinch = sinch diff --git a/sinch/domains/numbers/available_numbers.py b/sinch/domains/numbers/available_numbers.py new file mode 100644 index 00000000..3f5a25b5 --- /dev/null +++ b/sinch/domains/numbers/available_numbers.py @@ -0,0 +1,314 @@ +from typing import Optional, TypedDict, overload, Literal, Union, Annotated +from typing_extensions import NotRequired +from pydantic import StrictInt, StrictStr, Field +from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint +from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint +from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint +from sinch.domains.numbers.endpoints.available.rent_any_number import RentAnyNumberEndpoint + +from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest +from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest +from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest + +from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse +from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse + +from sinch.domains.numbers.models.numbers import NumberTypeValues, CapabilityTypeValues, NumberSearchPatternTypeValues + + +class SmsConfigurationDict(TypedDict): + service_plan_id: str + campaign_id: NotRequired[str] + + +class VoiceConfigurationDictRTC(TypedDict): + type: Literal["RTC"] + app_id: NotRequired[str] + + +class VoiceConfigurationDictEST(TypedDict): + type: Literal["EST"] + trunk_id: NotRequired[str] + + +class VoiceConfigurationDictFAX(TypedDict): + type: Literal["FAX"] + service_id: NotRequired[str] + + +class VoiceConfigurationDictCustom(TypedDict): + type: str + + +class NumberPatternDict(TypedDict): + pattern: NotRequired[str] + search_pattern: NotRequired[NumberSearchPatternTypeValues] + + +VoiceConfigurationDictType = Annotated[ + Union[VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, + VoiceConfigurationDictEST, VoiceConfigurationDictCustom], + Field(discriminator="type") +] + + +class AvailableNumbers: + def __init__(self, sinch): + self._sinch = sinch + + def _request(self, endpoint_class, request_data): + """ + A helper method to make requests to endpoints. + + Args: + endpoint_class: The endpoint class to call. + request_data: The request data to pass to the endpoint. + + Returns: + The response from the Sinch transport request. + """ + return self._sinch.configuration.transport.request( + endpoint_class( + project_id=self._sinch.configuration.project_id, + request_data=request_data, + ) + ) + + def list( + self, + region_code: StrictStr, + number_type: NumberTypeValues, + number_pattern: Optional[StrictStr] = None, + number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, + capabilities: Optional[CapabilityTypeValues] = None, + page_size: Optional[StrictInt] = None, + **kwargs + ) -> ListAvailableNumbersResponse: + """ + Search for available virtual numbers for you to activate using a variety of parameters to filter results. + + Args: + region_code (StrictStr): ISO 3166-1 alpha-2 country code of the phone number. + number_type (NumberType): Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). + number_pattern (Optional[StrictStr]): Specific sequence of digits to search for. + number_search_pattern (Optional[NumberSearchPatternType]): + Pattern to apply (e.g., "START", "CONTAINS", "END"). + capabilities (Optional[CapabilityType]): Capabilities required for the number. (e.g., ["SMS", "VOICE"]) + page_size (StrictInt): Maximum number of items to return. + **kwargs: Additional filters for the request. + + Returns: + ListAvailableNumbersResponse: A response object with available numbers and their details. + + For detailed documentation, visit https://developers.sinch.com + """ + request_data = ListAvailableNumbersRequest( + region_code=region_code, + number_type=number_type, + page_size=page_size, + capabilities=capabilities, + number_search_pattern=number_search_pattern, + number_pattern=number_pattern, + **kwargs + ) + + return self._request(AvailableNumbersEndpoint, request_data) + + @overload + def activate( + self, + phone_number: StrictStr, + sms_configuration: None, + voice_configuration: None, + callback_url: Optional[StrictStr] = None + ) -> ActivateNumberResponse: + pass + + @overload + def activate( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictEST, + callback_url: Optional[StrictStr] = None + ) -> ActivateNumberResponse: + pass + + @overload + def activate( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictFAX, + callback_url: Optional[StrictStr] = None + ) -> ActivateNumberResponse: + pass + + @overload + def activate( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictRTC, + callback_url: Optional[StrictStr] = None + ) -> ActivateNumberResponse: + pass + + def activate( + self, + phone_number: StrictStr, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDictType] = None, + callback_url: Optional[StrictStr] = None, + **kwargs + ) -> ActivateNumberResponse: + """ + Activate a virtual number to use with SMS, Voice, or both products. + + Args: + phone_number (StrictStr): The phone number in E.164 format with leading +. + sms_configuration (Optional[SmsConfigurationDict]): A dictionary defining the SMS configuration. + Including fields such as: + - service_plan_id (str): The service plan ID. + - campaign_id (Optional[str]): The campaign ID. + voice_configuration (Optional[VoiceConfigurationDictType]): A dictionary defining the Voice configuration. + Supported types include: + - `VoiceConfigurationDictRTC`: type 'RTC' with an `app_id` field. + - `VoiceConfigurationDictEST`: type 'EST' with a `trunk_id` field. + - `VoiceConfigurationDictFAX`: type 'FAX' with a `service_id` field. + callback_url (Optional[StrictStr]): The callback URL to be called. + **kwargs: Additional parameters for the request. + + Returns: + ActivateNumberResponse: A response object with the activated number and its details. + + For detailed documentation, visit https://developers.sinch.com + """ + request_data = ActivateNumberRequest( + phone_number=phone_number, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + callback_url=callback_url, + **kwargs + ) + return self._request(ActivateNumberEndpoint, request_data) + + @overload + def rent_any( + self, + region_code: StrictStr, + type_: NumberTypeValues, + sms_configuration: None, + voice_configuration: None, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityTypeValues] = None, + callback_url: Optional[str] = None, + ) -> RentAnyNumberResponse: + pass + + @overload + def rent_any( + self, + region_code: StrictStr, + type_: NumberTypeValues, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictRTC, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityTypeValues] = None, + callback_url: Optional[str] = None, + ) -> RentAnyNumberResponse: + pass + + @overload + def rent_any( + self, + region_code: StrictStr, + type_: NumberTypeValues, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictFAX, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityTypeValues] = None, + callback_url: Optional[str] = None, + ) -> RentAnyNumberResponse: + pass + + @overload + def rent_any( + self, + region_code: StrictStr, + type_: NumberTypeValues, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictEST, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityTypeValues] = None, + callback_url: Optional[str] = None, + ) -> RentAnyNumberResponse: + pass + + def rent_any( + self, + region_code: StrictStr, + type_: NumberTypeValues, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityTypeValues] = None, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDictType] = None, + callback_url: Optional[str] = None, + **kwargs + ) -> RentAnyNumberResponse: + """ + Search for and activate an available Sinch virtual number all in one API call. + Currently, the rentAny operation works only for US 10DLC numbers + + Args: + region_code (str): ISO 3166-1 alpha-2 country code of the phone number. + type_ (NumberType): Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). + number_pattern (Optional[NumberPatternDict]): Specific sequence of digits to search for. + capabilities (Optional[CapabilityType]): Capabilities required for the number. (e.g., ["SMS", "VOICE"]) + sms_configuration (Optional[SmsConfigurationDict]): A dictionary defining the SMS configuration. + Including fields such as: + - service_plan_id (str): The service plan ID. + - campaign_id (Optional[str]): The campaign ID. + voice_configuration (Optional[VoiceConfigurationDictType]): A dictionary defining the Voice configuration. + Supported types include: + - `VoiceConfigurationDictRTC`: type 'RTC' with an `app_id` field. + - `VoiceConfigurationDictEST`: type 'EST' with a `trunk_id` field. + - `VoiceConfigurationDictFAX`: type 'FAX' with a `service_id` field. + callback_url (str): The callback URL to receive notifications. + **kwargs: Additional parameters for the request. + + Returns: + RentAnyNumberRequest: A response object with the activated number and its details. + + For detailed documentation, visit https://developers.sinch.com + """ + request_data = RentAnyNumberRequest( + region_code=region_code, + type_=type_, + number_pattern=number_pattern, + capabilities=capabilities, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + callback_url=callback_url, + **kwargs + ) + return self._request(RentAnyNumberEndpoint, request_data) + + def check_availability(self, phone_number: StrictStr, **kwargs) -> CheckNumberAvailabilityResponse: + """ + Enter a specific phone number to check availability. + + Args: + phone_number (str): The phone number in E.164 format with leading +. + **kwargs: Additional parameters for the request. + + Returns: + CheckNumberAvailabilityResponse: A response object with the availability status of the number. + + For detailed documentation, visit https://developers.sinch.com + """ + request_data = CheckNumberAvailabilityRequest(phone_number=phone_number, **kwargs) + return self._request(SearchForNumberEndpoint, request_data) diff --git a/sinch/domains/numbers/endpoints/available/activate_number.py b/sinch/domains/numbers/endpoints/available/activate_number.py index 1155e89e..33c92617 100644 --- a/sinch/domains/numbers/endpoints/available/activate_number.py +++ b/sinch/domains/numbers/endpoints/available/activate_number.py @@ -1,32 +1,21 @@ +from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.available.requests import ActivateNumberRequest -from sinch.domains.numbers.models.available.responses import ActivateNumberResponse +from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest +from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse class ActivateNumberEndpoint(NumbersEndpoint): + """ + Endpoint to activate a virtual number for a project. + """ ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}:rent" HTTP_METHOD = HTTPMethods.POST.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__(self, project_id: str, request_data: ActivateNumberRequest): super(ActivateNumberEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) def handle_response(self, response: HTTPResponse) -> ActivateNumberResponse: super(ActivateNumberEndpoint, self).handle_response(response) - return ActivateNumberResponse( - phone_number=response.body["phoneNumber"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"] - ) + return self.process_response_model(response.body, ActivateNumberResponse) diff --git a/sinch/domains/numbers/endpoints/available/list_available_numbers.py b/sinch/domains/numbers/endpoints/available/list_available_numbers.py index 10036e71..8e87417c 100644 --- a/sinch/domains/numbers/endpoints/available/list_available_numbers.py +++ b/sinch/domains/numbers/endpoints/available/list_available_numbers.py @@ -1,61 +1,44 @@ +from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models import Number - -from sinch.domains.numbers.models.available.requests import ListAvailableNumbersRequest -from sinch.domains.numbers.models.available.responses import ListAvailableNumbersResponse +from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse class AvailableNumbersEndpoint(NumbersEndpoint): + """ + Endpoint to list available virtual numbers for a project. + """ ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest): super(AvailableNumbersEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) def build_query_params(self) -> dict: - query_params = { - "regionCode": self.request_data.region_code, - "type": self.request_data.number_type - } - - if self.request_data.page_size: - query_params["size"] = self.request_data.page_size + """ + Constructs the query parameters for the endpoint. + + Returns: + dict: The query parameters to be sent with the API request. + """ + # Serialize fields + query_params = self.request_data.model_dump(exclude_none=True, by_alias=True) + return query_params - if self.request_data.capabilities: - query_params["capabilities"] = self.request_data.capabilities + def request_body(self): + pass - if self.request_data.number_pattern: - query_params["numberPattern.pattern"] = self.request_data.number_pattern + def handle_response(self, response: HTTPResponse) -> ListAvailableNumbersResponse: + """ + Processes the API response and maps it to a response model. - if self.request_data.number_search_pattern: - query_params["numberPattern.searchPattern"] = self.request_data.number_search_pattern + Args: + response (HTTPResponse): The raw HTTP response object received from the API. - return query_params - - def handle_response(self, response: HTTPResponse) -> ListAvailableNumbersResponse: + Returns: + ListAvailableNumbersResponse: The response model containing the parsed response data. + """ super(AvailableNumbersEndpoint, self).handle_response(response) - return ListAvailableNumbersResponse( - [ - Number( - phone_number=number["phoneNumber"], - region_code=number["regionCode"], - type=number["type"], - capability=number["capability"], - setup_price=number["setupPrice"], - monthly_price=number["monthlyPrice"], - payment_interval_months=number["paymentIntervalMonths"], - supporting_documentation_required=number["supportingDocumentationRequired"] - ) for number in response.body["availableNumbers"] - ] - ) + return self.process_response_model(response.body, ListAvailableNumbersResponse) diff --git a/sinch/domains/numbers/endpoints/available/rent_any_number.py b/sinch/domains/numbers/endpoints/available/rent_any_number.py index 692a17d0..31478435 100644 --- a/sinch/domains/numbers/endpoints/available/rent_any_number.py +++ b/sinch/domains/numbers/endpoints/available/rent_any_number.py @@ -1,67 +1,30 @@ -import json from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.available.requests import RentAnyNumberRequest -from sinch.domains.numbers.models.available.responses import RentAnyNumberResponse +from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest +from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse class RentAnyNumberEndpoint(NumbersEndpoint): + """ + Endpoint to rent an available virtual number for a project. + """ ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers:rentAny" HTTP_METHOD = HTTPMethods.POST.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__(self, project_id: str, request_data: RentAnyNumberRequest): super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def request_body(self): - request_data = self.request_data.as_dict() - request_body = {} - - if request_data.get("region_code"): - request_body["regionCode"] = request_data["region_code"] - - if request_data.get("type_"): - request_body["type"] = request_data["type_"] - - if request_data.get("number_pattern"): - request_body["numberPattern"] = request_data["number_pattern"] - - if request_data.get("capabilities"): - request_body["capabilities"] = request_data["capabilities"] - - if request_data.get("sms_configuration"): - request_body["smsConfiguration"] = request_data["sms_configuration"] - - if request_data.get("voice_configuration"): - request_body["voiceConfiguration"] = request_data["voice_configuration"] - - if request_data.get("callback_url"): - request_body["callbackUrl"] = request_data["callback_url"] + def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: + """ + Handles the response from the API call. - return json.dumps(request_body) + Args: + response (HTTPResponse): The response object from the API call. - def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: + Returns: + RentAnyNumberResponse: The response data mapped to the RentAnyNumberResponse model. + """ super(RentAnyNumberEndpoint, self).handle_response(response) - return RentAnyNumberResponse( - phone_number=response.body["phoneNumber"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - project_id=response.body["projectId"], - callback_url=response.body["callbackUrl"], - expire_at=response.body["expireAt"], - money=response.body["money"], - next_charge_date=response.body["nextChargeDate"], - sms_configuration=response.body["smsConfiguration"], - voice_configuration=response.body["voiceConfiguration"], - payment_interval_months=response.body["paymentIntervalMonths"] - ) + return self.process_response_model(response.body, RentAnyNumberResponse) diff --git a/sinch/domains/numbers/endpoints/available/search_for_number.py b/sinch/domains/numbers/endpoints/available/search_for_number.py index 1d896247..9e48c37e 100644 --- a/sinch/domains/numbers/endpoints/available/search_for_number.py +++ b/sinch/domains/numbers/endpoints/available/search_for_number.py @@ -1,36 +1,34 @@ from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.available.responses import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.available.requests import CheckNumberAvailabilityRequest +from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest class SearchForNumberEndpoint(NumbersEndpoint): + """ + Endpoint to check the availability of a virtual number for a project. + """ ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest): super(SearchForNumberEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) + def request_body(self): + pass def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse: + """ + Processes the API response and maps it to a response + + Args: + response (HTTPResponse): The raw HTTP response object received from the API. + + Returns: + CheckNumberAvailabilityResponse: The response model containing the parsed response data + of the requested phone number. + """ super(SearchForNumberEndpoint, self).handle_response(response) - return CheckNumberAvailabilityResponse( - phone_number=response.body["phoneNumber"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - setup_price=response.body["setupPrice"], - monthly_price=response.body["monthlyPrice"], - payment_interval_months=response.body["paymentIntervalMonths"], - supporting_documentation_required=response.body["supportingDocumentationRequired"] - ) + return self.process_response_model(response.body, CheckNumberAvailabilityResponse) diff --git a/sinch/domains/numbers/endpoints/numbers_endpoint.py b/sinch/domains/numbers/endpoints/numbers_endpoint.py index 1d8a9346..5ba2bf8f 100644 --- a/sinch/domains/numbers/endpoints/numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/numbers_endpoint.py @@ -1,13 +1,73 @@ +import json +from pydantic import BaseModel from sinch.core.models.http_response import HTTPResponse from sinch.core.endpoint import HTTPEndpoint from sinch.domains.numbers.exceptions import NumbersException class NumbersEndpoint(HTTPEndpoint): + """ + A base class for all endpoints, providing reusable logic for URL building + and response parsing. + """ + ENDPOINT_URL: str = "" + HTTP_METHOD: str = "" + HTTP_AUTHENTICATION: str = "" + + def __init__(self, project_id: str, request_data: object): + self.project_id = project_id + self.request_data = request_data + + def build_url(self, sinch) -> str: + """ + Constructs the URL for the endpoint. + + Args: + sinch: The Sinch client instance. + + Returns: + str: Fully constructed endpoint URL. + """ + if not self.ENDPOINT_URL: + raise NotImplementedError("ENDPOINT_URL must be defined in the subclass.") + + return self.ENDPOINT_URL.format( + origin=sinch.configuration.numbers_origin, + project_id=self.project_id, + **vars(self.request_data) + ) + + def request_body(self): + """ + Returns the request body as a JSON string. + + Returns: + str: The request body as a JSON string. + """ + # Convert the request data to a dictionary and remove None values + request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + return json.dumps(request_data) + + def process_response_model(self, response_body: dict, response_model: type[BaseModel]) -> BaseModel: + """ + Processes the response body and maps it to a response model. + + Args: + response_body (dict): The raw response body. + response_model (type): The Pydantic model class to map the response. + + Returns: + Parsed response object. + """ + try: + return response_model.model_validate(response_body) + except Exception as e: + raise ValueError(f"Invalid response structure: {e}") from e + def handle_response(self, response: HTTPResponse): if response.status_code >= 400: raise NumbersException( - message=response.body["error"].get("message"), + message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}", response=response, is_from_server=True ) diff --git a/sinch/domains/numbers/models/__init__.py b/sinch/domains/numbers/models/__init__.py index 986eb93e..e69de29b 100644 --- a/sinch/domains/numbers/models/__init__.py +++ b/sinch/domains/numbers/models/__init__.py @@ -1,41 +0,0 @@ -from dataclasses import dataclass -from decimal import Decimal -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.numbers.enums import NumberType, NumberCapability - - -@dataclass -class Number(SinchBaseModel): - phone_number: str - region_code: str - type: NumberType - capability: NumberCapability - setup_price: dict - monthly_price: dict - payment_interval_months: int - supporting_documentation_required: bool - - -@dataclass -class ScheduledVoiceProvisioning(SinchBaseModel): - app_id: str - status: str - last_updated_time: str - - -@dataclass -class VoiceConfiguration(SinchBaseModel): - app_id: str - scheduled_provisioning: ScheduledVoiceProvisioning - - -@dataclass -class SmsConfiguration(SinchBaseModel): - service_plan_id: str - scheduled_provisioning: ScheduledVoiceProvisioning - - -@dataclass -class Money(SinchBaseModel): - currency_code: str - amount: Decimal diff --git a/sinch/domains/numbers/models/available/activate_number_request.py b/sinch/domains/numbers/models/available/activate_number_request.py new file mode 100644 index 00000000..fc21fb0b --- /dev/null +++ b/sinch/domains/numbers/models/available/activate_number_request.py @@ -0,0 +1,19 @@ +from typing import Optional, Dict +from pydantic import Field, StrictStr +from sinch.domains.numbers.validators import validate_sms_voice_configuration +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest + + +class ActivateNumberRequest(BaseModelConfigRequest): + phone_number: StrictStr = Field(alias="phoneNumber") + # Accepts only dictionary input, not Pydantic models + sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[Dict] = Field(default=None, alias="voiceConfiguration") + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") + + def __init__(self, **data): + """ + Custom initializer to validate nested dictionaries. + """ + validate_sms_voice_configuration(data) + super().__init__(**data) diff --git a/sinch/domains/numbers/models/available/activate_number_response.py b/sinch/domains/numbers/models/available/activate_number_response.py new file mode 100644 index 00000000..4e48f6e0 --- /dev/null +++ b/sinch/domains/numbers/models/available/activate_number_response.py @@ -0,0 +1,22 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictInt, StrictStr +from sinch.domains.numbers.models.numbers import Money, SmsConfigurationResponse, VoiceConfigurationResponse +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse +from sinch.domains.numbers.models.numbers import CapabilityType, NumberType + + +class ActivateNumberResponse(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + display_name: Optional[StrictStr] = Field(default=None, alias="displayName") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[NumberType] = Field(default=None) + capability: Optional[CapabilityType] = Field(default=None) + money: Optional[Money] = Field(default=None) + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") + expire_at: Optional[datetime] = Field(default=None, alias="expireAt") + sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/sinch/domains/numbers/models/available/check_number_availability_request.py b/sinch/domains/numbers/models/available/check_number_availability_request.py new file mode 100644 index 00000000..12fb87df --- /dev/null +++ b/sinch/domains/numbers/models/available/check_number_availability_request.py @@ -0,0 +1,6 @@ +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest + + +class CheckNumberAvailabilityRequest(BaseModelConfigRequest): + phone_number: StrictStr = Field(alias="phoneNumber") diff --git a/sinch/domains/numbers/models/available/check_number_availability_response.py b/sinch/domains/numbers/models/available/check_number_availability_response.py new file mode 100644 index 00000000..d613e443 --- /dev/null +++ b/sinch/domains/numbers/models/available/check_number_availability_response.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import Field, StrictInt, StrictStr, StrictBool +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse +from sinch.domains.numbers.models.numbers import CapabilityType, Money, NumberType + + +class CheckNumberAvailabilityResponse(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[NumberType] = None + capability: Optional[CapabilityType] = None + setup_price: Optional[Money] = Field(default=None, alias="setupPrice") + monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + supporting_documentation_required: Optional[StrictBool] = ( + Field(default=None, alias="supportingDocumentationRequired")) diff --git a/sinch/domains/numbers/models/available/list_available_numbers_request.py b/sinch/domains/numbers/models/available/list_available_numbers_request.py new file mode 100644 index 00000000..e24675a1 --- /dev/null +++ b/sinch/domains/numbers/models/available/list_available_numbers_request.py @@ -0,0 +1,14 @@ +from typing import Optional +from pydantic import Field, StrictInt, StrictStr +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest +from sinch.domains.numbers.models.numbers import CapabilityType, NumberType, NumberSearchPatternType + + +class ListAvailableNumbersRequest(BaseModelConfigRequest): + region_code: StrictStr = Field(alias="regionCode") + number_type: NumberType = Field(alias="type") + page_size: Optional[StrictInt] = Field(default=None, alias="size") + capabilities: Optional[CapabilityType] = Field(default=None) + number_search_pattern: Optional[NumberSearchPatternType] = ( + Field(default=None, alias="numberPattern.searchPattern")) + number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") diff --git a/sinch/domains/numbers/models/available/list_available_numbers_response.py b/sinch/domains/numbers/models/available/list_available_numbers_response.py new file mode 100644 index 00000000..b8b77b06 --- /dev/null +++ b/sinch/domains/numbers/models/available/list_available_numbers_response.py @@ -0,0 +1,11 @@ +from typing import List, Optional +from pydantic import BaseModel, ConfigDict, Field +from sinch.domains.numbers.models.numbers import Number + + +class ListAvailableNumbersResponse(BaseModel): + available_numbers: Optional[List[Number]] = Field(default=None, alias="availableNumbers") + + model_config = ConfigDict( + populate_by_name=True + ) diff --git a/sinch/domains/numbers/models/available/rent_any_number_request.py b/sinch/domains/numbers/models/available/rent_any_number_request.py new file mode 100644 index 00000000..090fed5e --- /dev/null +++ b/sinch/domains/numbers/models/available/rent_any_number_request.py @@ -0,0 +1,27 @@ +from typing import Optional, Dict +from pydantic import Field, StrictStr +from sinch.domains.numbers.validators import validate_sms_voice_configuration +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest +from sinch.domains.numbers.models.numbers import NumberSearchPatternType, CapabilityType + + +class NumberPattern(BaseModelConfigRequest): + pattern: Optional[StrictStr] + search_pattern: Optional[NumberSearchPatternType] = Field(alias="searchPattern") + + +class RentAnyNumberRequest(BaseModelConfigRequest): + region_code: StrictStr = Field(default=None, alias="regionCode") + type_: StrictStr = Field(default=None, alias="type") + number_pattern: Optional[NumberPattern] = Field(default=None, alias="numberPattern") + capabilities: Optional[CapabilityType] = Field(default=None) + sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[Dict] = Field(default=None, alias="voiceConfiguration") + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") + + def __init__(self, **data): + """ + Custom initializer to validate nested dictionaries. + """ + validate_sms_voice_configuration(data) + super().__init__(**data) diff --git a/sinch/domains/numbers/models/available/rent_any_number_response.py b/sinch/domains/numbers/models/available/rent_any_number_response.py new file mode 100644 index 00000000..e80020b3 --- /dev/null +++ b/sinch/domains/numbers/models/available/rent_any_number_response.py @@ -0,0 +1,21 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictStr, StrictInt +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse +from sinch.domains.numbers.models.numbers import (CapabilityType, Money, NumberType, + SmsConfigurationResponse, VoiceConfigurationResponse) + + +class RentAnyNumberResponse(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[NumberType] = Field(default=None) + capability: Optional[CapabilityType] = Field(default=None) + money: Optional[Money] = Field(default=None) + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") + expire_at: Optional[datetime] = Field(default=None, alias="expireAt") + sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/sinch/domains/numbers/models/available/requests.py b/sinch/domains/numbers/models/available/requests.py deleted file mode 100644 index 063cedfc..00000000 --- a/sinch/domains/numbers/models/available/requests.py +++ /dev/null @@ -1,36 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListAvailableNumbersRequest(SinchRequestBaseModel): - region_code: str - number_type: str - page_size: int - capabilities: list - number_search_pattern: str - number_pattern: str - - -@dataclass -class ActivateNumberRequest(SinchRequestBaseModel): - phone_number: str - sms_configuration: dict - voice_configuration: dict - - -@dataclass -class RentAnyNumberRequest(SinchRequestBaseModel): - region_code: str - type_: str - number_pattern: str - capabilities: list - sms_configuration: dict - voice_configuration: dict - callback_url: str - - -@dataclass -class CheckNumberAvailabilityRequest(SinchRequestBaseModel): - phone_number: str diff --git a/sinch/domains/numbers/models/available/responses.py b/sinch/domains/numbers/models/available/responses.py deleted file mode 100644 index 2e1d1501..00000000 --- a/sinch/domains/numbers/models/available/responses.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.numbers.models import Number - - -@dataclass -class ListAvailableNumbersResponse(SinchBaseModel): - available_numbers: List[Number] - - -@dataclass -class ActivateNumberResponse(SinchBaseModel): - phone_number: str - region_code: str - type: str - capability: list - - -@dataclass -class RentAnyNumberResponse(SinchBaseModel): - phone_number: str - project_id: str - region_code: str - type: str - capability: list - money: dict - payment_interval_months: int - next_charge_date: str - expire_at: str - sms_configuration: object - voice_configuration: object - callback_url: str - capability: tuple - - -@dataclass -class CheckNumberAvailabilityResponse(Number): - pass diff --git a/sinch/domains/numbers/models/base_model_numbers.py b/sinch/domains/numbers/models/base_model_numbers.py new file mode 100644 index 00000000..69aa822d --- /dev/null +++ b/sinch/domains/numbers/models/base_model_numbers.py @@ -0,0 +1,95 @@ +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfigRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + @staticmethod + def _to_camel_case(snake_str: str) -> str: + """Converts snake_case to camelCase while preserving multiple underscores.""" + components = snake_str.split('_') + return components[0] + ''.join(x.capitalize() if x else '_' for x in components[1:]) + + @classmethod + def _convert_dict_keys(cls, obj): + """Recursively convert dictionary keys to camelCase.""" + if isinstance(obj, dict): + new_dict = {} + for key, value in obj.items(): + # Convert dict key to camelCase + camel_key = cls._to_camel_case(key) + # Recurse on the value + new_dict[camel_key] = cls._convert_dict_keys(value) + return new_dict + elif isinstance(obj, list): + # Recurse through any list elements (they might be dicts too) + return [cls._convert_dict_keys(item) for item in obj] + else: + return obj + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow" + ) + + def model_dump(self, **kwargs) -> dict: + """Converts extra fields from snake_case to camelCase when dumping the model in endpoint.""" + # Get the standard model dump. + data = super().model_dump(**kwargs) + + # Get extra fields + extra_data = self.__pydantic_extra__ or {} + + # Merge known + unknown into one dictionary first + combined = {**data, **extra_data} + + final_dict = {} + + for key, value in combined.items(): + if key in extra_data: + # This is an unknown field to be converted + new_key = self._to_camel_case(key) + else: + # Known field - keep the top-level key as given + new_key = key + + # Recursively convert any nested dict keys + converted_value = self._convert_dict_keys(value) + + # Add to final dictionary + final_dict[new_key] = converted_value + + return final_dict + + +class BaseModelConfigResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case + """ + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r'(? None: + """ Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/numbers/models/numbers.py b/sinch/domains/numbers/models/numbers.py new file mode 100644 index 00000000..d37b35c9 --- /dev/null +++ b/sinch/domains/numbers/models/numbers.py @@ -0,0 +1,126 @@ +from datetime import datetime +from typing import Optional, Literal, Union, Annotated +from pydantic import Field, StrictStr, StrictInt, StrictBool, conlist +from decimal import Decimal +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest, BaseModelConfigResponse + +NumberTypeValues = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] +CapabilityTypeValues = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) +NumberSearchPatternTypeValues = Union[Literal["START", "CONTAINS", "END"], StrictStr] + +CapabilityType = Annotated[ + CapabilityTypeValues, + Field(default=None) +] + +NumberSearchPatternType = Annotated[ + NumberSearchPatternTypeValues, + Field(default=None) +] + +NumberType = Annotated[ + NumberTypeValues, + Field(default=None) +] + +StatusScheduledProvisioning = Annotated[ + Union[Literal["WAITING", "IN_PROGRESS", "FAILED"], StrictStr], + Field(default=None) +] + + +class SmsConfigurationRequest(BaseModelConfigRequest): + service_plan_id: StrictStr = Field(alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") + + +class VoiceConfigurationFAX(BaseModelConfigRequest): + type: Literal["FAX"] = "FAX" + service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") + + +class VoiceConfigurationEST(BaseModelConfigRequest): + type: Literal["EST"] = "EST" + trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") + + +class VoiceConfigurationRTC(BaseModelConfigRequest): + type: Literal["RTC"] = "RTC" + app_id: Optional[StrictStr] = Field(default=None, alias="appId") + + +class VoiceConfigurationCustom(BaseModelConfigRequest): + type: StrictStr + + +VoiceConfigurationType = Annotated[ + Union[VoiceConfigurationFAX, VoiceConfigurationEST, VoiceConfigurationRTC], + Field(discriminator="type") +] + + +class ScheduledProvisioningSmsConfiguration(BaseModelConfigResponse): + service_plan_id: Optional[StrictStr] = Field(default=None, alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") + status: Optional[StatusScheduledProvisioning] = None + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + error_codes: Optional[conlist(StrictStr, min_length=1)] = Field(default=None, alias="errorCodes") + + +class SmsConfigurationResponse(BaseModelConfigResponse): + service_plan_id: StrictStr = Field(alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") + scheduled_provisioning: Optional[ScheduledProvisioningSmsConfiguration] = ( + Field(default=None, alias="scheduledProvisioning")) + + +class ScheduledVoiceProvisioningVoiceConfigurationBase(BaseModelConfigResponse): + type: Literal["FAX", "EST", "RTC"] + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + status: Optional[StatusScheduledProvisioning] = None + + +class ScheduledVoiceProvisioningVoiceConfigurationCustom(BaseModelConfigResponse): + type: StrictStr + + +class ScheduledVoiceProvisioningVoiceConfigurationFAX(ScheduledVoiceProvisioningVoiceConfigurationBase): + service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") + + +class ScheduledVoiceProvisioningVoiceConfigurationEST(ScheduledVoiceProvisioningVoiceConfigurationBase): + trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") + + +class ScheduledVoiceProvisioningVoiceConfigurationRTC(ScheduledVoiceProvisioningVoiceConfigurationBase): + app_id: Optional[StrictStr] = Field(default=None, alias="appId") + + +class VoiceConfigurationResponse(BaseModelConfigResponse): + type: Union[Literal["RTC", "EST", "FAX"], StrictStr] + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + scheduled_voice_provisioning: Union[ScheduledVoiceProvisioningVoiceConfigurationRTC, + ScheduledVoiceProvisioningVoiceConfigurationEST, + ScheduledVoiceProvisioningVoiceConfigurationFAX, + ScheduledVoiceProvisioningVoiceConfigurationCustom, + None] = Field( + default=None, alias="scheduledVoiceProvisioning" + ) + app_id: Optional[StrictStr] = Field(default=None, alias="appId") + + +class Money(BaseModelConfigResponse): + currency_code: StrictStr = Field(alias="currencyCode") + amount: Decimal + + +class Number(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[NumberType] = Field(default=None) + capability: Optional[CapabilityType] = Field(default=None) + setup_price: Optional[Money] = Field(default=None, alias="setupPrice") + monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + supporting_documentation_required: Optional[StrictBool] = ( + Field(default=None, alias="supportingDocumentationRequired")) diff --git a/sinch/domains/numbers/validators.py b/sinch/domains/numbers/validators.py new file mode 100644 index 00000000..4dd74621 --- /dev/null +++ b/sinch/domains/numbers/validators.py @@ -0,0 +1,36 @@ +from typing import Dict, Any +from sinch.domains.numbers.models.numbers import ( + SmsConfigurationRequest, VoiceConfigurationRTC, + VoiceConfigurationEST, VoiceConfigurationFAX, + VoiceConfigurationCustom +) + + +def validate_sms_voice_configuration(data: Dict[str, Any]) -> None: + """ + Validates `sms_configuration` and `voice_configuration` fields in request data. + + Args: + data (dict): The request payload. + + Raises: + ValidationError: If validation fails for the configurations. + """ + # Validate SMS Configuration + for key in ("smsConfiguration", "sms_configuration"): + if key in data and data[key] is not None: + SmsConfigurationRequest(**data[key]) + + # Validate Voice Configuration + voice_config_map = { + "RTC": VoiceConfigurationRTC, + "EST": VoiceConfigurationEST, + "FAX": VoiceConfigurationFAX, + } + + for key in ("voiceConfiguration", "voice_configuration"): + if key in data and data[key] is not None: + # Handle legacy requests + voice_type = data[key].get("type") or "RTC" + voice_config_class = voice_config_map.get(voice_type, VoiceConfigurationCustom) + voice_config_class(**data[key]) diff --git a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py new file mode 100644 index 00000000..71a57c95 --- /dev/null +++ b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py @@ -0,0 +1,99 @@ +import pytest +import json +from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint +from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest +from sinch.core.models.http_response import HTTPResponse + + +@pytest.fixture +def mock_sinch_client(): + class MockConfiguration: + numbers_origin = "https://api.sinch.com" + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() + + +@pytest.fixture +def mock_request_data(): + return ActivateNumberRequest( + phone_number="+1234567890", + sms_configuration={"servicePlanId": "YOUR_SMS_servicePlanId"}, + voice_configuration={"type": "RTC", "appId": "YOUR_Voice_appId"} + ) + +@pytest.fixture +def mock_request_data_snake_case(): + return ActivateNumberRequest( + phone_number="+1234567890", + sms_configuration={"service_plan_id": "YOUR_SMS_servicePlanId"}, + voice_configuration={"type": "RTC", "appId": "YOUR_Voice_appId"} + ) + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "mobile", + "capability": ["SMS", "Voice"] + }, + headers={"Content-Type": "application/json"} + ) + +@pytest.fixture +def mock_response_body(): + expected_body = { + "phoneNumber": "+1234567890", + "smsConfiguration": { + "servicePlanId": "YOUR_SMS_servicePlanId" + }, + "voiceConfiguration": { + "type": "RTC", + "appId": "YOUR_Voice_appId" + } + } + return json.dumps(expected_body) + + +def test_build_url_expects_correct_url(mock_sinch_client, mock_request_data): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data) + expected_url = "https://api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890:rent" + assert endpoint.build_url(mock_sinch_client) == expected_url + +def test_request_body_expects_correct_json(mock_request_data, mock_response_body): + """ + Check if request body is constructed correctly based on input data. + """ + endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data) + request_body = endpoint.request_body() + assert request_body == mock_response_body + +def test_request_body_snake_case_dict_expects_correct_json(mock_request_data_snake_case, mock_response_body): + """ + Check if request body is constructed correctly based on input data. + """ + endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data_snake_case) + request_body = endpoint.request_body() + + assert request_body == mock_response_body + +def test_handle_response_expects_correct_mapping(mock_request_data, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data) + response = endpoint.handle_response(mock_response) + + # Verify each field is mapped as expected + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "mobile" + assert response.capability == ["SMS", "Voice"] diff --git a/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py new file mode 100644 index 00000000..7e68f74e --- /dev/null +++ b/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py @@ -0,0 +1,112 @@ +import pytest +from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint +from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.core.models.http_response import HTTPResponse + +@pytest.fixture +def mock_sinch_client(): + class MockConfiguration: + numbers_origin = "https://api.sinch.com" + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() + +@pytest.fixture +def request_data(): + return ListAvailableNumbersRequest( + region_code="US", + number_type="MOBILE", + page_size=10, + capabilities=["SMS"], + number_pattern="123", + number_search_pattern="STARTS_WITH", + extra_field="extra value" + ) + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "availableNumbers": [ + { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "LOCAL", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "monthlyPrice": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "supportingDocumentationRequired": True + }, + { + "phoneNumber": "+2345678901", + "regionCode": "US", + "type": "LOCAL", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "monthlyPrice": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "supportingDocumentationRequired": True + } + ], + }, + headers={"Content-Type": "application/json"} + ) + +@pytest.fixture +def endpoint(request_data): + return AvailableNumbersEndpoint("test_project_id", request_data) + +def test_build_url(endpoint, mock_sinch_client): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + expected_url = "https://api.sinch.com/v1/projects/test_project_id/availableNumbers" + assert endpoint.build_url(mock_sinch_client) == expected_url + +def test_build_query_params_expects_correct_mapping(endpoint): + """ + Check if Query params is handled and mapped to the appropriate fields correctly. + """ + expected_params = { + "regionCode": "US", + "type": "MOBILE", + "size": 10, + "capabilities": ["SMS"], + "numberPattern.pattern": "123", + "numberPattern.searchPattern": "STARTS_WITH", + "extraField": "extra value" + } + assert endpoint.build_query_params() == expected_params + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ListAvailableNumbersResponse) + assert len(parsed_response.available_numbers) == 2 + assert parsed_response.available_numbers[0].phone_number == "+1234567890" + assert parsed_response.available_numbers[1].phone_number == "+2345678901" \ No newline at end of file diff --git a/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py new file mode 100644 index 00000000..0c7a7472 --- /dev/null +++ b/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py @@ -0,0 +1,154 @@ +import pytest +import json +from datetime import datetime, timezone +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.available_numbers import RentAnyNumberEndpoint +from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest +from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse + + +@pytest.fixture +def mock_sinch_client(): + class MockConfiguration: + numbers_origin = "https://api.sinch.com" + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() + + +@pytest.fixture +def valid_request_data(): + """ + Provides valid mock request data for RentAnyNumberRequest. + """ + return RentAnyNumberRequest( + region_code="US", + type_="MOBILE", + number_pattern={"pattern": "string", "searchPattern": "START"}, + capabilities=["SMS"], + sms_configuration={"servicePlanId": "string", "campaignId": "string"}, + voice_configuration={"appId": "string"}, + callback_url="https://www.your-callback-server.com/callback", + ) + + +@pytest.fixture +def valid_response_data(): + """ + Provides valid mock response data for RentAnyNumberResponse. + """ + return { + "phoneNumber": "+12025550134", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "displayName": "string", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "nextChargeDate": "2025-01-24T09:32:27.437Z", + "expireAt": "2025-01-25T09:32:27.437Z", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string", + "scheduledProvisioning": { + "servicePlanId": "string", + "campaignId": "string", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "errorCodes": ["ERROR_CODE_UNSPECIFIED"], + }, + }, + "voiceConfiguration": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "trunkId": "string", + }, + "appId": "string", + }, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + + +def test_build_url_expects_correct_format(mock_sinch_client, valid_request_data): + """ + Test that the build_url method constructs the URL correctly. + """ + endpoint = RentAnyNumberEndpoint(project_id="test_project", request_data=valid_request_data) + expected_url = "https://api.sinch.com/v1/projects/test_project/availableNumbers:rentAny" + assert endpoint.build_url(mock_sinch_client) == expected_url + + +def test_request_body_expects_correct_json(valid_request_data): + """ + Test that the request_body method returns the correct JSON structure. + """ + endpoint = RentAnyNumberEndpoint(project_id="test_project", request_data=valid_request_data) + request_body = endpoint.request_body() + + expected_body = { + "numberPattern": {"pattern": "string", "searchPattern": "START"}, + "regionCode": "US", + "type": "MOBILE", + "capabilities": ["SMS"], + "smsConfiguration": {"servicePlanId": "string", "campaignId": "string"}, + "voiceConfiguration": {"appId": "string"}, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + + assert json.loads(request_body) == expected_body + + +def test_handle_response_expects_valid_mapping(valid_response_data): + """ + Test that the handle_response method correctly maps the response data. + """ + mock_response = HTTPResponse(status_code=200, body=valid_response_data, + headers="Content-Type:application/json") + + endpoint = RentAnyNumberEndpoint(project_id="test_project", request_data=None) + response = endpoint.handle_response(mock_response) + + # Validate response fields + assert isinstance(response, RentAnyNumberResponse) + assert response.phone_number == "+12025550134" + assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.money.amount == 2.00 + assert response.payment_interval_months == 0 + expected_next_charge_date = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert response.next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 1, 25, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert response.expire_at == expected_expire_at + + sms_config = response.sms_configuration + assert sms_config.service_plan_id == "string" + assert sms_config.campaign_id == "string" + assert sms_config.scheduled_provisioning.service_plan_id == "string" + assert sms_config.scheduled_provisioning.campaign_id == "string" + assert sms_config.scheduled_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + expected_last_updated_time = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert sms_config.scheduled_provisioning.last_updated_time == expected_last_updated_time + assert sms_config.scheduled_provisioning.error_codes == ["ERROR_CODE_UNSPECIFIED"] + + voice_config = response.voice_configuration + assert voice_config.type == "RTC" + assert voice_config.last_updated_time == expected_last_updated_time + assert voice_config.scheduled_voice_provisioning.type == "RTC" + assert voice_config.scheduled_voice_provisioning.last_updated_time == expected_last_updated_time + assert voice_config.scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + assert voice_config.scheduled_voice_provisioning.trunk_id == "string" + assert voice_config.app_id == "string" + assert response.callback_url == "https://www.your-callback-server.com/callback" diff --git a/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py new file mode 100644 index 00000000..cc92c2d0 --- /dev/null +++ b/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py @@ -0,0 +1,104 @@ +import pytest +from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint +from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest +from sinch.core.models.http_response import HTTPResponse + + +@pytest.fixture +def mock_sinch_client(): + """ + Mock the Sinch client with configuration. + """ + class MockConfiguration: + numbers_origin = "https://api.sinch.com" + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() + + +@pytest.fixture +def mock_request_data(): + """ + Mock the request data for the endpoint. + """ + return CheckNumberAvailabilityRequest(phone_number="+1234567890") + + +@pytest.fixture +def mock_response(): + """ + Mock the HTTP response object returned by the API. + """ + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "MOBILE", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "monthlyPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "paymentIntervalMonths": 0, + "supportingDocumentationRequired": True + }, + headers={"Content-Type": "application/json"} + ) + + +def test_build_url_expects_correct_url(mock_sinch_client, mock_request_data): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + endpoint = SearchForNumberEndpoint(project_id="test_project", request_data=mock_request_data) + expected_url = "https://api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890" + assert endpoint.build_url(mock_sinch_client) == expected_url + + +def test_handle_response_expects_correct_mapping(mock_request_data, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + endpoint = SearchForNumberEndpoint(project_id="test_project", request_data=mock_request_data) + response = endpoint.handle_response(mock_response) + + assert isinstance(response, CheckNumberAvailabilityResponse) + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.setup_price.currency_code == "USD" + assert response.setup_price.amount == 2.00 + assert response.monthly_price.currency_code == "USD" + assert response.monthly_price.amount == 2.00 + assert response.payment_interval_months == 0 + assert response.supporting_documentation_required is True + + +def test_handle_response_expects_missing_fields(mock_response): + """ + Check if response handles missing fields by excluding them without failure. + """ + mock_response.body.pop("paymentIntervalMonths") + endpoint = SearchForNumberEndpoint(project_id="test_project", request_data=None) + response = endpoint.handle_response(mock_response) + + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.monthly_price.currency_code == "USD" + assert response.monthly_price.amount == 2.00 + assert response.supporting_documentation_required is True + assert response.payment_interval_months is None diff --git a/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py new file mode 100644 index 00000000..db5369fa --- /dev/null +++ b/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py @@ -0,0 +1,90 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest + +def test_activate_number_request_expects_snake_case_input(): + """ + Test that the model correctly handles snake_case input. + """ + data = { + "phone_number": "+1234567890", + "sms_configuration": {"service_plan_id": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "app_id": "YOUR_voice_appID", + "type": "RTC" + }, + "callback_url": "https://example.com/callback" + } + + # Instantiate the model + request = ActivateNumberRequest(**data) + + # Assert the field values + assert request.phone_number == "+1234567890" + assert request.sms_configuration == {"service_plan_id": "YOUR_SMS_servicePlanId"} + assert request.voice_configuration == { + "app_id": "YOUR_voice_appID", + "type": "RTC" + } + assert request.callback_url == "https://example.com/callback" + +def test_activate_number_request_expects_mixed_case_input(): + """ + Test that the model correctly handles mixed camelCase and snake_case input. + """ + data = { + "phone_number": "+1234567890", + "smsConfiguration": {"servicePlanId": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "appId": "YOUR_voice_appID", + "type": "RTC" + }, + "callback_url": "https://example.com/callback" + } + request = ActivateNumberRequest(**data) + + # Assert fields are populated correctly + assert request.phone_number == "+1234567890" + assert request.sms_configuration == {"servicePlanId": "YOUR_SMS_servicePlanId"} + assert request.voice_configuration == { + "appId": "YOUR_voice_appID", + "type": "RTC" + } + assert request.callback_url == "https://example.com/callback" + +def test_activate_number_request_expects_validation_error_for_missing_field(): + """ + Test that the model raises a validation error for missing required fields. + """ + data = { + "sms_configuration": {"servicePlanId": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "appId": "YOUR_voice_appID", + "type": "RTC" + }, + "callback_url": "https://example.com/callback" + } + with pytest.raises(ValidationError) as exc_info: + ActivateNumberRequest(**data) + + # Assert the error mentions the missing phone_number field + assert "phone_number" in str(exc_info.value) or "phoneNumber" in str(exc_info.value) + +def test_activate_number_request_expects_optional_param_none(): + """ + Test that the model correctly handles snake_case input. + """ + data = { + "phone_number": "+1234567890", + "sms_configuration": {"service_plan_id": "YOUR_SMS_servicePlanId"}, + "callback_url": "https://example.com/callback" + } + + # Instantiate the model + request = ActivateNumberRequest(**data) + + # Assert the field values + assert request.phone_number == "+1234567890" + assert request.sms_configuration == {"service_plan_id": "YOUR_SMS_servicePlanId"} + assert request.voice_configuration is None + assert request.callback_url == "https://example.com/callback" \ No newline at end of file diff --git a/tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py b/tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py new file mode 100644 index 00000000..6dc7771e --- /dev/null +++ b/tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py @@ -0,0 +1,49 @@ +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest + +def test_to_camel_case_expects_parsed_standard_cases(): + """ + Test standard snake_case to camelCase conversion. + """ + assert BaseModelConfigRequest._to_camel_case("foo_bar") == "fooBar" + assert BaseModelConfigRequest._to_camel_case("hello_world") == "helloWorld" + assert BaseModelConfigRequest._to_camel_case("this_is_a_test") == "thisIsATest" + +def test_to_camel_case_expects_parsed_edge_cases(): + """ + Test edge cases like leading/trailing underscores and multiple underscores. + """ + assert BaseModelConfigRequest._to_camel_case("foo__bar") == "foo_Bar" + assert BaseModelConfigRequest._to_camel_case("foo___bar") == "foo__Bar" + assert BaseModelConfigRequest._to_camel_case("trailing_") == "trailing_" + +def test_to_camel_case_expects_empty_string(): + """ + Test empty string case. + """ + assert BaseModelConfigRequest._to_camel_case("") == "" + +def test_to_camel_case_expects_single_word(): + """ + Test single-word cases. + """ + assert BaseModelConfigRequest._to_camel_case("word") == "word" + assert BaseModelConfigRequest._to_camel_case("single") == "single" + +def test_dict_expects_camel_case_input(): + """ + Test that the model correctly handles camelCase input. + """ + data = { + "sms_configuration": {"service_plan_id": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "appId": "YOUR_voice_appID", + "type": "RTC" + } + } + request = BaseModelConfigRequest(**data) + response = request.model_dump() + + assert response == { + 'smsConfiguration': {'servicePlanId': 'YOUR_SMS_servicePlanId'}, + 'voiceConfiguration': {'appId': 'YOUR_voice_appID', 'type': 'RTC'} + } diff --git a/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py new file mode 100644 index 00000000..285a256d --- /dev/null +++ b/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py @@ -0,0 +1,144 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest + + +def test_list_available_numbers_request_expects_snake_case_input(): + """ + Test that the model correctly handles snake_case input. + """ + data = { + "region_code": "US", + "number_type": "MOBILE", + "page_size": 10, + "capabilities": ["SMS", "VOICE"], + "number_search_pattern": "prefix", + "number_pattern": "12345" + } + + # Instantiate the model + request = ListAvailableNumbersRequest(**data) + + # Assert the field values + assert request.region_code == "US" + assert request.number_type == "MOBILE" + assert request.page_size == 10 + assert request.capabilities == ["SMS", "VOICE"] + assert request.number_search_pattern == "prefix" + assert request.number_pattern == "12345" + + +def test_list_available_numbers_request_expects_camel_case_input(): + """ + Test that the model correctly handles camelCase input. + """ + data = { + "regionCode": "US", + "type": "MOBILE", + "size": 10, + "capabilities": ["SMS", "VOICE"], + "numberPattern.searchPattern": "prefix", + "numberPattern.pattern": "12345" + } + + # Instantiate the model + request = ListAvailableNumbersRequest(**data) + + # Assert the field values + assert request.region_code == "US" + assert request.number_type == "MOBILE" + assert request.page_size == 10 + assert request.capabilities == ["SMS", "VOICE"] + assert request.number_search_pattern == "prefix" + assert request.number_pattern == "12345" + + +def test_list_available_numbers_request_expects_mixed_case_input(): + """ + Test that the model correctly handles mixed camelCase and snake_case input. + """ + data = { + "region_code": "US", + "type": "MOBILE", + "size": 10, + "capabilities": ["SMS", "VOICE"], + "number_search_pattern": "prefix", + "numberPattern.pattern": "12345" + } + + # Instantiate the model + request = ListAvailableNumbersRequest(**data) + + # Assert the field values + assert request.region_code == "US" + assert request.number_type == "MOBILE" + assert request.page_size == 10 + assert request.capabilities == ["SMS", "VOICE"] + assert request.number_search_pattern == "prefix" + assert request.number_pattern == "12345" + + +def test_list_available_numbers_request_expects_validation_error_for_missing_required_field(): + """ + Test that the model raises a validation error for missing required fields. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "capabilities": ["SMS", "VOICE"] + } + + with pytest.raises(ValidationError) as exc_info: + ListAvailableNumbersRequest(**data) + + # Assert the error mentions the missing region_code field + assert "region_code" in str(exc_info.value) or "regionCode" in str(exc_info.value) + + +def test_list_available_numbers_expects_parsed_extra_field_snake_case(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "region_code": "US", + "capabilities": ["SMS", "VOICE"], + "extraField": "Extra Value" + } + response = ListAvailableNumbersRequest(**data) + + # Assert known fields + assert response.extraField == "Extra Value" + +def test_list_available_numbers_expects_snake_case_to_parsed_extra_field_snake_case(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "region_code": "US", + "capabilities": ["SMS", "VOICE"], + "extra_field": "Extra Value" + } + response = ListAvailableNumbersRequest(**data) + + # Assert known fields + assert response.extra_field == "Extra Value" + +def test_list_available_numbers_expects_extra_capability(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "region_code": "US", + "capabilities": ["SMS", "VOICE", "EXTRA"], + "extra_field": "Extra Value" + } + response = ListAvailableNumbersRequest(**data) + + # Assert known fields + assert response.capabilities == ["SMS", "VOICE", "EXTRA"] \ No newline at end of file diff --git a/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py new file mode 100644 index 00000000..da72c394 --- /dev/null +++ b/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py @@ -0,0 +1,64 @@ +from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest + + +def test_rent_any_number_request_expects_valid_data(): + """ + Test that RentAnyNumberRequest correctly parses valid data. + """ + data = { + "numberPattern": { + "pattern": "string", + "searchPattern": "START" + }, + "regionCode": "string", + "type": "MOBILE", + "capabilities": ["SMS"], + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string" + }, + "voiceConfiguration": { + "type": "RTC", + "appId": "string" + }, + "callbackUrl": "https://www.your-callback-server.com/callback" + } + + request = RentAnyNumberRequest(**data) + + assert request.number_pattern.pattern == "string" + assert request.number_pattern.search_pattern =="START" + assert request.region_code == "string" + assert request.type_ == "MOBILE" + assert request.capabilities == ["SMS"] + assert request.sms_configuration == { + "servicePlanId": "string", + "campaignId": "string" + } + assert request.voice_configuration == { + "type": "RTC", + "appId": "string" + } + assert request.callback_url == "https://www.your-callback-server.com/callback" + + +def test_rent_any_number_request_expects_missing_optional_fields(): + """ + Test that RentAnyNumberRequest handles missing optional fields correctly. + """ + data = { + "regionCode": "string", + "type": "MOBILE" + } + + request = RentAnyNumberRequest(**data) + + assert request.region_code == "string" + assert request.type_ == "MOBILE" + + assert request.number_pattern is None + assert request.capabilities is None + assert request.sms_configuration is None + assert request.voice_configuration is None + assert request.callback_url is None + diff --git a/tests/unit/domains/numbers/models/available/requests/test_search_for_number_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_search_for_number_request_model.py new file mode 100644 index 00000000..14ed584a --- /dev/null +++ b/tests/unit/domains/numbers/models/available/requests/test_search_for_number_request_model.py @@ -0,0 +1,47 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest + + +def test_check_number_availability_request_expects_accepts_snake_case_input(): + """ + Test that the model accepts snake_case input when allow_population_by_field_name is True. + """ + request = CheckNumberAvailabilityRequest(phone_number="+1234567890") + + assert request.phone_number == "+1234567890" + + +def test_check_number_availability_request_expects_accepts_camel_case_input(): + """ + Test that the model accepts snake_case input when allow_population_by_field_name is True. + """ + request = CheckNumberAvailabilityRequest(phoneNumber="+1234567890") + + assert request.phone_number == "+1234567890" + + +def test_check_number_availability_request_expects_alias_mapping_correct(): + """ + Test that the model correctly handles alias mappings for phoneNumber. + """ + request = CheckNumberAvailabilityRequest(phoneNumber="+1234567890") + + assert request.model_dump(by_alias=True)["phoneNumber"] == "+1234567890" + assert request.model_dump(by_alias=False)["phone_number"] == "+1234567890" + + +def test_search_number_request_expects_validation_error_for_missing_field(): + """ + Test that the model raises a ValidationError when a required field is missing. + """ + data = {} + + with pytest.raises(ValidationError) as excinfo: + CheckNumberAvailabilityRequest(**data) + + error_message = str(excinfo.value) + + assert "Field required" in error_message or "field required" in error_message + assert "phoneNumber" in error_message \ No newline at end of file diff --git a/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py b/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py new file mode 100644 index 00000000..0d89410b --- /dev/null +++ b/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py @@ -0,0 +1,130 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse + +@pytest.fixture +def test_data(): + return { + "phoneNumber": "+12025550134", + "displayName": "string", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "nextChargeDate": "2025-01-22T13:19:31.095Z", + "expireAt": "2025-02-04T13:15:31.095Z", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string", + "scheduledProvisioning": { + "servicePlanId": "string", + "campaignId": "string", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "lastUpdatedTime": "2025-01-24T13:19:31.095Z", + "errorCodes": ["ERROR_CODE_UNSPECIFIED"], + }, + }, + "voiceConfiguration": { + "lastUpdatedTime": "2025-01-25T18:19:31.095Z", + "scheduledVoiceProvisioning": { + "type": "RTC", + "lastUpdatedTime": "2025-01-26T18:19:31.095Z", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "trunkId": "string", + }, + "appId": "string", + }, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + +def assert_sms_configuration(sms_config): + """ + Assert sms_configuration fields. + """ + assert sms_config.service_plan_id == "string" + assert sms_config.campaign_id == "string" + scheduled_provisioning = sms_config.scheduled_provisioning + assert scheduled_provisioning.service_plan_id == "string" + assert scheduled_provisioning.campaign_id == "string" + assert scheduled_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + expected_last_updated_time = ( + datetime(2025, 2, 21, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert scheduled_provisioning.last_updated_time == expected_last_updated_time + assert scheduled_provisioning.error_codes == ["ERROR_CODE_UNSPECIFIED"] + +def assert_voice_configuration(voice_config): + """ + Assert voice_configuration fields. + """ + assert voice_config.type == "RTC" + expected_last_updated_time = ( + datetime(2025, 1, 25, 13, 49, 31, 95000, tzinfo=timezone.utc)) + assert voice_config.last_updated_time == expected_last_updated_time + assert voice_config.app_id == "string" + scheduled_voice_provisioning = voice_config.scheduled_voice_provisioning + assert scheduled_voice_provisioning.type == "RTC" + expected_last_updated_time = ( + datetime(2025, 2, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert scheduled_voice_provisioning.last_updated_time == expected_last_updated_time + assert scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + assert scheduled_voice_provisioning.app_id == "string" + +def test_activate_number_response_expects_all_fields_mapped_correctly(test_data): + """ + Expects all fields to map correctly from camelCase input, + converts nested keys to snake_case, and handles dynamic fields + """ + data = { + "phoneNumber": "+12025550134", + "displayName": "string", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "nextChargeDate": "2025-01-22T13:19:31.095Z", + "expireAt": "2025-03-29T13:19:31.095Z", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string", + "scheduledProvisioning": { + "servicePlanId": "string", + "campaignId": "string", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "lastUpdatedTime": "2025-02-21T13:19:31.095Z", + "errorCodes": ["ERROR_CODE_UNSPECIFIED"], + }, + }, + "voiceConfiguration": { + "type": "RTC", + "lastUpdatedTime": "2025-01-25T13:49:31.095Z", + "scheduledVoiceProvisioning": { + "type": "RTC", + "lastUpdatedTime": "2025-02-22T13:19:31.095Z", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "appId": "string", + }, + "appId": "string", + }, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + response = ActivateNumberResponse(**data) + + assert response.phone_number == "+12025550134" + assert response.display_name == "string" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.payment_interval_months == 0 + expected_next_charge_data = ( + datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert response.next_charge_date == expected_next_charge_data + expected_expire_at = ( + datetime(2025, 3, 29, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert response.expire_at == expected_expire_at + assert response.callback_url == "https://www.your-callback-server.com/callback" + # Assert sms_configuration and voice_configuration using helper functions + assert_sms_configuration(response.sms_configuration) + assert_voice_configuration(response.voice_configuration) diff --git a/tests/unit/domains/numbers/models/available/response/test_base_model_response.py b/tests/unit/domains/numbers/models/available/response/test_base_model_response.py new file mode 100644 index 00000000..cd609f5c --- /dev/null +++ b/tests/unit/domains/numbers/models/available/response/test_base_model_response.py @@ -0,0 +1,15 @@ +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse + +def test_base_model_response_expects_unrecognized_fields_snake_case(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "unexpectedField": "unexpectedValue", + "anotherExtraField": 42, + } + response = BaseModelConfigResponse(**data) + + # Assert unrecognized fields are dynamically added + assert response.unexpected_field == "unexpectedValue" + assert response.another_extra_field == 42 diff --git a/tests/unit/domains/numbers/models/available/response/test_list_available_numbers_response_model.py b/tests/unit/domains/numbers/models/available/response/test_list_available_numbers_response_model.py new file mode 100644 index 00000000..79ace32b --- /dev/null +++ b/tests/unit/domains/numbers/models/available/response/test_list_available_numbers_response_model.py @@ -0,0 +1,44 @@ +import pytest +from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse + +@pytest.fixture +def test_data(): + return { + "availableNumbers": [ + { + "phoneNumber": "+12025550134", + "regionCode": "US", + "type": "MOBILE", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "monthlyPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "paymentIntervalMonths": 0, + "supportingDocumentationRequired": True + } + ] + } + +def test_list_available_numbers_response_expects_correct_mapping(test_data): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + response = ListAvailableNumbersResponse(**test_data) + assert response.available_numbers[0].phone_number == "+12025550134" + assert response.available_numbers[0].region_code == "US" + assert response.available_numbers[0].type == "MOBILE" + assert response.available_numbers[0].capability == ["SMS", "VOICE"] + assert response.available_numbers[0].setup_price.currency_code == "USD" + assert response.available_numbers[0].setup_price.amount == 2.00 + assert response.available_numbers[0].monthly_price.currency_code == "USD" + assert response.available_numbers[0].monthly_price.amount == 2.00 + assert response.available_numbers[0].payment_interval_months == 0 + assert response.available_numbers[0].supporting_documentation_required == True diff --git a/tests/unit/domains/numbers/models/available/response/test_rent_any_number_response_model.py b/tests/unit/domains/numbers/models/available/response/test_rent_any_number_response_model.py new file mode 100644 index 00000000..c0358e95 --- /dev/null +++ b/tests/unit/domains/numbers/models/available/response/test_rent_any_number_response_model.py @@ -0,0 +1,163 @@ +import pytest +from datetime import datetime, timezone +from pydantic import ValidationError +from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse + +@pytest.fixture +def valid_data(): + """ + Provides valid test data for RentAnyNumberResponse. + """ + return { + "phoneNumber": "+12025550134", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "displayName": "string", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "nextChargeDate": "2025-01-24T09:32:27.437Z", + "expireAt": "2025-01-25T09:32:27.437Z", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string", + "scheduledProvisioning": { + "servicePlanId": "string", + "campaignId": "string", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "errorCodes": ["ERROR_CODE_UNSPECIFIED"], + }, + }, + "voiceConfiguration": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "appId": "string", + }, + "appId": "string", + }, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + +def test_rent_any_number_response_expects_valid_data(valid_data): + """ + Test that RentAnyNumberResponse correctly parses valid data. + """ + + response = RentAnyNumberResponse(**valid_data) + + assert response.phone_number == "+12025550134" + assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.money.amount == 2.00 + assert response.payment_interval_months == 0 + expected_next_charge_date = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert response.next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 1, 25, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert response.expire_at == expected_expire_at + + sms_config = response.sms_configuration + assert sms_config.service_plan_id == "string" + assert sms_config.campaign_id == "string" + assert sms_config.scheduled_provisioning.service_plan_id == "string" + assert sms_config.scheduled_provisioning.campaign_id == "string" + assert sms_config.scheduled_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + expected_last_updated_time = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert sms_config.scheduled_provisioning.last_updated_time == expected_last_updated_time + assert sms_config.scheduled_provisioning.error_codes == ["ERROR_CODE_UNSPECIFIED"] + + voice_config = response.voice_configuration + assert voice_config.type == "RTC" + expected_last_updated_time = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert voice_config.last_updated_time == expected_last_updated_time + scheduled_voice_provisioning = voice_config.scheduled_voice_provisioning + assert scheduled_voice_provisioning.type == "RTC" + assert scheduled_voice_provisioning.last_updated_time == expected_last_updated_time + assert scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + assert scheduled_voice_provisioning.app_id == "string" + assert voice_config.app_id == "string" + assert response.callback_url == "https://www.your-callback-server.com/callback" + +def test_rent_any_number_response_expects_missing_optional_fields(): + """ + Test that RentAnyNumberResponse handles missing optional fields correctly. + """ + data = { + "phoneNumber": "+12025550134", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + } + + response = RentAnyNumberResponse(**data) + + assert response.phone_number == "+12025550134" + assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.money.amount == 2.00 + assert response.payment_interval_months == 0 + + assert response.next_charge_date is None + assert response.expire_at is None + assert response.sms_configuration is None + assert response.voice_configuration is None + assert response.callback_url is None + +def test_rent_any_number_response_expects_validation_error_for_missing_required_fields(): + """ + Test that RentAnyNumberResponse raises a validation error for missing required fields. + """ + data = { + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "regionCode": "US", + "smsConfiguration": { + # Missing required field "service_plan_id" + "campaignId": "string" + } + } + + with pytest.raises(ValidationError) as exc_info: + RentAnyNumberResponse(**data) + # Assert the validation error mentions missing fields + assert "smsConfiguration.servicePlanId" in str(exc_info.value) + +def test_rent_any_number_response_expects_ignore_extra_fields(): + """ + Test that RentAnyNumberResponse ignores extra fields. + """ + data = { + "phoneNumber": "+12025550134", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currency_code": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "extraField": "unexpected", + } + + response = RentAnyNumberResponse(**data) + + # Assert valid fields are parsed correctly + assert response.phone_number == "+12025550134" + assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert response.region_code == "US" + assert response.extra_field == "unexpected" \ No newline at end of file diff --git a/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py b/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py new file mode 100644 index 00000000..8c8d41cf --- /dev/null +++ b/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py @@ -0,0 +1,89 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse + +def test_check_number_availability_response_expects_valid_data(): + """ + Expects CheckNumberAvailabilityResponse to be created with valid data. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"}, + "paymentIntervalMonths": 1, + "supportingDocumentationRequired": True + } + + response = CheckNumberAvailabilityResponse(**data) + + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.setup_price.amount == 10.00 + assert response.setup_price.currency_code == "USD" + assert response.monthly_price.amount == 5.00 + assert response.monthly_price.currency_code == "USD" + assert response.payment_interval_months == 1 + assert response.supporting_documentation_required is True + +def test_check_number_availability_response_missing_optional_fields_expects_valid_data(): + """ + Verifies CheckNumberAvailabilityResponse can be created with missing optional fields, + and doesn't include them in the response. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} + } + + response = CheckNumberAvailabilityResponse(**data) + + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.setup_price.amount == 10.00 + assert response.setup_price.currency_code == "USD" + assert response.monthly_price.amount == 5.00 + assert response.monthly_price.currency_code == "USD" + assert response.payment_interval_months is None + assert response.supporting_documentation_required is None + +def test_check_number_availability_response_expects_parsed_new_type(): + """ + Test CheckNumberAvailabilityResponse with invalid data. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "NEW_TYPE", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} + } + + response = CheckNumberAvailabilityResponse(**data) + assert response.type == "NEW_TYPE" + +def test_check_number_availability_response_expects_validation_error_for_missing_required_fields(): + """ + Check if validation fails when required fields are missing. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} + } + + with pytest.raises(ValidationError): + CheckNumberAvailabilityResponse.model_validate(data, strict=True) diff --git a/tests/unit/domains/numbers/models/test_numbers.py b/tests/unit/domains/numbers/models/test_numbers.py new file mode 100644 index 00000000..bbbe5718 --- /dev/null +++ b/tests/unit/domains/numbers/models/test_numbers.py @@ -0,0 +1,110 @@ +from datetime import datetime, timezone +from sinch.domains.numbers.models.numbers import ( + ScheduledProvisioningSmsConfiguration, + SmsConfigurationResponse, + VoiceConfigurationResponse, +) + +def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): + """ + Test a valid instance of ScheduledProvisioningSmsConfiguration + """ + data = { + "servicePlanId": "test_plan", + "campaignId": "test_campaign", + "status": "ACTIVE", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "errorCodes": ["ERROR_CODE_1"] + } + config = ScheduledProvisioningSmsConfiguration.model_validate(data) + + assert config.service_plan_id == "test_plan" + assert config.campaign_id == "test_campaign" + assert config.status == "ACTIVE" + expected_last_updated_time = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert config.last_updated_time == expected_last_updated_time + assert config.error_codes == ["ERROR_CODE_1"] + +def test_scheduled_provisioning_sms_configuration_optional_fields_expects_parsed_data(): + """ + Test missing optional fields in ScheduledProvisioningSmsConfiguration + """ + data = { + "servicePlanId": "test_plan" + } + config = ScheduledProvisioningSmsConfiguration.model_validate(data) + + assert config.service_plan_id == "test_plan" + assert config.campaign_id is None + assert config.status is None + assert config.last_updated_time is None + assert config.error_codes is None + +def test_sms_configuration_valid_expects_parsed_data(): + """ + Test a valid instance of SmsConfiguration + """ + data = { + "servicePlanId": "test_plan", + "campaignId": "test_campaign", + "scheduledProvisioning": { + "servicePlanId": "test_plan", + "status": "ACTIVE" + } + } + config = SmsConfigurationResponse.model_validate(data) + + assert config.service_plan_id == "test_plan" + assert config.campaign_id == "test_campaign" + assert config.scheduled_provisioning is not None + assert config.scheduled_provisioning.service_plan_id == "test_plan" + assert config.scheduled_provisioning.status == "ACTIVE" + +def test_voice_configuration_rtc_valid_expects_parsed_data(): + """ + Test a valid RTC voice configuration + """ + data = { + "type": "RTC", + "appId": "test_app", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "status": "ACTIVE", + "appId": "test_app" + } + } + config = VoiceConfigurationResponse.model_validate(data) + + assert config.type == "RTC" + assert config.app_id == "test_app" + assert (config.last_updated_time == + datetime(2025, 1, 24, 9, 32, 27, 437000, + tzinfo=timezone.utc)) + assert config.scheduled_voice_provisioning is not None + assert config.scheduled_voice_provisioning.type == "RTC" + assert config.scheduled_voice_provisioning.status == "ACTIVE" + +def test_voice_configuration_fax_valid_expects_parsed_data(): + """ + Test a valid FAX voice configuration + """ + data = { + "type": "FAX", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "FAX", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "status": "ACTIVE", + "serviceId": "test_service" + } + } + config = VoiceConfigurationResponse.model_validate(data) + + assert config.type == "FAX" + assert config.scheduled_voice_provisioning is not None + assert config.scheduled_voice_provisioning.type == "FAX" + assert config.scheduled_voice_provisioning.status == "ACTIVE" + assert config.scheduled_voice_provisioning.service_id == "test_service" diff --git a/tests/unit/domains/numbers/test_available_numbers.py b/tests/unit/domains/numbers/test_available_numbers.py new file mode 100644 index 00000000..869403d1 --- /dev/null +++ b/tests/unit/domains/numbers/test_available_numbers.py @@ -0,0 +1,102 @@ +import pytest +from unittest.mock import MagicMock +from sinch.domains.numbers.available_numbers import AvailableNumbers +from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint +from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint +from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint + +from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest +from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest + +from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse +from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse + +@pytest.fixture +def mock_sinch(): + """Creates a mocked Sinch client.""" + mock_sinch = MagicMock() + mock_sinch.configuration.project_id = "test_project_id" + mock_sinch.configuration.transport.request = MagicMock() + return mock_sinch + + +def test_list_available_numbers_expects_valid_request(mock_sinch, mocker): + """ + Test that the AvailableNumbers.list method sends the correct request + and handles the response properly. + """ + # Use construct to create a mock response without Pydantic validation + mock_response = ListAvailableNumbersResponse(availableNumbers=[]) + mock_sinch.configuration.transport.request.return_value = mock_response + + # Spy on the AvailableNumbersEndpoint to capture calls + spy_endpoint = mocker.spy(AvailableNumbersEndpoint, "__init__") + + available_numbers = AvailableNumbers(mock_sinch) + response = available_numbers.list( + region_code="US", + number_type="LOCAL", + capabilities=["SMS", "VOICE"], + page_size=10, + number_search_pattern="START" + ) + + # Verify the endpoint's constructor was called with the correct arguments + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + # Validate the kwargs + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListAvailableNumbersRequest( + region_code="US", + number_type="LOCAL", + page_size=10, + capabilities=["SMS", "VOICE"], + number_search_pattern="START", + ) + + assert response == mock_response + mock_sinch.configuration.transport.request.assert_called_once() + + +def test_activate_number_expects_correct_request(mock_sinch, mocker): + """ + Test that the AvailableNumbers.activate method sends the correct request + and handles the response properly. + """ + mock_response = ActivateNumberResponse.model_construct() + mock_sinch.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(ActivateNumberEndpoint, "__init__") + + available_numbers = AvailableNumbers(mock_sinch) + response = available_numbers.activate(phone_number="+1234567890") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ActivateNumberRequest(phone_number="+1234567890") + + assert response == mock_response + +def test_check_availability_expects_correct_request(mock_sinch, mocker): + """ + Test that the AvailableNumbers.check_availability method sends the correct request + and handles the response properly. + """ + mock_response = CheckNumberAvailabilityResponse.model_construct() + mock_sinch.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(SearchForNumberEndpoint, "__init__") + + available_numbers = AvailableNumbers(mock_sinch) + response = available_numbers.check_availability(phone_number="+1234567890") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == CheckNumberAvailabilityRequest(phone_number="+1234567890") + + assert response == mock_response