diff --git a/mpt_api_client/models/model.py b/mpt_api_client/models/model.py index daf5f002..85f97f88 100644 --- a/mpt_api_client/models/model.py +++ b/mpt_api_client/models/model.py @@ -113,6 +113,24 @@ def _process_item(self, item: Any) -> Any: return item +def _build_model_from_dict(value: Resource, target_class: Any) -> "BaseModel": + """Builds a BaseModel (or target subclass) from a raw dict value.""" + if target_class and isinstance(target_class, type) and issubclass(target_class, BaseModel): + model_class: type[BaseModel] = target_class + return model_class(**value) + return BaseModel(**value) + + +def _resolve_list_model_class(target_class: Any) -> type["BaseModel"]: + """Resolves the element model class for a list-typed field from its type hint.""" + if not target_class or get_origin(target_class) is not list: + return BaseModel + args = get_args(target_class) + if args and isinstance(args[0], type) and issubclass(args[0], BaseModel): # noqa: WPS221 + return args[0] + return BaseModel + + class BaseModel: """Base dataclass for models providing object-only access and case conversion.""" @@ -183,34 +201,13 @@ def _serialize_value(self, value: Any) -> Any: return [self._serialize_value(item) for item in value] return value - def _process_value(self, value: Any, target_class: Any = None) -> Any: # noqa: WPS231 C901 + def _process_value(self, value: Any, target_class: Any = None) -> Any: """Recursively processes values to ensure nested dicts are BaseModels.""" if isinstance(value, dict) and not isinstance(value, BaseModel): - # If a target class is provided and it's a subclass of BaseModel, use it - if ( - target_class - and isinstance(target_class, type) - and issubclass(target_class, BaseModel) - ): - return target_class(**value) - return BaseModel(**value) - + return _build_model_from_dict(value, target_class) if isinstance(value, (list, UserList)) and not isinstance(value, ModelList): - # Try to determine the model class for the list elements from type hints - model_class = BaseModel - if target_class: - # Handle list[ModelClass] - - origin = get_origin(target_class) - if origin is list: - args = get_args(target_class) - if args and isinstance(args[0], type) and issubclass(args[0], BaseModel): # noqa: WPS221 - model_class = args[0] # noqa: WPS220 - - return ModelList(value, model_class=model_class) - # Recursively handle BaseModel if it's already one - if isinstance(value, BaseModel): - return value + return ModelList(value, model_class=_resolve_list_model_class(target_class)) + # Already a BaseModel or a scalar: nothing to convert. return value diff --git a/tests/e2e/helpdesk/conftest.py b/tests/e2e/helpdesk/conftest.py index 9b0d14f1..6f8038ea 100644 --- a/tests/e2e/helpdesk/conftest.py +++ b/tests/e2e/helpdesk/conftest.py @@ -2,7 +2,9 @@ from tests.e2e.helper import ( async_create_fixture_resource_and_delete, + async_create_fixture_resource_and_finalize, create_fixture_resource_and_delete, + create_fixture_resource_and_finalize, ) @@ -21,8 +23,10 @@ def invalid_queue_id(): @pytest.fixture -def created_queue(mpt_ops, queue_data): - with create_fixture_resource_and_delete(mpt_ops.helpdesk.queues, queue_data) as queue: +def created_queue(mpt_ops, queue_data, logger): + with create_fixture_resource_and_finalize( + mpt_ops.helpdesk.queues, queue_data, mpt_ops.helpdesk.queues.disable, logger + ) as queue: yield queue @@ -35,9 +39,9 @@ def created_disabled_queue(mpt_ops, created_queue): @pytest.fixture -async def async_created_queue(async_mpt_ops, queue_data): - async with async_create_fixture_resource_and_delete( - async_mpt_ops.helpdesk.queues, queue_data +async def async_created_queue(async_mpt_ops, queue_data, logger): + async with async_create_fixture_resource_and_finalize( + async_mpt_ops.helpdesk.queues, queue_data, async_mpt_ops.helpdesk.queues.disable, logger ) as queue: yield queue @@ -64,8 +68,11 @@ def case_data(created_queue): @pytest.fixture -def created_case(mpt_ops, case_data): - return mpt_ops.helpdesk.cases.create(case_data) +def created_case(mpt_ops, case_data, logger): + with create_fixture_resource_and_finalize( + mpt_ops.helpdesk.cases, case_data, mpt_ops.helpdesk.cases.complete, logger + ) as case: + yield case @pytest.fixture @@ -84,8 +91,11 @@ def created_chat(created_case): @pytest.fixture -async def async_created_case(async_mpt_ops, case_data): - return await async_mpt_ops.helpdesk.cases.create(case_data) +async def async_created_case(async_mpt_ops, case_data, logger): + async with async_create_fixture_resource_and_finalize( + async_mpt_ops.helpdesk.cases, case_data, async_mpt_ops.helpdesk.cases.complete, logger + ) as case: + yield case @pytest.fixture diff --git a/tests/e2e/helper.py b/tests/e2e/helper.py index 14bf40bd..db937baf 100644 --- a/tests/e2e/helper.py +++ b/tests/e2e/helper.py @@ -18,6 +18,20 @@ def _delete_resource(service, resource): print(f"TEARDOWN - Unable to delete resource {resource}: {error.title}") # noqa: WPS421 +async def _finalize_async_resource(finalize, resource, logger): + try: + await finalize(resource.id) + except Exception: + logger.warning("TEARDOWN - Unable to finalize resource %s", resource, exc_info=True) + + +def _finalize_resource(finalize, resource, logger): + try: + finalize(resource.id) + except Exception: + logger.warning("TEARDOWN - Unable to finalize resource %s", resource, exc_info=True) + + @asynccontextmanager async def async_create_fixture_resource_and_delete(service, resource_data): resource = await service.create(resource_data) @@ -38,6 +52,36 @@ def create_fixture_resource_and_delete(service, resource_data): _delete_resource(service, resource) +@asynccontextmanager +async def async_create_fixture_resource_and_finalize(service, resource_data, finalize, logger): + """Create a resource, then transition it to a terminal state on teardown. + + ``finalize`` is the bound terminal-transition method (e.g. ``queues.disable`` or + ``cases.complete``) and is invoked best-effort so a failed teardown never fails the test. + """ + resource = await service.create(resource_data) + + try: + yield resource + finally: + await _finalize_async_resource(finalize, resource, logger) + + +@contextmanager +def create_fixture_resource_and_finalize(service, resource_data, finalize, logger): + """Create a resource, then transition it to a terminal state on teardown. + + ``finalize`` is the bound terminal-transition method (e.g. ``queues.disable`` or + ``cases.complete``) and is invoked best-effort so a failed teardown never fails the test. + """ + resource = service.create(resource_data) + + try: + yield resource + finally: + _finalize_resource(finalize, resource, logger) + + async def assert_async_service_filter_with_iterate(service, filter_by_id, select: list[str] | None): filtered = service.filter(RQLQuery(id=filter_by_id)) if select: