From 790a769eabaf83f45bc582aae4076219fffb6afa Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Thu, 12 Mar 2026 15:37:33 +0000 Subject: [PATCH] MPT-18701 Improve base model tests --- mpt_api_client/models/model.py | 7 +- tests/unit/conftest.py | 2 - .../resource/test_resource_custom_key.py | 17 --- .../test_resource.py => test_model.py} | 140 +++++++++++++++++- 4 files changed, 135 insertions(+), 31 deletions(-) delete mode 100644 tests/unit/models/resource/test_resource_custom_key.py rename tests/unit/models/{resource/test_resource.py => test_model.py} (63%) diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index 7fdeff39..d3736b58 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -1,7 +1,7 @@ import re from collections import UserList from collections.abc import Iterable -from typing import Any, ClassVar, Self, get_args, get_origin, override +from typing import Any, Self, get_args, get_origin, override from mpt_api_client.http.types import Response from mpt_api_client.models.meta import Meta @@ -169,7 +169,6 @@ def _process_value(self, value: Any, target_class: Any = None) -> Any: # noqa: class Model(BaseModel): """Provides a resource to interact with api data using fluent interfaces.""" - _data_key: ClassVar[str | None] = None id: str def __init__( @@ -192,7 +191,7 @@ def new(cls, resource_data: ResourceData | None = None, meta: Meta | None = None @classmethod def from_response(cls, response: Response) -> Self: - """Creates a collection from a response. + """Creates a Model from a response. Args: response: The httpx response object. @@ -200,8 +199,6 @@ def from_response(cls, response: Response) -> Self: response_data = response.json() if isinstance(response_data, dict): response_data.pop("$meta", None) - if cls._data_key: - response_data = response_data.get(cls._data_key) if not isinstance(response_data, dict): raise TypeError("Response data must be a dict.") meta = Meta.from_response(response) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 8f507ee5..07a2845b 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -10,8 +10,6 @@ class DummyModel(Model): """Dummy resource for testing.""" - _data_key = None - @pytest.fixture def http_client(): diff --git a/tests/unit/models/resource/test_resource_custom_key.py b/tests/unit/models/resource/test_resource_custom_key.py deleted file mode 100644 index 9d4877e3..00000000 --- a/tests/unit/models/resource/test_resource_custom_key.py +++ /dev/null @@ -1,17 +0,0 @@ -from httpx import Response - -from mpt_api_client.models import Model - - -class ChargeResourceMock(Model): - _data_key = "charge" - - -def test_custom_data_key(): - record_data = {"id": "1", "amount": 100} - response = Response(200, json={"charge": record_data}) - - result = ChargeResourceMock.from_response(response) - - assert result.id == "1" - assert result.amount == 100 diff --git a/tests/unit/models/resource/test_resource.py b/tests/unit/models/test_model.py similarity index 63% rename from tests/unit/models/resource/test_resource.py rename to tests/unit/models/test_model.py index ac8f1fbb..9082303a 100644 --- a/tests/unit/models/resource/test_resource.py +++ b/tests/unit/models/test_model.py @@ -2,6 +2,7 @@ from httpx import Response from mpt_api_client.models import Meta, Model +from mpt_api_client.models.model import BaseModel, ModelList, to_snake_case # noqa: WPS347 class AgreementDummy(Model): # noqa: WPS431 @@ -20,6 +21,30 @@ class AgreementWithContactDummy(Model): contact: ContactDummy +class TypedListItemDummy(BaseModel): + """Dummy item for typed list tests.""" + + name: str + + +class TypedListContainerDummy(BaseModel): + """Dummy container with a typed list field.""" + + entries: list[TypedListItemDummy] + + +class DictTypedContainerDummy(BaseModel): + """Dummy container with a dict-typed field.""" + + metadata: dict[str, str] + + +class ScalarListContainerDummy(BaseModel): + """Dummy container with a list[str] field.""" + + tags: list[str] + + @pytest.fixture def meta_data(): return {"pagination": {"limit": 10, "offset": 20, "total": 100}, "ignored": ["one"]} # noqa: WPS226 @@ -156,9 +181,11 @@ def test_append(): agreement.parameters.ordering.append(new_param) # act assert agreement.id == "AGR-123" - assert agreement.parameters.ordering[0].external_id == "contact" - assert agreement.parameters.ordering[1].external_id == "address" - assert agreement.parameters.ordering[2].external_id == "email" + assert [agr_param.external_id for agr_param in agreement.parameters.ordering] == [ + "contact", + "address", + "email", + ] agreement_data["parameters"]["ordering"].append(new_param) assert agreement.to_dict() == agreement_data @@ -177,8 +204,10 @@ def test_overwrite_list(): agreement.parameters.ordering = ordering_parameters # act assert agreement.id == "AGR-123" - assert agreement.parameters.ordering[0].external_id == "contact" - assert agreement.parameters.ordering[1].external_id == "address" + assert [agr_param.external_id for agr_param in agreement.parameters.ordering] == [ + "contact", + "address", + ] assert agreement.to_dict() == agreement_data @@ -197,6 +226,103 @@ def test_advanced_mapping(): agreement.parameters.ordering = ordering_parameters # act assert isinstance(agreement.contact, ContactDummy) - assert agreement.parameters.ordering[0].external_id == "contact" - assert agreement.parameters.ordering[1].external_id == "address" + assert [agr_param.external_id for agr_param in agreement.parameters.ordering] == [ + "contact", + "address", + ] assert agreement.to_dict() == agreement_data + + +def test_to_snake_case_already_snake(): + result = to_snake_case("already_snake") # act + + assert result == "already_snake" + + +def test_model_list_extend(): + ml = ModelList([{"id": "1"}]) + + ml.extend([{"id": "2"}, {"id": "3"}]) # act + + assert [ml_item.id for ml_item in ml] == ["1", "2", "3"] + + +def test_model_list_insert(): + ml = ModelList([{"id": "1"}, {"id": "3"}]) + + ml.insert(1, {"id": "2"}) # act + + assert [ml_item.id for ml_item in ml] == ["1", "2", "3"] + + +def test_model_list_process_item_nested_list(): + nested = [{"id": "a"}, {"id": "b"}] + + ml = ModelList([nested]) # act + + assert isinstance(ml[0], ModelList) + assert ml[0][0].id == "a" + + +def test_model_list_process_item_scalar(): + ml = ModelList(["a", "b", "c"]) # act + + assert ml == ["a", "b", "c"] + + +def test_base_model_getattr_from_dict(): + model = BaseModel(foo="bar") + + result = model.__getattr__("foo") # noqa: PLC2801 + + assert result == "bar" + + +def test_base_model_setattr_private(): + model = BaseModel(foo="bar") + + model._private = "secret" # noqa: SLF001 # act + + assert model._private == "secret" # noqa: SLF001 + + +def test_to_dict_excludes_private_attrs(): + model = BaseModel(foo="bar") + model._private = "secret" # noqa: SLF001 + + result = model.to_dict() + + assert result == {"foo": "bar"} + assert "_private" not in result + + +def test_process_value_typed_list(): + container = TypedListContainerDummy(entries=[{"name": "one"}, {"name": "two"}]) # act + + assert all(isinstance(entry, TypedListItemDummy) for entry in container.entries) + assert [entry.name for entry in container.entries] == ["one", "two"] + + +def test_process_value_existing_base_model(): + nested = BaseModel(value="test") + model = BaseModel() + + model.nested = nested # act + + assert model.nested is nested + + +def test_process_value_non_list_target(): + container = DictTypedContainerDummy() + + container.metadata = [{"id": "1"}] # act + + assert isinstance(container.metadata, ModelList) + assert container.metadata[0].id == "1" + + +def test_process_value_scalar_list_elements(): + container = ScalarListContainerDummy(tags=["a", "b", "c"]) # act + + assert isinstance(container.tags, ModelList) + assert list(container.tags) == ["a", "b", "c"]