diff --git a/src/rules_validation_api/validators/actions_mapper_validator.py b/src/rules_validation_api/validators/actions_mapper_validator.py index a6eafaf87..200b056d1 100644 --- a/src/rules_validation_api/validators/actions_mapper_validator.py +++ b/src/rules_validation_api/validators/actions_mapper_validator.py @@ -3,9 +3,9 @@ from eligibility_signposting_api.model.campaign_config import ActionsMapper -class ActionsMapperValidator(ActionsMapper): +class ActionsMapperValidation(ActionsMapper): @model_validator(mode="after") - def validate_keys(self) -> "ActionsMapperValidator": + def validate_keys(self) -> "ActionsMapperValidation": invalid_keys = [key for key in self.root if key is None or key == ""] if invalid_keys: msg = f"Invalid keys found in ActionsMapper: {invalid_keys}" diff --git a/src/rules_validation_api/validators/campaign_config_validator.py b/src/rules_validation_api/validators/campaign_config_validator.py index 61e7df691..94b007346 100644 --- a/src/rules_validation_api/validators/campaign_config_validator.py +++ b/src/rules_validation_api/validators/campaign_config_validator.py @@ -8,8 +8,8 @@ class CampaignConfigValidation(CampaignConfig): - @classmethod @field_validator("iterations") + @classmethod def validate_iterations(cls, iterations: list[Iteration]) -> list[IterationValidation]: return [IterationValidation(**i.model_dump()) for i in iterations] diff --git a/src/rules_validation_api/validators/iteration_cohort_validator.py b/src/rules_validation_api/validators/iteration_cohort_validator.py new file mode 100644 index 000000000..32e1a4b3a --- /dev/null +++ b/src/rules_validation_api/validators/iteration_cohort_validator.py @@ -0,0 +1,5 @@ +from eligibility_signposting_api.model.campaign_config import IterationCohort + + +class IterationCohortValidation(IterationCohort): + pass diff --git a/src/rules_validation_api/validators/iteration_rules_validator.py b/src/rules_validation_api/validators/iteration_rules_validator.py index 95d8dce66..341a08c1f 100644 --- a/src/rules_validation_api/validators/iteration_rules_validator.py +++ b/src/rules_validation_api/validators/iteration_rules_validator.py @@ -1,5 +1,18 @@ -from eligibility_signposting_api.model.campaign_config import IterationRule +from typing import Self + +from pydantic import model_validator + +from eligibility_signposting_api.model.campaign_config import IterationRule, RuleAttributeLevel, RuleAttributeName class IterationRuleValidation(IterationRule): - pass + @model_validator(mode="after") + def check_cohort_attribute_name(self) -> Self: + if ( + self.attribute_level == RuleAttributeLevel.COHORT + and self.attribute_name + and self.attribute_name != RuleAttributeName("COHORT_LABEL") + ): + msg = "When attribute_level is COHORT, attribute_name must be COHORT_LABEL or None (default:COHORT_LABEL)" + raise ValueError(msg) + return self diff --git a/src/rules_validation_api/validators/iteration_validator.py b/src/rules_validation_api/validators/iteration_validator.py index 3bfd3fec5..c16286ab2 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -1,37 +1,152 @@ import typing -from pydantic import ValidationError, field_validator, model_validator +from pydantic import Field, ValidationError, field_validator, model_validator from pydantic_core import InitErrorDetails -from eligibility_signposting_api.model.campaign_config import ActionsMapper, Iteration, IterationRule -from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidator +from eligibility_signposting_api.model.campaign_config import ( + ActionsMapper, + Iteration, + IterationCohort, + IterationRule, + RuleType, +) +from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidation +from rules_validation_api.validators.iteration_cohort_validator import IterationCohortValidation from rules_validation_api.validators.iteration_rules_validator import IterationRuleValidation class IterationValidation(Iteration): - @classmethod + iteration_cohorts: list[IterationCohort] = Field(..., alias="IterationCohorts") + iteration_rules: list[IterationRule] = Field(..., alias="IterationRules") + actions_mapper: ActionsMapper = Field(..., alias="ActionsMapper") + @field_validator("iteration_rules") - def validate_iterations(cls, iteration_rules: list[IterationRule]) -> list[IterationRuleValidation]: + @classmethod + def validate_iteration_rules(cls, iteration_rules: list[IterationRule]) -> list[IterationRuleValidation]: return [IterationRuleValidation(**i.model_dump()) for i in iteration_rules] + @field_validator("iteration_cohorts") @classmethod + def validate_iteration_cohorts(cls, iteration_cohorts: list[IterationCohort]) -> list[IterationCohortValidation]: + return [IterationCohortValidation(**i.model_dump()) for i in iteration_cohorts] + @field_validator("actions_mapper", mode="after") + @classmethod def transform_actions_mapper(cls, action_mapper: ActionsMapper) -> ActionsMapper: - ActionsMapperValidator.model_validate(action_mapper.model_dump()) + ActionsMapperValidation.model_validate(action_mapper.model_dump()) return action_mapper @model_validator(mode="after") + def action_mapper_validation(self) -> typing.Self: + all_errors = [] + + for validator in [ + self.validate_default_comms_routing_in_actions_mapper, + self.validate_default_not_eligible_routing_in_actions_mapper, + self.validate_default_not_actionable_routing_in_actions_mapper, + self.validate_iteration_rules_against_actions_mapper, + ]: + try: + validator() + except ValidationError as ve: + all_errors.extend(ve.errors(include_input=False)) + + if all_errors: + raise ValidationError.from_exception_data(title="IterationValidation", line_errors=all_errors) + + return self + def validate_default_comms_routing_in_actions_mapper(self) -> typing.Self: - default_routing = self.default_comms_routing - actions_mapper = self.actions_mapper.root.keys() - - if default_routing and (not actions_mapper or default_routing not in actions_mapper): - error = InitErrorDetails( - type="value_error", - loc=("actions_mapper",), - input=actions_mapper, - ctx={"error": f"Missing entry for DefaultCommsRouting '{default_routing}' in ActionsMapper"}, - ) - raise ValidationError.from_exception_data(title="IterationValidation", line_errors=[error]) + default_routes = self.default_comms_routing + actions_keys = list(self.actions_mapper.root.keys()) + line_errors = [] + + for routing in default_routes.split("|"): + cleaned_routing = routing.strip() + if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys): + error = InitErrorDetails( + type="value_error", + loc=("actions_mapper",), + input=actions_keys, + ctx={"error": f"Missing entry for DefaultCommsRouting '{cleaned_routing}' in ActionsMapper"}, + ) + line_errors.append(error) + + if line_errors: + raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors) + + return self + + def validate_default_not_eligible_routing_in_actions_mapper(self) -> typing.Self: + default_not_eligibile_routes = self.default_not_eligible_routing + actions_keys = list(self.actions_mapper.root.keys()) + line_errors = [] + + for routing in default_not_eligibile_routes.split("|"): + cleaned_routing = routing.strip() + if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys): + error = InitErrorDetails( + type="value_error", + loc=("actions_mapper",), + input=actions_keys, + ctx={"error": f"Missing entry for DefaultNotEligibleRouting '{cleaned_routing}' in ActionsMapper"}, + ) + line_errors.append(error) + + if line_errors: + raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors) + + return self + + def validate_default_not_actionable_routing_in_actions_mapper(self) -> typing.Self: + default_not_actionable_routes = self.default_not_actionable_routing + actions_keys = list(self.actions_mapper.root.keys()) + line_errors = [] + + for routing in default_not_actionable_routes.split("|"): + cleaned_routing = routing.strip() + if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys): + error = InitErrorDetails( + type="value_error", + loc=("actions_mapper",), + input=actions_keys, + ctx={ + "error": f"Missing entry for DefaultNotActionableRouting '{cleaned_routing}' in ActionsMapper" + }, + ) + line_errors.append(error) + + if line_errors: + raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors) + + return self + + def validate_iteration_rules_against_actions_mapper(self) -> typing.Self: + actions_keys = list(self.actions_mapper.root.keys()) + line_errors = [] + + for rule in self.iteration_rules: + if ( + rule.type + in [ + RuleType.redirect, + RuleType.not_actionable_actions, + RuleType.not_eligible_actions, + ] + and rule.comms_routing + ): + for routing in rule.comms_routing.split("|"): + cleaned_routing = routing.strip() + if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys): + error = InitErrorDetails( + type="value_error", + loc=("iteration_rules",), + input=actions_keys, + ctx={"error": f"Missing entry for CommsRouting '{cleaned_routing}' in ActionsMapper"}, + ) + line_errors.append(error) + + if line_errors: + raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors) return self diff --git a/src/rules_validation_api/validators/rules_validator.py b/src/rules_validation_api/validators/rules_validator.py index 8d52a545b..cacb143d0 100644 --- a/src/rules_validation_api/validators/rules_validator.py +++ b/src/rules_validation_api/validators/rules_validator.py @@ -5,7 +5,7 @@ class RulesValidation(Rules): - @classmethod @field_validator("campaign_config") + @classmethod def validate_campaign_config(cls, campaign_config: CampaignConfig) -> CampaignConfig: return CampaignConfigValidation(**campaign_config.model_dump()) diff --git a/tests/test_data/test_config/test_config.json b/tests/test_data/test_config/test_config.json index fe7b41ed6..8f9cb1445 100644 --- a/tests/test_data/test_config/test_config.json +++ b/tests/test_data/test_config/test_config.json @@ -8,13 +8,24 @@ "Manager": ["person@test.com"], "Approver": ["person@test.com"], "Reviewer": ["person@test.com"], + "StartDate": "20250101", + "EndDate": "20260101", + "ApprovalMinimum": 1, + "ApprovalMaximum": 5000000, "IterationFrequency": "X", "IterationType": "M", "IterationTime": "07:00:00", - "DefaultCommsRouting": "Default_Comms_1", "Iterations": [ { "ID": "id_100", + "Version": "1", + "Name": "Test Config", + "Type": "M", + "IterationDate": "20250101", + "IterationNumber": 1, + "CommsType": "R", + "ApprovalMinimum": 1, + "ApprovalMaximum": 5000000, "DefaultCommsRouting": "INTERNALCONTACTGP1", "DefaultNotActionableRouting": "INTERNALCONTACTGP1", "DefaultNotEligibleRouting": "INTERNALCONTACTGP1", @@ -119,20 +130,8 @@ "Comparator": "19000101", "CommsRouting": "YRULEID1|INTERNALTESCO" } - ], - "Version": "1", - "Name": "Test Config", - "Type": "M", - "IterationDate": "20250101", - "IterationNumber": 1, - "CommsType": "R", - "ApprovalMinimum": 1, - "ApprovalMaximum": 5000000 + ] } - ], - "StartDate": "20250101", - "EndDate": "20260101", - "ApprovalMinimum": 1, - "ApprovalMaximum": 5000000 + ] } } diff --git a/tests/unit/validation/conftest.py b/tests/unit/validation/conftest.py index efd625215..cb711c20d 100644 --- a/tests/unit/validation/conftest.py +++ b/tests/unit/validation/conftest.py @@ -23,20 +23,12 @@ def valid_campaign_config_with_only_mandatory_fields(): "ApprovalMinimum": 10, "ApprovalMaximum": 100, "Type": "A", - "DefaultCommsRouting": "BOOK_NBS", - "DefaultNotEligibleRouting": "RouteB", - "DefaultNotActionableRouting": "RouteC", + "DefaultCommsRouting": "", + "DefaultNotEligibleRouting": "", + "DefaultNotActionableRouting": "", "IterationCohorts": [], "IterationRules": [], - "ActionsMapper": { - "BOOK_NBS": { - "ExternalRoutingCode": "BookNBS", - "ActionDescription": "", - "ActionType": "ButtonWithAuthLink", - "UrlLink": "http://www.nhs.uk/book-rsv", - "UrlLabel": "Continue to booking", - } - }, + "ActionsMapper": {}, } ], } diff --git a/tests/unit/validation/test_actions_mapper_validator.py b/tests/unit/validation/test_actions_mapper_validator.py index e14989e90..89af4958f 100644 --- a/tests/unit/validation/test_actions_mapper_validator.py +++ b/tests/unit/validation/test_actions_mapper_validator.py @@ -2,7 +2,7 @@ from pydantic import ValidationError from eligibility_signposting_api.model.campaign_config import AvailableAction -from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidator +from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidation @pytest.fixture @@ -25,16 +25,31 @@ def test_valid_actions_mapper(self, valid_available_action): "action1": self.make_action(valid_available_action), "action2": self.make_action({**valid_available_action, "ExternalRoutingCode": "AltCode"}), } - mapper = ActionsMapperValidator(root=data) + mapper = ActionsMapperValidation(root=data) expected_action_count = 2 - assert isinstance(mapper, ActionsMapperValidator) + assert isinstance(mapper, ActionsMapperValidation) assert len(mapper.root) == expected_action_count + @pytest.mark.parametrize( + "invalid_action", + [ + {"action1": ""}, + {"action1": "invalid_action"}, + {"action3": None}, + {"action1": "", "action3": None}, + {"action1": "invalid_action", "action2": ""}, + ], + ) + def test_if_exception_raised_when_adding_invalid_actions_to_action_mapper(self, invalid_action): + data = {"": invalid_action} + with pytest.raises(ValidationError): + ActionsMapperValidation(root=data) + def test_invalid_actions_mapper_empty_key(self, valid_available_action): data = {"": self.make_action(valid_available_action), "action2": self.make_action(valid_available_action)} with pytest.raises(ValidationError) as exc_info: - ActionsMapperValidator(root=data) + ActionsMapperValidation(root=data) assert "Invalid keys found in ActionsMapper" in str(exc_info.value) assert "['']" in str(exc_info.value) @@ -45,5 +60,5 @@ def test_invalid_keys_parametrized(self, bad_key, valid_available_action): "valid_key": self.make_action(valid_available_action), } with pytest.raises(ValidationError) as exc_info: - ActionsMapperValidator(root=data) + ActionsMapperValidation(root=data) assert "Invalid keys found in ActionsMapper" in str(exc_info.value) diff --git a/tests/unit/validation/test_iteration_cohorts_validator.py b/tests/unit/validation/test_iteration_cohorts_validator.py new file mode 100644 index 000000000..2b8c2ac4c --- /dev/null +++ b/tests/unit/validation/test_iteration_cohorts_validator.py @@ -0,0 +1,65 @@ +import pytest +from pydantic import ValidationError + +from rules_validation_api.validators.iteration_cohort_validator import IterationCohortValidation + + +class TestMandatoryFieldsSchemaValidations: + def test_missing_cohort_label_raises_error(self): + data = {"CohortGroup": "rsv_age_rolling"} + with pytest.raises(ValidationError) as exc_info: + IterationCohortValidation(**data) + assert "CohortLabel" in str(exc_info.value) + + def test_missing_cohort_group_raises_error(self): + data = {"CohortLabel": "rsv_75_rolling"} + with pytest.raises(ValidationError) as exc_info: + IterationCohortValidation(**data) + assert "CohortGroup" in str(exc_info.value) + + def test_valid_with_only_mandatory_fields(self): + data = {"CohortLabel": "rsv_75_rolling", "CohortGroup": "rsv_age_rolling"} + cohort = IterationCohortValidation(**data) + assert cohort.cohort_label == "rsv_75_rolling" + assert cohort.cohort_group == "rsv_age_rolling" + + +class TestOptionalFieldsSchemaValidations: + def test_positive_description_can_be_none(self): + data = {"CohortLabel": "rsv_75_rolling", "CohortGroup": "rsv_age_rolling", "PositiveDescription": None} + cohort = IterationCohortValidation(**data) + assert cohort.positive_description is None + + def test_negative_description_can_be_none(self): + data = {"CohortLabel": "rsv_75_rolling", "CohortGroup": "rsv_age_rolling", "NegativeDescription": None} + cohort = IterationCohortValidation(**data) + assert cohort.negative_description is None + + def test_priority_can_be_none(self): + data = {"CohortLabel": "rsv_75_rolling", "CohortGroup": "rsv_age_rolling", "Priority": None} + cohort = IterationCohortValidation(**data) + assert cohort.priority is None + + def test_positive_description_accepts_valid_value(self): + data = { + "CohortLabel": "rsv_75_rolling", + "CohortGroup": "rsv_age_rolling", + "PositiveDescription": "Eligible for benefits", + } + cohort = IterationCohortValidation(**data) + assert cohort.positive_description == "Eligible for benefits" + + def test_negative_description_accepts_valid_value(self): + data = { + "CohortLabel": "rsv_75_rolling", + "CohortGroup": "rsv_age_rolling", + "NegativeDescription": "Not eligible", + } + cohort = IterationCohortValidation(**data) + assert cohort.negative_description == "Not eligible" + + def test_priority_accepts_valid_value(self): + cohort_priority = 10 + data = {"CohortLabel": "rsv_75_rolling", "CohortGroup": "rsv_age_rolling", "Priority": cohort_priority} + cohort = IterationCohortValidation(**data) + assert cohort.priority == cohort_priority diff --git a/tests/unit/validation/test_iteration_rules_validator.py b/tests/unit/validation/test_iteration_rules_validator.py index af7405cce..fd544528e 100644 --- a/tests/unit/validation/test_iteration_rules_validator.py +++ b/tests/unit/validation/test_iteration_rules_validator.py @@ -84,6 +84,7 @@ def test_invalid_priority(self, priority_value, valid_iteration_rule_with_only_m def test_valid_attribute_level(self, attribute_level, valid_iteration_rule_with_only_mandatory_fields): data = valid_iteration_rule_with_only_mandatory_fields.copy() data["AttributeLevel"] = attribute_level + data["AttributeName"] = None # Ignoring the validation constraint btw AttributeLevel and AttributeName result = IterationRuleValidation(**data) assert result.attribute_level == attribute_level @@ -91,6 +92,7 @@ def test_valid_attribute_level(self, attribute_level, valid_iteration_rule_with_ def test_invalid_attribute_level(self, attribute_level, valid_iteration_rule_with_only_mandatory_fields): data = valid_iteration_rule_with_only_mandatory_fields.copy() data["AttributeLevel"] = attribute_level + data["AttributeName"] = None # Ignoring the validation constraint btw AttributeLevel and AttributeName with pytest.raises(ValidationError): IterationRuleValidation(**data) @@ -122,6 +124,27 @@ def test_invalid_comparator(self, comparator_value, valid_iteration_rule_with_on with pytest.raises(ValidationError): IterationRuleValidation(**data) + @pytest.mark.parametrize( + ("rule_stop_input", "expected_bool"), + [ + (True, True), + (False, False), + ("Y", True), + ("N", False), + ("YES", False), + ("NO", False), + ("YEAH", False), + ("ONE", False), + ], + ) + def test_rule_stop_boolean_resolution( + self, rule_stop_input, expected_bool, valid_iteration_rule_with_only_mandatory_fields + ): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["RuleStop"] = rule_stop_input + result = IterationRuleValidation(**data) + assert result.rule_stop is expected_bool + class TestOptionalFieldsSchemaValidations: # AttributeName @@ -201,23 +224,24 @@ def test_invalid_comms_routing(self, routing_value, valid_iteration_rule_with_on class TestBUCValidations: - @pytest.mark.parametrize( - ("rule_stop_input", "expected_bool"), - [ - (True, True), - (False, False), - ("Y", True), - ("N", False), - ("YES", False), - ("NO", False), - ("YEAH", False), - ("ONE", False), - ], - ) - def test_rule_stop_boolean_resolution( - self, rule_stop_input, expected_bool, valid_iteration_rule_with_only_mandatory_fields + @pytest.mark.parametrize("attribute_name", [None, "", "COHORT_LABEL"]) + def test_valid_when_attribute_level_is_cohort_then_attribute_name_should_be_none_or_cohort_label( + self, attribute_name, valid_iteration_rule_with_only_mandatory_fields ): data = valid_iteration_rule_with_only_mandatory_fields.copy() - data["RuleStop"] = rule_stop_input + data["AttributeLevel"] = "COHORT" + data["AttributeName"] = attribute_name result = IterationRuleValidation(**data) - assert result.rule_stop is expected_bool + assert result.attribute_name == attribute_name + + @pytest.mark.parametrize("attribute_name", ["LAST_SUCCESSFUL_DATE", "cohort_label"]) + def test_invalid_when_attribute_level_is_cohort_but_attribute_name_is_neither_none_nor_cohort_label( + self, attribute_name, valid_iteration_rule_with_only_mandatory_fields + ): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["AttributeLevel"] = "COHORT" + data["AttributeName"] = attribute_name + with pytest.raises(ValidationError) as error: + IterationRuleValidation(**data) + msg = "When attribute_level is COHORT, attribute_name must be COHORT_LABEL or None (default:COHORT_LABEL)" + assert msg in str(error.value) diff --git a/tests/unit/validation/test_iteration_validator.py b/tests/unit/validation/test_iteration_validator.py index 8e58e5a38..bc2d54633 100644 --- a/tests/unit/validation/test_iteration_validator.py +++ b/tests/unit/validation/test_iteration_validator.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime +from typing import ClassVar import pytest from pydantic import ValidationError @@ -86,28 +87,55 @@ def test_valid_default_comms_routing(self, routing_value, valid_campaign_config_ data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultCommsRouting": routing_value, + "ActionsMapper": { + "BOOK_NBS": { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking", + } + }, } model = IterationValidation(**data) assert model.default_comms_routing == routing_value # DefaultNotEligibleRouting - @pytest.mark.parametrize("routing_value", ["RouteB", "NotEligComm", "NoComms"]) + @pytest.mark.parametrize("routing_value", ["", "BOOK_NBS"]) def test_valid_default_not_eligible_routing(self, routing_value, valid_campaign_config_with_only_mandatory_fields): data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultNotEligibleRouting": routing_value, + "ActionsMapper": { + "BOOK_NBS": { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking", + } + }, } model = IterationValidation(**data) assert model.default_not_eligible_routing == routing_value # DefaultNotActionableRouting - @pytest.mark.parametrize("routing_value", ["RouteC", "HoldComm", "Inactive"]) + @pytest.mark.parametrize("routing_value", ["", "BOOK_NBS"]) def test_valid_default_not_actionable_routing( self, routing_value, valid_campaign_config_with_only_mandatory_fields ): data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultNotActionableRouting": routing_value, + "ActionsMapper": { + "BOOK_NBS": { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking", + } + }, } model = IterationValidation(**data) assert model.default_not_actionable_routing == routing_value @@ -154,25 +182,51 @@ def test_approval_maximum(self, approval_maximum, valid_campaign_config_with_onl class TestIterationCohortsSchemaValidations: + book_local_1_action: ClassVar[dict] = { + "ExternalRoutingCode": "BookLocal_1", + "ActionDescription": "##Getting the vaccine\n" + "You can get an RSV vaccination at your GP surgery.\n" + "Your GP surgery may contact you about getting the RSV vaccine. " + "This may be by letter, text, phone call, email or through the NHS App. " + "You do not need to wait to be contacted before booking your vaccination.", + "ActionType": "InfoText", + } + + book_local_2_action: ClassVar[dict] = { + "ExternalRoutingCode": "BookLocal_2", + "ActionDescription": "##Getting the vaccine\n" + "You can get an RSV vaccination at your GP surgery.\n" + "Your GP surgery may contact you about getting the RSV vaccine. " + "This may be by letter, text, phone call, email or through the NHS App. " + "You do not need to wait to be contacted before booking your vaccination.", + "ActionType": "InfoText", + } + def test_valid_iteration_if_actions_mapper_has_entry_for_the_provided_default_routing_key( self, valid_campaign_config_with_only_mandatory_fields ): - expected_action = { - "ExternalRoutingCode": "BookLocal", - "ActionDescription": "##Getting the vaccine\n" - "You can get an RSV vaccination at your GP surgery.\n" - "Your GP surgery may contact you about getting the RSV vaccine. " - "This may be by letter, text, phone call, email or through the NHS App. " - "You do not need to wait to be contacted before booking your vaccination.", - "ActionType": "InfoText", + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultCommsRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action, "BOOK_LOCAL_2": self.book_local_2_action}, } + IterationValidation(**data) + def test_invalid_iteration_if_actions_mapper_has_doesnt_have_entries_for_every_default_not_default_routing_keys( + self, valid_campaign_config_with_only_mandatory_fields + ): data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], - "DefaultCommsRouting": "BOOK_LOCAL", - "ActionsMapper": {"BOOK_LOCAL": expected_action}, + "DefaultCommsRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action}, } - IterationValidation(**data) + with pytest.raises(ValidationError) as error: + IterationValidation(**data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL_2" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL_2 entry in ActionsMapper" + ) def test_invalid_iteration_if_actions_mapper_has_no_entry_for_the_provided_default_routing_key( self, valid_campaign_config_with_only_mandatory_fields @@ -190,3 +244,165 @@ def test_invalid_iteration_if_actions_mapper_has_no_entry_for_the_provided_defau assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL" in str(e["msg"]) for e in errors), ( "Expected validation error for missing BOOK_LOCAL entry in ActionsMapper" ) + + def test_valid_iteration_if_actions_mapper_has_entry_for_the_provided_default_not_eligible_routing_key( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotEligibleRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action, "BOOK_LOCAL_2": self.book_local_2_action}, + } + IterationValidation(**data) + + def test_invalid_iteration_if_actions_mapper_has_doesnt_have_entries_for_every_default_not_eligible_routing_keys( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotEligibleRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action}, + } + with pytest.raises(ValidationError) as error: + IterationValidation(**data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL_2" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL_2 entry in ActionsMapper" + ) + + def test_invalid_iteration_if_actions_mapper_has_no_entry_for_the_provided_default_not_eligible_routing( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotEligibleRouting": "BOOK_LOCAL", + "ActionsMapper": {}, + } # Missing BOOK_LOCAL in ActionsMapper + + with pytest.raises(ValidationError) as error: + IterationValidation(**data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL entry in ActionsMapper" + ) + + def test_valid_iteration_if_actions_mapper_has_entry_for_the_provided_default_not_actionable_routing_key( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotActionableRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action, "BOOK_LOCAL_2": self.book_local_2_action}, + } + IterationValidation(**data) + + def test_invalid_iteration_if_actions_mapper_has_doesnt_have_entries_for_every_default_not_actionable_routing_keys( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotActionableRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action}, + } + with pytest.raises(ValidationError) as error: + IterationValidation(**data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL_2" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL_2 entry in ActionsMapper" + ) + + def test_invalid_iteration_if_actions_mapper_has_no_entry_for_the_provided_default_not_actionable_routing( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotActionableRouting": "BOOK_LOCAL", + "ActionsMapper": {}, + } # Missing BOOK_LOCAL in ActionsMapper + + with pytest.raises(ValidationError) as error: + IterationValidation(**data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL entry in ActionsMapper" + ) + + @pytest.mark.parametrize("rule_type", ["R", "X", "Y", "F"]) + @pytest.mark.parametrize( + ("default_routing", "actions_mapper"), + [ + ("BOOK_LOCAL_1|BOOK_LOCAL_2", {"BOOK_LOCAL_1": book_local_1_action, "BOOK_LOCAL_2": book_local_2_action}), + ("BOOK_LOCAL_1", {"BOOK_LOCAL_1": book_local_1_action}), + ("", {"BOOK_LOCAL_1": book_local_1_action}), + ], + ) + def test_valid_iteration_if_actions_mapper_exists_for_rule_routing( + self, valid_campaign_config_with_only_mandatory_fields, rule_type, default_routing, actions_mapper + ): + iteration_rule = { + "Type": rule_type, + "Name": "Test Rule", + "Description": "Test rule description", + "Operator": "is_empty", + "Comparator": "", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "elid_all_people", + "Priority": 100, + "CommsRouting": default_routing, + } + + iteration_data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "IterationRules": [iteration_rule], + "ActionsMapper": actions_mapper, + } + + iteration = IterationValidation(**iteration_data) + assert iteration is not None, ( + f"Expected iteration to be valid for rule type '{rule_type}' with routing '{default_routing}'" + ) + + @pytest.mark.parametrize("rule_type", ["R", "X", "Y"]) + @pytest.mark.parametrize( + ("default_routing", "actions_mapper"), + [ + ("BOOK_LOCAL_1|BOOK_LOCAL_2", {"BOOK_LOCAL_2": book_local_2_action}), + ("BOOK_LOCAL_1", {"BOOK_LOCAL_2": book_local_2_action}), + ], + ) + def test_invalid_iteration_if_actions_mapper_exists_for_rule_routing( + self, valid_campaign_config_with_only_mandatory_fields, rule_type, default_routing, actions_mapper + ): + iteration_rule = { + "Type": rule_type, + "Name": "Test Rule", + "Description": "Test rule description", + "Operator": "is_empty", + "Comparator": "", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "elid_all_people", + "Priority": 100, + "CommsRouting": default_routing, + } + + iteration_data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "IterationRules": [iteration_rule], + "ActionsMapper": actions_mapper, + } + + with pytest.raises(ValidationError) as error: + IterationValidation(**iteration_data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "iteration_rules" and "BOOK_LOCAL_1" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL entry in ActionsMapper" + )