diff --git a/src/eligibility_signposting_api/app.py b/src/eligibility_signposting_api/app.py index 2cf614bea..69b01e77e 100644 --- a/src/eligibility_signposting_api/app.py +++ b/src/eligibility_signposting_api/app.py @@ -7,7 +7,7 @@ from mangum import Mangum from mangum.types import LambdaContext, LambdaEvent -from eligibility_signposting_api import repos, services +from eligibility_signposting_api import audit, repos, services from eligibility_signposting_api.config.config import config, init_logging from eligibility_signposting_api.error_handler import handle_exception from eligibility_signposting_api.views import eligibility_blueprint @@ -41,7 +41,9 @@ def create_app() -> Flask: app.register_error_handler(Exception, handle_exception) # Set up dependency injection using wireup - container = wireup.create_sync_container(service_modules=[services, repos], parameters={**app.config, **config()}) + container = wireup.create_sync_container( + service_modules=[services, repos, audit], parameters={**app.config, **config()} + ) wireup.integration.flask.setup(container, app) logger.info("app ready", extra={"config": {**app.config, **config()}}) diff --git a/src/eligibility_signposting_api/audit/__init__.py b/src/eligibility_signposting_api/audit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/eligibility_signposting_api/audit/audit_context.py b/src/eligibility_signposting_api/audit/audit_context.py new file mode 100644 index 000000000..09094d95b --- /dev/null +++ b/src/eligibility_signposting_api/audit/audit_context.py @@ -0,0 +1,146 @@ +import logging +from datetime import UTC, datetime +from operator import attrgetter +from uuid import UUID + +from flask import Request, g + +from eligibility_signposting_api.audit.audit_models import ( + AuditAction, + AuditCondition, + AuditEligibilityCohortGroups, + AuditEligibilityCohorts, + AuditEvent, + AuditFilterRule, + AuditRedirectRule, + AuditSuitabilityRule, + RequestAuditData, + RequestAuditHeader, + RequestAuditQueryParams, +) +from eligibility_signposting_api.audit.audit_service import AuditService +from eligibility_signposting_api.model.eligibility import ( + CohortGroupResult, + ConditionName, + IterationResult, + Status, + SuggestedAction, +) +from eligibility_signposting_api.model.rules import CampaignID, CampaignVersion, Iteration, RuleName, RulePriority + +logger = logging.getLogger(__name__) + + +class AuditContext: + @staticmethod + def add_request_details(request: Request) -> None: + g.audit_log = AuditEvent() + resource_id = None + if request.view_args and request.view_args["nhs_number"]: + resource_id = request.view_args["nhs_number"] + g.audit_log.request = RequestAuditData( + nhs_number=resource_id, + request_timestamp=datetime.now(tz=UTC), + headers=( + RequestAuditHeader( + x_request_id=request.headers.get("X-Request-ID"), + x_correlation_id=request.headers.get("X-Correlation-ID"), + nhsd_end_user_organisation_ods=request.headers.get("NHSD-End-User-Organisation-ODS"), + nhsd_application_id=request.headers.get("nhsd-application-id"), + ) + ), + query_params=( + RequestAuditQueryParams( + category=request.args.get("category"), + conditions=request.args.get("conditions"), + include_actions=request.args.get("includeActions"), + ) + ), + ) + + @staticmethod + def append_audit_condition( + suggested_actions: list[SuggestedAction] | None, + condition_name: ConditionName, + best_results: tuple[Iteration | None, IterationResult | None, dict[str, CohortGroupResult] | None], + campaign_details: tuple[CampaignID | None, CampaignVersion | None], + redirect_rule_details: tuple[RulePriority | None, RuleName | None], + ) -> None: + audit_eligibility_cohorts, audit_eligibility_cohort_groups, audit_actions = [], [], [] + audit_filter_rule, audit_suitability_rule, audit_redirect_rule = None, None, None + best_active_iteration = best_results[0] + best_candidate = best_results[1] + best_cohort_results = best_results[2] + + if best_cohort_results: + for value in sorted(best_cohort_results.values(), key=attrgetter("cohort_code")): + cohort_status_name = value.status.name if value.status else None + audit_eligibility_cohorts.append( + AuditEligibilityCohorts(cohort_code=value.cohort_code, cohort_status=cohort_status_name) + ) + + audit_eligibility_cohort_groups.append( + AuditEligibilityCohortGroups( + cohort_code=value.cohort_code, cohort_status=cohort_status_name, cohort_text=value.description + ) + ) + + if value.audit_rules and best_candidate: + if best_candidate.status and best_candidate.status.name == Status.not_eligible.name: + audit_filter_rule = AuditFilterRule( + rule_priority=value.audit_rules[0].rule_priority, + rule_name=value.audit_rules[0].rule_name, + ) + if best_candidate.status and best_candidate.status.name == Status.not_actionable.name: + audit_suitability_rule = AuditSuitabilityRule( + rule_priority=value.audit_rules[0].rule_priority, + rule_name=value.audit_rules[0].rule_name, + rule_message=value.audit_rules[0].rule_description, + ) + + if best_candidate and best_candidate.status and best_candidate.status.name == Status.actionable.name: + audit_redirect_rule = AuditRedirectRule( + rule_priority=str(redirect_rule_details[0]), rule_name=redirect_rule_details[1] + ) + + if suggested_actions is None: + audit_actions = None + elif len(suggested_actions) > 0: + for action in suggested_actions: + audit_actions.append( + AuditAction( + internal_action_code=action.internal_action_code, + action_code=action.action_code, + action_type=action.action_type, + action_description=action.action_description, + action_url=str(action.url_link) if action.url_link else None, + action_url_label=action.url_label, + ) + ) + + audit_condition = AuditCondition( + campaign_id=campaign_details[0], + campaign_version=campaign_details[1], + iteration_id=best_active_iteration.id if best_active_iteration else None, + iteration_version=best_active_iteration.version if best_active_iteration else None, + condition_name=condition_name, + status=best_candidate.status.name if best_candidate and best_candidate.status else None, + status_text=best_candidate.status.name if best_candidate and best_candidate.status else None, + eligibility_cohorts=audit_eligibility_cohorts, + eligibility_cohort_groups=audit_eligibility_cohort_groups, + filter_rules=audit_filter_rule, + suitability_rules=audit_suitability_rule, + action_rule=audit_redirect_rule, + actions=audit_actions, + ) + + g.audit_log.response.condition.append(audit_condition) + + @staticmethod + def add_response_details(response_id: UUID, last_updated: datetime) -> None: + g.audit_log.response.response_id = response_id + g.audit_log.response.last_updated = last_updated + + @staticmethod + def write_to_firehose(service: AuditService) -> None: + service.audit(g.audit_log.model_dump(by_alias=True)) diff --git a/src/eligibility_signposting_api/audit/audit_models.py b/src/eligibility_signposting_api/audit/audit_models.py new file mode 100644 index 000000000..17467130f --- /dev/null +++ b/src/eligibility_signposting_api/audit/audit_models.py @@ -0,0 +1,95 @@ +from datetime import UTC, datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class CamelCaseBaseModel(BaseModel): + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + ) + + +class RequestAuditHeader(CamelCaseBaseModel): + x_request_id: str | None = None + x_correlation_id: str | None = None + nhsd_end_user_organisation_ods: str | None = None + nhsd_application_id: str | None = None + + +class RequestAuditQueryParams(CamelCaseBaseModel): + category: str | None = None + conditions: str | None = None + include_actions: str | None = None + + +class RequestAuditData(CamelCaseBaseModel): + request_timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) + headers: RequestAuditHeader = Field(default_factory=RequestAuditHeader) + query_params: RequestAuditQueryParams = Field(default_factory=RequestAuditQueryParams) + nhs_number: str | None = None + + +class AuditEligibilityCohorts(CamelCaseBaseModel): + cohort_code: str | None = None + cohort_status: str | None = None + + +class AuditEligibilityCohortGroups(CamelCaseBaseModel): + cohort_code: str | None = None + cohort_text: str | None = None + cohort_status: str | None = None + + +class AuditFilterRule(CamelCaseBaseModel): + rule_priority: str | None = None + rule_name: str | None = None + + +class AuditSuitabilityRule(CamelCaseBaseModel): + rule_priority: str | None = None + rule_name: str | None = None + rule_message: str | None = None + + +class AuditRedirectRule(CamelCaseBaseModel): + rule_priority: str | None = None + rule_name: str | None = None + + +class AuditAction(CamelCaseBaseModel): + internal_action_code: str | None = None + action_type: str | None = None + action_code: str | None = None + action_description: str | None = None + action_url: str | None = None + action_url_label: str | None = None + + +class AuditCondition(CamelCaseBaseModel): + campaign_id: str | None = None + campaign_version: str | None = None + iteration_id: str | None = None + iteration_version: str | None = None + condition_name: str | None = None + status: str | None = None + status_text: str | None = None + eligibility_cohorts: list[AuditEligibilityCohorts] | None = None + eligibility_cohort_groups: list[AuditEligibilityCohortGroups] | None = None + filter_rules: AuditFilterRule | None = None + suitability_rules: AuditSuitabilityRule | None = None + action_rule: AuditRedirectRule | None = None + actions: list[AuditAction] | None = Field(default_factory=list) + + +class ResponseAuditData(CamelCaseBaseModel): + response_id: UUID | None = None + last_updated: str | None = None + condition: list[AuditCondition] = Field(default_factory=list) + + +class AuditEvent(CamelCaseBaseModel): + request: RequestAuditData = Field(default_factory=RequestAuditData) + response: ResponseAuditData = Field(default_factory=ResponseAuditData) diff --git a/src/eligibility_signposting_api/services/audit_service.py b/src/eligibility_signposting_api/audit/audit_service.py similarity index 91% rename from src/eligibility_signposting_api/services/audit_service.py rename to src/eligibility_signposting_api/audit/audit_service.py index b1d8b411e..7494661cf 100644 --- a/src/eligibility_signposting_api/services/audit_service.py +++ b/src/eligibility_signposting_api/audit/audit_service.py @@ -31,8 +31,9 @@ def audit(self, audit_record: dict) -> None: Returns: str: The Firehose record ID. """ + data = json.dumps(audit_record, default=str) response = self.firehose.put_record( DeliveryStreamName=self.audit_delivery_stream, - Record={"Data": (json.dumps(audit_record) + "\n").encode("utf-8")}, + Record={"Data": (data + "\n").encode("utf-8")}, ) logger.info("Successfully sent to the Firehose", extra={"firehose_record_id": response["RecordId"]}) diff --git a/src/eligibility_signposting_api/model/eligibility.py b/src/eligibility_signposting_api/model/eligibility.py index 985d0e561..bad948361 100644 --- a/src/eligibility_signposting_api/model/eligibility.py +++ b/src/eligibility_signposting_api/model/eligibility.py @@ -15,7 +15,9 @@ RuleName = NewType("RuleName", str) RuleDescription = NewType("RuleDescription", str) +RulePriority = NewType("RulePriority", str) +InternalActionCode = NewType("InternalActionCode", str) ActionType = NewType("ActionType", str) ActionCode = NewType("ActionCode", str) ActionDescription = NewType("ActionDescription", str) @@ -68,6 +70,7 @@ def best(*statuses: Status) -> Status: class Reason: rule_type: RuleType rule_name: RuleName + rule_priority: RulePriority rule_description: RuleDescription | None matcher_matched: bool @@ -79,6 +82,7 @@ class SuggestedAction: action_description: ActionDescription | None url_link: UrlLink | None url_label: UrlLabel | None + internal_action_code: InternalActionCode | None = None @dataclass @@ -95,6 +99,7 @@ class CohortGroupResult: status: Status reasons: list[Reason] description: str | None + audit_rules: list[Reason] @dataclass diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 79e6aa849..bc060bbef 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -7,8 +7,18 @@ from itertools import groupby from typing import TYPE_CHECKING, Any +from eligibility_signposting_api.audit.audit_context import AuditContext + if TYPE_CHECKING: - from eligibility_signposting_api.model.rules import ActionsMapper, Iteration, IterationCohort + from eligibility_signposting_api.model.rules import ( + ActionsMapper, + CampaignID, + CampaignVersion, + Iteration, + IterationCohort, + RuleName, + RulePriority, + ) from wireup import service @@ -20,6 +30,7 @@ CohortGroupResult, Condition, ConditionName, + InternalActionCode, IterationResult, Status, SuggestedAction, @@ -86,6 +97,7 @@ def get_the_best_cohort_memberships( status=cc.status, reasons=cc.reasons, description=(cc.description or "").strip() if cc.description else "", + audit_rules=cc.audit_rules, ) for cc in best_cohorts ] @@ -129,11 +141,21 @@ def evaluate_eligibility(self, *, include_actions_flag: bool = True) -> eligibil """Iterates over campaign groups, evaluates eligibility, and returns a consolidated status.""" condition_results: dict[ConditionName, IterationResult] = {} actions: list[SuggestedAction] | None = [] + redirect_rule_priority, redirect_rule_name = None, None for condition_name, campaign_group in self.campaigns_grouped_by_condition_name: - iteration_results: dict[str, tuple[Iteration, IterationResult]] = {} - - for active_iteration in [cc.current_iteration for cc in campaign_group]: + best_active_iteration: Iteration | None + best_candidate: IterationResult + best_campaign_id: CampaignID | None + best_campaign_version: CampaignVersion | None + best_cohort_results: dict[str, CohortGroupResult] | None + + iteration_results: dict[ + str, tuple[Iteration, IterationResult, CampaignID, CampaignVersion, dict[str, CohortGroupResult]] + ] = {} + + for cc in campaign_group: + active_iteration = cc.current_iteration cohort_results: dict[str, CohortGroupResult] = self.get_cohort_results(active_iteration) # Determine Result between cohorts - get the best @@ -141,35 +163,72 @@ def evaluate_eligibility(self, *, include_actions_flag: bool = True) -> eligibil iteration_results[active_iteration.name] = ( active_iteration, IterationResult(status, best_cohorts, actions), + cc.id, + cc.version, + cohort_results, ) # Determine results between iterations - get the best if iteration_results: - best_iteration_name, (best_active_iteration, best_candidate) = max( - iteration_results.items(), key=lambda item: item[1][1].status.value - ) + ( + best_iteration_name, + ( + best_active_iteration, + best_candidate, + best_campaign_id, + best_campaign_version, + best_cohort_results, + ), + ) = max(iteration_results.items(), key=lambda item: item[1][1].status.value) else: best_candidate = IterationResult(eligibility.Status.not_eligible, [], actions) + best_campaign_id = None + best_campaign_version = None best_active_iteration = None + best_cohort_results = None + condition_results[condition_name] = best_candidate if best_candidate.status == Status.actionable and best_active_iteration is not None: - actions = self.handle_redirect_rules(best_active_iteration) if include_actions_flag else None + if include_actions_flag: + actions, matched_r_rule_priority, matched_r_rule_name = self.handle_redirect_rules( + best_active_iteration + ) + redirect_rule_name = matched_r_rule_name + redirect_rule_priority = matched_r_rule_priority + else: + actions = None + if best_candidate.status in (Status.not_eligible, Status.not_actionable) and not include_actions_flag: actions = None # add actions to condition results condition_results[condition_name].actions = actions + # reset actions for the next condition + actions: list[SuggestedAction] | None = [] + + # add audit data + AuditContext.append_audit_condition( + condition_results[condition_name].actions, + condition_name, + (best_active_iteration, best_candidate, best_cohort_results), + (best_campaign_id, best_campaign_version), + (redirect_rule_priority, redirect_rule_name), + ) + # Consolidate all the results and return final_result = self.build_condition_results(condition_results) return eligibility.EligibilityStatus(conditions=final_result) - def handle_redirect_rules(self, best_active_iteration: Iteration) -> list[SuggestedAction] | None: + def handle_redirect_rules( + self, best_active_iteration: Iteration + ) -> tuple[list[SuggestedAction] | None, RulePriority | None, RuleName | None]: redirect_rules, action_mapper, default_comms = self.get_redirect_rules(best_active_iteration) priority_getter = attrgetter("priority") sorted_rules_by_priority = sorted(redirect_rules, key=priority_getter) actions: list[SuggestedAction] | None = self.get_actions_from_comms(action_mapper, default_comms) + matched_redirect_rule_priority, matched_redirect_rule_name = None, None for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): rule_group_list = list(rule_group) matcher_matched_list = [ @@ -182,9 +241,11 @@ def handle_redirect_rules(self, best_active_iteration: Iteration) -> list[Sugges rule_actions = self.get_actions_from_comms(action_mapper, comms_routing) if rule_actions and len(rule_actions) > 0: actions = rule_actions + matched_redirect_rule_priority = rule_group_list[0].priority + matched_redirect_rule_name = rule_group_list[0].name break - return actions + return actions, matched_redirect_rule_priority, matched_redirect_rule_name def get_cohort_results(self, active_iteration: rules.Iteration) -> dict[str, CohortGroupResult]: cohort_results: dict[str, CohortGroupResult] = {} @@ -200,10 +261,11 @@ def get_cohort_results(self, active_iteration: rules.Iteration) -> dict[str, Coh # Not base eligible elif cohort.cohort_label is not None: cohort_results[cohort.cohort_label] = CohortGroupResult( - (cohort.cohort_group), + cohort.cohort_group, Status.not_eligible, [], cohort.negative_description, + [], ) return cohort_results @@ -227,6 +289,7 @@ def build_condition_results(condition_results: dict[ConditionName, IterationResu reasons=[reason for cohort in group for reason in cohort.reasons], # get the first nonempty description description=next((c.description for c in group if c.description), group[0].description), + audit_rules=[], ) for group_cohort_code, group in grouped_cohort_results.items() if group @@ -264,6 +327,7 @@ def is_eligible_by_filter_rules( Status.not_eligible, [], cohort.negative_description, + group_exclusion_reasons, ) is_eligible = False break @@ -295,10 +359,7 @@ def evaluate_suppression_rules( key = cohort.cohort_label if is_actionable: cohort_results[key] = CohortGroupResult( - cohort.cohort_group, - Status.actionable, - [], - cohort.positive_description, + cohort.cohort_group, Status.actionable, [], cohort.positive_description, suppression_reasons ) else: cohort_results[key] = CohortGroupResult( @@ -306,6 +367,7 @@ def evaluate_suppression_rules( Status.not_actionable, suppression_reasons, cohort.positive_description, + suppression_reasons, ) def evaluate_rules_priority_group( @@ -336,6 +398,7 @@ def get_actions_from_comms(action_mapper: ActionsMapper, comms: str) -> list[Sug if action is not None: suggested_actions.append( SuggestedAction( + internal_action_code=InternalActionCode(comm), action_type=ActionType(action.action_type), action_code=ActionCode(action.action_code), action_description=ActionDescription(action.action_description) diff --git a/src/eligibility_signposting_api/services/calculators/rule_calculator.py b/src/eligibility_signposting_api/services/calculators/rule_calculator.py index 5834c678e..145a1e89f 100644 --- a/src/eligibility_signposting_api/services/calculators/rule_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/rule_calculator.py @@ -24,6 +24,7 @@ def evaluate_exclusion(self) -> tuple[eligibility.Status, eligibility.Reason]: reason = eligibility.Reason( rule_name=eligibility.RuleName(self.rule.name), rule_type=eligibility.RuleType(self.rule.type), + rule_priority=eligibility.RulePriority(str(self.rule.priority)), rule_description=eligibility.RuleDescription(self.rule.description), matcher_matched=matcher_matched, ) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 032b68816..c8d9c5b50 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -4,7 +4,6 @@ from eligibility_signposting_api.model import eligibility from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo -from eligibility_signposting_api.services.audit_service import AuditService from eligibility_signposting_api.services.calculators import eligibility_calculator as calculator logger = logging.getLogger(__name__) @@ -24,13 +23,11 @@ def __init__( self, person_repo: PersonRepo, campaign_repo: CampaignRepo, - audit_service: AuditService, calculator_factory: calculator.EligibilityCalculatorFactory, ) -> None: super().__init__() self.person_repo = person_repo self.campaign_repo = campaign_repo - self.audit_service = audit_service self.calculator_factory = calculator_factory def get_eligibility_status( @@ -54,7 +51,6 @@ def get_eligibility_status( raise UnknownPersonError from e else: calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, campaign_configs) - self.audit_service.audit({"test_audit": "check if audit works"}) return calc.evaluate_eligibility(include_actions_flag=include_actions_flag) raise UnknownPersonError # pragma: no cover diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index a313319ae..310c4e1eb 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -9,6 +9,8 @@ from flask.typing import ResponseReturnValue from wireup import Injected +from eligibility_signposting_api.audit.audit_context import AuditContext +from eligibility_signposting_api.audit.audit_service import AuditService from eligibility_signposting_api.model.eligibility import Condition, EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.services.eligibility_services import InvalidQueryParamError @@ -26,9 +28,16 @@ eligibility_blueprint = Blueprint("eligibility", __name__) +@eligibility_blueprint.before_request +def before_request() -> None: + AuditContext.add_request_details(request) + + @eligibility_blueprint.get("/", defaults={"nhs_number": ""}) @eligibility_blueprint.get("/") -def check_eligibility(nhs_number: NHSNumber, eligibility_service: Injected[EligibilityService]) -> ResponseReturnValue: +def check_eligibility( + nhs_number: NHSNumber, eligibility_service: Injected[EligibilityService], audit_service: Injected[AuditService] +) -> ResponseReturnValue: logger.info("checking nhs_number %r in %r", nhs_number, eligibility_service, extra={"nhs_number": nhs_number}) try: eligibility_status = eligibility_service.get_eligibility_status( @@ -40,6 +49,7 @@ def check_eligibility(nhs_number: NHSNumber, eligibility_service: Injected[Eligi return handle_unknown_person_error(nhs_number) else: eligibility_response: eligibility.EligibilityResponse = build_eligibility_response(eligibility_status) + AuditContext.write_to_firehose(audit_service) return make_response( eligibility_response.model_dump(by_alias=True, mode="json", exclude_none=True), HTTPStatus.OK ) @@ -108,9 +118,14 @@ def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibi processed_suggestions.append(suggestions) + response_id = uuid.uuid4() + updated = eligibility.LastUpdated(datetime.now(tz=UTC)) + + AuditContext.add_response_details(response_id, updated) + return eligibility.EligibilityResponse( # pyright: ignore[reportCallIssue] - responseId=uuid.uuid4(), # pyright: ignore[reportCallIssue] - meta=eligibility.Meta(lastUpdated=eligibility.LastUpdated(datetime.now(tz=UTC))), + responseId=response_id, # pyright: ignore[reportCallIssue] + meta=eligibility.Meta(lastUpdated=updated), # pyright: ignore[reportCallIssue] processedSuggestions=processed_suggestions, ) diff --git a/tests/conftest.py b/tests/conftest.py index 5d90004aa..d3e52d7f2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,11 @@ import pytest from faker import Faker from faker.providers import BaseProvider -from flask import Flask +from flask import Flask, g from flask.testing import FlaskClient from eligibility_signposting_api.app import create_app +from eligibility_signposting_api.audit.audit_models import AuditEvent @pytest.fixture(scope="session") @@ -20,6 +21,14 @@ def client(app) -> FlaskClient: return app.test_client() +@pytest.fixture(autouse=True) +def audit_log_context(app): + with app.app_context(): + g.audit_log = AuditEvent() + yield + g.pop("audit_log", None) + + @pytest.fixture(scope="session") def faker() -> Faker: faker = Faker("en_UK") diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d6ebc0f4f..44ffeda08 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -208,6 +208,22 @@ def flask_function(lambda_client: BaseClient, iam_role: str, lambda_zip: Path) - lambda_client.delete_function(FunctionName=function_name) +@pytest.fixture(autouse=True) +def clean_audit_bucket(s3_client: BaseClient, audit_bucket: str): + objects_to_delete = [] + paginator = s3_client.get_paginator("list_objects_v2") + pages = paginator.paginate(Bucket=audit_bucket) + for page in pages: + if "Contents" in page: + objects_to_delete.extend([{"Key": obj["Key"]} for obj in page["Contents"]]) + + if objects_to_delete: + s3_client.delete_objects( + Bucket=audit_bucket, + Delete={"Objects": objects_to_delete, "Quiet": True}, + ) + + @pytest.fixture(scope="session") def flask_function_url(lambda_client: BaseClient, flask_function: str) -> URL: response = lambda_client.create_function_url_config(FunctionName=flask_function, AuthType="NONE") @@ -350,6 +366,28 @@ def persisted_77yo_person(person_table: Any, faker: Faker) -> Generator[eligibil person_table.delete_item(Key={"NHS_NUMBER": row["NHS_NUMBER"], "ATTRIBUTE_TYPE": row["ATTRIBUTE_TYPE"]}) +@pytest.fixture +def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: + nhs_number = eligibility.NHSNumber(faker.nhs_number()) + date_of_birth = eligibility.DateOfBirth(faker.date_of_birth(minimum_age=74, maximum_age=74)) + + for row in ( + rows := person_rows_builder( + nhs_number, + date_of_birth=date_of_birth, + postcode="hp1", + cohorts=["cohort1", "cohort2", "cohort3"], + icb="QE1", + ) + ): + person_table.put_item(Item=row) + + yield nhs_number + + for row in rows: + person_table.delete_item(Key={"NHS_NUMBER": row["NHS_NUMBER"], "ATTRIBUTE_TYPE": row["ATTRIBUTE_TYPE"]}) + + @pytest.fixture def persisted_person_no_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: nhs_number = eligibility.NHSNumber(faker.nhs_number()) @@ -442,6 +480,50 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") +@pytest.fixture(scope="class") +def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[rules.CampaignConfig]]: + """Create and upload multiple campaign configs to S3, then clean up after tests.""" + campaigns, campaign_data_keys = [], [] + + targets = ["RSV", "COVID", "FLU"] + target_rules_map = { + targets[0]: [rule.PersonAgeSuppressionRuleFactory.build(type=rules.RuleType.filter)], + targets[1]: [rule.PersonAgeSuppressionRuleFactory.build()], + targets[2]: [rule.ICBRedirectRuleFactory.build()], + } + + for i in range(3): + campaign = rule.CampaignConfigFactory.build( + name=f"campaign_{i}", + target=targets[i], + iterations=[ + rule.IterationFactory.build( + iteration_rules=target_rules_map.get(targets[i]), + iteration_cohorts=[ + rule.IterationCohortFactory.build( + cohort_label="cohort1", + cohort_group=f"cohort_group{i + 1}", + positive_description=f"positive_desc_{i + 1}", + negative_description=f"negative_desc_{i + 1}", + ) + ], + ) + ], + ) + campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} + key = f"{campaign.name}.json" + s3_client.put_object( + Bucket=rules_bucket, Key=key, Body=json.dumps(campaign_data), ContentType="application/json" + ) + campaigns.append(campaign) + campaign_data_keys.append(key) + + yield campaigns + + for key in campaign_data_keys: + s3_client.delete_object(Bucket=rules_bucket, Key=key) + + @pytest.fixture(scope="class") def campaign_config_with_magic_cohort( s3_client: BaseClient, rules_bucket: BucketName diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 000424f61..8253e9a1a 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -10,7 +10,17 @@ from brunns.matchers.data import json_matching as is_json_that from brunns.matchers.response import is_response from faker import Faker -from hamcrest import assert_that, contains_exactly, contains_string, has_entries, has_item, has_key +from hamcrest import ( + assert_that, + contains_exactly, + contains_inanyorder, + contains_string, + equal_to, + has_entries, + has_item, + has_key, + is_not, +) from yarl import URL from eligibility_signposting_api.model.eligibility import NHSNumber @@ -156,7 +166,7 @@ def get_log_messages(flask_function: str, logs_client: BaseClient) -> list[str]: def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_if_audited( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa:ARG001 + campaign_config: CampaignConfig, s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -166,7 +176,14 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(persisted_person)}, + headers={ + "nhs-login-nhs-number": str(persisted_person), + "x_request_id": "x_request_id", + "x_correlation_id": "x_correlation_id", + "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", + "nhsd_application_id": "nhsd_application_id", + }, + params={"includeActions": "Y"}, timeout=10, ) @@ -181,7 +198,51 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i object_keys = [obj["Key"] for obj in objects] latest_key = sorted(object_keys)[-1] audit_data = json.loads(s3_client.get_object(Bucket=audit_bucket, Key=latest_key)["Body"].read()) - assert_that(audit_data, has_entries(test_audit="check if audit works")) + + expected_headers = { + "xRequestId": "x_request_id", + "xCorrelationId": "x_correlation_id", + "nhsdEndUserOrganisationOds": "nhsd_end_user_organisation_ods", + "nhsdApplicationId": "nhsd_application_id", + } + expected_query_params = {"category": None, "conditions": None, "includeActions": "Y"} + + expected_conditions = [ + { + "campaignId": campaign_config.id, + "campaignVersion": campaign_config.version, + "iterationId": campaign_config.iterations[0].id, + "iterationVersion": campaign_config.iterations[0].version, + "conditionName": campaign_config.target, + "status": "not_actionable", + "statusText": "not_actionable", + "eligibilityCohorts": [{"cohortCode": "cohort_group1", "cohortStatus": "not_actionable"}], + "eligibilityCohortGroups": [ + { + "cohortCode": "cohort_group1", + "cohortText": "positive_description", + "cohortStatus": "not_actionable", + } + ], + "filterRules": None, + "suitabilityRules": { + "rulePriority": "10", + "ruleName": "Exclude too young less than 75", + "ruleMessage": "Exclude too young less than 75", + }, + "actionRule": None, + "actions": [], + } + ] + + assert_that(audit_data["request"]["requestTimestamp"], is_not(equal_to(""))) + assert_that(audit_data["request"]["headers"], equal_to(expected_headers)) + assert_that(audit_data["request"]["nhsNumber"], equal_to(persisted_person)) + assert_that(audit_data["request"]["queryParams"], equal_to(expected_query_params)) + + assert_that(audit_data["response"]["responseId"], is_not(equal_to(""))) + assert_that(audit_data["response"]["lastUpdated"], is_not(equal_to(""))) + assert_that(audit_data["response"]["condition"], equal_to(expected_conditions)) def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_results_in_error_response( @@ -204,3 +265,135 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu response, is_response().with_status_code(HTTPStatus.FORBIDDEN).and_body("NHS number mismatch"), ) + + +def test_given_person_has_unique_status_for_different_conditions_with_audit( # noqa: PLR0913 + lambda_client: BaseClient, # noqa:ARG001 + persisted_person_all_cohorts: NHSNumber, + multiple_campaign_configs: list[CampaignConfig], + s3_client: BaseClient, + audit_bucket: BucketName, + api_gateway_endpoint: URL, +): + invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person_all_cohorts}" + response = httpx.get( + invoke_url, + headers={ + "nhs-login-nhs-number": str(persisted_person_all_cohorts), + "x_request_id": "x_request_id", + "x_correlation_id": "x_correlation_id", + "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", + "nhsd_application_id": "nhsd_application_id", + }, + params={"includeActions": "Y"}, + timeout=10, + ) + + assert_that( + response, + is_response().with_status_code(HTTPStatus.OK).and_body(is_json_that(has_key("processedSuggestions"))), + ) + + objects = s3_client.list_objects_v2(Bucket=audit_bucket).get("Contents", []) + object_keys = [obj["Key"] for obj in objects] + latest_key = sorted(object_keys)[-1] + audit_data = json.loads(s3_client.get_object(Bucket=audit_bucket, Key=latest_key)["Body"].read()) + + expected_headers = { + "xRequestId": "x_request_id", + "xCorrelationId": "x_correlation_id", + "nhsdEndUserOrganisationOds": "nhsd_end_user_organisation_ods", + "nhsdApplicationId": "nhsd_application_id", + } + expected_query_params = {"category": None, "conditions": None, "includeActions": "Y"} + + rsv_campaign = multiple_campaign_configs[0] + covid_campaign = multiple_campaign_configs[1] + flu_campaign = multiple_campaign_configs[2] + + expected_conditions = [ + { + "campaignId": rsv_campaign.id, + "campaignVersion": rsv_campaign.version, + "iterationId": rsv_campaign.iterations[0].id, + "iterationVersion": rsv_campaign.iterations[0].version, + "conditionName": rsv_campaign.target, + "status": "not_eligible", + "statusText": "not_eligible", + "eligibilityCohorts": [{"cohortCode": "cohort_group1", "cohortStatus": "not_eligible"}], + "eligibilityCohortGroups": [ + { + "cohortCode": "cohort_group1", + "cohortText": "negative_desc_1", + "cohortStatus": "not_eligible", + } + ], + "filterRules": {"rulePriority": "10", "ruleName": "Exclude too young less than 75"}, + "suitabilityRules": None, + "actionRule": None, + "actions": [], + }, + { + "campaignId": covid_campaign.id, + "campaignVersion": covid_campaign.version, + "iterationId": covid_campaign.iterations[0].id, + "iterationVersion": covid_campaign.iterations[0].version, + "conditionName": covid_campaign.target, + "status": "not_actionable", + "statusText": "not_actionable", + "eligibilityCohorts": [{"cohortCode": "cohort_group2", "cohortStatus": "not_actionable"}], + "eligibilityCohortGroups": [ + { + "cohortCode": "cohort_group2", + "cohortText": "positive_desc_2", + "cohortStatus": "not_actionable", + } + ], + "filterRules": None, + "suitabilityRules": { + "rulePriority": "10", + "ruleName": "Exclude too young less than 75", + "ruleMessage": "Exclude too young less than 75", + }, + "actionRule": None, + "actions": [], + }, + { + "campaignId": flu_campaign.id, + "campaignVersion": flu_campaign.version, + "iterationId": flu_campaign.iterations[0].id, + "iterationVersion": flu_campaign.iterations[0].version, + "conditionName": flu_campaign.target, + "status": "actionable", + "statusText": "actionable", + "eligibilityCohorts": [{"cohortCode": "cohort_group3", "cohortStatus": "actionable"}], + "eligibilityCohortGroups": [ + { + "cohortCode": "cohort_group3", + "cohortText": "positive_desc_3", + "cohortStatus": "actionable", + } + ], + "filterRules": None, + "suitabilityRules": None, + "actionRule": {"rulePriority": "20", "ruleName": "In QE1"}, + "actions": [ + { + "internalActionCode": "defaultcomms", + "actionType": "defaultcomms", + "actionCode": "action_code", + "actionDescription": None, + "actionUrl": None, + "actionUrlLabel": None, + } + ], + }, + ] + + assert_that(audit_data["request"]["requestTimestamp"], is_not(equal_to(""))) + assert_that(audit_data["request"]["headers"], equal_to(expected_headers)) + assert_that(audit_data["request"]["nhsNumber"], equal_to(persisted_person_all_cohorts)) + assert_that(audit_data["request"]["queryParams"], equal_to(expected_query_params)) + assert_that(audit_data["response"]["responseId"], is_not(equal_to(""))) + assert_that(audit_data["response"]["lastUpdated"], is_not(equal_to(""))) + assert_that(audit_data["response"]["condition"], contains_inanyorder(*expected_conditions)) diff --git a/tests/unit/audit/__init__.py b/tests/unit/audit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/audit/test_audit_context.py b/tests/unit/audit/test_audit_context.py new file mode 100644 index 000000000..2ee9aafdc --- /dev/null +++ b/tests/unit/audit/test_audit_context.py @@ -0,0 +1,192 @@ +import uuid +from datetime import UTC, datetime +from unittest.mock import Mock + +import pytest +from flask import Flask, g, request +from pydantic import HttpUrl + +from eligibility_signposting_api.audit.audit_context import AuditContext +from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent +from eligibility_signposting_api.audit.audit_service import AuditService +from eligibility_signposting_api.model.eligibility import ( + ActionCode, + ActionDescription, + ActionType, + CohortGroupResult, + ConditionName, + InternalActionCode, + IterationResult, + Reason, + RuleDescription, + RuleName, + RulePriority, + Status, + SuggestedAction, + UrlLabel, + UrlLink, +) +from eligibility_signposting_api.model.rules import CampaignID, CampaignVersion, Iteration, RuleType +from tests.fixtures.builders.model.rule import IterationFactory + + +@pytest.fixture +def app(): + return Flask(__name__) + + +def test_add_request_details_sets_audit_log_on_g(app): + headers = { + "X-Request-ID": "test-x-request-id", + "X-Correlation-ID": "test-x-correlation-id", + "NHSD-End-User-Organisation-ODS": "test-org", + "nhsd-application-id": "test-app-id", + } + + nhs_number = "1234567890" + url = "/patient-check?includeActions=Y" + + with app.test_request_context(url, headers=headers, method="GET"): + request.view_args = {"nhs_number": nhs_number} + AuditContext.add_request_details(request) + + assert hasattr(g, "audit_log") + audit_req = g.audit_log.request + assert audit_req.nhs_number == nhs_number + assert audit_req.headers.x_request_id == "test-x-request-id" + assert audit_req.headers.x_correlation_id == "test-x-correlation-id" + assert audit_req.headers.nhsd_end_user_organisation_ods == "test-org" + assert audit_req.headers.nhsd_application_id == "test-app-id" + assert audit_req.query_params.include_actions == "Y" + assert isinstance(audit_req.request_timestamp, datetime) + + +def test_add_request_details_when_headers_are_empty_sets_audit_log_on_g(app): + nhs_number = "1234567890" + url = "/patient-check?includeActions=Y" + + with app.test_request_context(url, method="GET"): + request.view_args = {"nhs_number": nhs_number} + AuditContext.add_request_details(request) + + assert hasattr(g, "audit_log") + audit_req = g.audit_log.request + assert audit_req.nhs_number == nhs_number + assert audit_req.headers.x_request_id is None + assert audit_req.headers.x_correlation_id is None + assert audit_req.headers.nhsd_end_user_organisation_ods is None + assert audit_req.headers.nhsd_application_id is None + assert audit_req.query_params.include_actions == "Y" + assert isinstance(audit_req.request_timestamp, datetime) + + +def test_append_audit_condition_adds_condition_to_audit_log_on_g(app): + suggested_actions: list[SuggestedAction] | None + condition_name: ConditionName + best_results: tuple[Iteration, IterationResult, dict[str, CohortGroupResult]] + campaign_details: tuple[CampaignID | None, CampaignVersion | None] + redirect_rule_details: tuple[RulePriority | None, RuleName | None] + + suggested_actions = [ + SuggestedAction( + internal_action_code=InternalActionCode("InternalActionCode1"), + action_code=ActionCode("ActionCode1"), + action_type=ActionType("ActionType1"), + action_description=ActionDescription("ActionDescription1"), + url_link=UrlLink(HttpUrl("https://example.com/")), + url_label=UrlLabel("ActionLabel1"), + ) + ] + + condition_name = ConditionName("Condition1") + iteration = IterationFactory.build() + audit_rules = [ + Reason( + rule_type=RuleType.filter, + rule_name=RuleName("FilterRuleName1"), + rule_description=RuleDescription("FilterRuleDescription1"), + matcher_matched=True, + rule_priority=RulePriority("1"), + ) + ] + cohort_group_result = CohortGroupResult( + status=Status.actionable, + cohort_code="CohortCode1", + description="CohortDescription1", + audit_rules=audit_rules, + reasons=audit_rules, + ) + iteration_result = IterationResult( + status=Status.actionable, cohort_results=[cohort_group_result], actions=suggested_actions + ) + best_results = (iteration, iteration_result, {"CohortCode1": cohort_group_result}) + campaign_details = (CampaignID("CampaignID1"), CampaignVersion("CampaignVersion1")) + redirect_rule_details = (RulePriority("1"), RuleName("RedirectRuleName1")) + + with app.app_context(): + g.audit_log = AuditEvent() + + AuditContext.append_audit_condition( + suggested_actions, condition_name, best_results, campaign_details, redirect_rule_details + ) + + expected_audit_action = [ + AuditAction( + internal_action_code="InternalActionCode1", + action_code="ActionCode1", + action_type="ActionType1", + action_description="ActionDescription1", + action_url="https://example.com/", + action_url_label="ActionLabel1", + ) + ] + + assert g.audit_log.response.condition, condition_name + cond = g.audit_log.response.condition[0] + assert cond.condition_name == condition_name + assert cond.campaign_id == campaign_details[0] + assert cond.campaign_version == campaign_details[1] + assert cond.iteration_id == iteration.id + assert cond.iteration_version == iteration.version + assert cond.status == best_results[1].status.name + assert cond.status_text == best_results[1].status.name + assert cond.actions == expected_audit_action + assert cond.action_rule.rule_priority == "1" + assert cond.action_rule.rule_name == "RedirectRuleName1" + assert cond.suitability_rules is None + assert cond.filter_rules is None + assert cond.eligibility_cohorts[0].cohort_code == "CohortCode1" + assert cond.eligibility_cohorts[0].cohort_status == "actionable" + assert cond.eligibility_cohort_groups[0].cohort_code == "CohortCode1" + assert cond.eligibility_cohort_groups[0].cohort_status == "actionable" + assert cond.eligibility_cohort_groups[0].cohort_text == "CohortDescription1" + + +def test_add_response_details_adds_to_audit_log_on_g(app): + response_id = uuid.uuid4() + last_updated = datetime(2023, 1, 1, 0, 0, tzinfo=UTC) + + with app.app_context(): + g.audit_log = AuditEvent() + + AuditContext.add_response_details(response_id, last_updated) + + assert g.audit_log.response.response_id == response_id + assert g.audit_log.response.last_updated is last_updated + + +def test_write_to_firehose_calls_audit_service_with_correct_data_from_g(app): + mock_audit_service = Mock(spec=AuditService) + response_id = uuid.uuid4() + last_updated = datetime(2023, 1, 1, 0, 0, tzinfo=UTC) + + with app.app_context(): + g.audit_log = AuditEvent() + + AuditContext.add_response_details(response_id, last_updated) + AuditContext.write_to_firehose(mock_audit_service) + + assert g.audit_log.response.response_id == response_id + assert g.audit_log.response.last_updated == last_updated + + mock_audit_service.audit.assert_called_once_with(g.audit_log.model_dump(by_alias=True)) diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 1ac57cbdb..86d7ff487 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -15,6 +15,7 @@ ActionType, ConditionName, DateOfBirth, + InternalActionCode, NHSNumber, Postcode, RuleDescription, @@ -614,11 +615,33 @@ def test_multiple_conditions_where_both_are_actionable(faker: Faker): is_condition() .with_condition_name(ConditionName("RSV")) .and_status(Status.actionable) - .and_actions([suggested_action_for_default_comms]), + .and_actions( + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultcomms"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), + url_link=None, + url_label=None, + ) + ] + ), is_condition() .with_condition_name(ConditionName("COVID")) .and_status(Status.actionable) - .and_actions([suggested_action_for_book_nbs]), + .and_actions( + [ + SuggestedAction( + internal_action_code=InternalActionCode("ActionCode1"), + action_type=ActionType("ButtonAuthLink"), + action_code=ActionCode("BookNBS"), + action_description=ActionDescription("Action description"), + url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), + url_label=UrlLabel("Continue to booking"), + ) + ] + ), ) ), ) @@ -1738,7 +1761,7 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( ActionType="ButtonAuthLink", ExternalRoutingCode="BookNBS", ActionDescription="Action description", - UrlLink="http://www.nhs.uk/book-rsv", + UrlLink=HttpUrl("https://www.nhs.uk/book-rsv"), UrlLabel="Continue to booking", ) @@ -1748,22 +1771,6 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( ActionDescription="You can get an RSV vaccination at your GP surgery", ) -suggested_action_for_book_nbs = SuggestedAction( - action_type=ActionType(book_nbs_comms.action_type), - action_code=ActionCode(book_nbs_comms.action_code), - action_description=ActionDescription(book_nbs_comms.action_description), - url_link=UrlLink(book_nbs_comms.url_link), - url_label=UrlLabel(book_nbs_comms.url_label), -) - -suggested_action_for_default_comms = SuggestedAction( - action_type=ActionType(default_comms_detail.action_type), - action_code=ActionCode(default_comms_detail.action_code), - action_description=ActionDescription(default_comms_detail.action_description), - url_link=None, - url_label=None, -) - @pytest.mark.parametrize( ("test_comment", "default_comms_routing", "comms_routing", "actions_mapper", "expected_actions"), @@ -1774,7 +1781,16 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( "defaultcomms", "InternalBookNBS", {"InternalBookNBS": book_nbs_comms, "defaultcomms": default_comms_detail}, - [suggested_action_for_book_nbs], + [ + SuggestedAction( + internal_action_code=InternalActionCode("InternalBookNBS"), + action_type=ActionType("ButtonAuthLink"), + action_code=ActionCode("BookNBS"), + action_description=ActionDescription("Action description"), + url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), + url_label=UrlLabel("Continue to booking"), + ) + ], ), ( """Rule match: default_comms_routing has multiple values, @@ -1782,7 +1798,24 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( "defaultcomms1|defaultcomms2", None, {"defaultcomms1": default_comms_detail, "defaultcomms2": default_comms_detail}, - [suggested_action_for_default_comms, suggested_action_for_default_comms], + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultcomms1"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), + url_link=None, + url_label=None, + ), + SuggestedAction( + internal_action_code=InternalActionCode("defaultcomms2"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), + url_link=None, + url_label=None, + ), + ], ), ( """Rule match: default_comms_routing has multiple values, @@ -1790,7 +1823,16 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( "defaultcomms1", "", {"defaultcomms1": default_comms_detail}, - [suggested_action_for_default_comms], + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultcomms1"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), + url_link=None, + url_label=None, + ) + ], ), ( """Rule match: default_comms_routing present, @@ -1798,7 +1840,16 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( "defaultcomms", "InternalBookNBS", {"defaultcomms": default_comms_detail}, - [suggested_action_for_default_comms], + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultcomms"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), + url_link=None, + url_label=None, + ) + ], ), ( """Rule match: default_comms_routing present, @@ -1806,7 +1857,16 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( "defaultcomms", "InvalidCode", {"defaultcomms": default_comms_detail}, - [suggested_action_for_default_comms], + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultcomms"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), + url_link=None, + url_label=None, + ) + ], ), ( """Rule match: action_mapper present without url, @@ -1822,6 +1882,7 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( }, [ SuggestedAction( + internal_action_code=InternalActionCode("InternalBookNBS"), action_type=ActionType(book_nbs_comms.action_type), action_code=ActionCode(book_nbs_comms.action_code), action_description=ActionDescription(book_nbs_comms.action_description), @@ -1844,7 +1905,16 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( "", "InternalBookNBS", {"InternalBookNBS": book_nbs_comms}, - [suggested_action_for_book_nbs], + [ + SuggestedAction( + internal_action_code=InternalActionCode("InternalBookNBS"), + action_type=ActionType("ButtonAuthLink"), + action_code=ActionCode("BookNBS"), + action_description=ActionDescription("Action description"), + url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), + url_label=UrlLabel("Continue to booking"), + ) + ], ), ( """Rule match: default_comms_routing present, @@ -1860,7 +1930,16 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( "defaultcomms1|invaliddefault", None, {"defaultcomms1": default_comms_detail}, - [suggested_action_for_default_comms], + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultcomms1"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), + url_link=None, + url_label=None, + ) + ], ), ], ) @@ -1963,7 +2042,20 @@ def test_cohort_label_not_supported_used_in_r_rules(test_comment: str, redirect_ is_condition() .with_condition_name(ConditionName("RSV")) .and_status(equal_to(Status.actionable)) - .and_actions(equal_to([suggested_action_for_book_nbs])) + .and_actions( + equal_to( + [ + SuggestedAction( + internal_action_code=InternalActionCode("ActionCode1"), + action_type=ActionType("ButtonAuthLink"), + action_code=ActionCode("BookNBS"), + action_description=ActionDescription("Action description"), + url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), + url_label=UrlLabel("Continue to booking"), + ) + ] + ) + ) ) ), test_comment, @@ -2019,7 +2111,20 @@ def test_multiple_r_rules_match_with_same_priority(faker: Faker): is_condition() .with_condition_name(ConditionName("RSV")) .and_status(equal_to(Status.actionable)) - .and_actions(equal_to([suggested_action_for_book_nbs])) + .and_actions( + equal_to( + [ + SuggestedAction( + internal_action_code=InternalActionCode("rule_1_comms_routing"), + action_type=ActionType("ButtonAuthLink"), + action_code=ActionCode("BookNBS"), + action_description=ActionDescription("Action description"), + url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), + url_label=UrlLabel("Continue to booking"), + ) + ] + ) + ) ) ), ) @@ -2073,7 +2178,22 @@ def test_multiple_r_rules_with_same_priority_one_rule_mismatch_should_return_def is_condition() .with_condition_name(ConditionName("RSV")) .and_status(equal_to(Status.actionable)) - .and_actions(equal_to([suggested_action_for_default_comms])) + .and_actions( + equal_to( + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultcomms"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription( + "You can get an RSV vaccination at your GP surgery" + ), + url_link=None, + url_label=None, + ) + ] + ) + ) ) ), ) @@ -2103,7 +2223,7 @@ def test_only_highest_priority_rule_is_applied_and_return_actions_only_for_that_ ActionType="AuthLink", ExternalRoutingCode="BookNBS", ActionDescription="Action description", - UrlLink="http://www.nhs.uk/book-rsv", + UrlLink=HttpUrl("https://www.nhs.uk/book-rsv"), UrlLabel="Continue to booking", ), "defaultcomms": default_comms_detail, @@ -2125,6 +2245,7 @@ def test_only_highest_priority_rule_is_applied_and_return_actions_only_for_that_ actual = calculator.evaluate_eligibility() expected_actions = SuggestedAction( + internal_action_code=InternalActionCode("rule_1_comms_routing"), action_type=ActionType("ButtonAuthLink"), action_code=ActionCode("BookNBS"), action_description=ActionDescription("Action description"), @@ -2187,7 +2308,20 @@ def test_should_include_actions_when_include_actions_flag_is_true_when_status_is is_condition() .with_condition_name(ConditionName("RSV")) .and_status(equal_to(Status.actionable)) - .and_actions(equal_to([suggested_action_for_book_nbs])) + .and_actions( + equal_to( + [ + SuggestedAction( + internal_action_code=InternalActionCode("book_nbs"), + action_type=ActionType("ButtonAuthLink"), + action_code=ActionCode("BookNBS"), + action_description=ActionDescription("Action description"), + url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), + url_label=UrlLabel("Continue to booking"), + ) + ] + ) + ) ) ), ) diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index a96c8b2bd..c99f3b73a 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -6,7 +6,6 @@ from eligibility_signposting_api.model.eligibility import NHSNumber from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError -from eligibility_signposting_api.services.audit_service import AuditService from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculatorFactory from tests.fixtures.matchers.eligibility import is_eligibility_status @@ -15,9 +14,8 @@ def test_eligibility_service_returns_from_repo(): # Given person_repo = MagicMock(spec=PersonRepo) campaign_repo = MagicMock(spec=CampaignRepo) - audit_service = MagicMock(spec=AuditService) person_repo.get_eligibility = MagicMock(return_value=[]) - service = EligibilityService(person_repo, campaign_repo, audit_service, EligibilityCalculatorFactory()) + service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory()) # When actual = service.get_eligibility_status(NHSNumber("1234567890")) @@ -30,9 +28,8 @@ def test_eligibility_service_for_nonexistent_nhs_number(): # Given person_repo = MagicMock(spec=PersonRepo) campaign_repo = MagicMock(spec=CampaignRepo) - audit_service = MagicMock(spec=AuditService) person_repo.get_eligibility_data = MagicMock(side_effect=NotFoundError) - service = EligibilityService(person_repo, campaign_repo, audit_service, EligibilityCalculatorFactory()) + service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory()) # When with pytest.raises(UnknownPersonError): diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 36fb887c5..c0b4c3fa7 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -14,6 +14,7 @@ from pydantic import HttpUrl from wireup.integration.flask import get_app_container +from eligibility_signposting_api.audit.audit_service import AuditService from eligibility_signposting_api.model.eligibility import ( ActionCode, ActionDescription, @@ -25,6 +26,7 @@ Reason, RuleDescription, RuleName, + RulePriority, RuleType, Status, SuggestedAction, @@ -50,6 +52,11 @@ logger = logging.getLogger(__name__) +class FakeAuditService: + def audit(self, audit_record): + pass + + class FakeEligibilityService(EligibilityService): def __init__(self): pass @@ -91,7 +98,10 @@ def get_eligibility_status( def test_nhs_number_given(app: Flask, client: FlaskClient): # Given - with get_app_container(app).override.service(EligibilityService, new=FakeEligibilityService()): + with ( + get_app_container(app).override.service(EligibilityService, new=FakeEligibilityService()), + get_app_container(app).override.service(AuditService, new=FakeAuditService()), + ): # When response = client.get("/patient-check/12345") @@ -224,18 +234,21 @@ def test_build_suitability_results_with_deduplication(): rule_name=RuleName("Exclude too young less than 75"), rule_description=RuleDescription("your age is greater than 75"), matcher_matched=False, + rule_priority=RulePriority(1), ), Reason( rule_type=RuleType.suppression, rule_name=RuleName("Exclude too young less than 75"), rule_description=RuleDescription("your age is greater than 75"), matcher_matched=False, + rule_priority=RulePriority(1), ), Reason( rule_type=RuleType.suppression, rule_name=RuleName("Exclude more than 100"), rule_description=RuleDescription("your age is greater than 100"), matcher_matched=False, + rule_priority=RulePriority(1), ), ], ), @@ -248,6 +261,7 @@ def test_build_suitability_results_with_deduplication(): rule_name=RuleName("Exclude too young less than 75"), rule_description=RuleDescription("your age is greater than 75"), matcher_matched=False, + rule_priority=RulePriority(1), ) ], ), @@ -260,6 +274,7 @@ def test_build_suitability_results_with_deduplication(): rule_name=RuleName("Exclude is present in sw1"), rule_description=RuleDescription("your a member of sw1"), matcher_matched=False, + rule_priority=RulePriority(1), ) ], ), @@ -273,6 +288,7 @@ def test_build_suitability_results_with_deduplication(): rule_name=RuleName("Already vaccinated"), rule_description=RuleDescription("you have already vaccinated"), matcher_matched=False, + rule_priority=RulePriority(1), ) ], ), @@ -306,18 +322,21 @@ def test_build_suitability_results_when_rule_text_is_empty_or_null(): rule_name=RuleName("Exclude too young less than 75"), rule_description=RuleDescription("your age is greater than 75"), matcher_matched=False, + rule_priority=RulePriority(1), ), Reason( rule_type=RuleType.suppression, rule_name=RuleName("Exclude more than 100"), rule_description=RuleDescription(""), matcher_matched=False, + rule_priority=RulePriority(1), ), Reason( rule_type=RuleType.suppression, rule_name=RuleName("Exclude more than 100"), matcher_matched=False, rule_description=None, + rule_priority=RulePriority(1), ), ], ), @@ -330,6 +349,7 @@ def test_build_suitability_results_when_rule_text_is_empty_or_null(): rule_name=RuleName("Exclude is present in sw1"), rule_description=RuleDescription(""), matcher_matched=False, + rule_priority=RulePriority(1), ) ], ), @@ -342,6 +362,7 @@ def test_build_suitability_results_when_rule_text_is_empty_or_null(): rule_name=RuleName("Exclude is present in sw1"), rule_description=None, matcher_matched=False, + rule_priority=RulePriority(1), ) ], ), @@ -431,7 +452,10 @@ def test_build_actions(suggested_actions, expected): def test_nhs_number_and_include_actions_param_given_and_is_yes(app: Flask, client: FlaskClient): # Given - with get_app_container(app).override.service(EligibilityService, new=FakeEligibilityService()): + with ( + get_app_container(app).override.service(EligibilityService, new=FakeEligibilityService()), + get_app_container(app).override.service(AuditService, new=FakeAuditService()), + ): # When response = client.get("/patient-check/12345?includeActions=Y") @@ -441,7 +465,10 @@ def test_nhs_number_and_include_actions_param_given_and_is_yes(app: Flask, clien def test_nhs_number_and_include_actions_param_no_given(app: Flask, client: FlaskClient): # Given - with get_app_container(app).override.service(EligibilityService, new=FakeEligibilityService()): + with ( + get_app_container(app).override.service(EligibilityService, new=FakeEligibilityService()), + get_app_container(app).override.service(AuditService, new=FakeAuditService()), + ): # When response = client.get("/patient-check/12345?includeActions=N") @@ -588,6 +615,10 @@ def test_excludes_nulls_via_build_response(client: FlaskClient): "eligibility_signposting_api.views.eligibility.EligibilityService.get_eligibility_status", return_value=MagicMock(), # No effect ), + patch( + "eligibility_signposting_api.views.eligibility.AuditService.audit", + return_value=MagicMock(), # No effect + ), patch( "eligibility_signposting_api.views.eligibility.build_eligibility_response", return_value=mocked_response, @@ -636,6 +667,10 @@ def test_build_response_include_values_that_are_not_null(client: FlaskClient): "eligibility_signposting_api.views.eligibility.EligibilityService.get_eligibility_status", return_value=MagicMock(), # No effect ), + patch( + "eligibility_signposting_api.views.eligibility.AuditService.audit", + return_value=MagicMock(), # No effect + ), patch( "eligibility_signposting_api.views.eligibility.build_eligibility_response", return_value=mocked_response,