diff --git a/fhircraft/fhir/resources/validators.py b/fhircraft/fhir/resources/validators.py index a4a13d75..d7dd87ff 100644 --- a/fhircraft/fhir/resources/validators.py +++ b/fhircraft/fhir/resources/validators.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from fhircraft.config import get_config -from fhircraft.utils import ensure_list, get_all_models_from_field, merge_dicts +from fhircraft.utils import ensure_list, get_all_models_from_field, is_dict_subset if TYPE_CHECKING: from fhircraft.fhir.resources.base import FHIRBaseModel, FHIRSliceModel @@ -261,16 +261,15 @@ def validate_FHIR_element_pattern( if isinstance(pattern, list): pattern = pattern[0] _element = element[0] if isinstance(element, list) else element + _element = ( + _element.model_dump() if isinstance(_element, FHIRBaseModel) else _element + ) + _pattern = pattern.model_dump() if isinstance(pattern, FHIRBaseModel) else pattern try: - if isinstance(_element, FHIRBaseModel): - assert ( - merge_dicts(_element.model_dump(), pattern.model_dump()) - == _element.model_dump() - ) - elif isinstance(_element, dict) and isinstance(pattern, dict): - assert merge_dicts(_element, pattern) == _element + if isinstance(_pattern, dict): + assert is_dict_subset(_pattern, _element) else: - assert _element == pattern + assert _element == _pattern except AssertionError: error = f"Value does not fulfill pattern:\n{pattern.model_dump_json(indent=2) if isinstance(pattern, FHIRBaseModel) else pattern}" if config.mode == "lenient": diff --git a/fhircraft/utils.py b/fhircraft/utils.py index f01fc6fb..e5e7f81c 100644 --- a/fhircraft/utils.py +++ b/fhircraft/utils.py @@ -357,55 +357,27 @@ def get_fhir_model_from_field(field: FieldInfo) -> type[BaseModel] | None: return next(get_all_models_from_field(field), None) -def merge_dicts(dict1: dict, dict2: dict) -> dict: - """ - Merge two dictionaries recursively, merging lists element by element and dictionaries at the same index. - - If a key exists in both dictionaries, the values are merged based on their types. If a key exists only in one dictionary, it is added to the merged dictionary. - - Args: - dict1 (dict): The first dictionary to merge. - dict2 (dict): The second dictionary to merge. - - Returns: - dict: The merged dictionary. - - Example: - >>> dict1 = {'a': 1, 'b': {'c': 2, 'd': [3, 4]}, 'e': [5, 6]} - >>> dict2 = {'b': {'c': 3, 'd': [4, 5]}, 'e': [6, 7], 'f': 8} - >>> merge_dicts(dict1, dict2) - {'a': 1, 'b': {'c': 3, 'd': [3, 4, 5]}, 'e': [5, 6, 7], 'f': 8} - """ - - def merge_lists(list1, list2): - # Merge two lists element by element - merged_list = [] - for idx in range(max(len(list1), len(list2))): - if idx < len(list1) and idx < len(list2): - if isinstance(list1[idx], dict) and isinstance(list2[idx], dict): - # Merge dictionaries at the same index - merged_list.append(merge_dicts(list1[idx], list2[idx])) - else: - # If they are not dictionaries, choose the element from the first list - merged_list.append(list1[idx]) - elif idx < len(list1): - merged_list.append(list1[idx]) - else: - merged_list.append(list2[idx]) - return merged_list - - merged_dict = dict1.copy() - for key, value in dict2.items(): - if key in merged_dict: - if isinstance(merged_dict[key], list) and isinstance(value, list): - merged_dict[key] = merge_lists(merged_dict[key], value) - elif isinstance(merged_dict[key], dict) and isinstance(value, dict): - merged_dict[key] = merge_dicts(merged_dict[key], value) - else: - merged_dict[key] = value - else: - merged_dict[key] = value - return merged_dict +def is_dict_subset(subset: dict, superset: dict) -> bool: + """Return True if all keys/values in subset are present in superset.""" + for key, value in subset.items(): + if key not in superset: + return False + sup_value = superset[key] + if isinstance(value, dict) and isinstance(sup_value, dict): + if not is_dict_subset(value, sup_value): + return False + elif isinstance(value, list) and isinstance(sup_value, list): + if len(value) > len(sup_value): + return False + for sub_item, sup_item in zip(value, sup_value): + if isinstance(sub_item, dict) and isinstance(sup_item, dict): + if not is_dict_subset(sub_item, sup_item): + return False + elif sub_item != sup_item: + return False + elif value != sup_value: + return False + return True def get_FHIR_release_from_version( diff --git a/test/test_fhir_path_engine_conversion.py b/test/test_fhir_path_engine_conversion.py index baf4db66..bb629876 100644 --- a/test/test_fhir_path_engine_conversion.py +++ b/test/test_fhir_path_engine_conversion.py @@ -544,7 +544,6 @@ def test_todatetime_returns_empty_for_invalid_type(): def test_todatetime_converts_correctly_for_valid_type(value, expected): collection = [FHIRPathCollectionItem(value=value)] result = ToDateTime().evaluate(collection, env) - print(result[0].value, expected) assert result == [FHIRPathCollectionItem.wrap(expected)] diff --git a/test/test_fhir_resources_factory_index.py b/test/test_fhir_resources_factory_index.py index 4e09bb42..6b736b15 100644 --- a/test/test_fhir_resources_factory_index.py +++ b/test/test_fhir_resources_factory_index.py @@ -398,7 +398,6 @@ def deep_index(): def test_get_subtree_includes_root_and_descendants(deep_index: DefinitionIndex): subtree_index = deep_index.get_subtree("Observation.component") ids = {n.id for n in subtree_index} - print(ids) assert subtree_index.root() is not None assert ids == { "Component", diff --git a/test/test_fhir_resources_regression.py b/test/test_fhir_resources_regression.py index 75c75484..c672adc8 100644 --- a/test/test_fhir_resources_regression.py +++ b/test/test_fhir_resources_regression.py @@ -466,8 +466,6 @@ def test_regression_issue_263(factory): structure_definition=structure_definition, mode="differential" ) - print(CodeGenerator().generate_resource_model_code(model)) - from typing import get_args import pydantic from fhircraft.fhir.resources.base import FHIRSliceModel @@ -494,7 +492,6 @@ def test_regression_issue_263(factory): # ----------------------------------------------------------------------- coding_annotation = code_model.model_fields["coding"].annotation list_type = next(a for a in get_args(coding_annotation) if a is not type(None)) - print(coding_annotation) annotated_item = get_args(list_type)[0] union_type = get_args(annotated_item)[0] union_members = get_args(union_type) @@ -981,7 +978,6 @@ def test_regression_issue_279(factory): cs_model = next(a for a in get_args(cs_annotation) if a is not type(None)) assert cs_model.__name__ == "MyConditionClinicalStatus" - print(cs_model.__bases__) assert issubclass(cs_model, CodeableConcept) assert "extension" in cs_model.model_fields diff --git a/test/test_fhir_resources_validators.py b/test/test_fhir_resources_validators.py index 35fd9acc..37cf7e11 100644 --- a/test/test_fhir_resources_validators.py +++ b/test/test_fhir_resources_validators.py @@ -1,18 +1,29 @@ +import warnings import pytest from unittest.mock import Mock, patch from pydantic import BaseModel -from typing import List, Optional +from typing import ClassVar, List, Optional +from fhircraft.config import override_config from fhircraft.fhir.resources.validators import ( - validate_element_constraint, _validate_FHIR_element_constraint, + validate_element_constraint, validate_model_constraint, + validate_FHIR_element_pattern, + validate_FHIR_model_pattern, + validate_FHIR_element_fixed_value, + validate_FHIR_model_fixed_value, validate_type_choice_element, validate_slicing_cardinalities, get_type_choice_value_by_base, ) +# =========================================================== +# Fixtures & Helpers +# =========================================================== + + class MockAddress(BaseModel): city: Optional[str] = None state: Optional[str] = None @@ -32,376 +43,877 @@ class MockPatient(BaseModel): active: Optional[bool] = None -class TestValidateElementConstraint: - """Test cases for the validate_element_constraint function.""" - - def setup_method(self): - """Set up test data.""" - self.patient = MockPatient( - name="John Doe", - address=MockAddress(city="Springfield", state="IL", postalCode="62701"), - telecom=[ - MockTelecom(value="555-1234", system="phone", use="home"), - MockTelecom(value="john@email.com", system="email", use="work"), - ], - active=True, +class MockTypeChoiceModel(BaseModel): + valueString: Optional[str] = None + valueInteger: Optional[int] = None + valueBoolean: Optional[bool] = None + + +@pytest.fixture +def mock_patient(): + return MockPatient( + name="John Doe", + address=MockAddress(city="Springfield", state="IL", postalCode="62701"), + telecom=[ + MockTelecom(value="555-1234", system="phone", use="home"), + MockTelecom(value="john@email.com", system="email", use="work"), + ], + active=True, + ) + + +# =========================================================== +# validate_element_constraint() +# =========================================================== + + +@patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") +def test_validate_element_constraint__simple_element_path(mock_validate, mock_patient): + mock_validate.return_value = "John Doe" + + result = validate_element_constraint( + mock_patient, + elements=["name"], + expression="name.exists()", + human="Name must exist", + key="test-1", + severity="error", + ) + + assert result == mock_patient + mock_validate.assert_called_once() + # Check that the correct value was passed + args, kwargs = mock_validate.call_args + assert args[0] == "John Doe" # The value + assert args[1] == mock_patient # The instance + assert kwargs["element"] == "name" + + +@patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") +def test_validate_element_constraint__nested_element_path(mock_validate, mock_patient): + mock_validate.return_value = "Springfield" + + result = validate_element_constraint( + mock_patient, + elements=["address.city"], + expression="address.city.exists()", + human="City must exist", + key="test-2", + severity="error", + ) + + assert result == mock_patient + mock_validate.assert_called_once() + # Check that the correct value was passed + args, kwargs = mock_validate.call_args + assert args[0] == "Springfield" # The value + assert args[1] == mock_patient # The instance + assert kwargs["element"] == "address.city" + + +@patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") +def test_validate_element_constraint__list_element_path(mock_validate, mock_patient): + mock_validate.side_effect = lambda v, *args, **kwargs: v + + result = validate_element_constraint( + mock_patient, + elements=["telecom"], + expression="telecom.exists()", + human="Telecom must exist", + key="test-3", + severity="error", + ) + + assert result == mock_patient + mock_validate.assert_called_once() + # Check that the correct value was passed (the list) + args, kwargs = mock_validate.call_args + assert args[0] == mock_patient.telecom + assert args[1] == mock_patient + assert kwargs["element"] == "telecom" + + +@patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") +def test_validate_element_constraint__nested_element_inside_list( + mock_validate, mock_patient +): + mock_validate.side_effect = lambda v, *args, **kwargs: v + + result = validate_element_constraint( + mock_patient, + elements=["telecom.value"], + expression="telecom.value.exists()", + human="Telecom value must exist", + key="test-4", + severity="error", + ) + + assert result == mock_patient + # Should be called twice (once for each telecom item) + assert mock_validate.call_count == 2 + + # Check the calls were made with correct values + calls = mock_validate.call_args_list + + # First call should be for "555-1234" + assert calls[0][0][0] == "555-1234" + assert calls[0][1]["element"] == "telecom[0].value" + + # Second call should be for "john@email.com" + assert calls[1][0][0] == "john@email.com" + assert calls[1][1]["element"] == "telecom[1].value" + + +@patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") +def test_validate_element_constraint__multiple_element_paths( + mock_validate, mock_patient +): + mock_validate.side_effect = lambda v, *args, **kwargs: v + + result = validate_element_constraint( + mock_patient, + elements=["name", "address.city", "active"], + expression="exists()", + human="Elements must exist", + key="test-5", + severity="error", + ) + + assert result == mock_patient + # Should be called 3 times + assert mock_validate.call_count == 3 + + # Verify all calls were made with correct values + calls = mock_validate.call_args_list + values = [call[0][0] for call in calls] + elements = [call[1]["element"] for call in calls] + + assert "John Doe" in values + assert "Springfield" in values + assert True in values + assert "name" in elements + assert "address.city" in elements + assert "active" in elements + + +@patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") +def test_validate_element_constraint__missing_element_path(mock_validate, mock_patient): + mock_validate.side_effect = lambda v, *args, **kwargs: v + + result = validate_element_constraint( + mock_patient, + ["nonexistent"], + "nonexistent.exists()", + "Nonexistent must exist", + "test-6", + "error", + ) + + assert result == mock_patient + # Should still be called once with None value + mock_validate.assert_called_once() + args, kwargs = mock_validate.call_args + assert args[0] is None + assert kwargs["element"] == "nonexistent" + + +@patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") +def test_validate_element_constraint__missing_nested_element_path( + mock_validate, mock_patient +): + mock_validate.side_effect = lambda v, *args, **kwargs: v + + result = validate_element_constraint( + mock_patient, + ["address.nonexistent"], + "address.nonexistent.exists()", + "Nonexistent nested must exist", + "test-7", + "error", + ) + + assert result == mock_patient + # Should be called once with None value + mock_validate.assert_called_once() + args, kwargs = mock_validate.call_args + assert args[0] is None + + assert kwargs["element"] == "address.nonexistent" + + +@patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") +def test_validate_element_constraint__empty_object(mock_validate): + empty_patient = MockPatient() + + mock_validate.side_effect = lambda v, *args, **kwargs: v + + result = validate_element_constraint( + empty_patient, + ["address.city", "telecom.value"], + "exists()", + "Must exist", + "test-8", + "error", + ) + + assert result == empty_patient + # Should be called twice with None values + assert mock_validate.call_count == 2 + + calls = mock_validate.call_args_list + assert calls[0][0][0] is None # address.city -> None + assert calls[1][0][0] is None # telecom.value -> None + + +# =========================================================== +# validate_model_constraint() +# =========================================================== + + +@patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") +def test_validate_model_constraint__basic(mock_validate): + patient = MockPatient(name="John") + mock_validate.return_value = patient + + result = validate_model_constraint( + patient, + expression="name.exists()", + human="Name must exist", + key="test-constraint", + severity="error", + ) + + assert result == patient + mock_validate.assert_called_once_with( + patient, + patient, + "name.exists()", + "Name must exist", + "test-constraint", + "error", + ) + + +# =========================================================== +# _validate_FHIR_element_constraint() +# =========================================================== + + +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__skip_mode_returns_value(mock_fhirpath): + with override_config(validation_mode="skip"): + result = _validate_FHIR_element_constraint( + "value", Mock(), "expr", "Human", "key-1", "error" ) + assert result == "value" + mock_fhirpath.parse.assert_not_called() - @patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") - def test_simple_element_path(self, mock_validate): - """Test validation with a simple element path.""" - mock_validate.return_value = "John Doe" - - result = validate_element_constraint( - self.patient, - ["name"], - "name.exists()", - "Name must exist", - "test-1", - "error", + +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__disabled_constraint_returns_value( + mock_fhirpath, +): + with override_config(disabled_fhir_constraints=frozenset({"key-1"})): + result = _validate_FHIR_element_constraint( + "value", Mock(), "expr", "Human", "key-1", "error" ) + assert result == "value" + mock_fhirpath.parse.assert_not_called() - assert result == self.patient - mock_validate.assert_called_once() - # Check that the correct value was passed - args, kwargs = mock_validate.call_args - assert args[0] == "John Doe" # The value - assert args[1] == self.patient # The instance - assert kwargs["element"] == "name" - - @patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") - def test_nested_element_path(self, mock_validate): - """Test validation with a nested element path.""" - mock_validate.return_value = "Springfield" - - result = validate_element_constraint( - self.patient, - ["address.city"], - "address.city.exists()", - "City must exist", - "test-2", - "error", + +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__disable_validation_warnings_skips_warning( + mock_fhirpath, +): + with override_config(disable_validation_warnings=True): + result = _validate_FHIR_element_constraint( + "value", Mock(), "expr", "Human", "key-1", "warning" ) + assert result == "value" + mock_fhirpath.parse.assert_not_called() - assert result == self.patient - mock_validate.assert_called_once() - # Check that the correct value was passed - args, kwargs = mock_validate.call_args - assert args[0] == "Springfield" # The value - assert args[1] == self.patient # The instance - assert kwargs["element"] == "address.city" - - @patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") - def test_list_element_path(self, mock_validate): - """Test validation with a list element path.""" - mock_validate.side_effect = lambda v, *args, **kwargs: v - - result = validate_element_constraint( - self.patient, - ["telecom"], - "telecom.exists()", - "Telecom must exist", - "test-3", - "error", + +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__disable_fhir_warnings_skips_warning( + mock_fhirpath, +): + with override_config(disable_fhir_warnings=True): + result = _validate_FHIR_element_constraint( + "value", Mock(), "expr", "Human", "key-1", "warning" ) + assert result == "value" + mock_fhirpath.parse.assert_not_called() - assert result == self.patient - mock_validate.assert_called_once() - # Check that the correct value was passed (the list) - args, kwargs = mock_validate.call_args - assert args[0] == self.patient.telecom - assert args[1] == self.patient - assert kwargs["element"] == "telecom" - - @patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") - def test_nested_list_element_path(self, mock_validate): - """Test validation with a nested element inside a list.""" - mock_validate.side_effect = lambda v, *args, **kwargs: v - - result = validate_element_constraint( - self.patient, - ["telecom.value"], - "telecom.value.exists()", - "Telecom value must exist", - "test-4", - "error", + +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__disable_fhir_errors_skips_error( + mock_fhirpath, +): + with override_config(disable_fhir_errors=True): + result = _validate_FHIR_element_constraint( + "value", Mock(), "expr", "Human", "key-1", "error" ) + assert result == "value" + mock_fhirpath.parse.assert_not_called() - assert result == self.patient - # Should be called twice (once for each telecom item) - assert mock_validate.call_count == 2 - # Check the calls were made with correct values - calls = mock_validate.call_args_list +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__none_value_returns_none(mock_fhirpath): + result = _validate_FHIR_element_constraint( + None, Mock(), "expr", "Human", "key-1", "error" + ) + assert result is None + mock_fhirpath.parse.assert_not_called() - # First call should be for "555-1234" - assert calls[0][0][0] == "555-1234" - assert calls[0][1]["element"] == "telecom[0].value" - # Second call should be for "john@email.com" - assert calls[1][0][0] == "john@email.com" - assert calls[1][1]["element"] == "telecom[1].value" +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__valid_expression_returns_value( + mock_fhirpath, +): + mock_fhirpath.parse.return_value.single.return_value = True - @patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") - def test_multiple_element_paths(self, mock_validate): - """Test validation with multiple element paths.""" - mock_validate.side_effect = lambda v, *args, **kwargs: v + result = _validate_FHIR_element_constraint( + "test_value", Mock(), "some.expr", "Human", "key-1", "error" + ) - result = validate_element_constraint( - self.patient, - ["name", "address.city", "active"], - "exists()", - "Elements must exist", - "test-5", - "error", - ) + assert result == "test_value" + mock_fhirpath.parse.assert_called_once_with("some.expr") - assert result == self.patient - # Should be called 3 times - assert mock_validate.call_count == 3 - - # Verify all calls were made with correct values - calls = mock_validate.call_args_list - values = [call[0][0] for call in calls] - elements = [call[1]["element"] for call in calls] - - assert "John Doe" in values - assert "Springfield" in values - assert True in values - assert "name" in elements - assert "address.city" in elements - assert "active" in elements - - @patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") - def test_missing_element_path(self, mock_validate): - """Test validation with a missing element path.""" - mock_validate.side_effect = lambda v, *args, **kwargs: v - - result = validate_element_constraint( - self.patient, - ["nonexistent"], - "nonexistent.exists()", - "Nonexistent must exist", - "test-6", - "error", - ) - assert result == self.patient - # Should still be called once with None value - mock_validate.assert_called_once() - args, kwargs = mock_validate.call_args - assert args[0] is None - assert kwargs["element"] == "nonexistent" - - @patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") - def test_missing_nested_element_path(self, mock_validate): - """Test validation with a missing nested element path.""" - mock_validate.side_effect = lambda v, *args, **kwargs: v - - result = validate_element_constraint( - self.patient, - ["address.nonexistent"], - "address.nonexistent.exists()", - "Nonexistent nested must exist", - "test-7", - "error", +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__invalid_expression_raises_on_error( + mock_fhirpath, +): + mock_fhirpath.parse.return_value.single.return_value = False + + with pytest.raises(AssertionError, match=r"\[key-1\]"): + _validate_FHIR_element_constraint( + "test_value", Mock(), "some.expr", "Human must hold", "key-1", "error" ) - assert result == self.patient - # Should be called once with None value - mock_validate.assert_called_once() - args, kwargs = mock_validate.call_args - assert args[0] is None - assert kwargs["element"] == "address.nonexistent" - - def test_empty_patient_with_nested_paths(self): - """Test with empty patient object and nested paths.""" - empty_patient = MockPatient() - - with patch( - "fhircraft.fhir.resources.validators._validate_FHIR_element_constraint" - ) as mock_validate: - mock_validate.side_effect = lambda v, *args, **kwargs: v - - result = validate_element_constraint( - empty_patient, - ["address.city", "telecom.value"], - "exists()", - "Must exist", - "test-8", - "error", - ) - assert result == empty_patient - # Should be called twice with None values - assert mock_validate.call_count == 2 +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__invalid_expression_warns_on_warning( + mock_fhirpath, +): + mock_fhirpath.parse.return_value.single.return_value = False - calls = mock_validate.call_args_list - assert calls[0][0][0] is None # address.city -> None - assert calls[1][0][0] is None # telecom.value -> None + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = _validate_FHIR_element_constraint( + "test_value", Mock(), "some.expr", "Human must hold", "key-1", "warning" + ) + assert result == "test_value" + assert len(caught) == 1 + assert "key-1" in str(caught[0].message) -class TestGetPathValueHelper: - """Direct tests for the _get_path_value helper function in validate_element_constraint.""" - def test_path_value_extraction_directly(self): - """Test the path value extraction logic directly by examining behavior.""" - patient = MockPatient( - name="John Doe", - address=MockAddress(city="Springfield"), - telecom=[ - MockTelecom(value="555-1234"), - MockTelecom(value="john@email.com"), - ], - ) +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__lenient_mode_converts_error_to_warning( + mock_fhirpath, +): + mock_fhirpath.parse.return_value.single.return_value = False - # Use a mock to capture what values are actually passed to _validate_FHIR_element_constraint - captured_calls = [] - - def capture_calls( - value, instance, expression, human, key, severity, element=None - ): - captured_calls.append({"value": value, "element": element}) - return value - - with patch( - "fhircraft.fhir.resources.validators._validate_FHIR_element_constraint", - side_effect=capture_calls, - ): - validate_element_constraint( - patient, ["telecom.value"], "exists()", "Must exist", "test", "error" + with override_config(validation_mode="lenient"): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = _validate_FHIR_element_constraint( + "test_value", Mock(), "some.expr", "Human must hold", "key-1", "error" ) - # Verify the captured calls - assert len(captured_calls) == 2 + assert result == "test_value" + assert len(caught) == 1 - # Check values and paths - values = [call["value"] for call in captured_calls] - elements = [call["element"] for call in captured_calls] - assert "555-1234" in values - assert "john@email.com" in values - assert "telecom[0].value" in elements - assert "telecom[1].value" in elements +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__fhirpath_exception_emits_warning( + mock_fhirpath, +): + mock_fhirpath.parse.side_effect = ValueError("bad expression") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = _validate_FHIR_element_constraint( + "test_value", Mock(), "bad.expr", "Human", "key-1", "error" + ) -class TestValidateModelConstraint: - """Test cases for the validate_model_constraint function.""" + assert result == "test_value" + assert len(caught) == 1 + assert "ValueError" in str(caught[0].message) - @patch("fhircraft.fhir.resources.validators._validate_FHIR_element_constraint") - def test_validate_model_constraint(self, mock_validate): - """Test model constraint validation.""" - patient = MockPatient(name="John") - mock_validate.return_value = patient - result = validate_model_constraint( - patient, "name.exists()", "Name must exist", "test-constraint", "error" - ) +@patch("fhircraft.fhir.path.parser.fhirpath") +def test__validate_FHIR_element_constraint__element_prefix_in_error_message( + mock_fhirpath, +): + mock_fhirpath.parse.return_value.single.return_value = False - assert result == patient - mock_validate.assert_called_once_with( - patient, - patient, - "name.exists()", - "Name must exist", - "test-constraint", + with pytest.raises(AssertionError) as exc_info: + _validate_FHIR_element_constraint( + "val", + Mock(), + "expr", + "Must hold", + "key-1", "error", + element="Patient.name", ) + assert "Patient.name" in str(exc_info.value) -class TestValidateTypeChoiceElement: - """Test cases for the validate_type_choice_element function.""" - class MockChoiceElement(BaseModel): - valuestr: Optional[str] = None - valueint: Optional[int] = None - valuebool: Optional[bool] = None +# =========================================================== +# validate_FHIR_element_pattern() +# =========================================================== - def test_valid_single_choice(self): - """Test with only one choice field set.""" - element = self.MockChoiceElement(valuestr="test") - result = validate_type_choice_element( - element, [str, int, bool], "value", required=False - ) +def test_validate_FHIR_element_pattern__skip_mode_returns_element(): + with override_config(validation_mode="skip"): + result = validate_FHIR_element_pattern(None, "some_value", "other_value") + assert result == "some_value" + + +def test_validate_FHIR_element_pattern__matching_scalar_returns_element(): + result = validate_FHIR_element_pattern(None, "John", "John") + assert result == "John" + + +def test_validate_FHIR_element_pattern__non_matching_scalar_raises(): + with pytest.raises(AssertionError, match="does not fulfill pattern"): + validate_FHIR_element_pattern(None, "John", "Jane") + + +def test_validate_FHIR_element_pattern__lenient_mode_warns_instead_of_raising(): + with override_config(validation_mode="lenient"): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = validate_FHIR_element_pattern(None, "John", "Jane") + assert result == "John" + assert len(caught) == 1 + assert "does not fulfill pattern" in str(caught[0].message) + + +def test_validate_FHIR_element_pattern__list_element_uses_first_item(): + result = validate_FHIR_element_pattern(None, ["John", "Jane"], "John") + assert result == ["John", "Jane"] + + +def test_validate_FHIR_element_pattern__list_pattern_uses_first_item(): + result = validate_FHIR_element_pattern(None, "John", ["John", "Jane"]) + assert result == "John" + + +def test_validate_FHIR_element_pattern__passes_for_matching_pattern(): + element = {"codes": ["A"]} + pattern = {"codes": ["A"]} + result = validate_FHIR_element_pattern(None, element, pattern) + assert result == element + + +def test_validate_FHIR_element_pattern__passes_for_superset_of_pattern(): + element = {"codes": ["A"], "extra": "value"} + pattern = {"codes": ["A"]} + result = validate_FHIR_element_pattern(None, element, pattern) + assert result == element + + +def test_validate_FHIR_element_pattern__raises_error_for_subset_of_pattern(): + element = {"codes": ["A"]} + pattern = {"codes": ["A"], "system": "B"} + with pytest.raises(AssertionError, match="does not fulfill pattern"): + validate_FHIR_element_pattern(None, element, pattern) + + +def test_validate_FHIR_element_pattern__raises_error_for_missing_pattern(): + element = {"extra": "value"} + pattern = {"codes": ["A"]} + with pytest.raises(AssertionError, match="does not fulfill pattern"): + validate_FHIR_element_pattern(None, element, pattern) + + +def test_validate_FHIR_element_pattern__raises_error_for_conflicting_scalar_pattern(): + element = {"codes": "A"} + pattern = {"codes": "B"} + with pytest.raises(AssertionError, match="does not fulfill pattern"): + validate_FHIR_element_pattern(None, element, pattern) + + +def test_validate_FHIR_element_pattern__raises_error_for_conflicting_pattern(): + element = {"codes": ["A"]} + pattern = {"codes": ["B"]} + with pytest.raises(AssertionError, match="does not fulfill pattern"): + validate_FHIR_element_pattern(None, element, pattern) + + +def test_validate_FHIR_element_pattern__raises_error_for_conflicting_nested_pattern(): + element = {"codes": [{"code": "A", "system": "C"}]} + pattern = {"codes": [{"code": "A", "system": "B"}]} + with pytest.raises(AssertionError, match="does not fulfill pattern"): + validate_FHIR_element_pattern(None, element, pattern) + + +def test_validate_FHIR_element_pattern__passes_for_nested_superset_of_pattern(): + element = {"codes": [{"code": "A", "system": "B"}, {"code": "C", "system": "D"}]} + pattern = {"codes": [{"code": "A", "system": "B"}]} + result = validate_FHIR_element_pattern(None, element, pattern) + assert result == element + + +# =========================================================== +# validate_FHIR_model_pattern() +# =========================================================== + + +@patch("fhircraft.fhir.resources.validators.validate_FHIR_element_pattern") +def test_validate_FHIR_model_pattern__delegates_to_element_pattern(mock_element): + model = {"name": "John"} + pattern = {"name": "John"} + mock_element.return_value = model + + result = validate_FHIR_model_pattern(model, pattern) + + mock_element.assert_called_once_with(cls=None, element=model, pattern=pattern) + assert result == model + + +# =========================================================== +# validate_FHIR_element_fixed_value() +# =========================================================== + + +def test_validate_FHIR_element_fixed_value__skip_mode_returns_element(): + with override_config(validation_mode="skip"): + result = validate_FHIR_element_fixed_value(None, "some_value", "other_value") + assert result == "some_value" + + +def test_validate_FHIR_element_fixed_value__matching_values_returns_element(): + result = validate_FHIR_element_fixed_value(None, "exact", "exact") + assert result == "exact" + + +def test_validate_FHIR_element_fixed_value__matching_int_returns_element(): + result = validate_FHIR_element_fixed_value(None, 42, 42) + assert result == 42 + - assert result == element +def test_validate_FHIR_element_fixed_value__non_matching_values_raises(): + with pytest.raises(AssertionError, match="does not fulfill constant"): + validate_FHIR_element_fixed_value(None, "actual", "expected") - def test_valid_no_choice_optional(self): - """Test with no choice fields set and optional.""" - element = self.MockChoiceElement() - result = validate_type_choice_element( - element, [str, int, bool], "value", required=False +def test_validate_FHIR_element_fixed_value__lenient_mode_warns_instead_of_raising(): + with override_config(validation_mode="lenient"): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = validate_FHIR_element_fixed_value(None, "actual", "expected") + assert result == "actual" + assert len(caught) == 1 + assert "does not fulfill constant" in str(caught[0].message) + + +def test_validate_FHIR_element_fixed_value__list_element_uses_first_item(): + result = validate_FHIR_element_fixed_value(None, ["exact", "other"], "exact") + assert result == ["exact", "other"] + + +def test_validate_FHIR_element_fixed_value__list_constant_uses_first_item(): + result = validate_FHIR_element_fixed_value(None, "exact", ["exact", "other"]) + assert result == "exact" + + +def test_validate_FHIR_element_fixed_value__non_matching_list_element_raises(): + with pytest.raises(AssertionError, match="does not fulfill constant"): + validate_FHIR_element_fixed_value(None, ["wrong", "other"], "exact") + + +# =========================================================== +# validate_FHIR_model_fixed_value() +# =========================================================== + + +@patch("fhircraft.fhir.resources.validators.validate_FHIR_element_fixed_value") +def test_validate_FHIR_model_fixed_value__delegates_to_element_fixed_value( + mock_element, +): + model = "exact" + constant = "exact" + mock_element.return_value = model + + result = validate_FHIR_model_fixed_value(model, constant) + + mock_element.assert_called_once_with(cls=None, element=model, constant=constant) + assert result == model + + +# =========================================================== +# validate_type_choice_element() +# =========================================================== + + +def test_validate_type_choice_element__skip_mode_returns_instance(): + instance = MockTypeChoiceModel(valueString="hello") + with override_config(validation_mode="skip"): + result = validate_type_choice_element(instance, ["String"], "value") + assert result is instance + + +def test_validate_type_choice_element__single_value_set_is_valid(): + instance = MockTypeChoiceModel(valueString="hello") + result = validate_type_choice_element( + instance, ["String", "Integer", "Boolean"], "value" + ) + assert result is instance + + +def test_validate_type_choice_element__no_value_set_not_required_is_valid(): + instance = MockTypeChoiceModel() + result = validate_type_choice_element( + instance, ["String", "Integer", "Boolean"], "value", required=False + ) + assert result is instance + + +def test_validate_type_choice_element__multiple_values_set_raises(): + instance = MockTypeChoiceModel(valueString="hello", valueInteger=42) + with pytest.raises(AssertionError, match="can only have one value set"): + validate_type_choice_element( + instance, ["String", "Integer", "Boolean"], "value" ) - assert result == element - def test_invalid_multiple_choices(self): - """Test with multiple choice fields set.""" - element = self.MockChoiceElement(valuestr="test", valueint=42) +def test_validate_type_choice_element__required_and_no_value_raises(): + instance = MockTypeChoiceModel() + with pytest.raises(AssertionError, match="must have one value set"): + validate_type_choice_element( + instance, ["String", "Integer", "Boolean"], "value", required=True + ) + - with pytest.raises(AssertionError, match="can only have one value set"): - validate_type_choice_element( - element, [str, int, bool], "value", required=False - ) +def test_validate_type_choice_element__required_and_value_set_is_valid(): + instance = MockTypeChoiceModel(valueInteger=7) + result = validate_type_choice_element( + instance, ["String", "Integer", "Boolean"], "value", required=True + ) + assert result is instance - def test_invalid_required_no_choice(self): - """Test with no choice fields set but required.""" - element = self.MockChoiceElement() - with pytest.raises(AssertionError, match="must have one value set"): - validate_type_choice_element( - element, [str, int, bool], "value", required=True +def test_validate_type_choice_element__non_allowed_type_raises(): + # valueBoolean is set but only String and Integer are allowed + instance = MockTypeChoiceModel(valueBoolean=True) + with pytest.raises(AssertionError, match="cannot use non-allowed type"): + validate_type_choice_element(instance, ["String", "Integer"], "value") + + +def test_validate_type_choice_element__explicit_non_allowed_type_raises(): + instance = MockTypeChoiceModel(valueBoolean=True) + with pytest.raises(AssertionError, match="cannot use non-allowed type"): + validate_type_choice_element( + instance, + ["String", "Integer", "Boolean"], + "value", + non_allowed_types=["Boolean"], + ) + + +def test_validate_type_choice_element__lenient_mode_warns_on_multiple_values(): + instance = MockTypeChoiceModel(valueString="a", valueInteger=1) + with override_config(validation_mode="lenient"): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = validate_type_choice_element( + instance, ["String", "Integer", "Boolean"], "value" ) + assert result is instance + assert any("can only have one value set" in str(w.message) for w in caught) + - def test_non_allowed_types(self): - """Test with non-allowed types.""" - element = self.MockChoiceElement(valuebool=True) - - with pytest.raises(AssertionError, match="cannot use non-allowed type"): - validate_type_choice_element( - element, - [str, int], # Boolean not in allowed types - "value", - required=False, - non_allowed_types=[bool], +def test_validate_type_choice_element__lenient_mode_warns_on_non_allowed_type(): + instance = MockTypeChoiceModel(valueBoolean=True) + with override_config(validation_mode="lenient"): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = validate_type_choice_element( + instance, ["String", "Integer"], "value" ) + assert result is instance + assert any("cannot use non-allowed type" in str(w.message) for w in caught) + + +# =========================================================== +# validate_slicing_cardinalities() +# =========================================================== + + +@patch("fhircraft.fhir.resources.validators.get_all_models_from_field") +def test_validate_slicing_cardinalities__skip_mode_returns_values(mock_get_models): + mock_cls = Mock() + mock_cls.model_fields = {"items": Mock()} + values = [Mock(), Mock()] + + with override_config(validation_mode="skip"): + result = validate_slicing_cardinalities(mock_cls, values, "items") + + assert result is values + mock_get_models.assert_not_called() + + +@patch("fhircraft.fhir.resources.validators.get_all_models_from_field") +def test_validate_slicing_cardinalities__none_values_returns_none(mock_get_models): + mock_cls = Mock() + mock_cls.model_fields = {"items": Mock()} + + result = validate_slicing_cardinalities(mock_cls, None, "items") + + assert result is None + mock_get_models.assert_not_called() + + +@patch("fhircraft.fhir.resources.validators.get_all_models_from_field") +def test_validate_slicing_cardinalities__valid_cardinalities_returns_values( + mock_get_models, +): + class MockSlice: + min_cardinality = 1 + max_cardinality = 3 + __name__ = "MockSlice" + + class MockSliceInstance(MockSlice): + pass + + mock_get_models.return_value = iter([MockSlice]) + mock_cls = Mock() + mock_cls.model_fields = {"items": Mock()} + values = [MockSliceInstance(), MockSliceInstance()] + + result = validate_slicing_cardinalities(mock_cls, values, "items") + + assert result is values + + +@patch("fhircraft.fhir.resources.validators.get_all_models_from_field") +def test_validate_slicing_cardinalities__violates_min_cardinality_raises( + mock_get_models, +): + class MockSlice: + min_cardinality = 3 + max_cardinality = None + __name__ = "MockSlice" + + class MockSliceInstance(MockSlice): + pass + + mock_get_models.return_value = iter([MockSlice]) + mock_cls = Mock() + mock_cls.model_fields = {"items": Mock()} + values = [MockSliceInstance()] # only 1, but min is 3 + + with pytest.raises(AssertionError, match="min. cardinality"): + validate_slicing_cardinalities(mock_cls, values, "items") + + +@patch("fhircraft.fhir.resources.validators.get_all_models_from_field") +def test_validate_slicing_cardinalities__violates_max_cardinality_raises( + mock_get_models, +): + class MockSlice: + min_cardinality = 1 + max_cardinality = 2 + __name__ = "MockSlice" + + class MockSliceInstance(MockSlice): + pass + + mock_get_models.return_value = iter([MockSlice]) + mock_cls = Mock() + mock_cls.model_fields = {"items": Mock()} + values = [ + MockSliceInstance(), + MockSliceInstance(), + MockSliceInstance(), + ] # 3 > max 2 + + with pytest.raises(AssertionError, match="max. cardinality"): + validate_slicing_cardinalities(mock_cls, values, "items") + + +@patch("fhircraft.fhir.resources.validators.get_all_models_from_field") +def test_validate_slicing_cardinalities__lenient_mode_warns_on_violation( + mock_get_models, +): + class MockSlice: + min_cardinality = 3 + max_cardinality = None + __name__ = "MockSlice" + + class MockSliceInstance(MockSlice): + pass + + mock_get_models.return_value = iter([MockSlice]) + mock_cls = Mock() + mock_cls.model_fields = {"items": Mock()} + values = [MockSliceInstance()] + + with override_config(validation_mode="lenient"): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = validate_slicing_cardinalities(mock_cls, values, "items") + + assert result is values + assert any("min. cardinality" in str(w.message) for w in caught) + + +@patch("fhircraft.fhir.resources.validators.get_all_models_from_field") +def test_validate_slicing_cardinalities__no_slice_instances_skips_check( + mock_get_models, +): + class MockSlice: + min_cardinality = 5 + max_cardinality = 5 + __name__ = "MockSlice" + + mock_get_models.return_value = iter([MockSlice]) + mock_cls = Mock() + mock_cls.model_fields = {"items": Mock()} + values = [Mock(), Mock()] # no MockSlice instances + # Should not raise even though cardinality would be violated if checked + result = validate_slicing_cardinalities(mock_cls, values, "items") + assert result is values -class TestGetTypeChoiceValueByBase: - """Test cases for the get_type_choice_value_by_base function.""" - class MockChoiceElement(BaseModel): - valueString: Optional[str] = None - valueInteger: Optional[int] = None - someOtherField: Optional[str] = None +# =========================================================== +# get_type_choice_value_by_base() +# =========================================================== - def test_get_existing_value(self): - """Test getting an existing value.""" - element = self.MockChoiceElement(valueString="test", someOtherField="other") - result = get_type_choice_value_by_base(element, "value") - assert result == "test" +def test_get_type_choice_value_by_base__returns_correct_value(): + instance = MockTypeChoiceModel(valueString="hello") + result = get_type_choice_value_by_base(instance, "value") + assert result == "hello" + + +def test_get_type_choice_value_by_base__returns_none_when_no_matching_field(): + instance = MockPatient(name="John") + result = get_type_choice_value_by_base(instance, "nonexistent") + assert result is None - def test_get_none_when_no_match(self): - """Test getting None when no field matches.""" - element = self.MockChoiceElement(someOtherField="other") - result = get_type_choice_value_by_base(element, "value") - assert result is None +def test_get_type_choice_value_by_base__returns_none_when_all_matching_fields_are_none(): + instance = MockTypeChoiceModel() + result = get_type_choice_value_by_base(instance, "value") + assert result is None - def test_get_none_when_field_none(self): - """Test getting None when matching field is None.""" - element = self.MockChoiceElement(valueString=None, someOtherField="other") - result = get_type_choice_value_by_base(element, "value") - assert result is None +def test_get_type_choice_value_by_base__returns_first_non_none_value(): + # valueBoolean is set; function should return the first non-None field starting with "value" + instance = MockTypeChoiceModel(valueBoolean=True) + result = get_type_choice_value_by_base(instance, "value") + assert result is True - def test_first_non_none_value(self): - """Test that it returns the first non-None value.""" - element = self.MockChoiceElement(valueString="test", valueInteger=42) - result = get_type_choice_value_by_base(element, "value") - # Should return one of the values (implementation dependent on field order) - assert result in ["test", 42] +def test_get_type_choice_value_by_base__integer_value(): + instance = MockTypeChoiceModel(valueInteger=99) + result = get_type_choice_value_by_base(instance, "value") + assert result == 99 diff --git a/test/test_utils.py b/test/test_utils.py index 28ecbf6c..caefc025 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -14,7 +14,6 @@ load_env_variables, load_file, load_url, - merge_dicts, remove_none_dicts, replace_nth, ) @@ -304,57 +303,6 @@ def test_load_env_variables_no_file(self, mocker): assert result == {} -class TestMergeDicts: - - # Merging two dictionaries with non-overlapping keys - def test_non_overlapping_keys(self): - dict1 = {"a": 1, "b": 2} - dict2 = {"c": 3, "d": 4} - result = merge_dicts(dict1, dict2) - expected = {"a": 1, "b": 2, "c": 3, "d": 4} - assert result == expected - - # Merging two dictionaries with overlapping keys and non-conflicting values - def test_overlapping_keys_non_conflicting_values(self): - dict1 = {"a": 1, "b": {"x": 10}} - dict2 = {"b": {"y": 20}, "c": 3} - result = merge_dicts(dict1, dict2) - expected = {"a": 1, "b": {"x": 10, "y": 20}, "c": 3} - assert result == expected - - # Merging dictionaries where values are lists of equal length - def test_lists_of_equal_length(self): - dict1 = {"a": [1, 2], "b": [3, 4]} - dict2 = {"a": [5, 6], "b": [7, 8]} - result = merge_dicts(dict1, dict2) - expected = {"a": [1, 2], "b": [3, 4]} - assert result == expected - - # Merging dictionaries where one dictionary is empty - def test_one_empty_dictionary(self): - dict1 = {} - dict2 = {"a": 1, "b": 2} - result = merge_dicts(dict1, dict2) - expected = {"a": 1, "b": 2} - assert result == expected - - # Merging dictionaries where both dictionaries are empty - def test_both_empty_dictionaries(self): - dict1 = {} - dict2 = {} - result = merge_dicts(dict1, dict2) - expected = {} - assert result == expected - - # Merging dictionaries with deeply nested structures - def test_deeply_nested_structures(self): - dict1 = {"a": {"b": {"c": 1}}} - dict2 = {"a": {"b": {"d": 2}}} - result = merge_dicts(dict1, dict2) - expected = {"a": {"b": {"c": 1, "d": 2}}} - assert result == expected - - class TestReplaceNth: # Replace the nth occurrence of a substring in a string correctly