diff --git a/CHANGELOG.md b/CHANGELOG.md index d15fc2a7..5754e64b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file. - **[deprecation notice]** `HTTPTransport.send(endpoint)` is deprecated in favour of `send_request(request_data)`; the legacy method still works for backward compatibility, but will be removed in 3.0. - **[deprecation notice]** `TokenManagerBase.invalidate_expired_token()` and `handle_invalid_token()` (and the `TokenState.EXPIRED` value) are deprecated and will be removed in 3.0, as token renewal now goes through `refresh_auth_token()`. - **[tech]** Removed unused GitHub environment secrets from CI workflow and simplified test fixtures to use hardcoded test values. +- **[refactor]** Consolidated the duplicated per-domain `BaseModelConfiguration` classes into three shared base classes in `sinch.core.models.internal` (`BaseConfigModel`, `SnakeCaseExtrasModel`, `CamelCaseDumpModel`). - **[doc]** Improve README structure and content. diff --git a/sinch/core/models/internal/__init__.py b/sinch/core/models/internal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sinch/core/models/internal/base_model_config.py b/sinch/core/models/internal/base_model_config.py new file mode 100644 index 00000000..956352bc --- /dev/null +++ b/sinch/core/models/internal/base_model_config.py @@ -0,0 +1,95 @@ +import re +from typing import Any + +from pydantic import BaseModel, ConfigDict, SerializationInfo, model_serializer +from pydantic.functional_serializers import SerializerFunctionWrapHandler + + +def _to_camel_case(snake_str: str) -> str: + """Convert ``snake_case`` to ``camelCase`` preserving consecutive underscores. + + :param snake_str: The snake_case input string. + :returns: The camelCase form, or the input unchanged when it contains no + underscore. + """ + if not snake_str or "_" not in snake_str: + return snake_str + components = snake_str.split("_") + return components[0].lower() + "".join( + (x.capitalize() if x else "_") for x in components[1:] + ) + + +def _to_snake_case(camel_str: str) -> str: + """Convert ``camelCase`` to ``snake_case``. + + :param camel_str: The camelCase input string. + :returns: The snake_case form. + """ + return re.sub(r"(? Any: + """Recursively camelize dict keys, walking into nested dicts and lists. + + :param value: An arbitrary JSON-like value. + :returns: The same structure with every ``snake_case`` dict key converted + to ``camelCase``. + """ + if isinstance(value, dict): + return {_to_camel_case(k): _camelize_keys(v) for k, v in value.items()} + if isinstance(value, list): + return [_camelize_keys(item) for item in value] + return value + + +class _SnakifyExtrasOnInit: + """Normalize ``__pydantic_extra__`` keys to ``snake_case`` at validation time.""" + + def model_post_init(self, __context: Any) -> None: + extra = self.__pydantic_extra__ + if extra: + self.__pydantic_extra__ = {_to_snake_case(k): v for k, v in extra.items()} + + +class _CamelizeKeysOnDump: + """Recursively rewrite every dict key in the serialized output to + ``camelCase`` when ``by_alias=True``. + """ + + @model_serializer(mode="wrap") + def _serialize_with_camel_extras( + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> dict: + data = handler(self) + if info.by_alias: + data = _camelize_keys(data) + return data + + +class BaseConfigModel(BaseModel): + """Base model that allows any extra attributes. + + Use for models that do not need automatic case normalization. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class SnakeCaseExtrasModel(_SnakifyExtrasOnInit, BaseConfigModel): + """Base model that normalizes extra attributes to ``snake_case`` at validation time. + + Use for response models where extra attributes received from the API + should be normalized to ``snake_case`` regardless of the format returned + by the server. + """ + + +class CamelCaseDumpModel(_CamelizeKeysOnDump, BaseConfigModel): + """Base model that recursively rewrites ``snake_case`` keys to ``camelCase`` in + the serialized output when ``by_alias=True``. + + Use for request models targeting ``camelCase`` APIs, so that extra + attributes are emitted as ``camelCase`` in the outgoing request payload. + """ + diff --git a/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py index 200cf35e..0e174152 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py +++ b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py @@ -1,32 +1,3 @@ -import re -from typing import Any -from pydantic import BaseModel, ConfigDict +from sinch.core.models.internal.base_model_config import SnakeCaseExtrasModel - -class BaseModelConfiguration(BaseModel): - """ - Base model for all conversation message models. - Both request and response use snake_case in the Conversation API. - """ - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow", - ) - - @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) +BaseModelConfiguration = SnakeCaseExtrasModel diff --git a/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py b/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py index 204ea49d..a3cb8354 100644 --- a/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py +++ b/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py @@ -1,44 +1,7 @@ -import re -from typing import Any -from pydantic import BaseModel, ConfigDict +from sinch.core.models.internal.base_model_config import ( + BaseConfigModel, + SnakeCaseExtrasModel, +) - -class BaseModelConfigurationRequest(BaseModel): - """ - A base model that allows extra fields and converts snake_case to camelCase. - """ - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow", - ) - - -class BaseModelConfigurationResponse(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) +BaseModelConfigurationRequest = BaseConfigModel +BaseModelConfigurationResponse = SnakeCaseExtrasModel diff --git a/sinch/domains/numbers/models/v1/errors/not_found_error.py b/sinch/domains/numbers/models/v1/errors/not_found_error.py index da52f032..d4ed8a9b 100644 --- a/sinch/domains/numbers/models/v1/errors/not_found_error.py +++ b/sinch/domains/numbers/models/v1/errors/not_found_error.py @@ -1,5 +1,5 @@ from typing import Optional -from pydantic import ConfigDict, conlist, Field, StrictInt, StrictStr +from pydantic import conlist, Field, StrictInt, StrictStr from sinch.domains.numbers.models.v1.internal.base import ( BaseModelConfigurationResponse, ) @@ -13,8 +13,3 @@ class NotFoundError(BaseModelConfigurationResponse): message: Optional[StrictStr] = Field(default=None) status: Optional[StrictStr] = Field(default=None) details: Optional[conlist(NotFoundErrorDetails)] = Field(default=None) - - model_config = ConfigDict( - populate_by_name=True, - alias_generator=BaseModelConfigurationResponse._to_snake_case, - ) diff --git a/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py index 8e9d179b..079affda 100644 --- a/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py +++ b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py @@ -1,112 +1,7 @@ -import re -from typing import Any -from pydantic import BaseModel, ConfigDict +from sinch.core.models.internal.base_model_config import ( + CamelCaseDumpModel, + SnakeCaseExtrasModel, +) - -class BaseModelConfigurationRequest(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.""" - if not snake_str or "_" not in snake_str: - return snake_str - components = snake_str.split("_") - return components[0].lower() + "".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 _convert_dict_to_camel_case(self, data): - if isinstance(data, dict): - return { - self._to_camel_case(k): self._convert_dict_to_camel_case(v) - for k, v in data.items() - } - elif isinstance(data, list): - return [self._convert_dict_to_camel_case(i) for i in data] - return data - - 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) - if not kwargs or kwargs["by_alias"]: - data = self._convert_dict_to_camel_case(data) - - # 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 BaseModelConfigurationResponse(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) +BaseModelConfigurationRequest = CamelCaseDumpModel +BaseModelConfigurationResponse = SnakeCaseExtrasModel diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index f832beb5..4b877009 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -1,13 +1,16 @@ from typing import Optional -from pydantic import Field, StrictInt, StrictStr, field_validator, conlist + +from pydantic import Field, StrictInt, StrictStr, conlist, field_validator + +from sinch.core.models.internal.base_model_config import _to_camel_case from sinch.domains.numbers.models.v1.internal.base import ( BaseModelConfigurationRequest, ) from sinch.domains.numbers.models.v1.types import ( CapabilityType, - OrderByType, NumberSearchPatternType, NumberType, + OrderByType, ) @@ -33,5 +36,5 @@ class ListActiveNumbersRequest(BaseModelConfigurationRequest): @classmethod def convert_order_by(cls, value): if isinstance(value, str): - return cls._to_camel_case(value) + return _to_camel_case(value) return value diff --git a/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py index 204ea49d..a3cb8354 100644 --- a/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py +++ b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py @@ -1,44 +1,7 @@ -import re -from typing import Any -from pydantic import BaseModel, ConfigDict +from sinch.core.models.internal.base_model_config import ( + BaseConfigModel, + SnakeCaseExtrasModel, +) - -class BaseModelConfigurationRequest(BaseModel): - """ - A base model that allows extra fields and converts snake_case to camelCase. - """ - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow", - ) - - -class BaseModelConfigurationResponse(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) +BaseModelConfigurationRequest = BaseConfigModel +BaseModelConfigurationResponse = SnakeCaseExtrasModel diff --git a/tests/unit/core/models/internal/test_base_model_config.py b/tests/unit/core/models/internal/test_base_model_config.py new file mode 100644 index 00000000..3e2710c7 --- /dev/null +++ b/tests/unit/core/models/internal/test_base_model_config.py @@ -0,0 +1,231 @@ +"""Tests for the core base model configuration. + +Covers the module-level case helpers, the private mixins and the three +public model classes (BaseConfigModel, SnakeCaseExtrasModel, +CamelCaseDumpModel) including composition behavior. +""" + +import json +from typing import Any, Dict, Optional + +import pytest +from pydantic import Field + +from sinch.core.models.internal.base_model_config import ( + BaseConfigModel, + CamelCaseDumpModel, + SnakeCaseExtrasModel, + _camelize_keys, + _to_camel_case, + _to_snake_case, +) + + +# --------------------------------------------------------------------------- +# Module-level helpers +# --------------------------------------------------------------------------- + + +class TestToCamelCase: + @pytest.mark.parametrize( + "snake, camel", + [ + ("foo_bar", "fooBar"), + ("hello_world", "helloWorld"), + ("this_is_a_test", "thisIsATest"), + ("PHONE_NUMBER", "phoneNumber"), + ("appId", "appId"), + ], + ) + def test_standard_cases(self, snake, camel): + assert _to_camel_case(snake) == camel + + @pytest.mark.parametrize( + "snake, camel", + [ + # Documented deviation from pydantic.alias_generators.to_camel: + # consecutive underscores are preserved rather than collapsed. + ("foo__bar", "foo_Bar"), + ("foo___bar", "foo__Bar"), + ("trailing_", "trailing_"), + ], + ) + def test_edge_cases_preserve_consecutive_underscores(self, snake, camel): + assert _to_camel_case(snake) == camel + + def test_empty_string(self): + assert _to_camel_case("") == "" + + @pytest.mark.parametrize("word", ["word", "single"]) + def test_single_word_returns_unchanged(self, word): + assert _to_camel_case(word) == word + + +class TestToSnakeCase: + @pytest.mark.parametrize( + "camel, snake", + [ + ("camelCase", "camel_case"), + ("fooBar", "foo_bar"), + ("simpleField", "simple_field"), + ("already_snake", "already_snake"), + ("lowercase", "lowercase"), + ], + ) + def test_standard_cases(self, camel, snake): + assert _to_snake_case(camel) == snake + + +class TestCamelizeKeys: + def test_flat_dict(self): + assert _camelize_keys({"foo_bar": 1, "baz_qux": 2}) == { + "fooBar": 1, + "bazQux": 2, + } + + def test_nested_dict(self): + assert _camelize_keys({"outer_field": {"inner_field": 42}}) == { + "outerField": {"innerField": 42} + } + + def test_list_of_dicts(self): + assert _camelize_keys([{"foo_bar": 1}, {"baz_qux": 2}]) == [ + {"fooBar": 1}, + {"bazQux": 2}, + ] + + def test_scalars_unchanged(self): + assert _camelize_keys(42) == 42 + assert _camelize_keys("foo_bar") == "foo_bar" + assert _camelize_keys(None) is None + + +# --------------------------------------------------------------------------- +# BaseConfigModel — permissive base, no normalization +# --------------------------------------------------------------------------- + + +class TestBaseConfigModel: + def test_extras_pass_through_unchanged_in_either_case(self): + model = BaseConfigModel(extraField="x", another_extra="y") + assert getattr(model, "extraField") == "x" + assert model.another_extra == "y" + + def test_dump_emits_extras_unchanged(self): + model = BaseConfigModel(camelKey="a", snake_key="b") + assert model.model_dump(by_alias=True) == { + "camelKey": "a", + "snake_key": "b", + } + + +# --------------------------------------------------------------------------- +# SnakeCaseExtrasModel — normalize extras on validation +# --------------------------------------------------------------------------- + + +class TestSnakeCaseExtrasModel: + def test_extras_normalized_camel_to_snake_on_init(self): + model = SnakeCaseExtrasModel(extraField="x", anotherCamel=42) + assert model.extra_field == "x" + assert model.another_camel == 42 + + def test_snake_extras_are_idempotent(self): + model = SnakeCaseExtrasModel(already_snake="value") + assert model.already_snake == "value" + + def test_dump_emits_normalized_snake_keys(self): + model = SnakeCaseExtrasModel(extraField="x") + assert model.model_dump(by_alias=True) == {"extra_field": "x"} + + +# --------------------------------------------------------------------------- +# CamelCaseDumpModel — recursive camelize on dump (by_alias only) +# --------------------------------------------------------------------------- + + +class TestCamelCaseDumpModel: + def test_extras_emit_as_camel_with_by_alias_true(self): + model = CamelCaseDumpModel(snake_extra="value", another_snake=42) + assert model.model_dump(by_alias=True) == { + "snakeExtra": "value", + "anotherSnake": 42, + } + + def test_extras_kept_as_snake_when_by_alias_false(self): + # Mirrors the previous Numbers override: conversion is gated on by_alias. + model = CamelCaseDumpModel(snake_extra="value") + assert model.model_dump(by_alias=False) == {"snake_extra": "value"} + + def test_nested_dict_keys_camelized_recursively(self): + class Outer(CamelCaseDumpModel): + sms_configuration: Optional[Dict[str, Any]] = Field( + default=None, alias="smsConfiguration" + ) + voice_configuration: Optional[Dict[str, Any]] = Field( + default=None, alias="voiceConfiguration" + ) + + model = Outer( + sms_configuration={"service_plan_id": "X"}, + voice_configuration={"appId": "Y", "type": "RTC"}, + ) + assert model.model_dump(by_alias=True) == { + "smsConfiguration": {"servicePlanId": "X"}, + "voiceConfiguration": {"appId": "Y", "type": "RTC"}, + } + + def test_model_dump_json_also_camelizes(self): + model = CamelCaseDumpModel(snake_extra="value") + assert json.loads(model.model_dump_json(by_alias=True)) == { + "snakeExtra": "value", + } + + +# --------------------------------------------------------------------------- +# Composition — each model handles its own extras independently +# --------------------------------------------------------------------------- + + +class TestComposition: + def test_nested_snake_extras_models_normalize_at_every_level(self): + class Inner(SnakeCaseExtrasModel): + foo_bar: int + + class Outer(SnakeCaseExtrasModel): + inner: Inner + name: int + + outer = Outer( + inner={"foo_bar": 12, "extraField": 123}, + name=112, + extraAtRoot="extra", + ) + assert outer.inner.foo_bar == 12 + assert outer.inner.extra_field == 123 + assert outer.extra_at_root == "extra" + + def test_snake_extras_inner_in_base_config_outer_keeps_outer_extras_raw(self): + class Common(SnakeCaseExtrasModel): + foo_bar: int + + class Outer(BaseConfigModel): + common: Common + name: int + + outer = Outer( + common={"foo_bar": 12, "extraField": 123}, + name=112, + extraAtRoot="extra", + ) + assert outer.common.extra_field == 123 + assert getattr(outer, "extraAtRoot") == "extra" + + def test_camel_dump_outer_camelizes_nested_free_form_dict_keys(self): + class Outer(CamelCaseDumpModel): + payload: Dict[str, Any] + + outer = Outer(payload={"user_id": "abc", "nested_obj": {"item_id": 1}}) + assert outer.model_dump(by_alias=True) == { + "payload": {"userId": "abc", "nestedObj": {"itemId": 1}} + } diff --git a/tests/unit/core/models/test_utils.py b/tests/unit/core/models/test_utils.py new file mode 100644 index 00000000..a5456076 --- /dev/null +++ b/tests/unit/core/models/test_utils.py @@ -0,0 +1,44 @@ +"""Tests for sinch.core.models.utils helpers.""" + +from typing import List, Optional + +from pydantic import BaseModel + +from sinch.core.models.utils import model_dump_for_query_params + + +class TestModelDumpForQueryParams: + def test_simple_fields_returned_as_is(self): + class M(BaseModel): + batch_id: str + status: Optional[str] = None + + result = model_dump_for_query_params( + M(batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="delivered") + ) + assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" + assert result["status"] == "delivered" + + def test_lists_converted_to_comma_separated_strings(self): + class M(BaseModel): + status: Optional[List[str]] = None + code: Optional[List[int]] = None + + result = model_dump_for_query_params( + M(status=["Delivered", "Failed"], code=[15, 0]) + ) + assert result["status"] == "Delivered,Failed" + assert result["code"] == "15,0" + + def test_empty_values_filtered_out(self): + class M(BaseModel): + batch_id: str + status: str = "" + code: List[int] = [] + + result = model_dump_for_query_params( + M(batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="", code=[]) + ) + assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" + assert "status" not in result + assert "code" not in result diff --git a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py deleted file mode 100644 index c6cf1fca..00000000 --- a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py +++ /dev/null @@ -1,56 +0,0 @@ -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest - - -def test_to_camel_case_expects_parsed_standard_cases(): - """ - Test standard snake_case to camelCase conversion. - """ - assert BaseModelConfigurationRequest._to_camel_case("foo_bar") == "fooBar" - assert BaseModelConfigurationRequest._to_camel_case("hello_world") == "helloWorld" - assert BaseModelConfigurationRequest._to_camel_case("this_is_a_test") == "thisIsATest" - assert BaseModelConfigurationRequest._to_camel_case("PHONE_NUMBER") == "phoneNumber" - assert BaseModelConfigurationRequest._to_camel_case("appId") == "appId" - - -def test_to_camel_case_expects_parsed_edge_cases(): - """ - Test edge cases like leading/trailing underscores and multiple underscores. - """ - assert BaseModelConfigurationRequest._to_camel_case("foo__bar") == "foo_Bar" - assert BaseModelConfigurationRequest._to_camel_case("foo___bar") == "foo__Bar" - assert BaseModelConfigurationRequest._to_camel_case("trailing_") == "trailing_" - - -def test_to_camel_case_expects_empty_string(): - """ - Test empty string case. - """ - assert BaseModelConfigurationRequest._to_camel_case("") == "" - - -def test_to_camel_case_expects_single_word(): - """ - Test single-word cases. - """ - assert BaseModelConfigurationRequest._to_camel_case("word") == "word" - assert BaseModelConfigurationRequest._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 = BaseModelConfigurationRequest(**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/v1/models/internal/base/test_base_model_response.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py deleted file mode 100644 index 2b2767cf..00000000 --- a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py +++ /dev/null @@ -1,16 +0,0 @@ -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse - - -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 = BaseModelConfigurationResponse(**data) - - # Assert unrecognized fields are dynamically added - assert response.unexpected_field == "unexpectedValue" - assert response.another_extra_field == 42 diff --git a/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py b/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py deleted file mode 100644 index 2ead9c16..00000000 --- a/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py +++ /dev/null @@ -1,59 +0,0 @@ -from sinch.core.models.utils import model_dump_for_query_params -from sinch.domains.sms.models.v1.internal.base import ( - BaseModelConfigurationRequest, -) - - -def test_model_dump_for_query_params_expects_simple_fields(): - """ - Test that simple fields are returned as-is. - """ - - class TestModel(BaseModelConfigurationRequest): - batch_id: str - status: str = None - - model = TestModel( - batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="delivered" - ) - result = model_dump_for_query_params(model) - - assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" - assert result["status"] == "delivered" - - -def test_model_dump_for_query_params_expects_list_to_comma_separated_string(): - """ - Test that lists are converted to comma-separated strings. - """ - - class TestModel(BaseModelConfigurationRequest): - status: list[str] = None - code: list[int] = None - - model = TestModel(status=["Delivered", "Failed"], code=[15, 0]) - result = model_dump_for_query_params(model) - - assert result["status"] == "Delivered,Failed" - assert result["code"] == "15,0" - - -def test_model_dump_for_query_params_expects_empty_values_filtered(): - """ - Test that empty strings and empty lists are filtered out. - """ - - class TestModel(BaseModelConfigurationRequest): - batch_id: str - status: str = "" - code: list[int] = [] - - model = TestModel( - batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="", code=[] - ) - result = model_dump_for_query_params(model) - - assert "batch_id" in result - assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" - assert "status" not in result - assert "code" not in result