From 9aa04530961a4165b0d06195074275c6e90dc167 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Tue, 8 Jul 2025 16:43:17 +0100 Subject: [PATCH 01/11] Adds validation for condition and category query param --- src/eligibility_signposting_api/app.py | 4 +- src/eligibility_signposting_api/wrapper.py | 55 +++++- tests/unit/test_wrapper.py | 195 +++++++++++++++++++++ 3 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 tests/unit/test_wrapper.py diff --git a/src/eligibility_signposting_api/app.py b/src/eligibility_signposting_api/app.py index 69b01e77e..9073bb779 100644 --- a/src/eligibility_signposting_api/app.py +++ b/src/eligibility_signposting_api/app.py @@ -11,7 +11,7 @@ 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 -from eligibility_signposting_api.wrapper import validate_matching_nhs_number +from eligibility_signposting_api.wrapper import validate_request_params init_logging() logger = logging.getLogger(__name__) @@ -23,7 +23,7 @@ def main() -> None: # pragma: no cover app.run(debug=config()["log_level"] == logging.DEBUG) -@validate_matching_nhs_number() +@validate_request_params() def lambda_handler(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: # pragma: no cover """Run the Flask app as an AWS Lambda.""" app = create_app() diff --git a/src/eligibility_signposting_api/wrapper.py b/src/eligibility_signposting_api/wrapper.py index f5b0c8b57..9b718be16 100644 --- a/src/eligibility_signposting_api/wrapper.py +++ b/src/eligibility_signposting_api/wrapper.py @@ -1,4 +1,5 @@ import logging +import re from collections.abc import Callable from functools import wraps @@ -13,21 +14,57 @@ class MismatchedNHSNumberError(ValueError): pass -def validate_matching_nhs_number() -> Callable: +condition_pattern = re.compile(r"^[a-zA-Z0-9]+$") +category_pattern = re.compile(r"^(VACCINATIONS|SCREENING|ALL)$") +include_actions_pattern = re.compile(r"^([YN])$") + + +def validate_query_params(params: dict[str, str]) -> bool: + conditions = params.get("conditions", "ALL").split(",") + for condition in conditions: + upper_condition = condition.upper().strip() + search = re.search(condition_pattern, upper_condition) + if not search: + logger.error("Invalid condition query param: '%s'", condition) + return False + + category = params.get("category", "ALL") + upper_category = category.upper().strip() + if not re.search(category_pattern, upper_category): + logger.error("Invalid category query param: '%s'", category) + return False + + include_actions = params.get("includeActions", "Y") + upper_include_actions = include_actions.upper().strip() + if not re.search(include_actions_pattern, upper_include_actions): + logger.error("Invalid include_actions query param: '%s'", include_actions) + return False + + return True + + +def validate_nhs_number(path_nhs, header_nhs): + logger.info("NHS numbers from the request", extra={"header_nhs": header_nhs, "path_nhs": path_nhs}) + if header_nhs != path_nhs: + logger.error("NHS number mismatch", extra={"header_nhs": header_nhs, "path_nhs": path_nhs}) + return False + return True + + +def validate_request_params() -> Callable: def decorator(func: Callable) -> Callable: # pragma: no cover @wraps(func) def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, int | str]: - headers = event.get("headers", {}) - path_params = event.get("pathParameters", {}) + path_param_nhs_number = event.get("pathParameters", {}).get("id") + header_nhs_number = event.get("headers", {}).get(NHS_NUMBER_HEADER) + query_params = event.get("queryStringParameters", {}) - header_nhs = headers.get(NHS_NUMBER_HEADER) - path_nhs = path_params.get("id") + if not validate_nhs_number(path_param_nhs_number, header_nhs_number): + return {"statusCode": 403, "body": "NHS number mismatch"} - logger.info("nhs numbers from the request", extra={"header_nhs": header_nhs, "path_nhs": path_nhs}) + if not validate_query_params(query_params): + return {"statusCode": 422, "body": "Unprocessable Entity"} - if header_nhs != path_nhs: - logger.error("NHS number mismatch", extra={"header_nhs_no": header_nhs, "path_nhs_no": path_nhs}) - return {"statusCode": 403, "body": "NHS number mismatch"} return func(event, context) return wrapper diff --git a/tests/unit/test_wrapper.py b/tests/unit/test_wrapper.py new file mode 100644 index 000000000..c7b1ed287 --- /dev/null +++ b/tests/unit/test_wrapper.py @@ -0,0 +1,195 @@ +import logging + +import pytest + +import eligibility_signposting_api.wrapper as wrapper +from eligibility_signposting_api.wrapper import logger + + +@pytest.fixture(autouse=True) +def setup_logging_for_tests(): + logger.handlers = [] + logger.setLevel(logging.INFO) + logger.addHandler(logging.NullHandler()) + yield + + +@pytest.mark.parametrize("conditions_input, expected_result, expected_log_msg", [ + ("ALL", True, None), + ("COVID", True, None), + ("covid19", True, None), + ("FLU,MMR", True, None), + (" RSV , COVID19", True, None), + + (" condition_with_spaces ", False, "Invalid condition query param: ' condition_with_spaces '"), + ("CONDITION_A,ANOTHER_ONE,123ABC", False, "Invalid condition query param: 'CONDITION_A'"), + ("condition1,", False, "Invalid condition query param: ''"), + (",condition2", False, "Invalid condition query param: ''"), + ("condition-invalid", False, "Invalid condition query param: 'condition-invalid'"), + ("condition with spaces", False, "Invalid condition query param: 'condition with spaces'"), + ("condition!", False, "Invalid condition query param: 'condition!'"), + ("condition@#$", False, "Invalid condition query param: 'condition@#$'"), +]) +def test_validate_query_params_conditions(conditions_input, expected_result, expected_log_msg, captured_log): + params = {"conditions": conditions_input} + + with captured_log.at_level(logging.ERROR): + result = wrapper.validate_query_params(params) + + assert result == expected_result + + if not expected_result: + assert any( + (record.levelname == "ERROR" and expected_log_msg in record.message) + for record in captured_log.records + ) + else: + assert not captured_log.records + + +def test_validate_query_params_conditions_default(captured_log): + params = {"category": "ALL", "includeActions": "Y"} + with captured_log.at_level(logging.ERROR): + result = wrapper.validate_query_params(params) + assert result is True + assert not captured_log.records + + +@pytest.mark.parametrize("category_input, expected_result, expected_log_msg", [ + ("VACCINATIONS", True, None), + ("SCREENING", True, None), + ("ALL", True, None), + ("vaccinations", True, None), + ("screening", True, None), + ("all", True, None), + (" VACCINATIONS ", True, None), + + ("OTHER_CATEGORY ", False, "Invalid category query param: 'OTHER_CATEGORY '"), + ("invalid!", False, "Invalid category query param: 'invalid!'"), + ("VACCINATION", False, "Invalid category query param: 'VACCINATION'"), +]) +def test_validate_query_params_category(category_input, expected_result, expected_log_msg, captured_log): + params = {"category": category_input} + with captured_log.at_level(logging.ERROR): + result = wrapper.validate_query_params(params) + assert result == expected_result + + if not expected_result: + assert any( + (record.levelname == "ERROR" and expected_log_msg in record.message) + for record in captured_log.records + ) + else: + assert not captured_log.records + + +def test_validate_query_params_category_default(captured_log): + params = {"conditions": "ALL", "includeActions": "Y"} + with captured_log.at_level(logging.ERROR): + result = wrapper.validate_query_params(params) + assert result is True + assert not captured_log.records + + +@pytest.mark.parametrize("include_actions_input, expected_result, expected_log_msg", [ + ("Y", True, None), + ("N", True, None), + ("y", True, None), + ("n", True, None), + ("n ", True, None), + + ("TRUE", False, "Invalid include_actions query param: 'TRUE'"), + ("YES", False, "Invalid include_actions query param: 'YES'"), + ("0", False, "Invalid include_actions query param: '0'"), + ("1", False, "Invalid include_actions query param: '1'"), + ("", False, "Invalid include_actions query param: ''"), + (" ", False, "Invalid include_actions query param: ' '"), +]) +def test_validate_query_params_include_actions(include_actions_input, expected_result, expected_log_msg, captured_log): + params = {"includeActions": include_actions_input} + with captured_log.at_level(logging.ERROR): + result = wrapper.validate_query_params(params) + assert result == expected_result + + if not expected_result: + assert any( + (record.levelname == "ERROR" and expected_log_msg in record.message) + for record in captured_log.records + ) + else: + assert not captured_log.records + + +def test_validate_query_params_include_actions_default(captured_log): + params = {"conditions": "ALL", "category": "ALL"} + with captured_log.at_level(logging.ERROR): + result = wrapper.validate_query_params(params) + assert result is True + assert not captured_log.records + + +def test_validate_query_params_all_valid_params(captured_log): + params = { + "conditions": "COND1,COND2", + "category": "SCREENING", + "includeActions": "N" + } + with captured_log.at_level(logging.ERROR): + result = wrapper.validate_query_params(params) + assert result is True + assert not captured_log.records + + +def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(captured_log): + params = { + "conditions": "VALID_COND,INVALID!,ANOTHER_VALID", + "category": "SCREENING", + "includeActions": "N" + } + with captured_log.at_level(logging.ERROR): + result = wrapper.validate_query_params(params) + assert result is False + assert any( + (record.levelname == "ERROR" and + "Invalid condition query param: " in record.message) + for record in captured_log.records + ) + + error_logs = [r for r in captured_log.records if r.levelname == "ERROR"] + assert len(error_logs) == 1 + + +def test_validate_query_params_valid_conditions_invalid_category_fail_second(captured_log): + params = { + "conditions": "CONDITION", + "category": "BAD_CAT", + "includeActions": "N" + } + with captured_log.at_level(logging.ERROR): + result = wrapper.validate_query_params(params) + assert result is False + assert any( + (record.levelname == "ERROR" and + "Invalid category query param: " in record.message) + for record in captured_log.records + ) + error_logs = [r for r in captured_log.records if r.levelname == "ERROR"] + assert len(error_logs) == 1 + + +def test_validate_query_params_valid_conditions_category_invalid_actions_fail_third(captured_log): + params = { + "conditions": "CONDITION", + "category": "VACCINATIONS", + "includeActions": "Nope" + } + with captured_log.at_level(logging.ERROR): + result = wrapper.validate_query_params(params) + assert result is False + assert any( + (record.levelname == "ERROR" and + "Invalid include_actions query param: " in record.message) + for record in captured_log.records + ) + error_logs = [r for r in captured_log.records if r.levelname == "ERROR"] + assert len(error_logs) == 1 From 7bdf7551e328bb5ffd67a410ff180a524ae52de5 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:37:19 +0100 Subject: [PATCH 02/11] test - nhs number missing in header, params --- src/eligibility_signposting_api/wrapper.py | 5 +++++ .../lambda/test_app_running_as_lambda.py | 21 ++++++++++++++++++ tests/unit/test_wrapper.py | 22 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/src/eligibility_signposting_api/wrapper.py b/src/eligibility_signposting_api/wrapper.py index 9b718be16..09f522e81 100644 --- a/src/eligibility_signposting_api/wrapper.py +++ b/src/eligibility_signposting_api/wrapper.py @@ -45,6 +45,11 @@ def validate_query_params(params: dict[str, str]) -> bool: def validate_nhs_number(path_nhs, header_nhs): logger.info("NHS numbers from the request", extra={"header_nhs": header_nhs, "path_nhs": path_nhs}) + + if not header_nhs or not path_nhs: + logger.error("NHS number is not present", extra={"header_nhs": header_nhs, "path_nhs": path_nhs}) + return False + if header_nhs != path_nhs: logger.error("NHS number mismatch", extra={"header_nhs": header_nhs, "path_nhs": path_nhs}) return False diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 8253e9a1a..d6e5597ad 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -267,6 +267,27 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu ) +def test_given_nhs_number_not_present_in_headers_results_in_error_response( + lambda_client: BaseClient, # noqa:ARG001 + persisted_person: NHSNumber, + campaign_config: CampaignConfig, # noqa:ARG001 + api_gateway_endpoint: URL, +): + # Given + # When + invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" + response = httpx.get( + invoke_url, + timeout=10, + ) + + # Then + assert_that( + 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, diff --git a/tests/unit/test_wrapper.py b/tests/unit/test_wrapper.py index c7b1ed287..ac1bdc8af 100644 --- a/tests/unit/test_wrapper.py +++ b/tests/unit/test_wrapper.py @@ -13,6 +13,28 @@ def setup_logging_for_tests(): logger.addHandler(logging.NullHandler()) yield +@pytest.mark.parametrize( + "path_nhs, header_nhs, expected_result, expected_log_msg", + [ + (None, None, False, "NHS number is not present"), + ("1234567890", None, False, "NHS number is not present"), + (None, "1234567890", False, "NHS number is not present"), + ("1234567890", "0987654321", False, "NHS number mismatch"), + ("1234567890", "1234567890", True, None), + ], +) +def test_validate_nhs_number(path_nhs, header_nhs, expected_result, expected_log_msg, caplog): + with caplog.at_level(logging.ERROR): + result = wrapper.validate_nhs_number(path_nhs, header_nhs) + + assert result == expected_result + + if expected_log_msg: + assert any(expected_log_msg in record.message for record in caplog.records) + else: + # No error logs expected if validation passes + assert not caplog.records + @pytest.mark.parametrize("conditions_input, expected_result, expected_log_msg", [ ("ALL", True, None), From 1e19007bb29fd8299b419b2266a55ced0dc7a19b Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 9 Jul 2025 14:29:46 +0100 Subject: [PATCH 03/11] Adds tests and refactors --- .../views/eligibility.py | 37 +---- src/eligibility_signposting_api/wrapper.py | 41 +++-- .../in_process/test_eligibility_endpoint.py | 22 +-- .../lambda/test_app_running_as_lambda.py | 40 +++++ tests/unit/test_wrapper.py | 137 ++++++++--------- tests/unit/views/test_eligibility.py | 142 +----------------- 6 files changed, 134 insertions(+), 285 deletions(-) diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 17432ce50..8b5af5c9e 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -2,7 +2,6 @@ import uuid from datetime import UTC, datetime from http import HTTPStatus -from typing import Never from fhir.resources.R4B.operationoutcome import OperationOutcome, OperationOutcomeIssue from flask import Blueprint, make_response, request @@ -13,7 +12,6 @@ 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 from eligibility_signposting_api.views.response_model import eligibility from eligibility_signposting_api.views.response_model.eligibility import ProcessedSuggestion @@ -41,10 +39,8 @@ def check_eligibility( 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( - nhs_number, include_actions_flag=get_include_actions_flag() + nhs_number, include_actions_flag=request.args.get("includeActions") == "Y" ) - except InvalidQueryParamError: - return handle_invalid_query_param_error() except UnknownPersonError: return handle_unknown_person_error(nhs_number) else: @@ -69,37 +65,6 @@ def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue: return make_response(problem.model_dump(by_alias=True, mode="json"), HTTPStatus.NOT_FOUND) -def handle_invalid_query_param_error() -> ResponseReturnValue: - logger.debug( - "Invalid query param", - ) - problem = OperationOutcome( - issue=[ - OperationOutcomeIssue( - severity="error", - code="invalid", - diagnostics="Invalid query param key or value.", - ) # pyright: ignore[reportCallIssue] - ] - ) - return make_response(problem.model_dump(by_alias=True, mode="json"), HTTPStatus.BAD_REQUEST) - - -def get_include_actions_flag() -> bool: - include_actions = request.args.get("includeActions") - if "includeActions" in request.args: - normalized = include_actions.upper() if include_actions is not None else None - if normalized not in ("Y", "N", None): - raise_invalid_query_param_error() - elif len(request.args) != 0 and "includeActions" not in request.args: - raise_invalid_query_param_error() - return include_actions is None or include_actions.upper() == "Y" - - -def raise_invalid_query_param_error() -> Never: - raise InvalidQueryParamError - - def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility.EligibilityResponse: """Return an object representing the API response we are going to send, given an evaluation of the person's eligibility.""" diff --git a/src/eligibility_signposting_api/wrapper.py b/src/eligibility_signposting_api/wrapper.py index 09f522e81..3d42c38e3 100644 --- a/src/eligibility_signposting_api/wrapper.py +++ b/src/eligibility_signposting_api/wrapper.py @@ -10,40 +10,33 @@ logger = logging.getLogger(__name__) -class MismatchedNHSNumberError(ValueError): - pass +condition_pattern = re.compile(r"^\s*[a-zA-Z0-9]+\s*$", re.IGNORECASE) +category_pattern = re.compile(r"^\s*(VACCINATIONS|SCREENING|ALL)\s*$", re.IGNORECASE) +include_actions_pattern = re.compile(r"^\s*([YN])\s*$", re.IGNORECASE) -condition_pattern = re.compile(r"^[a-zA-Z0-9]+$") -category_pattern = re.compile(r"^(VACCINATIONS|SCREENING|ALL)$") -include_actions_pattern = re.compile(r"^([YN])$") - - -def validate_query_params(params: dict[str, str]) -> bool: - conditions = params.get("conditions", "ALL").split(",") +def validate_query_params(query_params: dict[str, str]) -> bool: + conditions = query_params.get("conditions", "ALL").split(",") for condition in conditions: - upper_condition = condition.upper().strip() - search = re.search(condition_pattern, upper_condition) + search = re.search(condition_pattern, condition) if not search: logger.error("Invalid condition query param: '%s'", condition) return False - category = params.get("category", "ALL") - upper_category = category.upper().strip() - if not re.search(category_pattern, upper_category): + category = query_params.get("category", "ALL") + if not re.search(category_pattern, category): logger.error("Invalid category query param: '%s'", category) return False - include_actions = params.get("includeActions", "Y") - upper_include_actions = include_actions.upper().strip() - if not re.search(include_actions_pattern, upper_include_actions): - logger.error("Invalid include_actions query param: '%s'", include_actions) + include_actions = query_params.get("includeActions", "Y") + if not re.search(include_actions_pattern, include_actions): + logger.error("Invalid include actions query param: '%s'", include_actions) return False return True -def validate_nhs_number(path_nhs, header_nhs): +def validate_nhs_number(path_nhs: int, header_nhs: int) -> bool: logger.info("NHS numbers from the request", extra={"header_nhs": header_nhs, "path_nhs": path_nhs}) if not header_nhs or not path_nhs: @@ -57,16 +50,22 @@ def validate_nhs_number(path_nhs, header_nhs): def validate_request_params() -> Callable: - def decorator(func: Callable) -> Callable: # pragma: no cover + def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, int | str]: path_param_nhs_number = event.get("pathParameters", {}).get("id") header_nhs_number = event.get("headers", {}).get(NHS_NUMBER_HEADER) - query_params = event.get("queryStringParameters", {}) if not validate_nhs_number(path_param_nhs_number, header_nhs_number): return {"statusCode": 403, "body": "NHS number mismatch"} + query_params_raw = event.get("queryStringParameters") + if query_params_raw is None: + query_params = {"category": "ALL", "conditions": "ALL", "includeActions": "Y"} + event.setdefault("queryStringParameters", query_params) + else: + query_params = query_params_raw + if not validate_query_params(query_params): return {"statusCode": 422, "body": "Unprocessable Entity"} diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 0a97e474d..5356eed59 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -60,7 +60,7 @@ def test_not_base_eligible( # Given # When - response = client.get(f"/patient-check/{persisted_person_no_cohorts}") + response = client.get(f"/patient-check/{persisted_person_no_cohorts}?includeActions=Y") # Then assert_that( @@ -103,7 +103,7 @@ def test_not_eligible_by_rule( # Given # When - response = client.get(f"/patient-check/{persisted_person_pc_sw19}") + response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y") # Then assert_that( @@ -146,7 +146,7 @@ def test_not_actionable( # Given # When - response = client.get(f"/patient-check/{persisted_person}") + response = client.get(f"/patient-check/{persisted_person}?includeActions=Y") # Then assert_that( @@ -195,7 +195,7 @@ def test_actionable( # Given # When - response = client.get(f"/patient-check/{persisted_77yo_person}") + response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y") # Then assert_that( @@ -248,7 +248,7 @@ def test_not_eligible_by_rule_when_only_magic_cohort_is_present( # Given # When - response = client.get(f"/patient-check/{persisted_person_pc_sw19}") + response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y") # Then assert_that( @@ -291,7 +291,7 @@ def test_not_actionable_when_only_magic_cohort_is_present( # Given # When - response = client.get(f"/patient-check/{persisted_person}") + response = client.get(f"/patient-check/{persisted_person}?includeActions=Y") # Then assert_that( @@ -340,7 +340,7 @@ def test_actionable_when_only_magic_cohort_is_present( # Given # When - response = client.get(f"/patient-check/{persisted_77yo_person}") + response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y") # Then assert_that( @@ -393,7 +393,7 @@ def test_not_base_eligible( # Given # When - response = client.get(f"/patient-check/{persisted_person_no_cohorts}") + response = client.get(f"/patient-check/{persisted_person_no_cohorts}?includeActions=Y") # Then assert_that( @@ -430,7 +430,7 @@ def test_not_eligible_by_rule( # Given # When - response = client.get(f"/patient-check/{persisted_person_pc_sw19}") + response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y") # Then assert_that( @@ -467,7 +467,7 @@ def test_not_actionable( # Given # When - response = client.get(f"/patient-check/{persisted_person}") + response = client.get(f"/patient-check/{persisted_person}?includeActions=Y") # Then assert_that( @@ -510,7 +510,7 @@ def test_actionable( # Given # When - response = client.get(f"/patient-check/{persisted_77yo_person}") + response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y") # Then assert_that( diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index d6e5597ad..4af059e44 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -288,6 +288,46 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response( ) +def test_validation_of_query_params_when_all_are_valid( + lambda_client: BaseClient, # noqa:ARG001 + persisted_person: NHSNumber, + campaign_config: CampaignConfig, # noqa:ARG001 + api_gateway_endpoint: URL, +): + # Given + # When + invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" + response = httpx.get( + invoke_url, + headers={"nhs-login-nhs-number": persisted_person}, + params={"category": "VACCINATIONS", "conditions": "COVID19", "includeActions": "N"}, + timeout=10, + ) + + # Then + assert_that(response, is_response().with_status_code(HTTPStatus.OK)) + + +def test_validation_of_query_params_when_invalid_conditions_is_specified( + lambda_client: BaseClient, # noqa:ARG001 + persisted_person: NHSNumber, + campaign_config: CampaignConfig, # noqa:ARG001 + api_gateway_endpoint: URL, +): + # Given + # When + invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" + response = httpx.get( + invoke_url, + headers={"nhs-login-nhs-number": persisted_person}, + params={"category": "ALL", "conditions": "23-097"}, + timeout=10, + ) + + # Then + assert_that(response, is_response().with_status_code(HTTPStatus.UNPROCESSABLE_ENTITY)) + + def test_given_person_has_unique_status_for_different_conditions_with_audit( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, diff --git a/tests/unit/test_wrapper.py b/tests/unit/test_wrapper.py index ac1bdc8af..63c070182 100644 --- a/tests/unit/test_wrapper.py +++ b/tests/unit/test_wrapper.py @@ -2,7 +2,7 @@ import pytest -import eligibility_signposting_api.wrapper as wrapper +from eligibility_signposting_api import wrapper from eligibility_signposting_api.wrapper import logger @@ -11,7 +11,6 @@ def setup_logging_for_tests(): logger.handlers = [] logger.setLevel(logging.INFO) logger.addHandler(logging.NullHandler()) - yield @pytest.mark.parametrize( "path_nhs, header_nhs, expected_result, expected_log_msg", @@ -36,22 +35,24 @@ def test_validate_nhs_number(path_nhs, header_nhs, expected_result, expected_log assert not caplog.records -@pytest.mark.parametrize("conditions_input, expected_result, expected_log_msg", [ - ("ALL", True, None), - ("COVID", True, None), - ("covid19", True, None), - ("FLU,MMR", True, None), - (" RSV , COVID19", True, None), - - (" condition_with_spaces ", False, "Invalid condition query param: ' condition_with_spaces '"), - ("CONDITION_A,ANOTHER_ONE,123ABC", False, "Invalid condition query param: 'CONDITION_A'"), - ("condition1,", False, "Invalid condition query param: ''"), - (",condition2", False, "Invalid condition query param: ''"), - ("condition-invalid", False, "Invalid condition query param: 'condition-invalid'"), - ("condition with spaces", False, "Invalid condition query param: 'condition with spaces'"), - ("condition!", False, "Invalid condition query param: 'condition!'"), - ("condition@#$", False, "Invalid condition query param: 'condition@#$'"), -]) +@pytest.mark.parametrize( + ("conditions_input", "expected_result", "expected_log_msg"), + [ + ("ALL", True, None), + ("COVID", True, None), + ("covid19", True, None), + ("FLU,MMR", True, None), + (" RSV , COVID19", True, None), + (" condition_with_spaces ", False, "Invalid condition query param: ' condition_with_spaces '"), + ("CONDITION_A,ANOTHER_ONE,123ABC", False, "Invalid condition query param: 'CONDITION_A'"), + ("condition1,", False, "Invalid condition query param: ''"), + (",condition2", False, "Invalid condition query param: ''"), + ("condition-invalid", False, "Invalid condition query param: 'condition-invalid'"), + ("condition with spaces", False, "Invalid condition query param: 'condition with spaces'"), + ("condition!", False, "Invalid condition query param: 'condition!'"), + ("condition@#$", False, "Invalid condition query param: 'condition@#$'"), + ], +) def test_validate_query_params_conditions(conditions_input, expected_result, expected_log_msg, captured_log): params = {"conditions": conditions_input} @@ -62,8 +63,7 @@ def test_validate_query_params_conditions(conditions_input, expected_result, exp if not expected_result: assert any( - (record.levelname == "ERROR" and expected_log_msg in record.message) - for record in captured_log.records + (record.levelname == "ERROR" and expected_log_msg in record.message) for record in captured_log.records ) else: assert not captured_log.records @@ -77,19 +77,21 @@ def test_validate_query_params_conditions_default(captured_log): assert not captured_log.records -@pytest.mark.parametrize("category_input, expected_result, expected_log_msg", [ - ("VACCINATIONS", True, None), - ("SCREENING", True, None), - ("ALL", True, None), - ("vaccinations", True, None), - ("screening", True, None), - ("all", True, None), - (" VACCINATIONS ", True, None), - - ("OTHER_CATEGORY ", False, "Invalid category query param: 'OTHER_CATEGORY '"), - ("invalid!", False, "Invalid category query param: 'invalid!'"), - ("VACCINATION", False, "Invalid category query param: 'VACCINATION'"), -]) +@pytest.mark.parametrize( + ("category_input", "expected_result", "expected_log_msg"), + [ + ("VACCINATIONS", True, None), + ("SCREENING", True, None), + ("ALL", True, None), + ("vaccinations", True, None), + ("screening", True, None), + ("all", True, None), + (" VACCINATIONS ", True, None), + ("OTHER_CATEGORY ", False, "Invalid category query param: 'OTHER_CATEGORY '"), + ("invalid!", False, "Invalid category query param: 'invalid!'"), + ("VACCINATION", False, "Invalid category query param: 'VACCINATION'"), + ], +) def test_validate_query_params_category(category_input, expected_result, expected_log_msg, captured_log): params = {"category": category_input} with captured_log.at_level(logging.ERROR): @@ -98,8 +100,7 @@ def test_validate_query_params_category(category_input, expected_result, expecte if not expected_result: assert any( - (record.levelname == "ERROR" and expected_log_msg in record.message) - for record in captured_log.records + (record.levelname == "ERROR" and expected_log_msg in record.message) for record in captured_log.records ) else: assert not captured_log.records @@ -113,20 +114,22 @@ def test_validate_query_params_category_default(captured_log): assert not captured_log.records -@pytest.mark.parametrize("include_actions_input, expected_result, expected_log_msg", [ - ("Y", True, None), - ("N", True, None), - ("y", True, None), - ("n", True, None), - ("n ", True, None), - - ("TRUE", False, "Invalid include_actions query param: 'TRUE'"), - ("YES", False, "Invalid include_actions query param: 'YES'"), - ("0", False, "Invalid include_actions query param: '0'"), - ("1", False, "Invalid include_actions query param: '1'"), - ("", False, "Invalid include_actions query param: ''"), - (" ", False, "Invalid include_actions query param: ' '"), -]) +@pytest.mark.parametrize( + ("include_actions_input", "expected_result", "expected_log_msg"), + [ + ("Y", True, None), + ("N", True, None), + ("y", True, None), + ("n", True, None), + ("n ", True, None), + ("TRUE", False, "Invalid include actions query param: 'TRUE'"), + ("YES", False, "Invalid include actions query param: 'YES'"), + ("0", False, "Invalid include actions query param: '0'"), + ("1", False, "Invalid include actions query param: '1'"), + ("", False, "Invalid include actions query param: ''"), + (" ", False, "Invalid include actions query param: ' '"), + ], +) def test_validate_query_params_include_actions(include_actions_input, expected_result, expected_log_msg, captured_log): params = {"includeActions": include_actions_input} with captured_log.at_level(logging.ERROR): @@ -135,8 +138,7 @@ def test_validate_query_params_include_actions(include_actions_input, expected_r if not expected_result: assert any( - (record.levelname == "ERROR" and expected_log_msg in record.message) - for record in captured_log.records + (record.levelname == "ERROR" and expected_log_msg in record.message) for record in captured_log.records ) else: assert not captured_log.records @@ -151,11 +153,7 @@ def test_validate_query_params_include_actions_default(captured_log): def test_validate_query_params_all_valid_params(captured_log): - params = { - "conditions": "COND1,COND2", - "category": "SCREENING", - "includeActions": "N" - } + params = {"conditions": "COND1,COND2", "category": "SCREENING", "includeActions": "N"} with captured_log.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result is True @@ -163,17 +161,12 @@ def test_validate_query_params_all_valid_params(captured_log): def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(captured_log): - params = { - "conditions": "VALID_COND,INVALID!,ANOTHER_VALID", - "category": "SCREENING", - "includeActions": "N" - } + params = {"conditions": "VALID_COND,INVALID!,ANOTHER_VALID", "category": "SCREENING", "includeActions": "N"} with captured_log.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result is False assert any( - (record.levelname == "ERROR" and - "Invalid condition query param: " in record.message) + (record.levelname == "ERROR" and "Invalid condition query param: " in record.message) for record in captured_log.records ) @@ -182,17 +175,12 @@ def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(capture def test_validate_query_params_valid_conditions_invalid_category_fail_second(captured_log): - params = { - "conditions": "CONDITION", - "category": "BAD_CAT", - "includeActions": "N" - } + params = {"conditions": "CONDITION", "category": "BAD_CAT", "includeActions": "N"} with captured_log.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result is False assert any( - (record.levelname == "ERROR" and - "Invalid category query param: " in record.message) + (record.levelname == "ERROR" and "Invalid category query param: " in record.message) for record in captured_log.records ) error_logs = [r for r in captured_log.records if r.levelname == "ERROR"] @@ -200,17 +188,12 @@ def test_validate_query_params_valid_conditions_invalid_category_fail_second(cap def test_validate_query_params_valid_conditions_category_invalid_actions_fail_third(captured_log): - params = { - "conditions": "CONDITION", - "category": "VACCINATIONS", - "includeActions": "Nope" - } + params = {"conditions": "CONDITION", "category": "VACCINATIONS", "includeActions": "Nope"} with captured_log.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result is False assert any( - (record.levelname == "ERROR" and - "Invalid include_actions query param: " in record.message) + (record.levelname == "ERROR" and "Invalid include actions query param: " in record.message) for record in captured_log.records ) error_logs = [r for r in captured_log.records if r.levelname == "ERROR"] diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index ec8d7e2ed..8abc30c0b 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -2,13 +2,13 @@ import logging from datetime import UTC, datetime from http import HTTPStatus -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest from brunns.matchers.data import json_matching as is_json_that from brunns.matchers.werkzeug import is_werkzeug_response as is_response -from flask import Flask, Request +from flask import Flask from flask.testing import FlaskClient from hamcrest import assert_that, contains_exactly, has_entries, has_length, is_, none from wireup.integration.flask import get_app_container @@ -33,12 +33,10 @@ UrlLink, ) from eligibility_signposting_api.services import EligibilityService, UnknownPersonError -from eligibility_signposting_api.services.eligibility_services import InvalidQueryParamError from eligibility_signposting_api.views.eligibility import ( build_actions, build_eligibility_cohorts, build_suitability_results, - get_include_actions_flag, ) from eligibility_signposting_api.views.response_model import eligibility from tests.fixtures.builders.model.eligibility import ( @@ -449,142 +447,6 @@ def test_build_actions(suggested_actions, expected): assert_that(results, contains_exactly(*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()), - get_app_container(app).override.service(AuditService, new=FakeAuditService()), - ): - # When - response = client.get("/patient-check/12345?includeActions=Y") - - # Then - assert_that(response, is_response().with_status_code(HTTPStatus.OK)) - - -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()), - get_app_container(app).override.service(AuditService, new=FakeAuditService()), - ): - # When - response = client.get("/patient-check/12345?includeActions=N") - - # Then - assert_that(response, is_response().with_status_code(HTTPStatus.OK)) - - -def test_nhs_number_and_include_actions_param_value_is_incorrect(app: Flask, client: FlaskClient): - # Given - with get_app_container(app).override.service(EligibilityService, new=FakeEligibilityService()): - # When - response = client.get("/patient-check/12345?includeActions=abc") - - # Then - assert_that( - response, - is_response() - .with_status_code(HTTPStatus.BAD_REQUEST) - .and_text( - is_json_that( - has_entries( - resourceType="OperationOutcome", - issue=contains_exactly( - has_entries( - severity="error", code="invalid", diagnostics="Invalid query param key or value." - ) - ), - ) - ) - ), - ) - - -def test_nhs_number_and_unrecognised_query_param_provided(app: Flask, client: FlaskClient): - # Given - with get_app_container(app).override.service(EligibilityService, new=FakeEligibilityService()): - # When - response = client.get("/patient-check/12345?example-key=example-value") - - # Then - assert_that( - response, - is_response() - .with_status_code(HTTPStatus.BAD_REQUEST) - .and_text( - is_json_that( - has_entries( - resourceType="OperationOutcome", - issue=contains_exactly( - has_entries( - severity="error", code="invalid", diagnostics="Invalid query param key or value." - ) - ), - ) - ) - ), - ) - - -def test_query_param_include_actions_flag_with_yes(): - # Given: - mock_request = Mock(spec=Request) - mock_request.args = {"includeActions": "Y"} - with patch("eligibility_signposting_api.views.eligibility.request", mock_request): - # When: - result = get_include_actions_flag() - - # Then: - assert result is True - - -def test_query_param_include_actions_flag_with_no(): - # Given: - mock_request = Mock(spec=Request) - mock_request.args = {"includeActions": "N"} - with patch("eligibility_signposting_api.views.eligibility.request", mock_request): - # When: - result = get_include_actions_flag() - - # Then: - assert result is False - - -def test_query_param_include_actions_flag_with_no_param(): - # Given: - mock_request = Mock(spec=Request) - mock_request.args = {} - with patch("eligibility_signposting_api.views.eligibility.request", mock_request): - # When: - result = get_include_actions_flag() - - # Then: - assert result is True - - -def test_query_param_include_actions_flag_with_invalid_value(): - # Given: - mock_request = Mock(spec=Request) - mock_request.args = {"includeActions": "invalid"} - with ( - patch("eligibility_signposting_api.views.eligibility.request", mock_request), - pytest.raises(InvalidQueryParamError), - ): - get_include_actions_flag() - - -def test_query_param_include_actions_flag_with_other_params(): - # Given: - mock_request = Mock(spec=Request) - mock_request.args = {"otherParam": "value"} - with ( - patch("eligibility_signposting_api.views.eligibility.request", mock_request), - pytest.raises(InvalidQueryParamError), - ): - get_include_actions_flag() - - def test_excludes_nulls_via_build_response(client: FlaskClient): mocked_response = eligibility.EligibilityResponse( responseId=uuid4(), From eec290ac13e295e076635144d50f5f7d2706ccef Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:45:08 +0100 Subject: [PATCH 04/11] Sets the correct expected error response --- src/eligibility_signposting_api/wrapper.py | 71 +++++++++++++++++----- 1 file changed, 57 insertions(+), 14 deletions(-) diff --git a/src/eligibility_signposting_api/wrapper.py b/src/eligibility_signposting_api/wrapper.py index 3d42c38e3..ac856af2e 100644 --- a/src/eligibility_signposting_api/wrapper.py +++ b/src/eligibility_signposting_api/wrapper.py @@ -1,39 +1,82 @@ +import json import logging import re +import uuid from collections.abc import Callable +from datetime import datetime, UTC from functools import wraps +from http import HTTPStatus +from typing import Any +from fhir.resources.R4B.operationoutcome import OperationOutcome, OperationOutcomeIssue from mangum.types import LambdaContext, LambdaEvent from eligibility_signposting_api.config.contants import NHS_NUMBER_HEADER logger = logging.getLogger(__name__) - condition_pattern = re.compile(r"^\s*[a-zA-Z0-9]+\s*$", re.IGNORECASE) category_pattern = re.compile(r"^\s*(VACCINATIONS|SCREENING|ALL)\s*$", re.IGNORECASE) include_actions_pattern = re.compile(r"^\s*([YN])\s*$", re.IGNORECASE) -def validate_query_params(query_params: dict[str, str]) -> bool: +def validate_query_params(query_params: dict[str, str]) -> tuple[bool, dict[str, Any] | None]: conditions = query_params.get("conditions", "ALL").split(",") for condition in conditions: search = re.search(condition_pattern, condition) if not search: logger.error("Invalid condition query param: '%s'", condition) - return False + error_response = get_error_response("conditions", condition) + logger.error("Error response: %s", error_response) + return False, error_response category = query_params.get("category", "ALL") if not re.search(category_pattern, category): logger.error("Invalid category query param: '%s'", category) - return False + error_response = get_error_response("category", category) + return False, error_response include_actions = query_params.get("includeActions", "Y") if not re.search(include_actions_pattern, include_actions): logger.error("Invalid include actions query param: '%s'", include_actions) - return False - - return True + error_response = get_error_response("value", include_actions) + return False, error_response + + return True, None + + +def get_error_response(query_type: str, query_value: str): + operation_outcome = { + "id": uuid.uuid4(), + "meta": { + "lastUpdated": datetime.now(UTC), + }, + "issue": [ + { + "severity":"error", + "code":"value", + "diagnostics":f"{query_value} is not a {query_type} that is supported by the API", + "location":[f"parameters/{query_type}"], + "details":{ + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "code": "VALIDATION_ERROR", + "display": f"The supplied {query_type} was not recognised by the API." + } + ] + } + } + ], + "resourceType": "OperationOutcome", # TODO: use real class? + } + return { + "statusCode": HTTPStatus.UNPROCESSABLE_ENTITY, + "headers": { + "Content-Type": "application/fhir+json" + }, + "body": json.dumps(operation_outcome, default=str) + } def validate_nhs_number(path_nhs: int, header_nhs: int) -> bool: @@ -52,22 +95,22 @@ def validate_nhs_number(path_nhs: int, header_nhs: int) -> bool: def validate_request_params() -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, int | str]: + def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: path_param_nhs_number = event.get("pathParameters", {}).get("id") header_nhs_number = event.get("headers", {}).get(NHS_NUMBER_HEADER) if not validate_nhs_number(path_param_nhs_number, header_nhs_number): return {"statusCode": 403, "body": "NHS number mismatch"} + # TODO: check if this is needed? query_params_raw = event.get("queryStringParameters") if query_params_raw is None: - query_params = {"category": "ALL", "conditions": "ALL", "includeActions": "Y"} - event.setdefault("queryStringParameters", query_params) + event.setdefault("queryStringParameters", + {"category": "ALL", "conditions": "ALL", "includeActions": "Y"}) else: - query_params = query_params_raw - - if not validate_query_params(query_params): - return {"statusCode": 422, "body": "Unprocessable Entity"} + is_valid, problem = validate_query_params(query_params_raw) + if not is_valid: + return problem return func(event, context) From 9424f9efb3578ba0ef8d1d25854c57f6838554fa Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:47:22 +0100 Subject: [PATCH 05/11] Use caplog --- tests/unit/test_wrapper.py | 72 +++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/tests/unit/test_wrapper.py b/tests/unit/test_wrapper.py index 63c070182..52c5c94c9 100644 --- a/tests/unit/test_wrapper.py +++ b/tests/unit/test_wrapper.py @@ -53,28 +53,28 @@ def test_validate_nhs_number(path_nhs, header_nhs, expected_result, expected_log ("condition@#$", False, "Invalid condition query param: 'condition@#$'"), ], ) -def test_validate_query_params_conditions(conditions_input, expected_result, expected_log_msg, captured_log): +def test_validate_query_params_conditions(conditions_input, expected_result, expected_log_msg, caplog): params = {"conditions": conditions_input} - with captured_log.at_level(logging.ERROR): + with caplog.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result == expected_result if not expected_result: assert any( - (record.levelname == "ERROR" and expected_log_msg in record.message) for record in captured_log.records + (record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records ) else: - assert not captured_log.records + assert not caplog.records -def test_validate_query_params_conditions_default(captured_log): +def test_validate_query_params_conditions_default(caplog): params = {"category": "ALL", "includeActions": "Y"} - with captured_log.at_level(logging.ERROR): + with caplog.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result is True - assert not captured_log.records + assert not caplog.records @pytest.mark.parametrize( @@ -92,26 +92,26 @@ def test_validate_query_params_conditions_default(captured_log): ("VACCINATION", False, "Invalid category query param: 'VACCINATION'"), ], ) -def test_validate_query_params_category(category_input, expected_result, expected_log_msg, captured_log): +def test_validate_query_params_category(category_input, expected_result, expected_log_msg, caplog): params = {"category": category_input} - with captured_log.at_level(logging.ERROR): + with caplog.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result == expected_result if not expected_result: assert any( - (record.levelname == "ERROR" and expected_log_msg in record.message) for record in captured_log.records + (record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records ) else: - assert not captured_log.records + assert not caplog.records -def test_validate_query_params_category_default(captured_log): +def test_validate_query_params_category_default(caplog): params = {"conditions": "ALL", "includeActions": "Y"} - with captured_log.at_level(logging.ERROR): + with caplog.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result is True - assert not captured_log.records + assert not caplog.records @pytest.mark.parametrize( @@ -130,71 +130,71 @@ def test_validate_query_params_category_default(captured_log): (" ", False, "Invalid include actions query param: ' '"), ], ) -def test_validate_query_params_include_actions(include_actions_input, expected_result, expected_log_msg, captured_log): +def test_validate_query_params_include_actions(include_actions_input, expected_result, expected_log_msg, caplog): params = {"includeActions": include_actions_input} - with captured_log.at_level(logging.ERROR): + with caplog.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result == expected_result if not expected_result: assert any( - (record.levelname == "ERROR" and expected_log_msg in record.message) for record in captured_log.records + (record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records ) else: - assert not captured_log.records + assert not caplog.records -def test_validate_query_params_include_actions_default(captured_log): +def test_validate_query_params_include_actions_default(caplog): params = {"conditions": "ALL", "category": "ALL"} - with captured_log.at_level(logging.ERROR): + with caplog.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result is True - assert not captured_log.records + assert not caplog.records -def test_validate_query_params_all_valid_params(captured_log): +def test_validate_query_params_all_valid_params(caplog): params = {"conditions": "COND1,COND2", "category": "SCREENING", "includeActions": "N"} - with captured_log.at_level(logging.ERROR): + with caplog.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result is True - assert not captured_log.records + assert not caplog.records -def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(captured_log): +def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(caplog): params = {"conditions": "VALID_COND,INVALID!,ANOTHER_VALID", "category": "SCREENING", "includeActions": "N"} - with captured_log.at_level(logging.ERROR): + with caplog.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result is False assert any( (record.levelname == "ERROR" and "Invalid condition query param: " in record.message) - for record in captured_log.records + for record in caplog.records ) - error_logs = [r for r in captured_log.records if r.levelname == "ERROR"] + error_logs = [r for r in caplog.records if r.levelname == "ERROR"] assert len(error_logs) == 1 -def test_validate_query_params_valid_conditions_invalid_category_fail_second(captured_log): +def test_validate_query_params_valid_conditions_invalid_category_fail_second(caplog): params = {"conditions": "CONDITION", "category": "BAD_CAT", "includeActions": "N"} - with captured_log.at_level(logging.ERROR): + with caplog.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result is False assert any( (record.levelname == "ERROR" and "Invalid category query param: " in record.message) - for record in captured_log.records + for record in caplog.records ) - error_logs = [r for r in captured_log.records if r.levelname == "ERROR"] + error_logs = [r for r in caplog.records if r.levelname == "ERROR"] assert len(error_logs) == 1 -def test_validate_query_params_valid_conditions_category_invalid_actions_fail_third(captured_log): +def test_validate_query_params_valid_conditions_category_invalid_actions_fail_third(caplog): params = {"conditions": "CONDITION", "category": "VACCINATIONS", "includeActions": "Nope"} - with captured_log.at_level(logging.ERROR): + with caplog.at_level(logging.ERROR): result = wrapper.validate_query_params(params) assert result is False assert any( (record.levelname == "ERROR" and "Invalid include actions query param: " in record.message) - for record in captured_log.records + for record in caplog.records ) - error_logs = [r for r in captured_log.records if r.levelname == "ERROR"] + error_logs = [r for r in caplog.records if r.levelname == "ERROR"] assert len(error_logs) == 1 From 4acb4b3af5f2e6602b216859d8ac5e848cd2df34 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:54:00 +0100 Subject: [PATCH 06/11] Lint and format --- src/eligibility_signposting_api/wrapper.py | 34 ++++++++++------------ tests/unit/test_wrapper.py | 15 ++++------ 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/eligibility_signposting_api/wrapper.py b/src/eligibility_signposting_api/wrapper.py index ac856af2e..fa8e6be0c 100644 --- a/src/eligibility_signposting_api/wrapper.py +++ b/src/eligibility_signposting_api/wrapper.py @@ -3,12 +3,11 @@ import re import uuid from collections.abc import Callable -from datetime import datetime, UTC +from datetime import UTC, datetime from functools import wraps from http import HTTPStatus from typing import Any -from fhir.resources.R4B.operationoutcome import OperationOutcome, OperationOutcomeIssue from mangum.types import LambdaContext, LambdaEvent from eligibility_signposting_api.config.contants import NHS_NUMBER_HEADER @@ -45,7 +44,7 @@ def validate_query_params(query_params: dict[str, str]) -> tuple[bool, dict[str, return True, None -def get_error_response(query_type: str, query_value: str): +def get_error_response(query_type: str, query_value: str) -> dict[str, Any]: operation_outcome = { "id": uuid.uuid4(), "meta": { @@ -53,29 +52,27 @@ def get_error_response(query_type: str, query_value: str): }, "issue": [ { - "severity":"error", - "code":"value", - "diagnostics":f"{query_value} is not a {query_type} that is supported by the API", - "location":[f"parameters/{query_type}"], - "details":{ + "severity": "error", + "code": "value", + "diagnostics": f"{query_value} is not a {query_type} that is supported by the API", + "location": [f"parameters/{query_type}"], + "details": { "coding": [ { "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", "code": "VALIDATION_ERROR", - "display": f"The supplied {query_type} was not recognised by the API." + "display": f"The supplied {query_type} was not recognised by the API.", } ] - } + }, } ], - "resourceType": "OperationOutcome", # TODO: use real class? + "resourceType": "OperationOutcome", # TODO: use real class? } return { "statusCode": HTTPStatus.UNPROCESSABLE_ENTITY, - "headers": { - "Content-Type": "application/fhir+json" - }, - "body": json.dumps(operation_outcome, default=str) + "headers": {"Content-Type": "application/fhir+json"}, + "body": json.dumps(operation_outcome, default=str), } @@ -95,7 +92,7 @@ def validate_nhs_number(path_nhs: int, header_nhs: int) -> bool: def validate_request_params() -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: + def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None: path_param_nhs_number = event.get("pathParameters", {}).get("id") header_nhs_number = event.get("headers", {}).get(NHS_NUMBER_HEADER) @@ -105,8 +102,9 @@ def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: # TODO: check if this is needed? query_params_raw = event.get("queryStringParameters") if query_params_raw is None: - event.setdefault("queryStringParameters", - {"category": "ALL", "conditions": "ALL", "includeActions": "Y"}) + event.setdefault( + "queryStringParameters", {"category": "ALL", "conditions": "ALL", "includeActions": "Y"} + ) else: is_valid, problem = validate_query_params(query_params_raw) if not is_valid: diff --git a/tests/unit/test_wrapper.py b/tests/unit/test_wrapper.py index 52c5c94c9..5e12820b9 100644 --- a/tests/unit/test_wrapper.py +++ b/tests/unit/test_wrapper.py @@ -12,8 +12,9 @@ def setup_logging_for_tests(): logger.setLevel(logging.INFO) logger.addHandler(logging.NullHandler()) + @pytest.mark.parametrize( - "path_nhs, header_nhs, expected_result, expected_log_msg", + ("path_nhs", "header_nhs", "expected_result", "expected_log_msg"), [ (None, None, False, "NHS number is not present"), ("1234567890", None, False, "NHS number is not present"), @@ -62,9 +63,7 @@ def test_validate_query_params_conditions(conditions_input, expected_result, exp assert result == expected_result if not expected_result: - assert any( - (record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records - ) + assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) else: assert not caplog.records @@ -99,9 +98,7 @@ def test_validate_query_params_category(category_input, expected_result, expecte assert result == expected_result if not expected_result: - assert any( - (record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records - ) + assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) else: assert not caplog.records @@ -137,9 +134,7 @@ def test_validate_query_params_include_actions(include_actions_input, expected_r assert result == expected_result if not expected_result: - assert any( - (record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records - ) + assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) else: assert not caplog.records From 3597590893ab2499327d29bbf93ee8e7bc2379dc Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 10 Jul 2025 12:26:12 +0100 Subject: [PATCH 07/11] ELI-318: Added valid error format for each query param and tests --- src/eligibility_signposting_api/wrapper.py | 116 ++++++---- .../lambda/test_app_running_as_lambda.py | 2 +- tests/unit/test_wrapper.py | 198 ++++++++++++++---- 3 files changed, 232 insertions(+), 84 deletions(-) diff --git a/src/eligibility_signposting_api/wrapper.py b/src/eligibility_signposting_api/wrapper.py index fa8e6be0c..c3da56382 100644 --- a/src/eligibility_signposting_api/wrapper.py +++ b/src/eligibility_signposting_api/wrapper.py @@ -8,6 +8,7 @@ from http import HTTPStatus from typing import Any +from fhir.resources.operationoutcome import OperationOutcome, OperationOutcomeIssue from mangum.types import LambdaContext, LambdaEvent from eligibility_signposting_api.config.contants import NHS_NUMBER_HEADER @@ -24,59 +25,20 @@ def validate_query_params(query_params: dict[str, str]) -> tuple[bool, dict[str, for condition in conditions: search = re.search(condition_pattern, condition) if not search: - logger.error("Invalid condition query param: '%s'", condition) - error_response = get_error_response("conditions", condition) - logger.error("Error response: %s", error_response) - return False, error_response + return form_condition_error_response(condition) category = query_params.get("category", "ALL") if not re.search(category_pattern, category): - logger.error("Invalid category query param: '%s'", category) - error_response = get_error_response("category", category) - return False, error_response + return form_category_error_response(category) include_actions = query_params.get("includeActions", "Y") if not re.search(include_actions_pattern, include_actions): - logger.error("Invalid include actions query param: '%s'", include_actions) - error_response = get_error_response("value", include_actions) - return False, error_response + return form_include_actions_error_response(include_actions) return True, None -def get_error_response(query_type: str, query_value: str) -> dict[str, Any]: - operation_outcome = { - "id": uuid.uuid4(), - "meta": { - "lastUpdated": datetime.now(UTC), - }, - "issue": [ - { - "severity": "error", - "code": "value", - "diagnostics": f"{query_value} is not a {query_type} that is supported by the API", - "location": [f"parameters/{query_type}"], - "details": { - "coding": [ - { - "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - "code": "VALIDATION_ERROR", - "display": f"The supplied {query_type} was not recognised by the API.", - } - ] - }, - } - ], - "resourceType": "OperationOutcome", # TODO: use real class? - } - return { - "statusCode": HTTPStatus.UNPROCESSABLE_ENTITY, - "headers": {"Content-Type": "application/fhir+json"}, - "body": json.dumps(operation_outcome, default=str), - } - - -def validate_nhs_number(path_nhs: int, header_nhs: int) -> bool: +def validate_nhs_number(path_nhs: str, header_nhs: str) -> bool: logger.info("NHS numbers from the request", extra={"header_nhs": header_nhs, "path_nhs": path_nhs}) if not header_nhs or not path_nhs: @@ -115,3 +77,71 @@ def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None return wrapper return decorator + + +def form_include_actions_error_response(include_actions: str) -> tuple[bool, dict[str, Any]]: + logger.error("Invalid include actions query param: '%s'", include_actions) + diagnostics = f"{include_actions} is not a value that is supported by the API" + coding_display_message = "The supplied value was not recognised by the API." + error_response = get_formatted_error_response( + diagnostics, coding_display_message, "includeActions", HTTPStatus.UNPROCESSABLE_ENTITY + ) + return False, error_response + + +def form_category_error_response(category: str) -> tuple[bool, dict[str, Any]]: + logger.error("Invalid category query param: '%s'", category) + diagnostics = f"{category} is not a category that is supported by the API" + coding_display_message = "The supplied category was not recognised by the API." + error_response = get_formatted_error_response( + diagnostics, coding_display_message, "category", HTTPStatus.UNPROCESSABLE_ENTITY + ) + return False, error_response + + +def form_condition_error_response(condition: str) -> tuple[bool, dict[str, Any]]: + logger.error("Invalid condition query param: '%s'", condition) + diagnostics = ( + f"{condition} should be a single or comma separated list of condition " + f"strings with no other punctuation or special characters" + ) + coding_display_message = "The given conditions were not in the expected format." + error_response = get_formatted_error_response( + diagnostics, coding_display_message, "conditions", HTTPStatus.BAD_REQUEST + ) + logger.error("Error response: %s", error_response) + return False, error_response + + +def get_formatted_error_response( + diagnostics: str, coding_display_message: str, query_type: str, status_code: HTTPStatus +) -> dict[str, Any]: + problem = OperationOutcome( + id=str(uuid.uuid4()), + meta={"lastUpdated": datetime.now(UTC)}, + issue=[ + ( + OperationOutcomeIssue( + severity="error", + code="value", + diagnostics=diagnostics, + location=[f"parameters/{query_type}"], + details={ + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "code": "VALIDATION_ERROR", + "display": coding_display_message, + } + ] + }, + ) # pyright: ignore[reportCallIssue] + ) + ], + ) # pyright: ignore[reportCallIssue] + + return { + "statusCode": status_code, + "headers": {"Content-Type": "application/fhir+json"}, + "body": json.dumps(problem.model_dump(by_alias=True, mode="json")), + } diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 4af059e44..e2084c826 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -325,7 +325,7 @@ def test_validation_of_query_params_when_invalid_conditions_is_specified( ) # Then - assert_that(response, is_response().with_status_code(HTTPStatus.UNPROCESSABLE_ENTITY)) + assert_that(response, is_response().with_status_code(HTTPStatus.BAD_REQUEST)) def test_given_person_has_unique_status_for_different_conditions_with_audit( # noqa: PLR0913 diff --git a/tests/unit/test_wrapper.py b/tests/unit/test_wrapper.py index 5e12820b9..602a74f6a 100644 --- a/tests/unit/test_wrapper.py +++ b/tests/unit/test_wrapper.py @@ -1,4 +1,6 @@ +import json import logging +from http import HTTPStatus import pytest @@ -32,12 +34,11 @@ def test_validate_nhs_number(path_nhs, header_nhs, expected_result, expected_log if expected_log_msg: assert any(expected_log_msg in record.message for record in caplog.records) else: - # No error logs expected if validation passes assert not caplog.records @pytest.mark.parametrize( - ("conditions_input", "expected_result", "expected_log_msg"), + ("conditions_input", "is_valid_expected", "expected_log_msg"), [ ("ALL", True, None), ("COVID", True, None), @@ -54,30 +55,32 @@ def test_validate_nhs_number(path_nhs, header_nhs, expected_result, expected_log ("condition@#$", False, "Invalid condition query param: 'condition@#$'"), ], ) -def test_validate_query_params_conditions(conditions_input, expected_result, expected_log_msg, caplog): +def test_validate_query_params_conditions(conditions_input, is_valid_expected, expected_log_msg, caplog): params = {"conditions": conditions_input} with caplog.at_level(logging.ERROR): - result = wrapper.validate_query_params(params) + is_valid, problem = wrapper.validate_query_params(params) - assert result == expected_result - - if not expected_result: - assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) - else: + assert is_valid == is_valid_expected + if is_valid_expected: + assert problem is None assert not caplog.records + else: + assert problem is not None + assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) def test_validate_query_params_conditions_default(caplog): params = {"category": "ALL", "includeActions": "Y"} with caplog.at_level(logging.ERROR): - result = wrapper.validate_query_params(params) - assert result is True + is_valid, problem = wrapper.validate_query_params(params) + assert is_valid is True + assert problem is None assert not caplog.records @pytest.mark.parametrize( - ("category_input", "expected_result", "expected_log_msg"), + ("category_input", "is_valid_expected", "expected_log_msg"), [ ("VACCINATIONS", True, None), ("SCREENING", True, None), @@ -91,28 +94,31 @@ def test_validate_query_params_conditions_default(caplog): ("VACCINATION", False, "Invalid category query param: 'VACCINATION'"), ], ) -def test_validate_query_params_category(category_input, expected_result, expected_log_msg, caplog): +def test_validate_query_params_category(category_input, is_valid_expected, expected_log_msg, caplog): params = {"category": category_input} with caplog.at_level(logging.ERROR): - result = wrapper.validate_query_params(params) - assert result == expected_result + is_valid, problem = wrapper.validate_query_params(params) + assert is_valid == is_valid_expected - if not expected_result: - assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) - else: + if is_valid_expected: + assert problem is None assert not caplog.records + else: + assert problem is not None + assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) def test_validate_query_params_category_default(caplog): params = {"conditions": "ALL", "includeActions": "Y"} with caplog.at_level(logging.ERROR): - result = wrapper.validate_query_params(params) - assert result is True + is_valid, problem = wrapper.validate_query_params(params) + assert is_valid is True + assert problem is None assert not caplog.records @pytest.mark.parametrize( - ("include_actions_input", "expected_result", "expected_log_msg"), + ("include_actions_input", "is_valid_expected", "expected_log_msg"), [ ("Y", True, None), ("N", True, None), @@ -127,53 +133,56 @@ def test_validate_query_params_category_default(caplog): (" ", False, "Invalid include actions query param: ' '"), ], ) -def test_validate_query_params_include_actions(include_actions_input, expected_result, expected_log_msg, caplog): +def test_validate_query_params_include_actions(include_actions_input, is_valid_expected, expected_log_msg, caplog): params = {"includeActions": include_actions_input} with caplog.at_level(logging.ERROR): - result = wrapper.validate_query_params(params) - assert result == expected_result + is_valid, problem = wrapper.validate_query_params(params) + assert is_valid == is_valid_expected - if not expected_result: - assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) - else: + if is_valid_expected: + assert problem is None assert not caplog.records + else: + assert problem is not None + assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) def test_validate_query_params_include_actions_default(caplog): params = {"conditions": "ALL", "category": "ALL"} with caplog.at_level(logging.ERROR): - result = wrapper.validate_query_params(params) - assert result is True + is_valid, problem = wrapper.validate_query_params(params) + assert is_valid is True + assert problem is None assert not caplog.records def test_validate_query_params_all_valid_params(caplog): params = {"conditions": "COND1,COND2", "category": "SCREENING", "includeActions": "N"} with caplog.at_level(logging.ERROR): - result = wrapper.validate_query_params(params) - assert result is True + is_valid, problem = wrapper.validate_query_params(params) + assert is_valid is True + assert problem is None assert not caplog.records def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(caplog): params = {"conditions": "VALID_COND,INVALID!,ANOTHER_VALID", "category": "SCREENING", "includeActions": "N"} with caplog.at_level(logging.ERROR): - result = wrapper.validate_query_params(params) - assert result is False + is_valid, problem = wrapper.validate_query_params(params) + assert is_valid is False + assert problem is not None assert any( (record.levelname == "ERROR" and "Invalid condition query param: " in record.message) for record in caplog.records ) - error_logs = [r for r in caplog.records if r.levelname == "ERROR"] - assert len(error_logs) == 1 - def test_validate_query_params_valid_conditions_invalid_category_fail_second(caplog): params = {"conditions": "CONDITION", "category": "BAD_CAT", "includeActions": "N"} with caplog.at_level(logging.ERROR): - result = wrapper.validate_query_params(params) - assert result is False + is_valid, problem = wrapper.validate_query_params(params) + assert is_valid is False + assert problem is not None assert any( (record.levelname == "ERROR" and "Invalid category query param: " in record.message) for record in caplog.records @@ -185,11 +194,120 @@ def test_validate_query_params_valid_conditions_invalid_category_fail_second(cap def test_validate_query_params_valid_conditions_category_invalid_actions_fail_third(caplog): params = {"conditions": "CONDITION", "category": "VACCINATIONS", "includeActions": "Nope"} with caplog.at_level(logging.ERROR): - result = wrapper.validate_query_params(params) - assert result is False + is_valid, problem = wrapper.validate_query_params(params) + assert is_valid is False + assert problem is not None assert any( (record.levelname == "ERROR" and "Invalid include actions query param: " in record.message) for record in caplog.records ) error_logs = [r for r in caplog.records if r.levelname == "ERROR"] assert len(error_logs) == 1 + + +def test_validate_query_params_returns_correct_problem_details_for_conditions_error(): + invalid_condition = "FLU&COVID" + params = {"conditions": invalid_condition} + + is_valid, problem = wrapper.validate_query_params(params) + + assert is_valid is False + assert problem is not None + assert problem["statusCode"] == HTTPStatus.BAD_REQUEST + assert problem["headers"]["Content-Type"] == "application/fhir+json" + + response_body = json.loads(problem["body"]) + + assert response_body["resourceType"] == "OperationOutcome" + assert "id" in response_body + assert "meta" in response_body + assert "lastUpdated" in response_body["meta"] + + assert len(response_body["issue"]) == 1 + issue = response_body["issue"][0] + + assert issue["severity"] == "error" + assert issue["code"] == "value" + assert issue["diagnostics"] == ( + f"{invalid_condition} should be a single or comma separated list of condition " + f"strings with no other punctuation or special characters" + ) + assert issue["location"] == ["parameters/conditions"] + assert "details" in issue + assert "coding" in issue["details"] + assert len(issue["details"]["coding"]) == 1 + coding = issue["details"]["coding"][0] + + assert coding["system"] == "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode" + assert coding["code"] == "VALIDATION_ERROR" + assert coding["display"] == "The given conditions were not in the expected format." + + +def test_validate_query_params_returns_correct_problem_details_for_category_error(): + invalid_category = "HEALTHCHECKS" + params = {"category": invalid_category} + + is_valid, problem = wrapper.validate_query_params(params) + + assert is_valid is False + assert problem is not None + assert problem["statusCode"] == HTTPStatus.UNPROCESSABLE_ENTITY + assert problem["headers"]["Content-Type"] == "application/fhir+json" + + response_body = json.loads(problem["body"]) + + assert response_body["resourceType"] == "OperationOutcome" + assert "id" in response_body + assert "meta" in response_body + assert "lastUpdated" in response_body["meta"] + + assert len(response_body["issue"]) == 1 + issue = response_body["issue"][0] + + assert issue["severity"] == "error" + assert issue["code"] == "value" + assert issue["diagnostics"] == f"{invalid_category} is not a category that is supported by the API" + assert issue["location"] == ["parameters/category"] + assert "details" in issue + assert "coding" in issue["details"] + assert len(issue["details"]["coding"]) == 1 + coding = issue["details"]["coding"][0] + + assert coding["system"] == "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode" + assert coding["code"] == "VALIDATION_ERROR" + assert coding["display"] == "The supplied category was not recognised by the API." + + +def test_validate_query_params_returns_correct_problem_details_for_include_actions_error(): + invalid_include_actions = "NAH" + params = {"includeActions": invalid_include_actions} + + is_valid, problem = wrapper.validate_query_params(params) + + assert is_valid is False + assert problem is not None + assert problem["statusCode"] == HTTPStatus.UNPROCESSABLE_ENTITY + assert problem["headers"]["Content-Type"] == "application/fhir+json" + + response_body = json.loads(problem["body"]) + + assert response_body["resourceType"] == "OperationOutcome" + assert "id" in response_body + assert "meta" in response_body + assert "lastUpdated" in response_body["meta"] + + assert len(response_body["issue"]) == 1 + issue = response_body["issue"][0] + + assert issue["severity"] == "error" + assert issue["code"] == "value" + assert issue["diagnostics"] == f"{invalid_include_actions} is not a value that is supported by the API" + assert issue["location"] == ["parameters/includeActions"] + assert "details" in issue + assert "coding" in issue["details"] + assert len(issue["details"]["coding"]) == 1 + coding = issue["details"]["coding"][0] + + assert coding["system"] == "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode" + assert coding["code"] == "VALIDATION_ERROR" + assert coding["display"] == "The supplied value was not recognised by the API." From db92a99cb00722c3fd180f5843c901723f6286db Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:04:31 +0100 Subject: [PATCH 08/11] ELI-318: Added wrapper for api error response --- .../api_error_response.py | 126 ++++++++++++++++++ .../error_handler.py | 15 +-- .../views/eligibility.py | 16 +-- src/eligibility_signposting_api/wrapper.py | 95 +++++-------- .../lambda/test_app_running_as_lambda.py | 76 ++++++++++- tests/unit/views/test_eligibility.py | 45 ++++++- 6 files changed, 281 insertions(+), 92 deletions(-) create mode 100644 src/eligibility_signposting_api/api_error_response.py diff --git a/src/eligibility_signposting_api/api_error_response.py b/src/eligibility_signposting_api/api_error_response.py new file mode 100644 index 000000000..1bbeb51b6 --- /dev/null +++ b/src/eligibility_signposting_api/api_error_response.py @@ -0,0 +1,126 @@ +import json +import logging +import uuid +from datetime import UTC, datetime +from http import HTTPStatus +from typing import Any + +from fhir.resources.operationoutcome import OperationOutcome, OperationOutcomeIssue + +logger = logging.getLogger(__name__) + + +class APIErrorResponse: + def __init__( # noqa: PLR0913 + self, + status_code: HTTPStatus, + fhir_issue_code: str, + fhir_issue_severity: str, + fhir_coding_system: str, + fhir_error_code: str, + fhir_display_message: str, + ) -> None: + self.status_code = status_code + self.fhir_issue_code = fhir_issue_code + self.fhir_issue_severity = fhir_issue_severity + self.fhir_coding_system = fhir_coding_system + self.fhir_error_code = fhir_error_code + self.fhir_display_message = fhir_display_message + + def build_operation_outcome_issue(self, diagnostics: str, location: list[str] | None) -> OperationOutcomeIssue: + details = { + "coding": [ + { + "system": self.fhir_coding_system, + "code": self.fhir_error_code, + "display": self.fhir_display_message, + } + ] + } + return OperationOutcomeIssue( + severity=self.fhir_issue_severity, + code=self.fhir_issue_code, + diagnostics=diagnostics, + location=location, + details=details, + ) # pyright: ignore[reportCallIssue] + + def generate_response(self, diagnostics: str, location_param: str | None = None) -> dict[str, Any]: + issue_location = [f"parameters/{location_param}"] if location_param else None + + problem = OperationOutcome( + id=str(uuid.uuid4()), + meta={"lastUpdated": datetime.now(UTC)}, + issue=[self.build_operation_outcome_issue(diagnostics, issue_location)], + ) # pyright: ignore[reportCallIssue] + + response_body = json.dumps(problem.model_dump(by_alias=True, mode="json")) + + return { + "statusCode": self.status_code, + "headers": {"Content-Type": "application/fhir+json"}, + "body": response_body, + } + + def log_and_generate_response( + self, log_message: str, diagnostics: str, location_param: str | None = None + ) -> dict[str, Any]: + logger.error(log_message) + return self.generate_response(diagnostics, location_param) + + +INVALID_INCLUDE_ACTIONS_ERROR = APIErrorResponse( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + fhir_issue_code="value", + fhir_issue_severity="error", + fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_error_code="VALIDATION_ERROR", + fhir_display_message="The supplied value was not recognised by the API.", +) + +INVALID_CATEGORY_ERROR = APIErrorResponse( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + fhir_issue_code="value", + fhir_issue_severity="error", + fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_error_code="VALIDATION_ERROR", + fhir_display_message="The supplied category was not recognised by the API.", +) + +INVALID_CONDITION_FORMAT_ERROR = APIErrorResponse( + status_code=HTTPStatus.BAD_REQUEST, + fhir_issue_code="value", + fhir_issue_severity="error", + fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_error_code="VALIDATION_ERROR", + fhir_display_message="The given conditions were not in the expected format.", +) + +NHS_NUMBER_NOT_FOUND_ERROR = APIErrorResponse( + status_code=HTTPStatus.NOT_FOUND, + fhir_issue_code="processing", + fhir_issue_severity="error", + fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_error_code="RESOURCE_NOT_FOUND", + fhir_display_message="The given NHS number was not found in our datasets. " + "This could be because the number is incorrect or " + "some other reason we cannot process that number.", +) + +INTERNAL_SERVER_ERROR = APIErrorResponse( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + fhir_issue_code="unexpected", + fhir_issue_severity="severe", + fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_error_code="UNEXPECTED_ERROR", + fhir_display_message="An unexpected internal server error occurred.", +) + +NHS_NUMBER_MISMATCH_ERROR = APIErrorResponse( + status_code=HTTPStatus.FORBIDDEN, + fhir_issue_code="forbidden", + fhir_issue_severity="error", + fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_error_code="NHS_NUMBER_MISMATCH", + fhir_display_message="The provided NHS number does not match the record.", +) diff --git a/src/eligibility_signposting_api/error_handler.py b/src/eligibility_signposting_api/error_handler.py index 640bbea42..5ff156d5b 100644 --- a/src/eligibility_signposting_api/error_handler.py +++ b/src/eligibility_signposting_api/error_handler.py @@ -1,12 +1,12 @@ import logging import traceback -from http import HTTPStatus -from fhir.resources.operationoutcome import OperationOutcome, OperationOutcomeIssue from flask import make_response from flask.typing import ResponseReturnValue from werkzeug.exceptions import HTTPException +from eligibility_signposting_api.api_error_response import INTERNAL_SERVER_ERROR + logger = logging.getLogger(__name__) @@ -17,11 +17,8 @@ def handle_exception(e: Exception) -> ResponseReturnValue | HTTPException: if isinstance(e, HTTPException): return e - problem = OperationOutcome( - issue=[ - OperationOutcomeIssue( - severity="severe", code="unexpected", diagnostics="".join(traceback.format_exception(e)) - ) # pyright: ignore[reportCallIssue] - ] + full_traceback = "".join(traceback.format_exception(e)) + response = INTERNAL_SERVER_ERROR.log_and_generate_response( + log_message=f"An unexpected error occurred: {full_traceback}", diagnostics="An unexpected error occurred." ) - return make_response(problem.model_dump(by_alias=True), HTTPStatus.INTERNAL_SERVER_ERROR) + return make_response(response.get("body"), response.get("statusCode")) diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index c403041e2..a60affc47 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -3,11 +3,11 @@ from datetime import UTC, datetime from http import HTTPStatus -from fhir.resources.R4B.operationoutcome import OperationOutcome, OperationOutcomeIssue from flask import Blueprint, make_response, request from flask.typing import ResponseReturnValue from wireup import Injected +from eligibility_signposting_api.api_error_response import NHS_NUMBER_NOT_FOUND_ERROR 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 @@ -59,17 +59,11 @@ def check_eligibility( def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue: - logger.debug("nhs_number %r not found", nhs_number, extra={"nhs_number": nhs_number}) - problem = OperationOutcome( - issue=[ - OperationOutcomeIssue( - severity="information", - code="nhs-number-not-found", - diagnostics=f'NHS Number "{nhs_number}" not found.', - ) # pyright: ignore[reportCallIssue] - ] + diagnostics = f"NHS Number '{nhs_number}' was not recognised by the Eligibility Signposting API" + response = NHS_NUMBER_NOT_FOUND_ERROR.log_and_generate_response( + log_message=diagnostics, diagnostics=diagnostics, location_param="id" ) - return make_response(problem.model_dump(by_alias=True, mode="json"), HTTPStatus.NOT_FOUND) + return make_response(response.get("body"), response.get("statusCode")) def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility.EligibilityResponse: diff --git a/src/eligibility_signposting_api/wrapper.py b/src/eligibility_signposting_api/wrapper.py index c3da56382..66d3a8f18 100644 --- a/src/eligibility_signposting_api/wrapper.py +++ b/src/eligibility_signposting_api/wrapper.py @@ -1,16 +1,17 @@ -import json import logging import re -import uuid from collections.abc import Callable -from datetime import UTC, datetime from functools import wraps -from http import HTTPStatus from typing import Any -from fhir.resources.operationoutcome import OperationOutcome, OperationOutcomeIssue from mangum.types import LambdaContext, LambdaEvent +from eligibility_signposting_api.api_error_response import ( + INVALID_CATEGORY_ERROR, + INVALID_CONDITION_FORMAT_ERROR, + INVALID_INCLUDE_ACTIONS_ERROR, + NHS_NUMBER_MISMATCH_ERROR, +) from eligibility_signposting_api.config.contants import NHS_NUMBER_HEADER logger = logging.getLogger(__name__) @@ -25,15 +26,15 @@ def validate_query_params(query_params: dict[str, str]) -> tuple[bool, dict[str, for condition in conditions: search = re.search(condition_pattern, condition) if not search: - return form_condition_error_response(condition) + return False, get_condition_error_response(condition) category = query_params.get("category", "ALL") if not re.search(category_pattern, category): - return form_category_error_response(category) + return False, get_category_error_response(category) include_actions = query_params.get("includeActions", "Y") if not re.search(include_actions_pattern, include_actions): - return form_include_actions_error_response(include_actions) + return False, get_include_actions_error_response(include_actions) return True, None @@ -55,13 +56,17 @@ def validate_request_params() -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None: - path_param_nhs_number = event.get("pathParameters", {}).get("id") + path_nhs_number = event.get("pathParameters", {}).get("id") header_nhs_number = event.get("headers", {}).get(NHS_NUMBER_HEADER) - if not validate_nhs_number(path_param_nhs_number, header_nhs_number): - return {"statusCode": 403, "body": "NHS number mismatch"} + if not validate_nhs_number(path_nhs_number, header_nhs_number): + message = ( + f"NHS Number {path_nhs_number or ''} does not match the header NHS Number {header_nhs_number or ''}" + ) + return NHS_NUMBER_MISMATCH_ERROR.log_and_generate_response( + log_message=message, diagnostics=message, location_param="id" + ) - # TODO: check if this is needed? query_params_raw = event.get("queryStringParameters") if query_params_raw is None: event.setdefault( @@ -79,69 +84,29 @@ def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None return decorator -def form_include_actions_error_response(include_actions: str) -> tuple[bool, dict[str, Any]]: - logger.error("Invalid include actions query param: '%s'", include_actions) +def get_include_actions_error_response(include_actions: str) -> dict[str, Any]: diagnostics = f"{include_actions} is not a value that is supported by the API" - coding_display_message = "The supplied value was not recognised by the API." - error_response = get_formatted_error_response( - diagnostics, coding_display_message, "includeActions", HTTPStatus.UNPROCESSABLE_ENTITY + return INVALID_INCLUDE_ACTIONS_ERROR.log_and_generate_response( + log_message=f"Invalid include actions query param: '{include_actions}'", + diagnostics=diagnostics, + location_param="includeActions", ) - return False, error_response -def form_category_error_response(category: str) -> tuple[bool, dict[str, Any]]: - logger.error("Invalid category query param: '%s'", category) +def get_category_error_response(category: str) -> dict[str, Any]: diagnostics = f"{category} is not a category that is supported by the API" - coding_display_message = "The supplied category was not recognised by the API." - error_response = get_formatted_error_response( - diagnostics, coding_display_message, "category", HTTPStatus.UNPROCESSABLE_ENTITY + return INVALID_CATEGORY_ERROR.log_and_generate_response( + log_message=f"Invalid category query param: '{category}'", diagnostics=diagnostics, location_param="category" ) - return False, error_response -def form_condition_error_response(condition: str) -> tuple[bool, dict[str, Any]]: - logger.error("Invalid condition query param: '%s'", condition) +def get_condition_error_response(condition: str) -> dict[str, Any]: diagnostics = ( f"{condition} should be a single or comma separated list of condition " f"strings with no other punctuation or special characters" ) - coding_display_message = "The given conditions were not in the expected format." - error_response = get_formatted_error_response( - diagnostics, coding_display_message, "conditions", HTTPStatus.BAD_REQUEST + return INVALID_CONDITION_FORMAT_ERROR.log_and_generate_response( + log_message=f"Invalid condition query param: '{condition}'", + diagnostics=diagnostics, + location_param="conditions", ) - logger.error("Error response: %s", error_response) - return False, error_response - - -def get_formatted_error_response( - diagnostics: str, coding_display_message: str, query_type: str, status_code: HTTPStatus -) -> dict[str, Any]: - problem = OperationOutcome( - id=str(uuid.uuid4()), - meta={"lastUpdated": datetime.now(UTC)}, - issue=[ - ( - OperationOutcomeIssue( - severity="error", - code="value", - diagnostics=diagnostics, - location=[f"parameters/{query_type}"], - details={ - "coding": [ - { - "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - "code": "VALIDATION_ERROR", - "display": coding_display_message, - } - ] - }, - ) # pyright: ignore[reportCallIssue] - ) - ], - ) # pyright: ignore[reportCallIssue] - - return { - "statusCode": status_code, - "headers": {"Content-Type": "application/fhir+json"}, - "body": json.dumps(problem.model_dump(by_alias=True, mode="json")), - } diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index e2084c826..9a6584243 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -135,9 +135,21 @@ def test_install_and_call_flask_lambda_with_unknown_nhs_number( resourceType="OperationOutcome", issue=contains_exactly( has_entries( - severity="information", - code="nhs-number-not-found", - diagnostics=f'NHS Number "{nhs_number}" not found.', + severity="error", + code="processing", + diagnostics=f"NHS Number '{nhs_number!s}' was not " + f"recognised by the Eligibility Signposting API", + details={ + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "code": "RESOURCE_NOT_FOUND", + "display": "The given NHS number was not found in our datasets. " + "This could be because the number is incorrect or " + "some other reason we cannot process that number.", + } + ] + }, ) ), ) @@ -146,7 +158,10 @@ def test_install_and_call_flask_lambda_with_unknown_nhs_number( ) messages = get_log_messages(flask_function, logs_client) - assert_that(messages, has_item(contains_string(f"nhs_number '{nhs_number}' not found"))) + assert_that( + messages, + has_item(contains_string(f"NHS Number '{nhs_number}' was not recognised by the Eligibility Signposting API")), + ) def get_log_messages(flask_function: str, logs_client: BaseClient) -> list[str]: @@ -263,7 +278,32 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu # Then assert_that( response, - is_response().with_status_code(HTTPStatus.FORBIDDEN).and_body("NHS number mismatch"), + is_response() + .with_status_code(HTTPStatus.FORBIDDEN) + .and_body( + is_json_that( + has_entries( + resourceType="OperationOutcome", + issue=contains_exactly( + has_entries( + severity="error", + code="forbidden", + diagnostics=f"NHS Number {persisted_person} does " + f"not match the header NHS Number 123{persisted_person!s}", + details={ + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "code": "NHS_NUMBER_MISMATCH", + "display": "The provided NHS number does not match the record.", + } + ] + }, + ) + ), + ) + ) + ), ) @@ -284,7 +324,31 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response( # Then assert_that( response, - is_response().with_status_code(HTTPStatus.FORBIDDEN).and_body("NHS number mismatch"), + is_response() + .with_status_code(HTTPStatus.FORBIDDEN) + .and_body( + is_json_that( + has_entries( + resourceType="OperationOutcome", + issue=contains_exactly( + has_entries( + severity="error", + code="forbidden", + diagnostics=f"NHS Number {persisted_person} does not match the header NHS Number ", + details={ + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "code": "NHS_NUMBER_MISMATCH", + "display": "The provided NHS number does not match the record.", + } + ] + }, + ) + ), + ) + ) + ), ) diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index d4e374439..f272503e3 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -123,7 +123,20 @@ def test_no_nhs_number_given(app: Flask, client: FlaskClient): resourceType="OperationOutcome", issue=contains_exactly( has_entries( - severity="information", code="nhs-number-not-found", diagnostics='NHS Number "" not found.' + severity="error", + code="processing", + diagnostics="NHS Number '' was not recognised by the Eligibility Signposting API", + details={ + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "code": "RESOURCE_NOT_FOUND", + "display": "The given NHS number was not found in our datasets. " + "This could be because the number is incorrect or " + "some other reason we cannot process that number.", + } + ] + }, ) ), ) @@ -136,6 +149,36 @@ def test_unexpected_error(app: Flask, client: FlaskClient): # Given with get_app_container(app).override.service(EligibilityService, new=FakeUnexpectedErrorEligibilityService()): response = client.get("/patient-check/12345") + + assert_that( + response, + is_response() + .with_status_code(HTTPStatus.INTERNAL_SERVER_ERROR) + .and_text( + is_json_that( + has_entries( + resourceType="OperationOutcome", + issue=contains_exactly( + has_entries( + severity="severe", + code="unexpected", + diagnostics="An unexpected error occurred.", + details={ + "coding": [ + { + "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "code": "UNEXPECTED_ERROR", + "display": "An unexpected internal server error occurred.", + } + ] + }, + ) + ), + ) + ) + ), + ) + assert_that( response, is_response() From 4df429b8f3de6dffff96c36e6389ba72dff3a1a4 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:10:26 +0100 Subject: [PATCH 09/11] ELI-318: Setup error log inline with FHIR guidelines --- .../api_error_response.py | 61 +++++++++++++------ src/eligibility_signposting_api/wrapper.py | 1 + .../lambda/test_app_running_as_lambda.py | 6 +- tests/unit/test_wrapper.py | 6 +- tests/unit/views/test_eligibility.py | 22 ++----- 5 files changed, 52 insertions(+), 44 deletions(-) diff --git a/src/eligibility_signposting_api/api_error_response.py b/src/eligibility_signposting_api/api_error_response.py index 1bbeb51b6..856a47915 100644 --- a/src/eligibility_signposting_api/api_error_response.py +++ b/src/eligibility_signposting_api/api_error_response.py @@ -2,6 +2,7 @@ import logging import uuid from datetime import UTC, datetime +from enum import Enum from http import HTTPStatus from typing import Any @@ -10,12 +11,32 @@ logger = logging.getLogger(__name__) +class FHIRIssueSeverity(str, Enum): + FATAL = "fatal" + ERROR = "error" + WARNING = "warning" + INFORMATION = "information" + + +class FHIRIssueCode(str, Enum): + FORBIDDEN = "forbidden" + PROCESSING = "processing" + VALUE = "value" + + +class FHIRSpineErrorCode(str, Enum): + INVALID_NHS_NUMBER = "INVALID_NHS_NUMBER" + INVALID_PARAMETER = "INVALID_PARAMETER" + INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR" + REFERENCE_NOT_FOUND = "REFERENCE_NOT_FOUND" + + class APIErrorResponse: def __init__( # noqa: PLR0913 self, status_code: HTTPStatus, - fhir_issue_code: str, - fhir_issue_severity: str, + fhir_issue_code: FHIRIssueCode, + fhir_issue_severity: FHIRIssueSeverity, fhir_coding_system: str, fhir_error_code: str, fhir_display_message: str, @@ -71,37 +92,37 @@ def log_and_generate_response( INVALID_INCLUDE_ACTIONS_ERROR = APIErrorResponse( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - fhir_issue_code="value", - fhir_issue_severity="error", + fhir_issue_code=FHIRIssueCode.VALUE, + fhir_issue_severity=FHIRIssueSeverity.ERROR, fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - fhir_error_code="VALIDATION_ERROR", + fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER, fhir_display_message="The supplied value was not recognised by the API.", ) INVALID_CATEGORY_ERROR = APIErrorResponse( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, - fhir_issue_code="value", - fhir_issue_severity="error", + fhir_issue_code=FHIRIssueCode.VALUE, + fhir_issue_severity=FHIRIssueSeverity.ERROR, fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - fhir_error_code="VALIDATION_ERROR", + fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER, fhir_display_message="The supplied category was not recognised by the API.", ) INVALID_CONDITION_FORMAT_ERROR = APIErrorResponse( status_code=HTTPStatus.BAD_REQUEST, - fhir_issue_code="value", - fhir_issue_severity="error", + fhir_issue_code=FHIRIssueCode.VALUE, + fhir_issue_severity=FHIRIssueSeverity.ERROR, fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - fhir_error_code="VALIDATION_ERROR", + fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER, fhir_display_message="The given conditions were not in the expected format.", ) NHS_NUMBER_NOT_FOUND_ERROR = APIErrorResponse( status_code=HTTPStatus.NOT_FOUND, - fhir_issue_code="processing", - fhir_issue_severity="error", + fhir_issue_code=FHIRIssueCode.PROCESSING, + fhir_issue_severity=FHIRIssueSeverity.ERROR, fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - fhir_error_code="RESOURCE_NOT_FOUND", + fhir_error_code=FHIRSpineErrorCode.REFERENCE_NOT_FOUND, fhir_display_message="The given NHS number was not found in our datasets. " "This could be because the number is incorrect or " "some other reason we cannot process that number.", @@ -109,18 +130,18 @@ def log_and_generate_response( INTERNAL_SERVER_ERROR = APIErrorResponse( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - fhir_issue_code="unexpected", - fhir_issue_severity="severe", + fhir_issue_code=FHIRIssueCode.PROCESSING, + fhir_issue_severity=FHIRIssueSeverity.ERROR, fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - fhir_error_code="UNEXPECTED_ERROR", + fhir_error_code=FHIRSpineErrorCode.INTERNAL_SERVER_ERROR, fhir_display_message="An unexpected internal server error occurred.", ) NHS_NUMBER_MISMATCH_ERROR = APIErrorResponse( status_code=HTTPStatus.FORBIDDEN, - fhir_issue_code="forbidden", - fhir_issue_severity="error", + fhir_issue_code=FHIRIssueCode.FORBIDDEN, + fhir_issue_severity=FHIRIssueSeverity.ERROR, fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - fhir_error_code="NHS_NUMBER_MISMATCH", + fhir_error_code=FHIRSpineErrorCode.INVALID_NHS_NUMBER, fhir_display_message="The provided NHS number does not match the record.", ) diff --git a/src/eligibility_signposting_api/wrapper.py b/src/eligibility_signposting_api/wrapper.py index 66d3a8f18..48693a17a 100644 --- a/src/eligibility_signposting_api/wrapper.py +++ b/src/eligibility_signposting_api/wrapper.py @@ -72,6 +72,7 @@ def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None event.setdefault( "queryStringParameters", {"category": "ALL", "conditions": "ALL", "includeActions": "Y"} ) + logger.info("No query params provided, using default: %s", event["queryStringParameters"]) else: is_valid, problem = validate_query_params(query_params_raw) if not is_valid: diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 9a6584243..d133ea72e 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -143,7 +143,7 @@ def test_install_and_call_flask_lambda_with_unknown_nhs_number( "coding": [ { "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - "code": "RESOURCE_NOT_FOUND", + "code": "REFERENCE_NOT_FOUND", "display": "The given NHS number was not found in our datasets. " "This could be because the number is incorrect or " "some other reason we cannot process that number.", @@ -294,7 +294,7 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu "coding": [ { "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - "code": "NHS_NUMBER_MISMATCH", + "code": "INVALID_NHS_NUMBER", "display": "The provided NHS number does not match the record.", } ] @@ -339,7 +339,7 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response( "coding": [ { "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - "code": "NHS_NUMBER_MISMATCH", + "code": "INVALID_NHS_NUMBER", "display": "The provided NHS number does not match the record.", } ] diff --git a/tests/unit/test_wrapper.py b/tests/unit/test_wrapper.py index 602a74f6a..f780c02cd 100644 --- a/tests/unit/test_wrapper.py +++ b/tests/unit/test_wrapper.py @@ -239,7 +239,7 @@ def test_validate_query_params_returns_correct_problem_details_for_conditions_er coding = issue["details"]["coding"][0] assert coding["system"] == "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode" - assert coding["code"] == "VALIDATION_ERROR" + assert coding["code"] == "INVALID_PARAMETER" assert coding["display"] == "The given conditions were not in the expected format." @@ -274,7 +274,7 @@ def test_validate_query_params_returns_correct_problem_details_for_category_erro coding = issue["details"]["coding"][0] assert coding["system"] == "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode" - assert coding["code"] == "VALIDATION_ERROR" + assert coding["code"] == "INVALID_PARAMETER" assert coding["display"] == "The supplied category was not recognised by the API." @@ -309,5 +309,5 @@ def test_validate_query_params_returns_correct_problem_details_for_include_actio coding = issue["details"]["coding"][0] assert coding["system"] == "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode" - assert coding["code"] == "VALIDATION_ERROR" + assert coding["code"] == "INVALID_PARAMETER" assert coding["display"] == "The supplied value was not recognised by the API." diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index f272503e3..4d38ad989 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -130,7 +130,7 @@ def test_no_nhs_number_given(app: Flask, client: FlaskClient): "coding": [ { "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - "code": "RESOURCE_NOT_FOUND", + "code": "REFERENCE_NOT_FOUND", "display": "The given NHS number was not found in our datasets. " "This could be because the number is incorrect or " "some other reason we cannot process that number.", @@ -160,14 +160,14 @@ def test_unexpected_error(app: Flask, client: FlaskClient): resourceType="OperationOutcome", issue=contains_exactly( has_entries( - severity="severe", - code="unexpected", + severity="error", + code="processing", diagnostics="An unexpected error occurred.", details={ "coding": [ { "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", - "code": "UNEXPECTED_ERROR", + "code": "INTERNAL_SERVER_ERROR", "display": "An unexpected internal server error occurred.", } ] @@ -179,20 +179,6 @@ def test_unexpected_error(app: Flask, client: FlaskClient): ), ) - assert_that( - response, - is_response() - .with_status_code(HTTPStatus.INTERNAL_SERVER_ERROR) - .and_text( - is_json_that( - has_entries( - resourceType="OperationOutcome", - issue=contains_exactly(has_entries(severity="severe", code="unexpected")), - ) - ) - ), - ) - @pytest.mark.parametrize( ("cohort_results", "expected_eligibility_cohorts", "test_comment"), From a458e941930284b2f70bdc35c0f9fb3bda1472d0 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Fri, 11 Jul 2025 11:33:26 +0100 Subject: [PATCH 10/11] ELI-318: Adding logs for when default query params used --- .../views/eligibility.py | 11 +++++++++- src/eligibility_signposting_api/wrapper.py | 21 +++++++------------ .../lambda/test_app_running_as_lambda.py | 8 +++++++ 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index a60affc47..8c91d7de7 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -46,7 +46,7 @@ def check_eligibility( 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( - nhs_number, include_actions_flag=request.args.get("includeActions") == "Y" + nhs_number, include_actions_flag=get_include_actions_flag() ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) @@ -58,6 +58,15 @@ def check_eligibility( ) +def get_include_actions_flag() -> bool: + if not request.args.get("includeActions"): + logger.info("Defaulting includeActions query param to Y as no value was provided") + include_actions_flag = True + else: + include_actions_flag = request.args.get("includeActions") == "Y" + return include_actions_flag + + def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue: diagnostics = f"NHS Number '{nhs_number}' was not recognised by the Eligibility Signposting API" response = NHS_NUMBER_NOT_FOUND_ERROR.log_and_generate_response( diff --git a/src/eligibility_signposting_api/wrapper.py b/src/eligibility_signposting_api/wrapper.py index 48693a17a..c3437afea 100644 --- a/src/eligibility_signposting_api/wrapper.py +++ b/src/eligibility_signposting_api/wrapper.py @@ -56,25 +56,18 @@ def validate_request_params() -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None: - path_nhs_number = event.get("pathParameters", {}).get("id") - header_nhs_number = event.get("headers", {}).get(NHS_NUMBER_HEADER) + path_nhs_no = event.get("pathParameters", {}).get("id") + header_nhs_no = event.get("headers", {}).get(NHS_NUMBER_HEADER) - if not validate_nhs_number(path_nhs_number, header_nhs_number): - message = ( - f"NHS Number {path_nhs_number or ''} does not match the header NHS Number {header_nhs_number or ''}" - ) + if not validate_nhs_number(path_nhs_no, header_nhs_no): + message = f"NHS Number {path_nhs_no or ''} does not match the header NHS Number {header_nhs_no or ''}" return NHS_NUMBER_MISMATCH_ERROR.log_and_generate_response( log_message=message, diagnostics=message, location_param="id" ) - query_params_raw = event.get("queryStringParameters") - if query_params_raw is None: - event.setdefault( - "queryStringParameters", {"category": "ALL", "conditions": "ALL", "includeActions": "Y"} - ) - logger.info("No query params provided, using default: %s", event["queryStringParameters"]) - else: - is_valid, problem = validate_query_params(query_params_raw) + query_params = event.get("queryStringParameters") + if query_params: + is_valid, problem = validate_query_params(query_params) if not is_valid: return problem diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 1de841994..597f84e5f 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -185,6 +185,8 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, + flask_function: str, + logs_client: BaseClient, ): # Given # When @@ -259,6 +261,12 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i assert_that(audit_data["response"]["lastUpdated"], is_not(equal_to(""))) assert_that(audit_data["response"]["condition"], equal_to(expected_conditions)) + messages = get_log_messages(flask_function, logs_client) + assert_that( + messages, + has_item(contains_string("Defaulting includeActions query param to Y as no value was provided")), + ) + def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_results_in_error_response( lambda_client: BaseClient, # noqa:ARG001 From 7cf2a6aa55700f165e7713af0deb69d491467b97 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Fri, 11 Jul 2025 11:45:05 +0100 Subject: [PATCH 11/11] ELI-318: Changing OperationOutcome to have valid system URL --- .../api_error_response.py | 12 ++++++------ .../integration/lambda/test_app_running_as_lambda.py | 6 +++--- tests/unit/test_wrapper.py | 6 +++--- tests/unit/views/test_eligibility.py | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/eligibility_signposting_api/api_error_response.py b/src/eligibility_signposting_api/api_error_response.py index 856a47915..9b81a740c 100644 --- a/src/eligibility_signposting_api/api_error_response.py +++ b/src/eligibility_signposting_api/api_error_response.py @@ -94,7 +94,7 @@ def log_and_generate_response( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, fhir_issue_code=FHIRIssueCode.VALUE, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER, fhir_display_message="The supplied value was not recognised by the API.", ) @@ -103,7 +103,7 @@ def log_and_generate_response( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, fhir_issue_code=FHIRIssueCode.VALUE, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER, fhir_display_message="The supplied category was not recognised by the API.", ) @@ -112,7 +112,7 @@ def log_and_generate_response( status_code=HTTPStatus.BAD_REQUEST, fhir_issue_code=FHIRIssueCode.VALUE, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER, fhir_display_message="The given conditions were not in the expected format.", ) @@ -121,7 +121,7 @@ def log_and_generate_response( status_code=HTTPStatus.NOT_FOUND, fhir_issue_code=FHIRIssueCode.PROCESSING, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.REFERENCE_NOT_FOUND, fhir_display_message="The given NHS number was not found in our datasets. " "This could be because the number is incorrect or " @@ -132,7 +132,7 @@ def log_and_generate_response( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, fhir_issue_code=FHIRIssueCode.PROCESSING, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.INTERNAL_SERVER_ERROR, fhir_display_message="An unexpected internal server error occurred.", ) @@ -141,7 +141,7 @@ def log_and_generate_response( status_code=HTTPStatus.FORBIDDEN, fhir_issue_code=FHIRIssueCode.FORBIDDEN, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.INVALID_NHS_NUMBER, fhir_display_message="The provided NHS number does not match the record.", ) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 597f84e5f..2e712a076 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -142,7 +142,7 @@ def test_install_and_call_flask_lambda_with_unknown_nhs_number( details={ "coding": [ { - "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", "code": "REFERENCE_NOT_FOUND", "display": "The given NHS number was not found in our datasets. " "This could be because the number is incorrect or " @@ -301,7 +301,7 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu details={ "coding": [ { - "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", "code": "INVALID_NHS_NUMBER", "display": "The provided NHS number does not match the record.", } @@ -346,7 +346,7 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response( details={ "coding": [ { - "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", "code": "INVALID_NHS_NUMBER", "display": "The provided NHS number does not match the record.", } diff --git a/tests/unit/test_wrapper.py b/tests/unit/test_wrapper.py index f780c02cd..18ef5d477 100644 --- a/tests/unit/test_wrapper.py +++ b/tests/unit/test_wrapper.py @@ -238,7 +238,7 @@ def test_validate_query_params_returns_correct_problem_details_for_conditions_er assert len(issue["details"]["coding"]) == 1 coding = issue["details"]["coding"][0] - assert coding["system"] == "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode" + assert coding["system"] == "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1" assert coding["code"] == "INVALID_PARAMETER" assert coding["display"] == "The given conditions were not in the expected format." @@ -273,7 +273,7 @@ def test_validate_query_params_returns_correct_problem_details_for_category_erro assert len(issue["details"]["coding"]) == 1 coding = issue["details"]["coding"][0] - assert coding["system"] == "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode" + assert coding["system"] == "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1" assert coding["code"] == "INVALID_PARAMETER" assert coding["display"] == "The supplied category was not recognised by the API." @@ -308,6 +308,6 @@ def test_validate_query_params_returns_correct_problem_details_for_include_actio assert len(issue["details"]["coding"]) == 1 coding = issue["details"]["coding"][0] - assert coding["system"] == "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode" + assert coding["system"] == "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1" assert coding["code"] == "INVALID_PARAMETER" assert coding["display"] == "The supplied value was not recognised by the API." diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 4d38ad989..99b0fac34 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -129,7 +129,7 @@ def test_no_nhs_number_given(app: Flask, client: FlaskClient): details={ "coding": [ { - "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", "code": "REFERENCE_NOT_FOUND", "display": "The given NHS number was not found in our datasets. " "This could be because the number is incorrect or " @@ -166,7 +166,7 @@ def test_unexpected_error(app: Flask, client: FlaskClient): details={ "coding": [ { - "system": "https://fhir.nhs.uk/CodeSystem/Spine-ErrorOrWarningCode", + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", "code": "INTERNAL_SERVER_ERROR", "display": "An unexpected internal server error occurred.", }