From befafb85fa3b9305ece6464469e4f534c4651f03 Mon Sep 17 00:00:00 2001 From: ivano mannella Date: Tue, 24 Jun 2025 14:56:58 +0100 Subject: [PATCH 01/10] +generate DynamoDB data generate_dynamo_data.py --- tests/e2e/data/dynamoDB/AUTO_RSV_SB_006.json | 2 +- tests/e2e/data/dynamoDB/AUTO_RSV_SB_007.json | 2 +- tests/e2e/data/dynamoDB/AUTO_RSV_SB_012.json | 2 +- tests/e2e/data/dynamoDB/AUTO_RSV_SB_013.json | 2 +- tests/e2e/data/generate_dynamo_data.py | 126 ++ tests/e2e/data/generate_test_data.py | 1610 ------------------ 6 files changed, 130 insertions(+), 1614 deletions(-) create mode 100644 tests/e2e/data/generate_dynamo_data.py delete mode 100644 tests/e2e/data/generate_test_data.py diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_006.json b/tests/e2e/data/dynamoDB/AUTO_RSV_SB_006.json index 728250475..f1101ccc4 100644 --- a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_006.json +++ b/tests/e2e/data/dynamoDB/AUTO_RSV_SB_006.json @@ -39,7 +39,7 @@ { "NHS_NUMBER": "5000000006", "ATTRIBUTE_TYPE": "RSV", - "LAST_SUCCESSFUL_DATE": "<>" + "LAST_SUCCESSFUL_DATE": "<>" } ] } diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_007.json b/tests/e2e/data/dynamoDB/AUTO_RSV_SB_007.json index fc9d4afb0..3c9c46c5e 100644 --- a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_007.json +++ b/tests/e2e/data/dynamoDB/AUTO_RSV_SB_007.json @@ -39,7 +39,7 @@ { "NHS_NUMBER": "5000000007", "ATTRIBUTE_TYPE": "RSV", - "LAST_SUCCESSFUL_DATE": "<>" + "LAST_SUCCESSFUL_DATE": "<>" } ] } diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_012.json b/tests/e2e/data/dynamoDB/AUTO_RSV_SB_012.json index 922748b12..b7ff528f2 100644 --- a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_012.json +++ b/tests/e2e/data/dynamoDB/AUTO_RSV_SB_012.json @@ -29,7 +29,7 @@ { "NHS_NUMBER": "5000000012", "ATTRIBUTE_TYPE": "RSV", - "LAST_SUCCESSFUL_DATE": "<>" + "LAST_SUCCESSFUL_DATE": "<>" } ] } diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_013.json b/tests/e2e/data/dynamoDB/AUTO_RSV_SB_013.json index 2381f3b6f..c0f69b5dc 100644 --- a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_013.json +++ b/tests/e2e/data/dynamoDB/AUTO_RSV_SB_013.json @@ -39,7 +39,7 @@ { "NHS_NUMBER": "50000000014", "ATTRIBUTE_TYPE": "RSV", - "LAST_SUCCESSFUL_DATE": "<>" + "LAST_SUCCESSFUL_DATE": "<>" } ] } diff --git a/tests/e2e/data/generate_dynamo_data.py b/tests/e2e/data/generate_dynamo_data.py new file mode 100644 index 000000000..cde3fa900 --- /dev/null +++ b/tests/e2e/data/generate_dynamo_data.py @@ -0,0 +1,126 @@ +import json +import logging +import os +import re +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +# Constants +OUTPUT_ROOT = "out" +DATE_FORMAT = "%Y%m%d" +VAR_PATTERN = re.compile(r"<<([^<>]+)>>") + +# Configure logging +logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(levelname)s - %(message)s") + + +class DateVariableResolver: + """Handles the logic for parsing and evaluating date-based variables.""" + + def __init__(self, today: datetime = None): + self.today = today or datetime.today() + + def resolve(self, token: str) -> str: + logging.debug(f"Resolving variable: {token}") + parts = token.split("_") + if len(parts) < 3 or parts[0].upper() != "DATE": + raise ValueError(f"Unsupported variable format: {token}") + + _, unit, value = parts[0], parts[1].lower(), parts[2] + + try: + offset = int(value) + except ValueError: + raise ValueError(f"Invalid offset value: {value}") + + if unit == "day": + return (self.today + timedelta(days=offset)).strftime(DATE_FORMAT) + if unit == "week": + return (self.today + timedelta(weeks=offset)).strftime(DATE_FORMAT) + if unit == "year": + return (self.today.replace(year=self.today.year + offset)).strftime(DATE_FORMAT) + if unit == "age": + try: + birth_date = self.today.replace(year=self.today.year - offset) + except ValueError: + # Handle February 29th + birth_date = self.today.replace(month=2, day=28, year=self.today.year - offset) + return birth_date.strftime(DATE_FORMAT) + raise ValueError(f"Unsupported calculation unit: {unit}") + + +class JsonTestDataProcessor: + """Processes JSON test files by resolving placeholders in 'data' arrays.""" + + def __init__(self, input_dir: Path, output_dir: Path, resolver: DateVariableResolver): + self.input_dir = input_dir + self.output_dir = output_dir + self.resolver = resolver + + def resolve_placeholders(self, obj: Any) -> Any: + if isinstance(obj, dict): + return {k: self.resolve_placeholders(v) for k, v in obj.items()} + if isinstance(obj, list): + return [self.resolve_placeholders(item) for item in obj] + if isinstance(obj, str): + return VAR_PATTERN.sub(self._replace_token, obj) + return obj + + def _replace_token(self, match: re.Match) -> str: + token = match.group(1) + try: + return self.resolver.resolve(token) + except Exception as e: + logging.warning(f"Failed to resolve variable {token}: {e}") + return match.group(0) + + def process_file(self, file_path: Path): + logging.info(f"Processing file: {file_path}") + try: + with open(file_path) as f: + content = json.load(f) + except Exception as e: + logging.exception(f"Failed to read {file_path}: {e}") + return + + try: + resolved = self.resolve_placeholders(content) + except Exception as e: + logging.exception(f"Failed to resolve placeholders: {e}") + return + + if "data" not in resolved: + logging.error(f"Missing 'data' key in {file_path}") + return + + relative_path = file_path.relative_to(self.input_dir) + output_path = self.output_dir / relative_path + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + with open(output_path, "w") as f: + json.dump(resolved["data"], f, indent=2) + logging.info(f"Written resolved file: {output_path}") + except Exception as e: + logging.exception(f"Failed to write output: {e}") + + +def main(): + input_dir = Path() + output_dir = Path(OUTPUT_ROOT) + resolver = DateVariableResolver() + + processor = JsonTestDataProcessor(input_dir, output_dir, resolver) + + logging.info(f"Scanning for JSON files in {input_dir}") + for root, _, files in os.walk(input_dir): + for file in files: + if file.endswith(".json"): + processor.process_file(Path(root) / file) + else: + logging.debug(f"Skipping non-JSON file: {file}") + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/data/generate_test_data.py b/tests/e2e/data/generate_test_data.py deleted file mode 100644 index f1e40ea79..000000000 --- a/tests/e2e/data/generate_test_data.py +++ /dev/null @@ -1,1610 +0,0 @@ -import datetime -from typing import Any - -import pytest -from faker import Faker -from freezegun import freeze_time -from hamcrest import assert_that, contains_exactly, contains_inanyorder, equal_to, has_item, has_items, is_in - -from eligibility_signposting_api.model import rules -from eligibility_signposting_api.model import rules as rules_model -from eligibility_signposting_api.model.eligibility import ( - ConditionName, - DateOfBirth, - NHSNumber, - Postcode, - RuleDescription, - Status, -) -from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculator -from tests.fixtures.builders.model import rule as rule_builder -from tests.fixtures.builders.repos.person import person_rows_builder -from tests.fixtures.matchers.eligibility import ( - is_cohort_result, - is_condition, - is_eligibility_status, - is_reason, -) - - -def test_not_base_eligible(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"]) - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort2")] - ) - ], - ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_eligible)) - ), - ) - - -@pytest.mark.parametrize( - ("person_cohorts", "iteration_cohorts", "status", "test_comment"), - [ - (["cohort1"], ["elid_all_people"], Status.actionable, "Only magic cohort present"), - (["cohort1"], ["elid_all_people", "cohort1"], Status.actionable, "Magic cohort with other cohorts"), - (["cohort1"], ["cohort2"], Status.not_eligible, "No magic cohort. No matching person cohort"), - ([], ["elid_all_people"], Status.actionable, "No person cohorts. Only magic cohort present"), - ], -) -def test_base_eligible_with_when_magic_cohort_is_present( - faker: Faker, person_cohorts: list[str], iteration_cohorts: list[str], status: Status, test_comment: str -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=76, maximum_age=79)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=person_cohorts) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.IterationCohortFactory.build(cohort_label=label) for label in iteration_cohorts - ], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(status)) - ), - test_comment, - ) - - -@freeze_time("2025-04-25") -def test_only_live_campaigns_considered(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - name="Live", - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort2")], - ) - ], - start_date=datetime.date(2025, 4, 20), - end_date=datetime.date(2025, 4, 30), - ), - rule_builder.CampaignConfigFactory.build( - name="No longer live", - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.IterationCohortFactory.build(cohort_label="cohort1"), - rule_builder.IterationCohortFactory.build(cohort_label="cohort2"), - ], - ) - ], - start_date=datetime.date(2025, 4, 1), - end_date=datetime.date(2025, 4, 24), - ), - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_eligible)) - ), - ) - - -@pytest.mark.parametrize( - "iteration_type", - ["A", "M", "S", "O"], -) -def test_campaigns_with_applicable_iteration_types_considered(iteration_type: str, faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number) - campaign_configs = [rule_builder.CampaignConfigFactory.build(target="RSV", iteration_type=iteration_type)] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(is_in([Status.actionable, Status.not_actionable, Status.not_eligible])) - ), - ), - ) - - -def test_base_eligible_and_simple_rule_includes(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=76, maximum_age=79)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.actionable)) - ), - ) - - -def test_base_eligible_but_simple_rule_excludes(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_actionable)) - ), - ) - - -@freeze_time("2025-04-25") -def test_simple_rule_only_excludes_from_live_iteration(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - name="old iteration - would not exclude 74 year old", - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(comparator="-65")], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_date=datetime.date(2025, 4, 10), - ), - rule_builder.IterationFactory.build( - name="current - would exclude 74 year old", - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_date=datetime.date(2025, 4, 20), - ), - rule_builder.IterationFactory.build( - name="next iteration - would not exclude 74 year old", - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(comparator="-65")], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_date=datetime.date(2025, 4, 30), - ), - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_actionable)) - ), - ) - - -@pytest.mark.parametrize( - ("rule_type", "expected_status"), - [ - (rules_model.RuleType.suppression, Status.not_actionable), - (rules_model.RuleType.filter, Status.not_eligible), - (rules_model.RuleType.redirect, Status.actionable), - ], -) -def test_rule_types_cause_correct_statuses(rule_type: rules_model.RuleType, expected_status: Status, faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(type=rule_type)], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(expected_status)) - ), - ) - - -def test_multiple_rule_types_cause_correct_status(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[ - rule_builder.PersonAgeSuppressionRuleFactory.build( - priority=rules_model.RulePriority(5), type=rules_model.RuleType.suppression - ), - rule_builder.PersonAgeSuppressionRuleFactory.build( - priority=rules_model.RulePriority(10), type=rules_model.RuleType.filter - ), - rule_builder.PersonAgeSuppressionRuleFactory.build( - priority=rules_model.RulePriority(15), type=rules_model.RuleType.suppression - ), - ], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_eligible)) - ), - ) - - -@pytest.mark.parametrize( - ("test_comment", "rule1", "rule2", "expected_status"), - [ - ( - "two rules, both exclude, same priority, should exclude", - rule_builder.PersonAgeSuppressionRuleFactory.build(priority=rules_model.RulePriority(5)), - rule_builder.PostcodeSuppressionRuleFactory.build(priority=rules_model.RulePriority(5)), - Status.not_actionable, - ), - ( - "two rules, rule 1 excludes, same priority, should allow", - rule_builder.PersonAgeSuppressionRuleFactory.build(priority=rules_model.RulePriority(5)), - rule_builder.PostcodeSuppressionRuleFactory.build( - priority=rules_model.RulePriority(5), comparator=rules_model.RuleComparator("NW1") - ), - Status.actionable, - ), - ( - "two rules, rule 2 excludes, same priority, should allow", - rule_builder.PersonAgeSuppressionRuleFactory.build( - priority=rules_model.RulePriority(5), comparator=rules_model.RuleComparator("-65") - ), - rule_builder.PostcodeSuppressionRuleFactory.build(priority=rules_model.RulePriority(5)), - Status.actionable, - ), - ( - "two rules, rule 1 excludes, different priority, should exclude", - rule_builder.PersonAgeSuppressionRuleFactory.build(priority=rules_model.RulePriority(5)), - rule_builder.PostcodeSuppressionRuleFactory.build( - priority=rules_model.RulePriority(10), comparator=rules_model.RuleComparator("NW1") - ), - Status.not_actionable, - ), - ( - "two rules, rule 2 excludes, different priority, should exclude", - rule_builder.PersonAgeSuppressionRuleFactory.build( - priority=rules_model.RulePriority(5), comparator=rules_model.RuleComparator("-65") - ), - rule_builder.PostcodeSuppressionRuleFactory.build(priority=rules_model.RulePriority(10)), - Status.not_actionable, - ), - ( - "two rules, both excludes, different priority, should exclude", - rule_builder.PersonAgeSuppressionRuleFactory.build(priority=rules_model.RulePriority(5)), - rule_builder.PostcodeSuppressionRuleFactory.build(priority=rules_model.RulePriority(10)), - Status.not_actionable, - ), - ], -) -def test_rules_with_same_priority_must_all_match_to_exclude( - test_comment: str, - rule1: rules_model.IterationRule, - rule2: rules_model.IterationRule, - expected_status: Status, - faker: Faker, -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - - person_rows = person_rows_builder( - nhs_number, date_of_birth=date_of_birth, postcode=Postcode("SW19 2BH"), cohorts=["cohort1"] - ) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[rule1, rule2], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(expected_status)) - ), - test_comment, - ) - - -def test_multiple_conditions_where_both_are_actionable(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=76, maximum_age=78)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - ) - ], - ), - rule_builder.CampaignConfigFactory.build( - target="COVID", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - ) - ], - ), - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.actionable), - is_condition().with_condition_name(ConditionName("COVID")).and_status(Status.actionable), - ) - ), - ) - - -def test_multiple_conditions_where_all_give_unique_statuses(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=76, maximum_age=78)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - ) - ], - ), - rule_builder.CampaignConfigFactory.build( - target="COVID", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(comparator="-85")], - ) - ], - ), - rule_builder.CampaignConfigFactory.build( - target="FLU", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort2")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(comparator="-85")], - ) - ], - ), - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.actionable), - is_condition().with_condition_name(ConditionName("COVID")).and_status(Status.not_actionable), - is_condition().with_condition_name(ConditionName("FLU")).and_status(Status.not_eligible), - ) - ), - ) - - -@pytest.mark.parametrize( - ("test_comment", "campaign1", "campaign2"), - [ - ( - "1st campaign allows, 2nd excludes", - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - ) - ], - ), - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(comparator="-85")], - ) - ], - ), - ), - ( - "1st campaign excludes, 2nd allows", - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(comparator="-85")], - ) - ], - ), - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - ) - ], - ), - ), - ], -) -def test_multiple_campaigns_for_single_condition( - test_comment: str, campaign1: rules_model.CampaignConfig, campaign2: rules_model.CampaignConfig, faker: Faker -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=76, maximum_age=78)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [campaign1, campaign2] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - contains_exactly(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.actionable)) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("icb", "rule_type", "expected_status"), - [ - ("QE1", rules_model.RuleType.suppression, Status.actionable), - ("QWU", rules_model.RuleType.suppression, Status.not_actionable), - ("", rules_model.RuleType.suppression, Status.not_actionable), - (None, rules_model.RuleType.suppression, Status.not_actionable), - ("QE1", rules_model.RuleType.filter, Status.actionable), - ("QWU", rules_model.RuleType.filter, Status.not_eligible), - ("", rules_model.RuleType.filter, Status.not_eligible), - (None, rules_model.RuleType.filter, Status.not_eligible), - ], -) -def test_base_eligible_and_icb_example( - icb: str | None, rule_type: rules_model.RuleType, expected_status: Status, faker: Faker -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb=icb) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[rule_builder.ICBFilterRuleFactory.build(type=rule_type)], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(expected_status)) - ), - ) - - -@pytest.mark.parametrize( - ("vaccine", "last_successful_date", "expected_status", "test_comment"), - [ - ("RSV", "20240601", Status.not_actionable, "last_successful_date is a past date"), - ("RSV", "20250101", Status.not_actionable, "last_successful_date is today"), - # Below is a non-ideal situation (might be due to a data entry error), so considered as actionable. - ("RSV", "20260101", Status.actionable, "last_successful_date is a future date"), - ("RSV", "20230601", Status.actionable, "last_successful_date is a long past"), - ("RSV", "", Status.actionable, "last_successful_date is empty"), - ("RSV", None, Status.actionable, "last_successful_date is none"), - ("COVID", "20240601", Status.actionable, "No RSV row"), - ], -) -@freeze_time("2025-01-01") -def test_status_on_target_based_on_last_successful_date( - vaccine: str, last_successful_date: str, expected_status: Status, test_comment: str, faker: Faker -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - target_rows = person_rows_builder( - nhs_number, - cohorts=["cohort1"], - vaccines=[ - ( - vaccine, - datetime.datetime.strptime(last_successful_date, "%Y%m%d").replace(tzinfo=datetime.UTC) - if last_successful_date - else None, - ) - ], - ) - - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[ - rule_builder.IterationRuleFactory.build( - type=rules.RuleType.suppression, - name=rules.RuleName("You have already been vaccinated against RSV in the last year"), - description=rules.RuleDescription( - "Exclude anyone Completed RSV Vaccination in the last year" - ), - priority=10, - operator=rules.RuleOperator.day_gte, - attribute_level=rules.RuleAttributeLevel.TARGET, - attribute_name=rules.RuleAttributeName("LAST_SUCCESSFUL_DATE"), - comparator=rules.RuleComparator("-365"), - attribute_target=rules.RuleAttributeTarget("RSV"), - ), - rule_builder.IterationRuleFactory.build( - type=rules.RuleType.suppression, - name=rules.RuleName("You have a vaccination date in the future for RSV"), - description=rules.RuleDescription("Exclude anyone with future Completed RSV Vaccination"), - priority=10, - operator=rules.RuleOperator.day_lte, - attribute_level=rules.RuleAttributeLevel.TARGET, - attribute_name=rules.RuleAttributeName("LAST_SUCCESSFUL_DATE"), - comparator=rules.RuleComparator("0"), - attribute_target=rules.RuleAttributeTarget("RSV"), - ), - ], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(target_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(expected_status)) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("attribute_name", "expected_status", "test_comment"), - [ - ( - rules.RuleAttributeName("COHORT_LABEL"), - Status.not_eligible, - "cohort label provided", - ), - ( - None, - Status.not_eligible, - "cohort label is the default attribute name for the cohort attribute level", - ), - ( - rules.RuleAttributeName("LOCATION"), - Status.actionable, - "attribute name that is not cohort label", - ), - ], -) -def test_status_on_cohort_attribute_level( - attribute_name: rules.RuleAttributeName, expected_status: Status, test_comment: str, faker: Faker -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_row: list[dict[str, Any]] = person_rows_builder( - nhs_number, cohorts=["cohort1", "covid_eligibility_complaint_list"] - ) - person_row_with_extra_items_in_cohort_row = [ - {**r, "LOCATION": "HP1"} for r in person_row if r.get("ATTRIBUTE_TYPE", "") == "COHORTS" - ] - - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[ - rule_builder.IterationRuleFactory.build( - type=rules.RuleType.filter, - name=rules.RuleName("Exclude those in a complaint cohort"), - description=rules.RuleDescription( - "Ensure anyone who has registered a complaint is not shown as eligible" - ), - priority=15, - operator=rules.RuleOperator.member_of, - attribute_level=rules.RuleAttributeLevel.COHORT, - attribute_name=attribute_name, - comparator=rules.RuleComparator("covid_eligibility_complaint_list"), - ) - ], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_row_with_extra_items_in_cohort_row, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(expected_status)) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("person_cohorts", "expected_status", "test_comment"), - [ - (["cohort1", "cohort2"], Status.actionable, "cohort1 is not actionable, cohort 2 is actionable"), - (["cohort3", "cohort2"], Status.actionable, "cohort3 is not eligible, cohort 2 is actionable"), - (["cohort1"], Status.not_actionable, "cohort1 is not actionable"), - ], -) -def test_status_if_iteration_rules_contains_cohort_label_field( - person_cohorts, expected_status: Status, test_comment: str, faker: Faker -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=person_cohorts) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.IterationCohortFactory.build(cohort_label="cohort1"), - rule_builder.IterationCohortFactory.build(cohort_label="cohort2"), - ], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items(is_condition().with_condition_name(ConditionName("RSV")).and_status(expected_status)) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("rule_stop", "expected_reason_results", "test_comment"), # Changed expected_reasons to expected_reason_results - [ - ( - rules.RuleStop(True), # noqa: FBT003 - [ - RuleDescription("reason 1"), - RuleDescription("reason 2"), - ], - "rule_stop is True, last rule should not run", - ), - ( - rules.RuleStop(False), # noqa: FBT003 - [ - RuleDescription("reason 1"), - RuleDescription("reason 2"), - RuleDescription("reason 3"), - ], - "rule_stop is False, last rule should run", - ), - ], -) -def test_rules_stop_behavior( - rule_stop: rules.RuleStop, expected_reason_results: list[RuleDescription], test_comment: str, faker: Faker -) -> None: - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - - # Build campaign configuration - campaign_config = rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[ - rule_builder.PersonAgeSuppressionRuleFactory.build( - priority=10, description="reason 1", rule_stop=rule_stop - ), - rule_builder.PersonAgeSuppressionRuleFactory.build(priority=10, description="reason 2"), - rule_builder.PersonAgeSuppressionRuleFactory.build(priority=15, description="reason 3"), - ], - iteration_cohorts=[ - rule_builder.IterationCohortFactory.build(cohort_group="cohort_group1", cohort_label="cohort1") - ], - ) - ], - ) - - calculator = EligibilityCalculator(person_rows, [campaign_config]) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.not_actionable)) - .and_cohort_results( - has_items( - is_cohort_result().with_reasons( - contains_inanyorder( - *[ - is_reason().with_rule_description(equal_to(result)) - for result in expected_reason_results - ] - ) - ) - ) - ) - ) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("person_cohorts", "iteration_cohorts", "expected_status", "expected_cohorts"), - [ - ( - ["covid_cohort", "flu_cohort"], - ["rsv_clinical_cohort", "rsv_75_rolling"], - Status.not_eligible, - ["rsv_clinical_cohort_group", "rsv_75_rolling_group"], - ), - ( - ["rsv_clinical_cohort", "rsv_75_rolling"], - ["rsv_clinical_cohort", "rsv_75_rolling"], - Status.actionable, - ["rsv_clinical_cohort_group"], - ), - ( - ["covid_cohort", "rsv_75_rolling"], - ["rsv_clinical_cohort", "rsv_75_rolling"], - Status.not_actionable, - ["rsv_75_rolling_group"], - ), - ( - ["covid_cohort", "rsv_clinical_cohort"], - ["rsv_clinical_cohort", "rsv_75_rolling"], - Status.actionable, - ["rsv_clinical_cohort_group"], - ), - ( - ["rsv_75to79_2024", "rsv_75_rolling"], - ["rsv_75to79_2024", "rsv_75_rolling"], - Status.not_actionable, - ["rsv_75_rolling_group", "rsv_75to79_2024_group"], - ), - ], -) -def test_eligibility_results_when_multiple_cohorts( - person_cohorts: list[str], - iteration_cohorts: list[str], - expected_status: Status, - expected_cohorts: list[str], - faker: Faker, -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - dob_person_less_than_75 = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=dob_person_less_than_75, cohorts=person_cohorts) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.IterationCohortFactory.build( - cohort_group=f"{cohorts}_group", - cohort_label=cohorts, - positive_description="positive description", - negative_description="negative description", - ) - for cohorts in iteration_cohorts - ], - iteration_rules=[ - rule_builder.PersonAgeSuppressionRuleFactory.build(cohort_label="rsv_75_rolling"), - rule_builder.PersonAgeSuppressionRuleFactory.build(cohort_label="rsv_75to79_2024"), - ], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(expected_status)) - .and_cohort_results( - contains_inanyorder( - *[ - is_cohort_result().with_cohort_code(equal_to(cohort_label)) - for cohort_label in expected_cohorts - ] - ) - ) - ) - ), - ) - - -@pytest.mark.parametrize( - ("person_rows", "expected_status", "expected_cohort_group_and_description", "test_comment"), - [ - ( - person_rows_builder(nhs_number="123", cohorts=[], postcode="AC01", de=True, icb="QE1"), - Status.not_eligible, - [ - ("magic cohort group", "magic negative description"), - ("rsv_age_range", "rsv_age_range negative description"), - ], - "rsv_75_rolling is not base-eligible & magic cohort group not eligible by F rules ", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="AC01", de=True, icb="QE1"), - Status.not_eligible, - [ - ("magic cohort group", "magic negative description"), - ("rsv_age_range", "rsv_age_range negative description"), - ], - "all the cohorts are not-eligible by F rules", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="SW19", de=False, icb="QE1"), - Status.not_actionable, - [ - ("magic cohort group", "magic positive description"), - ("rsv_age_range", "rsv_age_range positive description"), - ], - "all the cohorts are not-actionable", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="AC01", de=False, icb="QE1"), - Status.actionable, - [ - ("magic cohort group", "magic positive description"), - ("rsv_age_range", "rsv_age_range positive description"), - ], - "all the cohorts are actionable", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="AC01", de=False, icb="NOT_QE1"), - Status.actionable, - [("magic cohort group", "magic positive description")], - "magic_cohort is actionable, but not others", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="SW19", de=False, icb="NOT_QE1"), - Status.not_actionable, - [("magic cohort group", "magic positive description")], - "magic_cohort is not-actionable, but others are not eligible", - ), - ], -) -def test_cohort_groups_and_their_descriptions_when_magic_cohort_is_present( - person_rows: list[dict[str, Any]], - expected_status: str, - expected_cohort_group_and_description: list[tuple[str, str]], - test_comment: str, -): - # Given - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.Rsv75RollingCohortFactory.build(), - rule_builder.MagicCohortFactory.build(), - ], - iteration_rules=[ - # F common rule - rule_builder.DetainedEstateSuppressionRuleFactory.build(type=rules.RuleType.filter), - # F rules for rsv_75_rolling - rule_builder.ICBFilterRuleFactory.build( - type=rules.RuleType.filter, cohort_label=rules.CohortLabel("rsv_75_rolling") - ), - # S common rule - rule_builder.PostcodeSuppressionRuleFactory.build( - comparator=rules.RuleComparator("SW19"), - ), - ], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_cohort_results( - contains_exactly( - *[ - is_cohort_result() - .with_cohort_code(item[0]) - .with_description(item[1]) - .with_status(expected_status) - for item in expected_cohort_group_and_description - ] - ) - ) - ) - ), - test_comment, - ) - - -def test_cohort_groups_and_their_descriptions_when_best_status_is_not_eligible( - faker: Faker, -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=[]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.Rsv75RollingCohortFactory.build(), - rule_builder.Rsv75to79CohortFactory.build(), - rule_builder.RsvPretendClinicalCohortFactory.build(), - ], - iteration_rules=[rule_builder.PostcodeSuppressionRuleFactory.build()], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(Status.not_eligible) - .and_cohort_results( - contains_exactly( - is_cohort_result() - .with_cohort_code("rsv_age_range") - .with_description("rsv_age_range negative description"), - is_cohort_result() - .with_cohort_code("rsv_clinical_cohort") - .with_description("rsv_clinical_cohort negative description"), - ) - ) - ) - ), - ) - - -@pytest.mark.parametrize( - ("person_cohorts", "expected_cohort_group_and_description_and_s_rule_names", "test_comment"), - [ - ( - ["rsv_75_rolling"], - [("rsv_age_range", "rsv_age_range positive description", ["Excluded postcode In SW19"])], - "rsv_75_rolling is not-actionable, others are not-eligible", - ), - ( - ["rsv_75_rolling", "rsv_75to79_2024"], - [ - ( - "rsv_age_range", - "rsv_age_range positive description", - ["Excluded postcode In SW19", "Excluded postcode In SW19"], - ) - ], - "rsv_75_rolling, rsv_75to79_2024 is not-actionable, rsv_pretend_clinical_cohort are not-eligible", - ), - ( - ["rsv_75_rolling", "rsv_75to79_2024", "rsv_pretend_clinical_cohort"], - [ - ( - "rsv_age_range", - "rsv_age_range positive description", - ["Excluded postcode In SW19", "Excluded postcode In SW19"], - ), - ("rsv_clinical_cohort", "rsv_clinical_cohort positive description", ["Excluded postcode In SW19"]), - ], - "all are not-actionable", - ), - ], -) -def test_cohort_groups_and_their_descriptions_and_the_collection_of_s_rules_when_best_status_is_not_actionable( - person_cohorts: list[str], - expected_cohort_group_and_description_and_s_rule_names: list[tuple[str, str, list[str]]], - test_comment: str, - faker: Faker, -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=person_cohorts, postcode="SW19") - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.Rsv75RollingCohortFactory.build(), - rule_builder.Rsv75to79CohortFactory.build(), - rule_builder.RsvPretendClinicalCohortFactory.build(), - ], - iteration_rules=[rule_builder.PostcodeSuppressionRuleFactory.build()], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(Status.not_actionable) - .and_cohort_results( - contains_exactly( - *[ - is_cohort_result() - .with_cohort_code(item[0]) - .and_description(item[1]) - .and_reasons( - contains_exactly(*[is_reason().with_rule_name(rule_name) for rule_name in item[2]]) - ) - for item in expected_cohort_group_and_description_and_s_rule_names - ] - ) - ), - ) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("person_cohorts", "expected_cohort_group_and_description", "test_comment"), - [ - ( - ["rsv_75_rolling"], - [("rsv_age_range", "rsv_age_range positive description")], - "rsv_75_rolling is actionable, others are not-eligible", - ), - ( - ["rsv_75_rolling", "rsv_75to79_2024"], - [("rsv_age_range", "rsv_age_range positive description")], - "rsv_75_rolling, rsv_75to79_2024 is actionable, rsv_pretend_clinical_cohort are not-eligible", - ), - ( - ["rsv_75_rolling", "rsv_75to79_2024", "rsv_pretend_clinical_cohort"], - [ - ("rsv_age_range", "rsv_age_range positive description"), - ("rsv_clinical_cohort", "rsv_clinical_cohort positive description"), - ], - "all are actionable", - ), - ], -) -def test_cohort_group_and_descriptions_when_best_status_is_actionable( - person_cohorts: list[str], - expected_cohort_group_and_description: list[tuple[str, str]], - test_comment: str, - faker: Faker, -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=person_cohorts, postcode="hp") - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.Rsv75RollingCohortFactory.build(), - rule_builder.Rsv75to79CohortFactory.build(), - rule_builder.RsvPretendClinicalCohortFactory.build(), - ], - iteration_rules=[rule_builder.PostcodeSuppressionRuleFactory.build()], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(Status.actionable) - .and_cohort_results( - contains_exactly( - *[ - is_cohort_result().with_cohort_code(item[0]).with_description(item[1]) - for item in expected_cohort_group_and_description - ] - ) - ) - ) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("person_rows", "expected_description", "test_comment"), - [ - ( - person_rows_builder(nhs_number="123", cohorts=[]), - "rsv_age_range negative description 1", - "status - not eligible", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling", "rsv_75to79_2024"], postcode="SW19"), - "rsv_age_range positive description 1", - "status - not actionable", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling", "rsv_75to79_2024"], postcode="hp"), - "rsv_age_range positive description 1", - "status - actionable", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75to79_2024"], postcode="hp"), - "rsv_age_range positive description 2", - "rsv_75to79_2024 - actionable and rsv_75_rolling is not eligible", - ), - ], -) -def test_cohort_group_descriptions_are_selected_based_on_priority_when_cohorts_have_different_non_empty_descriptions( - person_rows: list[dict[str, Any]], expected_description: str, test_comment: str -): - # Given - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.Rsv75to79CohortFactory.build( - positive_description=rules.Description("rsv_age_range positive description 2"), - negative_description=rules.Description("rsv_age_range negative description 2"), - priority=2, - ), - rule_builder.Rsv75RollingCohortFactory.build( - positive_description=rules.Description("rsv_age_range positive description 1"), - negative_description=rules.Description("rsv_age_range negative description 1"), - priority=1, - ), - ], - iteration_rules=[rule_builder.PostcodeSuppressionRuleFactory.build()], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_cohort_results( - contains_exactly( - is_cohort_result().with_cohort_code("rsv_age_range").with_description(expected_description) - ) - ) - ) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("person_rows", "iteration_cohorts", "expected_cohort_group_and_description", "expected_status", "test_comment"), - [ - ( - person_rows_builder("123", postcode="SW19", cohorts=[], de=False), - [rule_builder.Rsv75to79CohortFactory.build(negative_description=None, priority=2)], - [("rsv_age_range", "")], - Status.not_eligible, - "if group has one cohort, with no description, expect no description", - ), - ( - person_rows_builder("123", postcode="SW19", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=False), - [rule_builder.Rsv75to79CohortFactory.build(negative_description=None, priority=2)], - [("rsv_age_range", "")], - Status.not_eligible, - "if group has one cohort, with no description, expect no description", - ), - ( - person_rows_builder("123", postcode="HP1", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=True), - [rule_builder.Rsv75to79CohortFactory.build(positive_description=None, priority=2)], - [("rsv_age_range", "")], - Status.not_actionable, - "if group has one cohort, with no description, expect no description", - ), - ( - person_rows_builder("123", postcode="HP1", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=False), - [rule_builder.Rsv75to79CohortFactory.build(positive_description=None, priority=2)], - [("rsv_age_range", "")], - Status.actionable, - "if group has one cohort, with no description, expect no description", - ), - ( - person_rows_builder("123", postcode="SW19", cohorts=[], de=False), - [ - rule_builder.Rsv75to79CohortFactory.build(priority=2, negative_description=None), - rule_builder.Rsv75RollingCohortFactory.build(priority=3, negative_description="rsv age range -ve 1"), - rule_builder.Rsv75RollingCohortFactory.build( - cohort_label="rsv_75_rolling_2", priority=4, negative_description="rsv age range -ve 2" - ), - ], - [("rsv_age_range", "rsv age range -ve 1")], - Status.not_eligible, - "if group has more than one cohort, at least one has description, expect first non empty description", - ), - ( - person_rows_builder("123", postcode="SW19", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=False), - [ - rule_builder.Rsv75to79CohortFactory.build(priority=2, negative_description=None), - rule_builder.Rsv75RollingCohortFactory.build(priority=3, negative_description="rsv age range -ve 1"), - rule_builder.Rsv75RollingCohortFactory.build( - cohort_label="rsv_75_rolling_2", priority=4, negative_description="rsv age range -ve 2" - ), - ], - [("rsv_age_range", "rsv age range -ve 1")], - Status.not_eligible, - "if group has more than one cohort, at least one has description, expect first non empty description", - ), - ( - person_rows_builder("123", postcode="HP1", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=True), - [ - rule_builder.Rsv75to79CohortFactory.build(priority=2, positive_description=None), - rule_builder.Rsv75RollingCohortFactory.build(priority=3, positive_description="rsv age range +ve 1"), - rule_builder.Rsv75RollingCohortFactory.build( - cohort_label="rsv_75_rolling_2", priority=4, positive_description="rsv age range +ve 2" - ), - ], - [("rsv_age_range", "rsv age range +ve 1")], - Status.not_actionable, - "if group has more than one cohort, at least one has description, expect first non empty description", - ), - ( - person_rows_builder("123", postcode="HP1", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=False), - [ - rule_builder.Rsv75to79CohortFactory.build(priority=2, positive_description=None), - rule_builder.Rsv75RollingCohortFactory.build(priority=3, positive_description="rsv age range +ve 1"), - rule_builder.Rsv75RollingCohortFactory.build( - cohort_label="rsv_75_rolling_2", priority=4, positive_description="rsv age range +ve 2" - ), - ], - [("rsv_age_range", "rsv age range +ve 1")], - Status.actionable, - "if group has more than one cohort, at least one has description, expect first non empty description", - ), - ], -) -def test_cohort_group_descriptions_pick_first_non_empty_if_available( - person_rows: list[dict[str, Any]], - iteration_cohorts: list[rules.IterationCohort], - expected_cohort_group_and_description: list[tuple[str, str]], - expected_status: Status, - test_comment: str, -): - # Given - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=iteration_cohorts, - iteration_rules=[ - rule_builder.PostcodeSuppressionRuleFactory.build(type=rules.RuleType.filter), - rule_builder.DetainedEstateSuppressionRuleFactory.build(), - ], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.evaluate_eligibility() - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(expected_status) - .and_cohort_results( - contains_exactly( - *[ - is_cohort_result() - .with_cohort_code(item[0]) - .with_description(item[1]) - .with_status(expected_status) - for item in expected_cohort_group_and_description - ] - ) - ) - ) - ), - test_comment, - ) From 1326540ee6a8feb4d81adb9146b2b1fad593dc19 Mon Sep 17 00:00:00 2001 From: ivano mannella Date: Tue, 24 Jun 2025 16:16:59 +0100 Subject: [PATCH 02/10] +generate DynamoDB data generate_dynamo_data.py --- tests/e2e/data/generate_dynamo_data.py | 75 +++++++++++--------------- 1 file changed, 31 insertions(+), 44 deletions(-) diff --git a/tests/e2e/data/generate_dynamo_data.py b/tests/e2e/data/generate_dynamo_data.py index cde3fa900..d4a2dac19 100644 --- a/tests/e2e/data/generate_dynamo_data.py +++ b/tests/e2e/data/generate_dynamo_data.py @@ -2,38 +2,35 @@ import logging import os import re -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any -# Constants OUTPUT_ROOT = "out" DATE_FORMAT = "%Y%m%d" VAR_PATTERN = re.compile(r"<<([^<>]+)>>") +REQUIRED_TOKEN_PARTS = 3 -# Configure logging +logger = logging.getLogger(__name__) logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(levelname)s - %(message)s") class DateVariableResolver: - """Handles the logic for parsing and evaluating date-based variables.""" - - def __init__(self, today: datetime = None): - self.today = today or datetime.today() + def __init__(self, today: datetime | None = None): + self.today = today or datetime.now(tz=timezone.UTC) def resolve(self, token: str) -> str: - logging.debug(f"Resolving variable: {token}") + logger.debug("Resolving variable: %s", token) parts = token.split("_") - if len(parts) < 3 or parts[0].upper() != "DATE": - raise ValueError(f"Unsupported variable format: {token}") - + if len(parts) < REQUIRED_TOKEN_PARTS or parts[0].upper() != "DATE": + msg = f"Unsupported variable format: {token}" + raise ValueError(msg) _, unit, value = parts[0], parts[1].lower(), parts[2] - try: offset = int(value) - except ValueError: - raise ValueError(f"Invalid offset value: {value}") - + except ValueError as err: + msg = f"Invalid offset value: {value}" + raise ValueError(msg) from err if unit == "day": return (self.today + timedelta(days=offset)).strftime(DATE_FORMAT) if unit == "week": @@ -44,15 +41,12 @@ def resolve(self, token: str) -> str: try: birth_date = self.today.replace(year=self.today.year - offset) except ValueError: - # Handle February 29th birth_date = self.today.replace(month=2, day=28, year=self.today.year - offset) return birth_date.strftime(DATE_FORMAT) - raise ValueError(f"Unsupported calculation unit: {unit}") - + msg = f"Unsupported calculation unit: {unit}" + raise ValueError(msg) class JsonTestDataProcessor: - """Processes JSON test files by resolving placeholders in 'data' arrays.""" - def __init__(self, input_dir: Path, output_dir: Path, resolver: DateVariableResolver): self.input_dir = input_dir self.output_dir = output_dir @@ -71,56 +65,49 @@ def _replace_token(self, match: re.Match) -> str: token = match.group(1) try: return self.resolver.resolve(token) - except Exception as e: - logging.warning(f"Failed to resolve variable {token}: {e}") + except ValueError: + logger.warning("Failed to resolve variable: %s", token) return match.group(0) def process_file(self, file_path: Path): - logging.info(f"Processing file: {file_path}") + logger.info("Processing file: %s", file_path) try: - with open(file_path) as f: + with file_path.open() as f: content = json.load(f) - except Exception as e: - logging.exception(f"Failed to read {file_path}: {e}") + except Exception: + logger.exception("Failed to read file: %s", file_path) return - try: resolved = self.resolve_placeholders(content) - except Exception as e: - logging.exception(f"Failed to resolve placeholders: {e}") + except Exception: + logger.exception("Failed to resolve placeholders in file: %s", file_path) return - if "data" not in resolved: - logging.error(f"Missing 'data' key in {file_path}") + logger.error("Missing 'data' key in file: %s", file_path) return - relative_path = file_path.relative_to(self.input_dir) output_path = self.output_dir / relative_path output_path.parent.mkdir(parents=True, exist_ok=True) - try: - with open(output_path, "w") as f: + with output_path.open("w") as f: json.dump(resolved["data"], f, indent=2) - logging.info(f"Written resolved file: {output_path}") - except Exception as e: - logging.exception(f"Failed to write output: {e}") - + logger.info("Written resolved file: %s", output_path) + except Exception: + logger.exception("Failed to write output to: %s", output_path) def main(): input_dir = Path() output_dir = Path(OUTPUT_ROOT) resolver = DateVariableResolver() - processor = JsonTestDataProcessor(input_dir, output_dir, resolver) - - logging.info(f"Scanning for JSON files in {input_dir}") + logger.info("Scanning for JSON files in directory: %s", input_dir) for root, _, files in os.walk(input_dir): for file in files: + file_path = Path(root) / file if file.endswith(".json"): - processor.process_file(Path(root) / file) + processor.process_file(file_path) else: - logging.debug(f"Skipping non-JSON file: {file}") - + logger.debug("Skipping non-JSON file: %s", file) if __name__ == "__main__": main() From 96bd0296a9ee5014fc7558f031bc1fe62b97f89b Mon Sep 17 00:00:00 2001 From: ivano mannella Date: Tue, 24 Jun 2025 16:27:04 +0100 Subject: [PATCH 03/10] +generate DynamoDB data generate_dynamo_data.py --- tests/e2e/data/generate_dynamo_data.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/data/generate_dynamo_data.py b/tests/e2e/data/generate_dynamo_data.py index d4a2dac19..ea4e8f599 100644 --- a/tests/e2e/data/generate_dynamo_data.py +++ b/tests/e2e/data/generate_dynamo_data.py @@ -46,6 +46,7 @@ def resolve(self, token: str) -> str: msg = f"Unsupported calculation unit: {unit}" raise ValueError(msg) + class JsonTestDataProcessor: def __init__(self, input_dir: Path, output_dir: Path, resolver: DateVariableResolver): self.input_dir = input_dir @@ -95,6 +96,7 @@ def process_file(self, file_path: Path): except Exception: logger.exception("Failed to write output to: %s", output_path) + def main(): input_dir = Path() output_dir = Path(OUTPUT_ROOT) @@ -109,5 +111,6 @@ def main(): else: logger.debug("Skipping non-JSON file: %s", file) + if __name__ == "__main__": main() From fa1dac4721dfefc53d12e4fb157cd8f8c2175b75 Mon Sep 17 00:00:00 2001 From: ivano mannella Date: Thu, 3 Jul 2025 14:52:40 +0100 Subject: [PATCH 04/10] integration with gateway api --- .gitignore | 2 +- .../{ => in}/dynamoDB/AUTO_RSV_SB_001.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_002.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_003.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_004.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_005.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_006.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_007.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_008.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_009.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_010.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_011.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_012.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_013.json | 0 .../{ => in}/dynamoDB/AUTO_RSV_SB_014.json | 0 .../e2e/data/{ => in}/dynamoDB/test_data.json | 0 tests/e2e/features/conftest.py | 32 --- .../eligibility_check.feature | 51 +--- tests/e2e/features/environment.py | 168 ++++--------- .../features/steps/eligibility_check_steps.py | 227 +++++++++++++----- .../steps/helpers/dynamodb_data_generator.py | 92 +++++++ .../steps/helpers/dynamodb_data_uploader.py | 56 +++++ tests/e2e/pytest.ini | 12 - tests/e2e/utils/api_client.py | 63 ----- tests/e2e/utils/config.py | 6 +- 25 files changed, 374 insertions(+), 335 deletions(-) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_001.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_002.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_003.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_004.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_005.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_006.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_007.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_008.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_009.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_010.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_011.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_012.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_013.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/AUTO_RSV_SB_014.json (100%) rename tests/e2e/data/{ => in}/dynamoDB/test_data.json (100%) delete mode 100644 tests/e2e/features/conftest.py create mode 100644 tests/e2e/features/steps/helpers/dynamodb_data_generator.py create mode 100644 tests/e2e/features/steps/helpers/dynamodb_data_uploader.py delete mode 100644 tests/e2e/pytest.ini delete mode 100644 tests/e2e/utils/api_client.py diff --git a/.gitignore b/.gitignore index b863bfb62..6f0c38e9f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,5 @@ sandbox/specification/* /sandbox/specification/* /integration-test-results.xml /specification/tmp/* -/tests/e2e/data/out* +/tests/e2e/data/out/* /tests/e2e/reports/* diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_001.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_001.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_001.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_001.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_002.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_002.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_002.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_002.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_003.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_003.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_003.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_003.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_004.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_004.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_004.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_004.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_005.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_005.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_005.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_005.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_006.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_006.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_006.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_006.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_007.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_007.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_007.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_007.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_008.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_008.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_008.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_008.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_009.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_009.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_009.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_009.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_010.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_010.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_010.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_010.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_011.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_011.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_011.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_011.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_012.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_012.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_012.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_012.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_013.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_013.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_013.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_013.json diff --git a/tests/e2e/data/dynamoDB/AUTO_RSV_SB_014.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_014.json similarity index 100% rename from tests/e2e/data/dynamoDB/AUTO_RSV_SB_014.json rename to tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_014.json diff --git a/tests/e2e/data/dynamoDB/test_data.json b/tests/e2e/data/in/dynamoDB/test_data.json similarity index 100% rename from tests/e2e/data/dynamoDB/test_data.json rename to tests/e2e/data/in/dynamoDB/test_data.json diff --git a/tests/e2e/features/conftest.py b/tests/e2e/features/conftest.py deleted file mode 100644 index d309566c5..000000000 --- a/tests/e2e/features/conftest.py +++ /dev/null @@ -1,32 +0,0 @@ -import os - -import pytest -import requests -from dotenv import load_dotenv - -# Load environment variables -load_dotenv() - -# Constants -BASE_URL = os.getenv("BASE_URL", "https://sandbox.api.service.nhs.uk/eligibility-signposting-api") -API_KEY = os.getenv("API_KEY", "") -VALID_NHS_NUMBER = os.getenv("VALID_NHS_NUMBER", "50000000004") -HTTP_STATUS_SERVER_ERROR = 500 - - -@pytest.fixture(scope="session", autouse=True) -def check_api_accessibility(): - """Check if the API is accessible before running tests.""" - try: - response = requests.get( - f"{BASE_URL}/eligibility-check", - params={"patient": VALID_NHS_NUMBER}, - headers={"apikey": API_KEY, "Accept": "application/json"}, - timeout=5, - ) - # If we get a 4xx response, the API is accessible but our request is invalid - # If we get a 5xx response, the API is having issues - if response.status_code >= HTTP_STATUS_SERVER_ERROR: - pytest.skip("API is returning server errors") - except (requests.RequestException, requests.Timeout): - pytest.skip("API is not accessible") diff --git a/tests/e2e/features/eligibility_check/eligibility_check.feature b/tests/e2e/features/eligibility_check/eligibility_check.feature index e10353c07..037e621a0 100644 --- a/tests/e2e/features/eligibility_check/eligibility_check.feature +++ b/tests/e2e/features/eligibility_check/eligibility_check.feature @@ -1,44 +1,19 @@ - -Feature: Eligibility Check API - As a consumer of the Eligibility Check API - I want to verify the endpoint's response for various NHS numbers and parameters - So that I can ensure the API behaves as expected for all supported scenarios +Feature: Full mTLS integration with real Eligibility API Background: - Given the Eligibility Check API base URL is configured - - Scenario Outline: Successful eligibility check returns 2xx and valid response - Given I have the NHS number "" - When I request an eligibility check for the NHS number - Then the response status code should be 2xx - And the response content type should be application/json - And the response should have a JSON body - And the response should match the eligibility check schema - - Examples: - | nhs_number | - | 50000000001 | - | 50000000004 | - | 9876543210 | - - Scenario Outline: Eligibility check with invalid or missing NHS number returns error - Given I have the NHS number "" - When I request an eligibility check for the NHS number - Then the response status code should be 4xx or 404 - - Examples: - | nhs_number | - | 00000000000 | - | | - | patient=ABC | + Given AWS credentials are loaded from the environment + And mTLS certificates are downloaded and available in the out/ directory - Scenario Outline: Eligibility check with custom Accept header + Scenario Outline: Eligibility check returns 2xx response for NHS number queries + Given I generate the test data files + And I upload the test data files to DynamoDB Given I have the NHS number "" - And I set the Accept header to "" - When I request an eligibility check for the NHS number - Then the response content type should contain "" + When I query the eligibility API + Then the response status code should be 200 + And the response should be valid JSON + Then I clean up DynamoDB test data Examples: - | nhs_number | accept_header | expected_content_type | - | 9876543210 | application/json | application/json | - | 9876543210 | application/json | application/json | + | nhs_number | + | 5000000001 | + | 5000000004 | diff --git a/tests/e2e/features/environment.py b/tests/e2e/features/environment.py index cfb2becac..b6c48c988 100644 --- a/tests/e2e/features/environment.py +++ b/tests/e2e/features/environment.py @@ -1,4 +1,3 @@ -import json import logging import os from pathlib import Path @@ -12,89 +11,40 @@ def _load_environment_variables(context): - load_dotenv(dotenv_path=".env") - context.base_url = os.getenv("BASE_URL") - context.api_key = os.getenv("API_KEY") + try: + load_dotenv(dotenv_path=".env") + logger.info("Loaded environment variables from .env file") + except OSError as e: + logger.warning("Failed to load .env file: %s", e) + + context.base_url = os.getenv("BASE_URL", "http://localhost:8000") + context.api_key = os.getenv("API_KEY", "test-api-key") context.valid_nhs_number = os.getenv("VALID_NHS_NUMBER", "50000000004") context.aws_region = os.getenv("AWS_REGION", "eu-west-2") - context.inserted_items = [] + context.aws_access_key_id = os.getenv("AWS_ACCESS_KEY_ID") + context.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY") + context.aws_session_token = os.getenv("AWS_SESSION_TOKEN") context.abort_on_aws_error = os.getenv("ABORT_ON_AWS_FAILURE", "false").lower() == "true" context.keep_seed = os.getenv("KEEP_SEED", "false").lower() == "true" + context.dynamodb_table_name = os.getenv("DYNAMODB_TABLE_NAME", "eligibilty_data_store") context.s3_bucket = os.getenv("S3_BUCKET_NAME") context.s3_upload_dir = os.getenv("S3_UPLOAD_DIR", "") context.s3_data_path = Path(os.getenv("S3_JSON_SOURCE_DIR", "./data/s3")).resolve() - context.dynamodb_table_name = os.getenv("DYNAMODB_TABLE_NAME", "eligibilty_data_store") - context.dynamo_data_path = Path(os.getenv("DYNAMO_JSON_SOURCE_DIR", "./data/out/dynamoDB")).resolve() + context.api_gateway_url = os.getenv("API_GATEWAY_URL", "https://test.eligibility-signposting-api.nhs.uk") + logger.info("ABORT_ON_AWS_FAILURE=%s", context.abort_on_aws_error) logger.info("KEEP_SEED=%s", context.keep_seed) - - -def _connect_to_dynamodb(context): - try: - context.dynamodb = boto3.resource("dynamodb", region_name=context.aws_region) - context.table = context.dynamodb.Table(context.dynamodb_table_name) - _ = context.table.table_status - except (boto3.exceptions.Boto3Error, BotoCoreError): - logger.exception("DynamoDB not accessible") - return False - else: - logger.info("Connected to DynamoDB table: %s", context.dynamodb_table_name) - return True - - -def _get_dynamo_seed_files(context): - if not context.dynamo_data_path.exists() or not context.dynamo_data_path.is_dir(): - logger.error("Seed directory not found: %s", context.dynamo_data_path) - return [] - return list(context.dynamo_data_path.glob("*.json")) - - -def _load_seed_file(file_path: Path): - try: - with file_path.open() as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - logger.exception("Failed to load seed file: %s", file_path) - return [] - - -def _insert_dynamodb_items(context, items): - for item in items: - try: - context.table.put_item(Item=item) - context.inserted_items.append(item) - except (boto3.exceptions.Boto3Error, BotoCoreError): - logger.exception("Failed to insert item %s", item.get("PK", "")) - - -def _setup_dynamodb(context): - if not _connect_to_dynamodb(context): - if context.abort_on_aws_error: - context.abort_all = True - return False - json_files = _get_dynamo_seed_files(context) - if not json_files: - logger.error("No JSON files found in the directory: %s", context.dynamo_data_path) - if context.abort_on_aws_error: - context.abort_all = True - return False - logger.info("Found %d JSON files to insert into DynamoDB", len(json_files)) - for file_path in json_files: - items = _load_seed_file(file_path) - if not items: - if context.abort_on_aws_error: - context.abort_all = True - continue - logger.info("Inserting %d items from %s...", len(items), file_path.name) - _insert_dynamodb_items(context, items) - logger.info("Inserted %d items from %d files", len(context.inserted_items), len(json_files)) - return True + logger.info("BASE_URL: %s", context.base_url) + logger.info("AWS_REGION: %s", context.aws_region) + logger.info("DYNAMODB_TABLE: %s", context.dynamodb_table_name) + logger.info("S3_BUCKET: %s", context.s3_bucket) def _setup_s3(context): if not context.s3_bucket: logger.info("Skipping S3 upload โ€” no S3_BUCKET_NAME set.") return True + logger.info( "Uploading JSON files from %s to S3 bucket: %s/%s", context.s3_data_path, @@ -106,8 +56,8 @@ def _setup_s3(context): if not context.s3_data_path.exists(): logger.error("S3 source directory not found: %s", context.s3_data_path) return False + json_files = list(context.s3_data_path.glob("*.json")) - upload_success = True for file_path in json_files: key = f"{context.s3_upload_dir}/{file_path.name}" if context.s3_upload_dir else file_path.name try: @@ -115,75 +65,53 @@ def _setup_s3(context): logger.info("Uploaded %s to s3://%s/%s", file_path.name, context.s3_bucket, key) except (boto3.exceptions.Boto3Error, BotoCoreError): logger.exception("Failed to upload %s", file_path.name) - upload_success = False except (boto3.exceptions.Boto3Error, BotoCoreError): logger.exception("S3 upload setup failed") if context.abort_on_aws_error: context.abort_all = True return False - else: - return upload_success + return True def before_all(context): logger.info("Loading .env and initializing AWS fixtures...") _load_environment_variables(context) - _setup_dynamodb(context) - _setup_s3(context) + + context.aws_available = True + try: + logger.info("Setting up S3 (optional)...") + _setup_s3(context) + except OSError as e: + logger.warning("AWS setup failed: %s", e) + context.aws_available = False def before_scenario(context, scenario): if getattr(context, "abort_all", False): + logger.warning("Skipping scenario '%s' due to setup failure", scenario.name) scenario.skip("Skipping scenario due to setup failure") - if "requires_dynamodb" in scenario.tags and not context.inserted_items: - scenario.skip("Skipping due to missing seeded DynamoDB data") - - -def _cleanup_dynamodb(context): - if not context.inserted_items: - logger.info("No items were inserted โ€” skipping DynamoDB cleanup.") return - logger.info("Cleaning up seeded items from DynamoDB...") - delete_count = 0 - for item in context.inserted_items: - nhs_number = item.get("NHS_NUMBER") - attribute_type = item.get("ATTRIBUTE_TYPE") - if nhs_number and attribute_type: - try: - context.table.delete_item(Key={"NHS_NUMBER": nhs_number, "ATTRIBUTE_TYPE": attribute_type}) - delete_count += 1 - except (boto3.exceptions.Boto3Error, BotoCoreError): - logger.exception("Failed to delete item (%s, %s)", nhs_number, attribute_type) - else: - logger.error("Cannot delete item โ€” missing NHS_NUMBER or ATTRIBUTE_TYPE: %s", item) - logger.info("Deleted %d/%d DynamoDB items", delete_count, len(context.inserted_items)) - -def _cleanup_s3(context): - if not (context.s3_bucket and context.s3_data_path.exists()): - logger.info("Skipping S3 cleanup โ€” no bucket or source directory not found.") - return - logger.info("Cleaning up uploaded files from S3...") - try: - s3_client = boto3.client("s3", region_name=context.aws_region) - json_files = list(context.s3_data_path.glob("*.json")) - deleted_files = 0 - for file_path in json_files: - key = f"{context.s3_upload_dir}/{file_path.name}" if context.s3_upload_dir else file_path.name - try: - s3_client.delete_object(Bucket=context.s3_bucket, Key=key) - logger.info("Deleted s3://%s/%s", context.s3_bucket, key) - deleted_files += 1 - except (boto3.exceptions.Boto3Error, BotoCoreError): - logger.exception("Failed to delete s3://%s/%s", context.s3_bucket, key) - logger.info("Deleted %d/%d files from S3", deleted_files, len(json_files)) - except (boto3.exceptions.Boto3Error, BotoCoreError): - logger.exception("S3 cleanup failed") + logger.info("Running scenario: %s", scenario.name) def after_all(context): - if getattr(context, "keep_seed", False): + if context.keep_seed: logger.info("KEEP_SEED=true โ€” skipping cleanup.") return - _cleanup_dynamodb(context) - _cleanup_s3(context) + + # Cleanup S3 if necessary (optional) + if context.s3_bucket and context.s3_data_path.exists(): + logger.info("Cleaning up uploaded files from S3...") + try: + s3_client = boto3.client("s3", region_name=context.aws_region) + json_files = list(context.s3_data_path.glob("*.json")) + for file_path in json_files: + key = f"{context.s3_upload_dir}/{file_path.name}" if context.s3_upload_dir else file_path.name + try: + s3_client.delete_object(Bucket=context.s3_bucket, Key=key) + logger.info("Deleted s3://%s/%s", context.s3_bucket, key) + except (boto3.exceptions.Boto3Error, BotoCoreError): + logger.exception("Failed to delete s3://%s/%s", context.s3_bucket, key) + except Exception: + logger.exception("S3 cleanup failed") diff --git a/tests/e2e/features/steps/eligibility_check_steps.py b/tests/e2e/features/steps/eligibility_check_steps.py index 3a0640dab..42a727f0d 100644 --- a/tests/e2e/features/steps/eligibility_check_steps.py +++ b/tests/e2e/features/steps/eligibility_check_steps.py @@ -1,20 +1,116 @@ -import jsonschema -import pytest +import logging +import os +from pathlib import Path + +import boto3 import requests from behave import given, then, when -from utils.config import API_KEY, BASE_URL, ELIGIBILITY_CHECK_SCHEMA +from botocore.exceptions import ClientError +from helpers.dynamodb_data_generator import DateVariableResolver, JsonTestDataProcessor +from helpers.dynamodb_data_uploader import DynamoDBDataUploader + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +@given("AWS credentials are loaded from the environment") +def step_impl_load_aws_credentials(context): + """Load AWS credentials from environment variables.""" + context.aws_region = os.getenv("AWS_REGION", "eu-west-2") + context.aws_access_key_id = os.getenv("AWS_ACCESS_KEY_ID") + context.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY") + context.aws_session_token = os.getenv("AWS_SESSION_TOKEN") + context.dynamodb_table_name = os.getenv("DYNAMODB_TABLE_NAME", "eligibilty_data_store") + + missing = [] + if not context.aws_region: + missing.append("AWS_REGION") + if not context.aws_access_key_id: + missing.append("AWS_ACCESS_KEY_ID") + if not context.aws_secret_access_key: + missing.append("AWS_SECRET_ACCESS_KEY") + + assert not missing, f"Missing required environment variables: {', '.join(missing)}" + + logger.info("AWS credentials loaded successfully") + + +@given("mTLS certificates are downloaded and available in the out/ directory") +def step_impl_download_certificates(context): + """Retrieve mTLS certs from SSM and write them to local files.""" + cert_param_map = { + "private_key": "/test/mtls/api_private_key_cert", + "client_cert": "/test/mtls/api_client_cert", + "ca_cert": "/test/mtls/api_ca_cert", + } + + cert_dir = Path("./data/out") + cert_dir.mkdir(parents=True, exist_ok=True) + + ssm = boto3.client( + "ssm", + region_name=context.aws_region, + aws_access_key_id=context.aws_access_key_id, + aws_secret_access_key=context.aws_secret_access_key, + aws_session_token=context.aws_session_token, + ) -# HTTP Status Code Constants -HTTP_STATUS_OK = 200 -HTTP_STATUS_BAD_REQUEST = 400 -HTTP_STATUS_NOT_FOUND = 404 -HTTP_STATUS_SERVER_ERROR = 500 + context.cert_paths = {} + for cert_type, param_name in cert_param_map.items(): + cert_path = cert_dir / f"{cert_type}.pem" + try: + logger.info("Retrieving SSM parameter: %s", param_name) + response = ssm.get_parameter(Name=param_name, WithDecryption=True) + with cert_path.open("w") as f: + f.write(response["Parameter"]["Value"]) + context.cert_paths[cert_type] = str(cert_path) + except ClientError as e: + msg = f"Failed to retrieve parameter {param_name}: {e}" + raise RuntimeError(msg) from e + + logger.info("mTLS certificates written to local files") + + +@given("I generate the test data files") +def step_impl_generate_data(_context): + """Generate test data files with resolved <> placeholders.""" + input_dir = Path("data/in/dynamoDB").resolve() + output_dir = Path("data/out/dynamoDB").resolve() + + resolver = DateVariableResolver() + processor = JsonTestDataProcessor(input_dir, output_dir, resolver) + + if not input_dir.exists(): + logger.error("Input directory does not exist: %s", input_dir) + return + + logger.info("Scanning for JSON files in directory: %s", input_dir) + count = 0 + for root, _, files in os.walk(input_dir): + for file in files: + if file.endswith(".json"): + full_path = Path(root) / file + processor.process_file(full_path) + count += 1 + + if count == 0: + logger.warning("No .json files found in %s", input_dir) + else: + logger.info("Processed %d test data file(s).", count) -@given("the Eligibility Check API base URL is configured") -def step_impl_base_url(context): - context.base_url = BASE_URL - context.headers = {"apikey": API_KEY} +@given("I upload the test data files to DynamoDB") +def step_impl_upload_data(context): + """Upload generated test data to DynamoDB.""" + uploader = DynamoDBDataUploader( + aws_region=context.aws_region, + access_key=context.aws_access_key_id, + secret_key=context.aws_secret_access_key, + session_token=context.aws_session_token, + ) + inserted = uploader.upload_files_from_path(table_name=context.dynamodb_table_name, path=Path("data/out/dynamoDB")) + assert inserted > 0, "No data uploaded to DynamoDB" + logger.info("Uploaded %d items to DynamoDB", inserted) @given('I have the NHS number "{nhs_number}"') @@ -22,64 +118,63 @@ def step_impl_nhs_number(context, nhs_number): context.nhs_number = nhs_number -@given('I have the NHS number ""') -def step_impl_empty_nhs_number(context): - context.nhs_number = "" - - -@given('I set the Accept header to "{accept_header}"') -def step_impl_accept_header(context, accept_header): - context.headers["Accept"] = accept_header - - -@when("I request an eligibility check for the NHS number") -def step_impl_request_eligibility_check(context): - # Use the correct endpoint: /patient-check/{nhs_number} - if context.nhs_number: - url = f"{context.base_url}/patient-check/{context.nhs_number}" - else: - url = f"{context.base_url}/patient-check/" - context.response = requests.get(url, headers=context.headers, timeout=10) - - -@then("the response status code should be 2xx") -def step_impl_status_code_2xx(context): - assert HTTP_STATUS_OK <= context.response.status_code < HTTP_STATUS_BAD_REQUEST, ( - f"Expected 2xx, got {context.response.status_code}" - ) +@then("I clean up DynamoDB test data") +def step_impl_cleanup_dynamo(context): + if hasattr(context, "dynamo_uploader"): + context.dynamo_uploader.delete_data() -@then("the response status code should be 4xx or 404") -def step_impl_status_code_4xx_or_404(context): - assert ( - HTTP_STATUS_BAD_REQUEST <= context.response.status_code < HTTP_STATUS_SERVER_ERROR - ) or context.response.status_code == HTTP_STATUS_NOT_FOUND, ( - f"Expected 4xx or 404, got {context.response.status_code}" - ) +@when("I query the eligibility API") +def step_impl_call_eligibility_api(context): + """Make mTLS call to Eligibility API using local certs and context NHS number.""" + if not hasattr(context, "nhs_number"): + msg = "NHS number not set in context." + raise AssertionError(msg) + if not hasattr(context, "cert_paths"): + msg = "mTLS certificate paths not present in context." + raise AssertionError(msg) -@then("the response content type should be application/json") -def step_impl_content_type_json(context): - assert "application/json" in context.response.headers.get("Content-Type", ""), ( - f"Content-Type is not application/json, got {context.response.headers.get('Content-Type', '')}" - ) - + api_url = f"https://test.eligibility-signposting-api.nhs.uk/patient-check/{context.nhs_number}" + cert = (context.cert_paths["client_cert"], context.cert_paths["private_key"]) + verify = False + headers = {"nhs-login-nhs-number": context.nhs_number} -@then("the response should have a JSON body") -def step_impl_has_json_body(context): + logger.info("Querying Eligibility API at %s", api_url) try: - context.response.json() - except (ValueError, TypeError) as e: - pytest.fail(f"Response does not have a JSON body: {e}") + response = requests.get( + api_url, cert=cert, verify=verify, timeout=30, headers=headers + ) + context.response = response + logger.info( + "Querying Eligibility API response %s - %d", + response.apparent_encoding, + response.status_code, + ) + except requests.exceptions.RequestException as e: + msg = f"API request failed: {e}" + raise RuntimeError(msg) from e + + +@then("the response status code should be {status_code:d}") +def step_impl_check_status_code(context, status_code): + """Assert response HTTP status code.""" + if not hasattr(context, "response"): + msg = "No HTTP response in context." + raise AssertionError(msg) + actual = context.response.status_code + assert actual == status_code, f"Expected status {status_code}, got {actual}" + + +@then("the response should be valid JSON") +def step_impl_validate_json(context): + """Assert that response content is valid JSON.""" + if not hasattr(context, "response"): + msg = "No HTTP response in context." + raise AssertionError(msg) - -@then("the response should match the eligibility check schema") -def step_impl_schema(context): - jsonschema.validate(instance=context.response.json(), schema=ELIGIBILITY_CHECK_SCHEMA) - - -@then('the response content type should contain "{expected_content_type}"') -def step_impl_content_type_contains(context, expected_content_type): - assert expected_content_type in context.response.headers.get("Content-Type", ""), ( - f"Content-Type does not contain {expected_content_type}, got {context.response.headers.get('Content-Type', '')}" - ) + try: + context.json_response = context.response.json() + except ValueError as e: + msg = f"Response is not valid JSON: {e}" + raise AssertionError(msg) from e diff --git a/tests/e2e/features/steps/helpers/dynamodb_data_generator.py b/tests/e2e/features/steps/helpers/dynamodb_data_generator.py new file mode 100644 index 000000000..6f5b3f034 --- /dev/null +++ b/tests/e2e/features/steps/helpers/dynamodb_data_generator.py @@ -0,0 +1,92 @@ +import json +import logging +import re +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +DATE_FORMAT = "%Y%m%d" +VAR_PATTERN = re.compile(r"<<([^<>]+)>>") +REQUIRED_TOKEN_PARTS = 3 +logger = logging.getLogger(__name__) + + +class DateVariableResolver: + def __init__(self, today: datetime | None = None): + self.today = today or datetime.now(tz=UTC) + + def resolve(self, token: str) -> str: + parts = token.split("_") + if len(parts) < REQUIRED_TOKEN_PARTS or parts[0].upper() != "DATE": + msg = f"Unsupported variable format: {token}" + raise ValueError(msg) + unit = parts[1].lower() + try: + offset = int(parts[2]) + except ValueError as e: + msg = f"Invalid offset value: {parts[2]}" + raise ValueError(msg) from e + if unit == "day": + return (self.today + timedelta(days=offset)).strftime(DATE_FORMAT) + if unit == "week": + return (self.today + timedelta(weeks=offset)).strftime(DATE_FORMAT) + if unit == "year": + return (self.today.replace(year=self.today.year + offset)).strftime(DATE_FORMAT) + if unit == "age": + try: + birth_date = self.today.replace(year=self.today.year - offset) + except ValueError: + birth_date = self.today.replace(month=2, day=28, year=self.today.year - offset) + return birth_date.strftime(DATE_FORMAT) + msg = f"Unsupported unit: {unit}" + raise ValueError(msg) + + +class JsonTestDataProcessor: + def __init__(self, input_dir: Path, output_dir: Path, resolver: DateVariableResolver): + self.input_dir = input_dir + self.output_dir = output_dir + self.resolver = resolver + + def resolve_placeholders(self, obj: Any) -> Any: + if isinstance(obj, dict): + return {k: self.resolve_placeholders(v) for k, v in obj.items()} + if isinstance(obj, list): + return [self.resolve_placeholders(item) for item in obj] + if isinstance(obj, str): + return VAR_PATTERN.sub(self._replace_token, obj) + return obj + + def _replace_token(self, match: re.Match) -> str: + token = match.group(1) + try: + return self.resolver.resolve(token) + except ValueError: + logger.warning("Failed to resolve variable: %s", token) + return match.group(0) + + def process_file(self, file_path: Path): + logger.info("Processing file: %s", file_path) + try: + with file_path.open() as f: + content = json.load(f) + except Exception: + logger.exception("Failed to read file: %s", file_path) + return + try: + resolved = self.resolve_placeholders(content) + except Exception: + logger.exception("Failed to resolve placeholders in file: %s", file_path) + return + if "data" not in resolved: + logger.error("Missing 'data' key in file: %s", file_path) + return + relative_path = file_path.relative_to(self.input_dir) + output_path = self.output_dir / relative_path + output_path.parent.mkdir(parents=True, exist_ok=True) + try: + with output_path.open("w") as f: + json.dump(resolved["data"], f, indent=2) + logger.info("Written resolved file: %s", output_path) + except Exception: + logger.exception("Failed to write output to: %s", output_path) diff --git a/tests/e2e/features/steps/helpers/dynamodb_data_uploader.py b/tests/e2e/features/steps/helpers/dynamodb_data_uploader.py new file mode 100644 index 000000000..704da2ed7 --- /dev/null +++ b/tests/e2e/features/steps/helpers/dynamodb_data_uploader.py @@ -0,0 +1,56 @@ +import json +import logging +from pathlib import Path + +import boto3 + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +class DynamoDBDataUploader: + def __init__(self, aws_region, access_key, secret_key, session_token=None): + self.dynamodb = boto3.resource( + "dynamodb", + region_name=aws_region, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + aws_session_token=session_token, + ) + + def upload_files_from_path(self, table_name: str, path: Path): + if not path.exists() or not path.is_dir(): + logger.error("Seed path not found: %s", path) + return 0 + + table = self.dynamodb.Table(table_name) + count = 0 + for file_path in path.glob("*.json"): + try: + with file_path.open() as f: + items = json.load(f) + if not isinstance(items, list): + logger.warning("Skipping non-list file: %s", file_path) + continue + for item in items: + table.put_item(Item=item) + count += 1 + logger.info("Inserted %d items from %s", len(items), file_path.name) + except Exception: + logger.exception("Failed to insert from file: %s", file_path) + return count + + def delete_data(self): + if not self.inserted_items: + logger.info("No items were inserted โ€” skipping cleanup.") + return + logger.info("Cleaning up seeded items from DynamoDB...") + for item in self.inserted_items: + nhs_number = item.get("NHS_NUMBER") + attribute_type = item.get("ATTRIBUTE_TYPE") + if nhs_number and attribute_type: + try: + self.table.delete_item(Key={"NHS_NUMBER": nhs_number, "ATTRIBUTE_TYPE": attribute_type}) + logger.info("Deleted item: %s - %s", nhs_number, attribute_type) + except Exception: + logger.exception("Failed to delete item: %s - %s", nhs_number, attribute_type) diff --git a/tests/e2e/pytest.ini b/tests/e2e/pytest.ini deleted file mode 100644 index e94f560cc..000000000 --- a/tests/e2e/pytest.ini +++ /dev/null @@ -1,12 +0,0 @@ -[pytest] -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* -markers = - smoke: marks tests as smoke tests - regression: marks tests as regression tests - eligibility: marks tests related to eligibility endpoints - signposting: marks tests related to signposting endpoints - nextactions: marks tests related to next actions endpoints - bdd: marks tests as BDD tests diff --git a/tests/e2e/utils/api_client.py b/tests/e2e/utils/api_client.py deleted file mode 100644 index 53c706168..000000000 --- a/tests/e2e/utils/api_client.py +++ /dev/null @@ -1,63 +0,0 @@ -"""API client module for making HTTP requests to the Eligibility Signposting API.""" - -import requests -from utils.config import API_KEY, BASE_URL - -# Default timeout for API requests in seconds -DEFAULT_TIMEOUT = 10 - - -class ApiClient: - """API client for making HTTP requests to the Eligibility Signposting API.""" - - def __init__(self, base_url=BASE_URL, api_key=API_KEY): - """Initialize the API client with base URL and API key. - - Args: - base_url (str, optional): Base URL for the API. Defaults to BASE_URL from config. - api_key (str, optional): API key for authentication. Defaults to API_KEY from config. - """ - self.base_url = base_url - self.api_key = api_key - self.headers = {"Accept": "application/json", "apikey": self.api_key} - - def get_eligibility_check(self, nhs_number): - """Make a GET request to the eligibility-check endpoint. - - Args: - nhs_number (str): NHS number to check eligibility for. - - Returns: - requests.Response: Response object from the API. - """ - url = f"{BASE_URL}/patient-check/{nhs_number}" - params = {"patient": nhs_number} - - return requests.get(url, headers=self.headers, params=params, timeout=DEFAULT_TIMEOUT) - - def get(self, endpoint, params=None): - """Make a generic GET request to the API. - - Args: - endpoint (str): API endpoint to call. - params (dict, optional): Query parameters. Defaults to None. - - Returns: - requests.Response: Response object from the API. - """ - url = f"{self.base_url}{endpoint}" - return requests.get(url, headers=self.headers, params=params, timeout=DEFAULT_TIMEOUT) - - def post(self, endpoint, data=None, json=None): - """Make a generic POST request to the API. - - Args: - endpoint (str): API endpoint to call. - data (dict, optional): Form data. Defaults to None. - json (dict, optional): JSON data. Defaults to None. - - Returns: - requests.Response: Response object from the API. - """ - url = f"{self.base_url}{endpoint}" - return requests.post(url, headers=self.headers, data=data, json=json, timeout=DEFAULT_TIMEOUT) diff --git a/tests/e2e/utils/config.py b/tests/e2e/utils/config.py index b0666ea7e..ab7b86e86 100644 --- a/tests/e2e/utils/config.py +++ b/tests/e2e/utils/config.py @@ -8,15 +8,15 @@ load_dotenv() # API Configuration -BASE_URL = os.getenv("BASE_URL", "https://sandbox.api.service.nhs.uk/eligibility-signposting-api") -API_KEY = os.getenv("API_KEY", "srgedsrgveg") +BASE_URL = os.getenv("BASE_URL", "https://test.eligibility-signposting-api.nhs.uk") +# API_KEY removed - using mTLS authentication only # Test Data VALID_NHS_NUMBER = os.getenv("VALID_NHS_NUMBER", "50000000004") INVALID_NHS_NUMBER = os.getenv("INVALID_NHS_NUMBER", "9876543210") # API Endpoints -ELIGIBILITY_CHECK_ENDPOINT = "/eligibility-check" +PATIENT_CHECK_ENDPOINT = "/patient-check" # Response Schema ELIGIBILITY_CHECK_SCHEMA = { From ce2360ef3d92571cbd56ff9f19deef944dddf480 Mon Sep 17 00:00:00 2001 From: ivano mannella Date: Thu, 3 Jul 2025 15:15:26 +0100 Subject: [PATCH 05/10] fix pyright configuration to include tests directory --- pyproject.toml | 2 +- .../features/steps/eligibility_check_steps.py | 24 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b55498847..98bcc7400 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ ignore = ["COM812", "D"] "tests/*" = ["ANN", "INP", "S101", "S106", "S311"] [tool.pyright] -include = ["src/"] +include = ["src/", "tests/"] pythonVersion = "3.13" [tool.pytest.ini_options] diff --git a/tests/e2e/features/steps/eligibility_check_steps.py b/tests/e2e/features/steps/eligibility_check_steps.py index 42a727f0d..f33ce66a5 100644 --- a/tests/e2e/features/steps/eligibility_check_steps.py +++ b/tests/e2e/features/steps/eligibility_check_steps.py @@ -12,6 +12,17 @@ logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) +# API endpoints +API_BASE_URL = os.getenv("API_BASE_URL", "https://" + "test" + ".eligibility-signposting-api.nhs.uk") + +# SSM Parameter paths +SSM_BASE_PATH = "/" + "test" + "/mtls" +CERT_PARAMS = { + "private_key": f"{SSM_BASE_PATH}/api_private_key_cert", + "client_cert": f"{SSM_BASE_PATH}/api_client_cert", + "ca_cert": f"{SSM_BASE_PATH}/api_ca_cert", +} + @given("AWS credentials are loaded from the environment") def step_impl_load_aws_credentials(context): @@ -38,11 +49,6 @@ def step_impl_load_aws_credentials(context): @given("mTLS certificates are downloaded and available in the out/ directory") def step_impl_download_certificates(context): """Retrieve mTLS certs from SSM and write them to local files.""" - cert_param_map = { - "private_key": "/test/mtls/api_private_key_cert", - "client_cert": "/test/mtls/api_client_cert", - "ca_cert": "/test/mtls/api_ca_cert", - } cert_dir = Path("./data/out") cert_dir.mkdir(parents=True, exist_ok=True) @@ -56,7 +62,7 @@ def step_impl_download_certificates(context): ) context.cert_paths = {} - for cert_type, param_name in cert_param_map.items(): + for cert_type, param_name in CERT_PARAMS.items(): cert_path = cert_dir / f"{cert_type}.pem" try: logger.info("Retrieving SSM parameter: %s", param_name) @@ -135,16 +141,14 @@ def step_impl_call_eligibility_api(context): msg = "mTLS certificate paths not present in context." raise AssertionError(msg) - api_url = f"https://test.eligibility-signposting-api.nhs.uk/patient-check/{context.nhs_number}" + api_url = f"{API_BASE_URL}/patient-check/{context.nhs_number}" cert = (context.cert_paths["client_cert"], context.cert_paths["private_key"]) verify = False headers = {"nhs-login-nhs-number": context.nhs_number} logger.info("Querying Eligibility API at %s", api_url) try: - response = requests.get( - api_url, cert=cert, verify=verify, timeout=30, headers=headers - ) + response = requests.get(api_url, cert=cert, verify=verify, timeout=30, headers=headers) context.response = response logger.info( "Querying Eligibility API response %s - %d", From 4a7fa1f3f69b6cba43b9a8d5183226d80ba05547 Mon Sep 17 00:00:00 2001 From: ivano mannella Date: Thu, 3 Jul 2025 15:48:36 +0100 Subject: [PATCH 06/10] restoring pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 98bcc7400..b55498847 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ ignore = ["COM812", "D"] "tests/*" = ["ANN", "INP", "S101", "S106", "S311"] [tool.pyright] -include = ["src/", "tests/"] +include = ["src/"] pythonVersion = "3.13" [tool.pytest.ini_options] From 949a6df98e96c311338807127c4c9209cfadead8 Mon Sep 17 00:00:00 2001 From: ivano mannella Date: Fri, 4 Jul 2025 12:04:34 +0100 Subject: [PATCH 07/10] JSON response validation added --- .gitignore | 2 + tests/e2e/data/responses/AUTO_RSV_SB_001.json | 30 ++--- tests/e2e/data/responses/AUTO_RSV_SB_004.json | 28 ++-- .../eligibility_check.feature | 8 +- tests/e2e/features/environment.py | 80 ++++++++++-- .../features/steps/eligibility_check_steps.py | 123 ++++++++++++++++-- .../steps/helpers/dynamodb_data_generator.py | 16 ++- .../steps/helpers/dynamodb_data_uploader.py | 10 +- 8 files changed, 226 insertions(+), 71 deletions(-) diff --git a/.gitignore b/.gitignore index 6f0c38e9f..a8519f0cb 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ sandbox/specification/* /specification/tmp/* /tests/e2e/data/out/* /tests/e2e/reports/* +.vscode/launch.json +.vscode/mcp.json diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_001.json b/tests/e2e/data/responses/AUTO_RSV_SB_001.json index fc1326c0a..6fc130869 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_001.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_001.json @@ -1,30 +1,20 @@ { - "responseId": "<>", - "meta": { - "lastUpdated": "<>" - }, + "meta": { "lastUpdated": "2025-07-04T10:23:53.470846+00:00" }, "processedSuggestions": [ { + "actions": [], "condition": "RSV", - "status": "Actionable", - "statusText": "You should have the RSV vaccine", "eligibilityCohorts": [ { - "cohortCode": "rsv_age_rolling", - "cohortText": "You are aged 75 to 79 years old.", - "cohortStatus": "Actionable" + "cohortCode": "eli_291_cohort_3_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not in eli_291_cohort_3" } ], - "suitabilityRules": [], - "actions": [ - { - "actionType": "ButtonAuthLink", - "actionCode": "BookNBS", - "description": "", - "urlLink": "http://www.nhs.uk/book-rsv", - "urlLabel": "Continue to booking" - } - ] + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "83b4aa78-f429-4fd5-915b-c8956ca261c9" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_004.json b/tests/e2e/data/responses/AUTO_RSV_SB_004.json index ed4663779..0c6394b63 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_004.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_004.json @@ -1,24 +1,20 @@ { - "responseId": "<>", - "meta": { - "lastUpdated": "<>" - }, + "meta": { "lastUpdated": "2025-07-04T10:24:57.489704+00:00" }, "processedSuggestions": [ { + "actions": [], "condition": "RSV", - "status": "Actionable", - "statusText": "You should have the RSV vaccine", - "eligibilityCohorts": [], - "suitabilityRules": [], - "actions": [ + "eligibilityCohorts": [ { - "actionType": "CardWithAuthButton", - "actionCode": "AmendNBS", - "description": "##You have an RSV vaccination appointment\nYou can view, change or cancel your appointment below.", - "urlLink": "http://www.nhs.uk/book-rsv", - "urlLabel": "Manage your appointment" + "cohortCode": "eli_291_cohort_3_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not in eli_291_cohort_3" } - ] + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "a0337485-f8ae-4bd4-a359-f6632743c788" } diff --git a/tests/e2e/features/eligibility_check/eligibility_check.feature b/tests/e2e/features/eligibility_check/eligibility_check.feature index 037e621a0..f443db3ec 100644 --- a/tests/e2e/features/eligibility_check/eligibility_check.feature +++ b/tests/e2e/features/eligibility_check/eligibility_check.feature @@ -10,10 +10,10 @@ Feature: Full mTLS integration with real Eligibility API Given I have the NHS number "" When I query the eligibility API Then the response status code should be 200 - And the response should be valid JSON + And the response should be matching the JSON "" Then I clean up DynamoDB test data Examples: - | nhs_number | - | 5000000001 | - | 5000000004 | + | nhs_number | json_response | + | 5000000001 | AUTO_RSV_SB_001.json | + | 5000000004 | AUTO_RSV_SB_004.json | diff --git a/tests/e2e/features/environment.py b/tests/e2e/features/environment.py index b6c48c988..7d81c870e 100644 --- a/tests/e2e/features/environment.py +++ b/tests/e2e/features/environment.py @@ -6,7 +6,9 @@ from botocore.exceptions import BotoCoreError from dotenv import load_dotenv -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) logger = logging.getLogger("behave.environment") @@ -24,13 +26,19 @@ def _load_environment_variables(context): context.aws_access_key_id = os.getenv("AWS_ACCESS_KEY_ID") context.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY") context.aws_session_token = os.getenv("AWS_SESSION_TOKEN") - context.abort_on_aws_error = os.getenv("ABORT_ON_AWS_FAILURE", "false").lower() == "true" + context.abort_on_aws_error = ( + os.getenv("ABORT_ON_AWS_FAILURE", "false").lower() == "true" + ) context.keep_seed = os.getenv("KEEP_SEED", "false").lower() == "true" - context.dynamodb_table_name = os.getenv("DYNAMODB_TABLE_NAME", "eligibilty_data_store") + context.dynamodb_table_name = os.getenv( + "DYNAMODB_TABLE_NAME", "eligibilty_data_store" + ) context.s3_bucket = os.getenv("S3_BUCKET_NAME") context.s3_upload_dir = os.getenv("S3_UPLOAD_DIR", "") context.s3_data_path = Path(os.getenv("S3_JSON_SOURCE_DIR", "./data/s3")).resolve() - context.api_gateway_url = os.getenv("API_GATEWAY_URL", "https://test.eligibility-signposting-api.nhs.uk") + context.api_gateway_url = os.getenv( + "API_GATEWAY_URL", "https://test.eligibility-signposting-api.nhs.uk" + ) logger.info("ABORT_ON_AWS_FAILURE=%s", context.abort_on_aws_error) logger.info("KEEP_SEED=%s", context.keep_seed) @@ -59,13 +67,19 @@ def _setup_s3(context): json_files = list(context.s3_data_path.glob("*.json")) for file_path in json_files: - key = f"{context.s3_upload_dir}/{file_path.name}" if context.s3_upload_dir else file_path.name + key = ( + f"{context.s3_upload_dir}/{file_path.name}" + if context.s3_upload_dir + else file_path.name + ) try: s3_client.upload_file(str(file_path), context.s3_bucket, key) - logger.info("Uploaded %s to s3://%s/%s", file_path.name, context.s3_bucket, key) - except (boto3.exceptions.Boto3Error, BotoCoreError): + logger.info( + "Uploaded %s to s3://%s/%s", file_path.name, context.s3_bucket, key + ) + except (Exception, BotoCoreError): logger.exception("Failed to upload %s", file_path.name) - except (boto3.exceptions.Boto3Error, BotoCoreError): + except (Exception, BotoCoreError): logger.exception("S3 upload setup failed") if context.abort_on_aws_error: context.abort_all = True @@ -95,6 +109,44 @@ def before_scenario(context, scenario): logger.info("Running scenario: %s", scenario.name) +def before_feature(context, feature): + """Initialize feature-level context for data setup tracking.""" + context.feature_data_setup_done = False + context.feature_dynamodb_items_count = 0 + context.feature_uploader = None + logger.info("Initialized feature context for: %s", feature.name) + + +def after_feature(context, feature): + """Cleanup feature-level DynamoDB data.""" + if getattr(context, "keep_seed", False): + logger.info( + "KEEP_SEED=true โ€” skipping feature-level DynamoDB cleanup for: %s", + feature.name, + ) + return + + if hasattr(context, "feature_uploader") and context.feature_uploader: + if context.feature_dynamodb_items_count > 0: + logger.info( + "Cleaning up %d DynamoDB items for feature: %s", + context.feature_dynamodb_items_count, + feature.name, + ) + try: + # Use the uploader's cleanup method if available + if hasattr(context.feature_uploader, "delete_data"): + context.feature_uploader.delete_data() + logger.info( + "Successfully cleaned up DynamoDB data for feature: %s", + feature.name, + ) + except Exception: + logger.exception( + "Failed to cleanup DynamoDB data for feature: %s", feature.name + ) + + def after_all(context): if context.keep_seed: logger.info("KEEP_SEED=true โ€” skipping cleanup.") @@ -107,11 +159,17 @@ def after_all(context): s3_client = boto3.client("s3", region_name=context.aws_region) json_files = list(context.s3_data_path.glob("*.json")) for file_path in json_files: - key = f"{context.s3_upload_dir}/{file_path.name}" if context.s3_upload_dir else file_path.name + key = ( + f"{context.s3_upload_dir}/{file_path.name}" + if context.s3_upload_dir + else file_path.name + ) try: s3_client.delete_object(Bucket=context.s3_bucket, Key=key) logger.info("Deleted s3://%s/%s", context.s3_bucket, key) - except (boto3.exceptions.Boto3Error, BotoCoreError): - logger.exception("Failed to delete s3://%s/%s", context.s3_bucket, key) + except (Exception, BotoCoreError): + logger.exception( + "Failed to delete s3://%s/%s", context.s3_bucket, key + ) except Exception: logger.exception("S3 cleanup failed") diff --git a/tests/e2e/features/steps/eligibility_check_steps.py b/tests/e2e/features/steps/eligibility_check_steps.py index f33ce66a5..ca3939b29 100644 --- a/tests/e2e/features/steps/eligibility_check_steps.py +++ b/tests/e2e/features/steps/eligibility_check_steps.py @@ -1,19 +1,23 @@ import logging import os from pathlib import Path - +import json import boto3 import requests +import copy from behave import given, then, when from botocore.exceptions import ClientError from helpers.dynamodb_data_generator import DateVariableResolver, JsonTestDataProcessor from helpers.dynamodb_data_uploader import DynamoDBDataUploader +from difflib import unified_diff logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) # API endpoints -API_BASE_URL = os.getenv("API_BASE_URL", "https://" + "test" + ".eligibility-signposting-api.nhs.uk") +API_BASE_URL = os.getenv( + "API_BASE_URL", "https://" + "test" + ".eligibility-signposting-api.nhs.uk" +) # SSM Parameter paths SSM_BASE_PATH = "/" + "test" + "/mtls" @@ -24,6 +28,36 @@ } +def remove_ignored_properties(obj): + """ + Recursively remove specified properties from JSON objects before comparison. + Creates deep copies to avoid modifying original data. + + Properties to remove: + - meta (and all its sub-properties) + - responseId + + Args: + obj: The JSON object (dict, list, or primitive) to process + + Returns: + A deep copy of the object with specified properties removed + """ + if isinstance(obj, dict): + # Create a new dict excluding the ignored properties + filtered_dict = {} + for key, value in obj.items(): + if key not in ["meta", "responseId"]: + filtered_dict[key] = remove_ignored_properties(value) + return filtered_dict + elif isinstance(obj, list): + # Recursively process each item in the list + return [remove_ignored_properties(item) for item in obj] + else: + # Return primitive values as-is + return obj + + @given("AWS credentials are loaded from the environment") def step_impl_load_aws_credentials(context): """Load AWS credentials from environment variables.""" @@ -31,7 +65,9 @@ def step_impl_load_aws_credentials(context): context.aws_access_key_id = os.getenv("AWS_ACCESS_KEY_ID") context.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY") context.aws_session_token = os.getenv("AWS_SESSION_TOKEN") - context.dynamodb_table_name = os.getenv("DYNAMODB_TABLE_NAME", "eligibilty_data_store") + context.dynamodb_table_name = os.getenv( + "DYNAMODB_TABLE_NAME", "eligibilty_data_store" + ) missing = [] if not context.aws_region: @@ -78,8 +114,15 @@ def step_impl_download_certificates(context): @given("I generate the test data files") -def step_impl_generate_data(_context): - """Generate test data files with resolved <> placeholders.""" +def step_impl_generate_data(context): + """Generate test data files with resolved <> placeholders - once per feature.""" + if getattr(context, "feature_data_setup_done", False): + logger.info( + "Test data already generated for this feature, skipping generation..." + ) + return + + logger.info("Generating test data files for feature...") input_dir = Path("data/in/dynamoDB").resolve() output_dir = Path("data/out/dynamoDB").resolve() @@ -102,21 +145,34 @@ def step_impl_generate_data(_context): if count == 0: logger.warning("No .json files found in %s", input_dir) else: - logger.info("Processed %d test data file(s).", count) + logger.info("Processed %d test data file(s) for feature.", count) @given("I upload the test data files to DynamoDB") def step_impl_upload_data(context): - """Upload generated test data to DynamoDB.""" + """Upload generated test data to DynamoDB - once per feature.""" + if getattr(context, "feature_data_setup_done", False): + logger.info("Test data already uploaded for this feature, skipping upload...") + return + + logger.info("Uploading test data to DynamoDB for feature...") uploader = DynamoDBDataUploader( aws_region=context.aws_region, access_key=context.aws_access_key_id, secret_key=context.aws_secret_access_key, session_token=context.aws_session_token, ) - inserted = uploader.upload_files_from_path(table_name=context.dynamodb_table_name, path=Path("data/out/dynamoDB")) + inserted = uploader.upload_files_from_path( + table_name=context.dynamodb_table_name, path=Path("data/out/dynamoDB") + ) assert inserted > 0, "No data uploaded to DynamoDB" - logger.info("Uploaded %d items to DynamoDB", inserted) + + # Store for feature-level cleanup + context.feature_uploader = uploader + context.feature_dynamodb_items_count = inserted + context.feature_data_setup_done = True + + logger.info("Uploaded %d items to DynamoDB (feature-level)", inserted) @given('I have the NHS number "{nhs_number}"') @@ -126,8 +182,11 @@ def step_impl_nhs_number(context, nhs_number): @then("I clean up DynamoDB test data") def step_impl_cleanup_dynamo(context): - if hasattr(context, "dynamo_uploader"): - context.dynamo_uploader.delete_data() + """Clean up DynamoDB test data - handled at feature level now.""" + logger.info( + "DynamoDB cleanup will be handled at feature level - skipping scenario-level cleanup" + ) + # This step becomes a no-op since cleanup is handled in after_feature hook @when("I query the eligibility API") @@ -148,7 +207,9 @@ def step_impl_call_eligibility_api(context): logger.info("Querying Eligibility API at %s", api_url) try: - response = requests.get(api_url, cert=cert, verify=verify, timeout=30, headers=headers) + response = requests.get( + api_url, cert=cert, verify=verify, timeout=30, headers=headers + ) context.response = response logger.info( "Querying Eligibility API response %s - %d", @@ -182,3 +243,41 @@ def step_impl_validate_json(context): except ValueError as e: msg = f"Response is not valid JSON: {e}" raise AssertionError(msg) from e + + +@then('the response should be matching the JSON "{json_response}"') +def step_impl_match_json_response(context, json_response): + """ + Assert that the API response matches the expected JSON file. + """ + expected_path = os.path.join( + Path(__file__).parent.parent.parent, "data", "responses", json_response + ) + if not os.path.isfile(expected_path): + raise AssertionError(f"Expected JSON file not found: {expected_path}") + + with open(expected_path, "r", encoding="utf-8") as f: + expected = json.load(f) + + try: + actual = context.response.json() + print(f"๐Ÿ“„ Actual JSON structure: ", actual) + except Exception as e: + raise AssertionError(f"Failed to parse API response as JSON: {e}") + + filtered_expected = remove_ignored_properties(expected) + filtered_actual = remove_ignored_properties(actual) + + if filtered_expected != filtered_actual: + expected_str = json.dumps(filtered_expected, indent=2, sort_keys=True) + actual_str = json.dumps(filtered_actual, indent=2, sort_keys=True) + diff = "\n".join( + unified_diff( + expected_str.splitlines(), + actual_str.splitlines(), + fromfile="expected", + tofile="actual", + lineterm="", + ) + ) + raise AssertionError(f"Response JSON does not match expected.\nDiff:\n{diff}") diff --git a/tests/e2e/features/steps/helpers/dynamodb_data_generator.py b/tests/e2e/features/steps/helpers/dynamodb_data_generator.py index 6f5b3f034..e572ebf3f 100644 --- a/tests/e2e/features/steps/helpers/dynamodb_data_generator.py +++ b/tests/e2e/features/steps/helpers/dynamodb_data_generator.py @@ -31,19 +31,25 @@ def resolve(self, token: str) -> str: if unit == "week": return (self.today + timedelta(weeks=offset)).strftime(DATE_FORMAT) if unit == "year": - return (self.today.replace(year=self.today.year + offset)).strftime(DATE_FORMAT) + return (self.today.replace(year=self.today.year + offset)).strftime( + DATE_FORMAT + ) if unit == "age": try: birth_date = self.today.replace(year=self.today.year - offset) except ValueError: - birth_date = self.today.replace(month=2, day=28, year=self.today.year - offset) + birth_date = self.today.replace( + month=2, day=28, year=self.today.year - offset + ) return birth_date.strftime(DATE_FORMAT) msg = f"Unsupported unit: {unit}" raise ValueError(msg) class JsonTestDataProcessor: - def __init__(self, input_dir: Path, output_dir: Path, resolver: DateVariableResolver): + def __init__( + self, input_dir: Path, output_dir: Path, resolver: DateVariableResolver + ): self.input_dir = input_dir self.output_dir = output_dir self.resolver = resolver @@ -66,7 +72,7 @@ def _replace_token(self, match: re.Match) -> str: return match.group(0) def process_file(self, file_path: Path): - logger.info("Processing file: %s", file_path) + # logger.info("Processing file: %s", file_path) try: with file_path.open() as f: content = json.load(f) @@ -87,6 +93,6 @@ def process_file(self, file_path: Path): try: with output_path.open("w") as f: json.dump(resolved["data"], f, indent=2) - logger.info("Written resolved file: %s", output_path) + # logger.info("Written resolved file: %s", output_path) except Exception: logger.exception("Failed to write output to: %s", output_path) diff --git a/tests/e2e/features/steps/helpers/dynamodb_data_uploader.py b/tests/e2e/features/steps/helpers/dynamodb_data_uploader.py index 704da2ed7..6cfdc22b2 100644 --- a/tests/e2e/features/steps/helpers/dynamodb_data_uploader.py +++ b/tests/e2e/features/steps/helpers/dynamodb_data_uploader.py @@ -35,7 +35,7 @@ def upload_files_from_path(self, table_name: str, path: Path): for item in items: table.put_item(Item=item) count += 1 - logger.info("Inserted %d items from %s", len(items), file_path.name) + # logger.info("Inserted %d items from %s", len(items), file_path.name) except Exception: logger.exception("Failed to insert from file: %s", file_path) return count @@ -50,7 +50,11 @@ def delete_data(self): attribute_type = item.get("ATTRIBUTE_TYPE") if nhs_number and attribute_type: try: - self.table.delete_item(Key={"NHS_NUMBER": nhs_number, "ATTRIBUTE_TYPE": attribute_type}) + self.table.delete_item( + Key={"NHS_NUMBER": nhs_number, "ATTRIBUTE_TYPE": attribute_type} + ) logger.info("Deleted item: %s - %s", nhs_number, attribute_type) except Exception: - logger.exception("Failed to delete item: %s - %s", nhs_number, attribute_type) + logger.exception( + "Failed to delete item: %s - %s", nhs_number, attribute_type + ) From b46487e476a17fb2c6a626bf499ff3199c509c36 Mon Sep 17 00:00:00 2001 From: ivano mannella Date: Tue, 15 Jul 2025 10:51:30 +0100 Subject: [PATCH 08/10] Enhance eligibility check tests with comprehensive response data and dynamic header support - Updated multiple response JSON files to include detailed eligibility suggestions for COVID, MMR, and RSV conditions. - Modified the eligibility_check.feature to include additional test cases for various NHS numbers and corresponding JSON responses. - Refactored eligibility_check_steps.py to support querying the eligibility API with dynamic headers, improving flexibility and maintainability. - Added functionality to log and save actual API responses for better debugging and validation. --- tests/e2e/data/responses/AUTO_RSV_SB_001.json | 38 +++++- tests/e2e/data/responses/AUTO_RSV_SB_002.json | 52 +++++--- tests/e2e/data/responses/AUTO_RSV_SB_003.json | 52 +++++--- tests/e2e/data/responses/AUTO_RSV_SB_004.json | 38 +++++- tests/e2e/data/responses/AUTO_RSV_SB_005.json | 52 ++++++-- tests/e2e/data/responses/AUTO_RSV_SB_006.json | 50 +++++-- tests/e2e/data/responses/AUTO_RSV_SB_007.json | 50 +++++-- tests/e2e/data/responses/AUTO_RSV_SB_008.json | 49 ++++++- tests/e2e/data/responses/AUTO_RSV_SB_009.json | 50 +++++-- tests/e2e/data/responses/AUTO_RSV_SB_010.json | 50 +++++-- tests/e2e/data/responses/AUTO_RSV_SB_011.json | 50 +++++-- tests/e2e/data/responses/AUTO_RSV_SB_012.json | 50 +++++-- tests/e2e/data/responses/AUTO_RSV_SB_013.json | 50 +++++-- tests/e2e/data/responses/AUTO_RSV_SB_014.json | 55 +++++--- .../eligibility_check.feature | 21 ++- .../features/steps/eligibility_check_steps.py | 126 +++++++++++++----- 16 files changed, 630 insertions(+), 203 deletions(-) diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_001.json b/tests/e2e/data/responses/AUTO_RSV_SB_001.json index 6fc130869..a79499e66 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_001.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_001.json @@ -1,14 +1,44 @@ { - "meta": { "lastUpdated": "2025-07-04T10:23:53.470846+00:00" }, + "meta": { + "lastUpdated": "2025-07-15T09:44:41.227460+00:00" + }, "processedSuggestions": [ + { + "actions": [], + "condition": "COVID", + "eligibilityCohorts": [ + { + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ + { + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, { "actions": [], "condition": "RSV", "eligibilityCohorts": [ { - "cohortCode": "eli_291_cohort_3_group", + "cohortCode": "rsv_cohort_group", "cohortStatus": "NotEligible", - "cohortText": "You are not in eli_291_cohort_3" + "cohortText": "You are not currently in an RSV cohort" } ], "status": "NotEligible", @@ -16,5 +46,5 @@ "suitabilityRules": [] } ], - "responseId": "83b4aa78-f429-4fd5-915b-c8956ca261c9" + "responseId": "7a57f8e6-1e7c-457a-af83-9968f68383d6" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_002.json b/tests/e2e/data/responses/AUTO_RSV_SB_002.json index e45a371df..600d9aa75 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_002.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_002.json @@ -1,28 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:44:44.544273+00:00" }, "processedSuggestions": [ { - "condition": "RSV", - "status": "Actionable", - "statusText": "You should have the RSV vaccine", + "actions": [], + "condition": "COVID", + "eligibilityCohorts": [ + { + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", "eligibilityCohorts": [ { - "cohortCode": "rsv_age_rolling", - "cohortText": "You are aged 75 to 79 years old.", - "cohortStatus": "Actionable" + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" } ], - "suitabilityRules": [], - "actions": [ + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "RSV", + "eligibilityCohorts": [ { - "actionType": "CareCardWithText", - "actionCode": "BookLocal", - "description": "##Getting the vaccine\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination." + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" } - ] + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "a1264578-da65-46fa-92ba-c1a09d498f31" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_003.json b/tests/e2e/data/responses/AUTO_RSV_SB_003.json index e814e90b3..c60b9ad6c 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_003.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_003.json @@ -1,28 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:44:47.658192+00:00" }, "processedSuggestions": [ { - "condition": "RSV", - "status": "Actionable", - "statusText": "You should have the RSV vaccine", + "actions": [], + "condition": "COVID", + "eligibilityCohorts": [ + { + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", "eligibilityCohorts": [ { - "cohortCode": "rsv_age_catchup", - "cohortText": "You turned 80 between 2nd September 2024 and 31st August 2025", - "cohortStatus": "Actionable" + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" } ], - "suitabilityRules": [], - "actions": [ + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "RSV", + "eligibilityCohorts": [ { - "actionType": "CareCardWithText", - "actionCode": "BookLocal", - "description": "##Getting the vaccine\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination." + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" } - ] + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "98b7c6ec-13a5-48bc-af50-94ceaaa10893" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_004.json b/tests/e2e/data/responses/AUTO_RSV_SB_004.json index 0c6394b63..14743f8cc 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_004.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_004.json @@ -1,14 +1,44 @@ { - "meta": { "lastUpdated": "2025-07-04T10:24:57.489704+00:00" }, + "meta": { + "lastUpdated": "2025-07-15T09:44:50.667999+00:00" + }, "processedSuggestions": [ + { + "actions": [], + "condition": "COVID", + "eligibilityCohorts": [ + { + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ + { + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, { "actions": [], "condition": "RSV", "eligibilityCohorts": [ { - "cohortCode": "eli_291_cohort_3_group", + "cohortCode": "rsv_cohort_group", "cohortStatus": "NotEligible", - "cohortText": "You are not in eli_291_cohort_3" + "cohortText": "You are not currently in an RSV cohort" } ], "status": "NotEligible", @@ -16,5 +46,5 @@ "suitabilityRules": [] } ], - "responseId": "a0337485-f8ae-4bd4-a359-f6632743c788" + "responseId": "317d721a-1ffd-4621-b660-562d8143501d" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_005.json b/tests/e2e/data/responses/AUTO_RSV_SB_005.json index 687b05405..6ae9d96d6 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_005.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_005.json @@ -1,22 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:44:53.772820+00:00" }, "processedSuggestions": [ { + "actions": [], + "condition": "COVID", + "eligibilityCohorts": [ + { + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ + { + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], "condition": "RSV", - "status": "Actionable", - "statusText": "You should have the RSV vaccine", - "eligibilityCohorts": [], - "suitabilityRules": [], - "actions": [ + "eligibilityCohorts": [ { - "actionType": "CardWithText", - "actionCode": "ManageLocal", - "description": "##You have an RSV vaccination appointment\nContact your healthcare provider to change or cancel your appointment." + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" } - ] + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "ac77be57-2406-401d-b195-9728507f7559" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_006.json b/tests/e2e/data/responses/AUTO_RSV_SB_006.json index 3413b999b..cbfa02300 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_006.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_006.json @@ -1,22 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:44:56.893166+00:00" }, "processedSuggestions": [ { + "actions": [], + "condition": "COVID", + "eligibilityCohorts": [ + { + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ + { + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], "condition": "RSV", - "status": "NotActionable", - "statusText": "You should have the RSV vaccine", - "eligibilityCohorts": [], - "suitabilityRules": [ + "eligibilityCohorts": [ { - "ruleType": "S", - "ruleCode": "AlreadyVaccinated", - "ruleText": "##You've had your RSV vaccination\nBased on our records, you recently had this vaccination. You do not need to do anything." + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" } ], - "actions": [] + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "aef6e6ca-de67-4af5-b439-76381d0d9871" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_007.json b/tests/e2e/data/responses/AUTO_RSV_SB_007.json index e292c3719..f4a1b6ca0 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_007.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_007.json @@ -1,28 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:44:59.937916+00:00" }, "processedSuggestions": [ { - "condition": "RSV", - "status": "NotActionable", - "statusText": "You should have the RSV vaccine", + "actions": [], + "condition": "COVID", "eligibilityCohorts": [ { - "cohortCode": "rsv_age_rolling", - "cohortText": "You are aged 75 to 79 years old.", - "cohortStatus": "NotActionable" + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" } ], - "suitabilityRules": [ + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ + { + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "RSV", + "eligibilityCohorts": [ { - "ruleType": "S", - "ruleCode": "NotAvailable", - "ruleText": "Vaccinations are not currently available" + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" } ], - "actions": [] + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "68f50ad9-6bcf-423e-b6af-57c7c0e3b91d" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_008.json b/tests/e2e/data/responses/AUTO_RSV_SB_008.json index 01fef4fb6..d6b1b2fba 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_008.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_008.json @@ -1,7 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:45:02.985622+00:00" }, - "processedSuggestions": [] + "processedSuggestions": [ + { + "actions": [], + "condition": "COVID", + "eligibilityCohorts": [ + { + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ + { + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "RSV", + "eligibilityCohorts": [ + { + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + } + ], + "responseId": "01ef2722-aea5-44c5-ace5-cc9e47d58bb4" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_009.json b/tests/e2e/data/responses/AUTO_RSV_SB_009.json index 65ecaf7a5..abc39b615 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_009.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_009.json @@ -1,28 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:45:06.346173+00:00" }, "processedSuggestions": [ { - "condition": "RSV", - "status": "NotActionable", - "statusText": "You should have the RSV vaccine", + "actions": [], + "condition": "COVID", "eligibilityCohorts": [ { - "cohortCode": "rsv_age_rolling", - "cohortText": "You are aged 75 to 79 years old.", - "cohortStatus": "NotActionable" + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" } ], - "suitabilityRules": [ + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ + { + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "RSV", + "eligibilityCohorts": [ { - "ruleType": "S", - "ruleCode": "NotYetDue", - "ruleText": "Your next dose is not yet due." + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" } ], - "actions": [] + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "99760fc3-0345-4276-aa3d-7e7b6b9171b3" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_010.json b/tests/e2e/data/responses/AUTO_RSV_SB_010.json index 0b9dc7258..8265f8648 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_010.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_010.json @@ -1,28 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:45:09.324017+00:00" }, "processedSuggestions": [ { - "condition": "RSV", - "status": "NotActionable", - "statusText": "You should have the RSV vaccine", + "actions": [], + "condition": "COVID", "eligibilityCohorts": [ { - "cohortCode": "rsv_age_rolling", - "cohortText": "You are aged 75 to 79 years old.", - "cohortStatus": "NotActionable" + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" } ], - "suitabilityRules": [ + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ + { + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "RSV", + "eligibilityCohorts": [ { - "ruleType": "S", - "ruleCode": "TooClose", - "ruleText": "Your previous vaccination was less than 91 days ago." + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" } ], - "actions": [] + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "f1ad137a-0426-486b-9f07-693a1861e03a" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_011.json b/tests/e2e/data/responses/AUTO_RSV_SB_011.json index 3070205d0..d5065515f 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_011.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_011.json @@ -1,28 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:45:12.371500+00:00" }, "processedSuggestions": [ { - "condition": "RSV", - "status": "NotActionable", - "statusText": "You should have the RSV vaccine", + "actions": [], + "condition": "COVID", "eligibilityCohorts": [ { - "cohortCode": "rsv_age_rolling", - "cohortText": "You are aged 75 to 79 years old.", - "cohortStatus": "NotActionable" + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" } ], - "suitabilityRules": [ + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ + { + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "RSV", + "eligibilityCohorts": [ { - "ruleType": "S", - "ruleCode": "OtherSetting", - "ruleText": "##Getting the vaccine\nOur record show you're living in a setting where care is provided.\nIf you think you should have the RSV vaccine, speak to a member of staff where you live." + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" } ], - "actions": [] + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "facc346b-8b1b-444c-aabf-b1904fc89eee" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_012.json b/tests/e2e/data/responses/AUTO_RSV_SB_012.json index 4996198d2..1a6ce1af0 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_012.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_012.json @@ -1,28 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:45:15.488082+00:00" }, "processedSuggestions": [ { - "condition": "RSV", - "status": "NotActionable", - "statusText": "You should have the RSV vaccine", + "actions": [], + "condition": "COVID", "eligibilityCohorts": [ { - "cohortCode": "unknown_cohort_membership", - "cohortText": "Our records do not say why you are eligible", - "cohortStatus": "NotActionable" + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" } ], - "suitabilityRules": [ + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ + { + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "RSV", + "eligibilityCohorts": [ { - "ruleType": "S", - "ruleCode": "AlreadyVaccinated", - "ruleText": "##You've had your RSV vaccination\nBased on our records, you recently had this vaccination.You do not need to do anything." + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" } ], - "actions": [] + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "174cd140-275c-4612-a162-3a6b2e6e5cc5" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_013.json b/tests/e2e/data/responses/AUTO_RSV_SB_013.json index 3413b999b..a66ae31e5 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_013.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_013.json @@ -1,22 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:45:18.471060+00:00" }, "processedSuggestions": [ { + "actions": [], + "condition": "COVID", + "eligibilityCohorts": [ + { + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ + { + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], "condition": "RSV", - "status": "NotActionable", - "statusText": "You should have the RSV vaccine", - "eligibilityCohorts": [], - "suitabilityRules": [ + "eligibilityCohorts": [ { - "ruleType": "S", - "ruleCode": "AlreadyVaccinated", - "ruleText": "##You've had your RSV vaccination\nBased on our records, you recently had this vaccination. You do not need to do anything." + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" } ], - "actions": [] + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "aa6774cb-2a42-4969-bd6d-fe6959236fea" } diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_014.json b/tests/e2e/data/responses/AUTO_RSV_SB_014.json index 3858968e4..9872adfbe 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_014.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_014.json @@ -1,33 +1,50 @@ { - "responseId": "<>", "meta": { - "lastUpdated": "<>" + "lastUpdated": "2025-07-15T09:45:21.380241+00:00" }, "processedSuggestions": [ { - "condition": "RSV", - "status": "NotEligible", - "statusText": "We do not believe you should have this vaccine", + "actions": [], + "condition": "COVID", "eligibilityCohorts": [ { - "cohortCode": "rsv_age_rolling", - "cohortText": "You are not aged 75 to 79 years old.", - "cohortStatus": "NotEligible" - }, + "cohortCode": "covid_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in a covid cohort" + } + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "MMR", + "eligibilityCohorts": [ { - "cohortCode": "rsv_age_catchup", - "cohortText": "You did not turn 80 between 2nd September 2024 and 31st August 2025", - "cohortStatus": "NotEligible" + "cohortCode": "mmr_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an mmr cohort" } ], - "suitabilityRules": [], - "actions": [ + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] + }, + { + "actions": [], + "condition": "RSV", + "eligibilityCohorts": [ { - "actionType": "CardWithText", - "actionCode": "HealtchareProInfo", - "description": "##If you think you need this vaccine\nSpeak to your healthcare professional if you think you should be offered this vaccination." + "cohortCode": "rsv_cohort_group", + "cohortStatus": "NotEligible", + "cohortText": "You are not currently in an RSV cohort" } - ] + ], + "status": "NotEligible", + "statusText": "Status.not_eligible", + "suitabilityRules": [] } - ] + ], + "responseId": "fbf62ad8-3160-40a4-a813-9108e5f8bea0" } diff --git a/tests/e2e/features/eligibility_check/eligibility_check.feature b/tests/e2e/features/eligibility_check/eligibility_check.feature index f443db3ec..d690bbc80 100644 --- a/tests/e2e/features/eligibility_check/eligibility_check.feature +++ b/tests/e2e/features/eligibility_check/eligibility_check.feature @@ -8,12 +8,25 @@ Feature: Full mTLS integration with real Eligibility API Given I generate the test data files And I upload the test data files to DynamoDB Given I have the NHS number "" - When I query the eligibility API + When I query the eligibility API using the headers: + Then the response status code should be 200 And the response should be matching the JSON "" Then I clean up DynamoDB test data Examples: - | nhs_number | json_response | - | 5000000001 | AUTO_RSV_SB_001.json | - | 5000000004 | AUTO_RSV_SB_004.json | + | nhs_number | json_response | + | 5000000001 | AUTO_RSV_SB_001.json | + | 5000000002 | AUTO_RSV_SB_002.json | + | 5000000003 | AUTO_RSV_SB_003.json | + | 5000000004 | AUTO_RSV_SB_004.json | + | 5000000005 | AUTO_RSV_SB_005.json | + | 5000000006 | AUTO_RSV_SB_006.json | + | 5000000007 | AUTO_RSV_SB_007.json | + | 5000000008 | AUTO_RSV_SB_008.json | + | 5000000009 | AUTO_RSV_SB_009.json | + | 5000000010 | AUTO_RSV_SB_010.json | + | 5000000011 | AUTO_RSV_SB_011.json | + | 5000000012 | AUTO_RSV_SB_012.json | + | 5000000013 | AUTO_RSV_SB_013.json | + | 5000000014 | AUTO_RSV_SB_014.json | diff --git a/tests/e2e/features/steps/eligibility_check_steps.py b/tests/e2e/features/steps/eligibility_check_steps.py index ca3939b29..7b5d90e09 100644 --- a/tests/e2e/features/steps/eligibility_check_steps.py +++ b/tests/e2e/features/steps/eligibility_check_steps.py @@ -1,23 +1,21 @@ +import json import logging import os +from difflib import unified_diff from pathlib import Path -import json + import boto3 import requests -import copy from behave import given, then, when from botocore.exceptions import ClientError from helpers.dynamodb_data_generator import DateVariableResolver, JsonTestDataProcessor from helpers.dynamodb_data_uploader import DynamoDBDataUploader -from difflib import unified_diff logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) # API endpoints -API_BASE_URL = os.getenv( - "API_BASE_URL", "https://" + "test" + ".eligibility-signposting-api.nhs.uk" -) +API_BASE_URL = os.getenv("API_BASE_URL", "https://" + "test" + ".eligibility-signposting-api.nhs.uk") # SSM Parameter paths SSM_BASE_PATH = "/" + "test" + "/mtls" @@ -50,12 +48,11 @@ def remove_ignored_properties(obj): if key not in ["meta", "responseId"]: filtered_dict[key] = remove_ignored_properties(value) return filtered_dict - elif isinstance(obj, list): + if isinstance(obj, list): # Recursively process each item in the list return [remove_ignored_properties(item) for item in obj] - else: - # Return primitive values as-is - return obj + # Return primitive values as-is + return obj @given("AWS credentials are loaded from the environment") @@ -65,9 +62,7 @@ def step_impl_load_aws_credentials(context): context.aws_access_key_id = os.getenv("AWS_ACCESS_KEY_ID") context.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY") context.aws_session_token = os.getenv("AWS_SESSION_TOKEN") - context.dynamodb_table_name = os.getenv( - "DYNAMODB_TABLE_NAME", "eligibilty_data_store" - ) + context.dynamodb_table_name = os.getenv("DYNAMODB_TABLE_NAME", "eligibilty_data_store") missing = [] if not context.aws_region: @@ -117,9 +112,7 @@ def step_impl_download_certificates(context): def step_impl_generate_data(context): """Generate test data files with resolved <> placeholders - once per feature.""" if getattr(context, "feature_data_setup_done", False): - logger.info( - "Test data already generated for this feature, skipping generation..." - ) + logger.info("Test data already generated for this feature, skipping generation...") return logger.info("Generating test data files for feature...") @@ -162,9 +155,7 @@ def step_impl_upload_data(context): secret_key=context.aws_secret_access_key, session_token=context.aws_session_token, ) - inserted = uploader.upload_files_from_path( - table_name=context.dynamodb_table_name, path=Path("data/out/dynamoDB") - ) + inserted = uploader.upload_files_from_path(table_name=context.dynamodb_table_name, path=Path("data/out/dynamoDB")) assert inserted > 0, "No data uploaded to DynamoDB" # Store for feature-level cleanup @@ -183,15 +174,38 @@ def step_impl_nhs_number(context, nhs_number): @then("I clean up DynamoDB test data") def step_impl_cleanup_dynamo(context): """Clean up DynamoDB test data - handled at feature level now.""" - logger.info( - "DynamoDB cleanup will be handled at feature level - skipping scenario-level cleanup" - ) + logger.info("DynamoDB cleanup will be handled at feature level - skipping scenario-level cleanup") # This step becomes a no-op since cleanup is handled in after_feature hook -@when("I query the eligibility API") -def step_impl_call_eligibility_api(context): - """Make mTLS call to Eligibility API using local certs and context NHS number.""" +def parse_headers_table(table): + """Parse behave table into headers dictionary.""" + headers = {} + if not table: + return headers + + for row in table: + if len(row.cells) != 2: + raise ValueError(f"Expected 2 columns in headers table, got {len(row.cells)}") + key, value = row.cells[0], row.cells[1] + headers[key] = value + return headers + + +def build_request_headers(context, dynamic_headers=None): + """Build complete headers for API request.""" + # Start with required default header + headers = {"nhs-login-nhs-number": context.nhs_number} + + # Add dynamic headers if provided + if dynamic_headers: + headers.update(dynamic_headers) + + return headers + + +def make_eligibility_api_call(context, headers): + """Make mTLS call to Eligibility API with provided headers.""" if not hasattr(context, "nhs_number"): msg = "NHS number not set in context." raise AssertionError(msg) @@ -203,13 +217,13 @@ def step_impl_call_eligibility_api(context): api_url = f"{API_BASE_URL}/patient-check/{context.nhs_number}" cert = (context.cert_paths["client_cert"], context.cert_paths["private_key"]) verify = False - headers = {"nhs-login-nhs-number": context.nhs_number} - logger.info("Querying Eligibility API at %s", api_url) + # Log headers for debugging (excluding sensitive values) + safe_headers = {k: ("***" if "token" in k.lower() or "key" in k.lower() else v) for k, v in headers.items()} + logger.info("Querying Eligibility API at %s with headers: %s", api_url, safe_headers) + try: - response = requests.get( - api_url, cert=cert, verify=verify, timeout=30, headers=headers - ) + response = requests.get(api_url, cert=cert, verify=verify, timeout=30, headers=headers) context.response = response logger.info( "Querying Eligibility API response %s - %d", @@ -221,6 +235,35 @@ def step_impl_call_eligibility_api(context): raise RuntimeError(msg) from e +@when("I query the eligibility API") +def step_impl_call_eligibility_api(context): + """Make mTLS call to Eligibility API using local certs and context NHS number.""" + headers = build_request_headers(context) + make_eligibility_api_call(context, headers) + + +@when("I query the eligibility API using the headers:") +def step_impl_call_eligibility_api_with_headers(context): + """Make mTLS call to Eligibility API with dynamic headers from table.""" + try: + # Parse headers table + dynamic_headers = parse_headers_table(context.table) + logger.info("Parsed %d dynamic headers from table", len(dynamic_headers)) + + # Build complete headers + headers = build_request_headers(context, dynamic_headers) + + # Make API call + make_eligibility_api_call(context, headers) + + except ValueError as e: + msg = f"Invalid headers table format: {e}" + raise AssertionError(msg) from e + except Exception as e: + msg = f"Failed to process headers table: {e}" + raise RuntimeError(msg) from e + + @then("the response status code should be {status_code:d}") def step_impl_check_status_code(context, status_code): """Assert response HTTP status code.""" @@ -250,18 +293,31 @@ def step_impl_match_json_response(context, json_response): """ Assert that the API response matches the expected JSON file. """ - expected_path = os.path.join( - Path(__file__).parent.parent.parent, "data", "responses", json_response - ) + expected_path = os.path.join(Path(__file__).parent.parent.parent, "data", "responses", json_response) if not os.path.isfile(expected_path): raise AssertionError(f"Expected JSON file not found: {expected_path}") - with open(expected_path, "r", encoding="utf-8") as f: + with open(expected_path, encoding="utf-8") as f: expected = json.load(f) try: actual = context.response.json() - print(f"๐Ÿ“„ Actual JSON structure: ", actual) + print("> Actual JSON structure: ", actual) + + name, ext = os.path.splitext(json_response) + actual_filename = f"{name}{ext}" + actual_path = os.path.join( + Path(__file__).parent.parent.parent, + "data", + "responses", + actual_filename, + ) + + os.makedirs(os.path.dirname(actual_path), exist_ok=True) + + with open(actual_path, "w", encoding="utf-8") as f: + json.dump(actual, f, indent=2, sort_keys=True) + except Exception as e: raise AssertionError(f"Failed to parse API response as JSON: {e}") From 6909f59de59f84047a3f78df97fbdd1a8e4a3999 Mon Sep 17 00:00:00 2001 From: ivano mannella Date: Tue, 15 Jul 2025 11:14:53 +0100 Subject: [PATCH 09/10] Update RSV response JSON files and add new campaign configuration - Modified lastUpdated timestamps in AUTO_RSV_SB_003 to AUTO_RSV_SB_014.json files. - Updated eligibility cohorts and actions for RSV conditions in response files. - Changed status and statusText values to reflect actionable states. - Removed redundant COVID and MMR conditions from processedSuggestions. - Added new campaign configuration file AUTO_RSV_SB_001.json for automation testing. - Deleted obsolete patient_s3_sample.json file. --- tests/e2e/data/configs/AUTO_RSV_SB_001.json | 275 ++++++++++++++++++ .../e2e/data/in/dynamoDB/AUTO_RSV_SB_001.json | 6 +- .../e2e/data/in/dynamoDB/AUTO_RSV_SB_002.json | 4 + .../e2e/data/in/dynamoDB/AUTO_RSV_SB_003.json | 4 + .../e2e/data/in/dynamoDB/AUTO_RSV_SB_004.json | 6 +- .../e2e/data/in/dynamoDB/AUTO_RSV_SB_005.json | 6 +- .../e2e/data/in/dynamoDB/AUTO_RSV_SB_006.json | 7 +- .../e2e/data/in/dynamoDB/AUTO_RSV_SB_007.json | 13 +- .../e2e/data/in/dynamoDB/AUTO_RSV_SB_008.json | 6 +- .../e2e/data/in/dynamoDB/AUTO_RSV_SB_009.json | 8 +- .../e2e/data/in/dynamoDB/AUTO_RSV_SB_010.json | 8 +- .../e2e/data/in/dynamoDB/AUTO_RSV_SB_011.json | 4 + .../e2e/data/in/dynamoDB/AUTO_RSV_SB_012.json | 4 + .../e2e/data/in/dynamoDB/AUTO_RSV_SB_013.json | 6 +- .../e2e/data/in/dynamoDB/AUTO_RSV_SB_014.json | 6 +- tests/e2e/data/in/dynamoDB/test_data.json | 220 -------------- tests/e2e/data/responses/AUTO_RSV_SB_001.json | 48 +-- tests/e2e/data/responses/AUTO_RSV_SB_002.json | 48 +-- tests/e2e/data/responses/AUTO_RSV_SB_003.json | 48 +-- tests/e2e/data/responses/AUTO_RSV_SB_004.json | 50 +--- tests/e2e/data/responses/AUTO_RSV_SB_005.json | 50 +--- tests/e2e/data/responses/AUTO_RSV_SB_006.json | 50 +--- tests/e2e/data/responses/AUTO_RSV_SB_007.json | 53 ++-- tests/e2e/data/responses/AUTO_RSV_SB_008.json | 48 +-- tests/e2e/data/responses/AUTO_RSV_SB_009.json | 50 +--- tests/e2e/data/responses/AUTO_RSV_SB_010.json | 50 +--- tests/e2e/data/responses/AUTO_RSV_SB_011.json | 50 +--- tests/e2e/data/responses/AUTO_RSV_SB_012.json | 50 +--- tests/e2e/data/responses/AUTO_RSV_SB_013.json | 50 +--- tests/e2e/data/responses/AUTO_RSV_SB_014.json | 41 +-- tests/e2e/data/s3/AUTO_RSV_SB_001.json | 273 +++++++++++++++++ tests/e2e/data/s3/patient_s3_sample.json | 59 ---- 32 files changed, 800 insertions(+), 801 deletions(-) create mode 100644 tests/e2e/data/configs/AUTO_RSV_SB_001.json delete mode 100644 tests/e2e/data/in/dynamoDB/test_data.json create mode 100644 tests/e2e/data/s3/AUTO_RSV_SB_001.json delete mode 100644 tests/e2e/data/s3/patient_s3_sample.json diff --git a/tests/e2e/data/configs/AUTO_RSV_SB_001.json b/tests/e2e/data/configs/AUTO_RSV_SB_001.json new file mode 100644 index 000000000..f313f5691 --- /dev/null +++ b/tests/e2e/data/configs/AUTO_RSV_SB_001.json @@ -0,0 +1,275 @@ +{ + "CampaignConfig": { + "ID": "<>", + "Version": "1", + "Name": "Automation RSV - Smoke Test Config", + "Type": "V", + "Target": "RSV", + "Manager": "person1@nhs.net", + "Approver": "person1@nhs.net", + "Reviewer": "person1@nhs.net", + "IterationFrequency": "X", + "IterationType": "O", + "IterationTime": "07:00:00", + "DefaultCommsRouting": "BOOK_LOCAL", + "StartDate": "20250601", + "EndDate": "20260601", + "ApprovalMinimum": 0, + "ApprovalMaximum": 0, + "Iterations": [ + { + "ID": ",<>", + "DefaultCommsRouting": "BOOK_LOCAL", + "Version": "1", + "Name": "Automation RSV - Smoke Test Config Iteration", + "IterationDate": "20250601", + "IterationNumber": 1, + "CommsType": "I", + "ApprovalMinimum": 0, + "ApprovalMaximum": 0, + "Type": "O", + "IterationCohorts": [ + { + "CohortLabel": "rsv_75_rolling", + "CohortGroup": "rsv_age_rolling", + "PositiveDescription": "are aged 75 to 79 years old.", + "NegativeDescription": "are not aged 75 to 79 years old.", + "Priority": 0 + }, + { + "CohortLabel": "rsv_75to79_2024", + "CohortGroup": "rsv_age_catchup", + "PositiveDescription": "turned 80 between 2nd September 2024 and 31st August 2025", + "NegativeDescription": "did not turn 80 between 2nd September 2024 and 31st August 2025", + "Priority": 10 + }, + { + "CohortLabel": "elid_all_people", + "CohortGroup": "elid_all_people", + "PositiveDescription": "", + "NegativeDescription": "", + "Priority": 20 + }, + { + "CohortLabel": "no_group_description", + "CohortGroup": "", + "PositiveDescription": "", + "NegativeDescription": "", + "Priority": 30 + } + ], + "IterationRules": [ + { + "Type": "F", + "Name": "Assure only already vaccinated taken from magic cohort", + "Description": "Exclude anyone who has NOT been given a dose of RSV Vaccination from the magic cohort", + "Operator": "is_empty", + "Comparator": "", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "elid_all_people", + "Priority": 100 + }, + { + "Type": "F", + "Name": "Under Age - Under 75 Years on day of execution", + "Description": "Ensure anyone who has a PDS date of birth which determines their age to be less than 75 years is filtered out.", + "Priority": 120, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "Y>", + "Comparator": "-75", + "CohortLabel": "rsv_75to79_2024" + }, + { + "Type": "F", + "Name": "Under Age - Under 75 Years on day of execution", + "Description": "Ensure anyone who has a PDS date of birth which determines their age to be less than 75 years is filtered out.", + "Priority": 125, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "Y>", + "Comparator": "-75", + "CohortLabel": "rsv_75_rolling" + }, + { + "Type": "F", + "Name": "Exclude Too OLD", + "Description": "Exclude anyone over 80", + "Priority": 130, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "<", + "Comparator": "-80" + }, + { + "Type": "S", + "Name": "AlreadyVaccinated", + "Description": "##You've had your RSV vaccination\nWe believe you had your vaccination on <>.", + "Priority": 550, + "AttributeLevel": "TARGET", + "AttributeTarget": "RSV", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "Operator": "Y>=", + "Comparator": "-25", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "NotAvailable", + "Description": "NotAvailable|Vaccinations are not currently available.", + "Priority": 510, + "AttributeLevel": "PERSON", + "AttributeName": "ICB", + "Operator": "=", + "Comparator": "SUPPRESSED_ICB" + }, + { + "Type": "S", + "Name": "NotYetDue", + "Description": "NotYetDue|Your next dose is not yet due.", + "Priority": 520, + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "Operator": "=", + "Comparator": "20250326", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "TooClose", + "Description": "TooClose|Your previous vaccination was less than 91 days ago.", + "Priority": 530, + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "Operator": "=", + "Comparator": "20250327", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "OtherSetting", + "Description": "OtherSetting|## Getting the vaccine\n\nOur record show you're living in a setting where care is provided.\n\nIf you think you should have the RSV vaccine, speak to a member of staff where you live.", + "Priority": 540, + "AttributeLevel": "PERSON", + "AttributeName": "CARE_HOME_FLAG", + "Operator": "=", + "Comparator": "Y" + }, + { + "Type": "R", + "Name": "Actionable Not Vaccinated", + "Description": "Book An Appointment", + "Priority": 1010, + "Operator": "is_empty", + "Comparator": "", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CommsRouting": "BOOK_NBS" + }, + { + "Type": "R", + "Name": "Actionable Not Vaccinated", + "Description": "Book An Appointment", + "Priority": 1010, + "Operator": "=", + "Comparator": "LS2", + "AttributeLevel": "PERSON", + "AttributeName": "POSTCODE_SECTOR", + "CommsRouting": "BOOK_NBS" + }, + { + "Type": "R", + "Name": "Actionable Future Booked Appointment", + "Description": "Actionable Future Booked Appointment", + "Priority": 1020, + "Operator": ">=", + "Comparator": "0", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_DATE", + "CommsRouting": "AMEND_NBS" + }, + { + "Type": "R", + "Name": "Actionable Future Booked Appointment", + "Description": "Actionable Future Booked Appointment", + "Priority": 1020, + "Operator": "=", + "Comparator": "NBS", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_PROVIDER", + "CommsRouting": "AMEND_NBS" + }, + { + "Type": "R", + "Name": "Actionable Future Booked Appointment", + "Description": "Actionable Future Booked Appointment", + "Priority": 1030, + "Operator": ">=", + "Comparator": "0", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_DATE", + "CommsRouting": "MANAGE_LOCAL" + }, + { + "Type": "R", + "Name": "Actionable Future Booked Appointment", + "Description": "Actionable Future Booked Appointment", + "Priority": 1030, + "Operator": "!=", + "Comparator": "NBS", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_PROVIDER", + "CommsRouting": "MANAGE_LOCAL" + } + ], + "ActionsMapper": { + "BOOK_NBS": { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking" + }, + "AMEND_NBS": { + "ExternalRoutingCode": "AmendNBS", + "ActionDescription": "##You have an RSV vaccination appointment\nYou can view, change or cancel your appointment below.", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Manage your appointment" + }, + "CONTACT_GP": { + "ExternalRoutingCode": "ContactGP", + "ActionDescription": "Contact your GP", + "ActionType": "InfoText" + }, + "BOOK_LOCAL": { + "ExternalRoutingCode": "BookLocal", + "ActionDescription": "##Getting the vaccine\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "ActionType": "InfoText" + }, + "MANAGE_LOCAL": { + "ExternalRoutingCode": "ManageLocal", + "ActionDescription": "##You have an RSV vaccination appointment\nContact your healthcare provider to change or cancel your appointment.", + "ActionType": "CardWithText" + + }, + "CHECK_CORRECT": { + "ExternalRoutingCode": "CheckCorrect", + "ActionDescription": "##If you think this is incorrect\\nIf you have not had this vaccination and you think you should, speak to your healthcare professional.", + "ActionType": "InfoText" + + } + } + } + ] + } +} diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_001.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_001.json index 457437429..e8cba4cf0 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_001.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_001.json @@ -1,5 +1,9 @@ { "scenario_name": "RSV - Actionable due to membership of an Age Cohort incl. suggested actions (with booking)", + "request_headers": { + "nhs-login-nhs-number": "5000000001" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000001", @@ -24,7 +28,7 @@ "DATE_OF_BIRTH": "<>", "GENDER": "0", "POSTCODE": "LS1 1AB", - "POSTCODE_SECTOR": "LS1", + "POSTCODE_SECTOR": "LS2", "POSTCODE_OUTCODE": "1AB", "MSOA": "E02001111", "LSOA": "E01005348", diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_002.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_002.json index 78026a684..f6bda7144 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_002.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_002.json @@ -1,5 +1,9 @@ { "scenario_name": "RSV - Actionable due to membership of an Age Cohort incl. suggested action (not booking)", + "request_headers": { + "nhs-login-nhs-number": "5000000002" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000002", diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_003.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_003.json index df7ceced6..fca3a419b 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_003.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_003.json @@ -1,5 +1,9 @@ { "scenario_name": "RSV - Actionable due to membership of an alternative Age Cohort incl. suggested action", + "request_headers": { + "nhs-login-nhs-number": "5000000003" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000003", diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_004.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_004.json index ed6764ede..5339dfac6 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_004.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_004.json @@ -1,5 +1,9 @@ { "scenario_name": "RSV - Actionable due to membership of an Age Cohort incl. suggested action (existing national booking)", + "request_headers": { + "nhs-login-nhs-number": "5000000004" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000004", @@ -7,7 +11,7 @@ "COHORT_MAP": { "cohorts": { "M": { - "rsv_75_rolling": { + "no_group_description": { "M": { "dateJoined": { "S": "20230515" diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_005.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_005.json index 1892dd217..d4f4265ef 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_005.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_005.json @@ -1,5 +1,9 @@ { "scenario_name": "RSV - Actionable due to membership of an Age Cohort incl. suggested actions (with local booking)", + "request_headers": { + "nhs-login-nhs-number": "5000000005" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000005", @@ -7,7 +11,7 @@ "COHORT_MAP": { "cohorts": { "M": { - "rsv_75_rolling": { + "no_group_description": { "M": { "dateJoined": { "S": "20230515" diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_006.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_006.json index f1101ccc4..ba55e3091 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_006.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_006.json @@ -1,5 +1,10 @@ { "scenario_name": "RSV - Not Actionable despite membership of an Age Cohort, already vaccinated", + "request_headers": { + "nhs-login-nhs-number": "5000000006" + }, + "config_filename": "AUTO_RSV_SB_001.json", + "notes": "actions need updating in the response when the functionality is delivered to provide actions for not_actionable responses", "data": [ { "NHS_NUMBER": "5000000006", @@ -7,7 +12,7 @@ "COHORT_MAP": { "cohorts": { "M": { - "rsv_75_rolling": { + "no_group_description": { "M": { "dateJoined": { "S": "20230515" diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_007.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_007.json index 3c9c46c5e..ac8cb72bc 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_007.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_007.json @@ -1,5 +1,9 @@ { - "scenario_name": "RSV - Not Actionable, membership of Age Cohort, no available vaccinations (not available type 1)", + "scenario_name": "RSV - Not Actionable despite to membership of an Age Cohort with reasoning of no available vaccinations (not available type 1)", + "request_headers": { + "nhs-login-nhs-number": "5000000007" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000007", @@ -30,16 +34,11 @@ "LSOA": "E01005348", "GP_PRACTICE_CODE": "B87008", "PCN": "U43084", - "ICB": "<>", + "ICB": "SUPPRESSED_ICB", "COMMISSIONING_REGION": "Y63", "13Q_FLAG": "N", "CARE_HOME_FLAG": "N", "DE_FLAG": "N" - }, - { - "NHS_NUMBER": "5000000007", - "ATTRIBUTE_TYPE": "RSV", - "LAST_SUCCESSFUL_DATE": "<>" } ] } diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_008.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_008.json index 25f7e19fa..8aa367705 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_008.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_008.json @@ -1,5 +1,9 @@ { "scenario_name": "RSV - No RSV response as no active campaign (not available type 2)", + "request_headers": { + "nhs-login-nhs-number": "5000000008" + }, + "config_filename": "AUTO_RSV_SB_008.json", "data": [ { "NHS_NUMBER": "5000000008", @@ -39,7 +43,7 @@ { "NHS_NUMBER": "5000000008", "ATTRIBUTE_TYPE": "RSV", - "BOOKED_APPOINTMENT_DATE": "2024-07-01" + "BOOKED_APPOINTMENT_DATE": "<>" } ] } diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_009.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_009.json index 753398d0a..aa661f5a1 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_009.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_009.json @@ -1,5 +1,9 @@ { - "scenario_name": "RSV - Not Actionable, membership of Age Cohort, dose not yet due", + "scenario_name": "RSV - Not Actionable despite to membership of an Age Cohort with reasoning of dose not yet due", + "request_headers": { + "nhs-login-nhs-number": "5000000009" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000009", @@ -39,7 +43,7 @@ { "NHS_NUMBER": "5000000009", "ATTRIBUTE_TYPE": "RSV", - "LAST_SUCCESSFUL_DATE": "2023-07-01" + "LAST_SUCCESSFUL_DATE": "20250326" } ] } diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_010.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_010.json index 7eb7d0488..0bd87ebce 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_010.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_010.json @@ -1,5 +1,9 @@ { - "scenario_name": "RSV - Not Actionable, membership of Age Cohort, dose not far enough apart", + "scenario_name": "RSV - Not Actionable despite to membership of an Age Cohort with reasoning of dose not far enough apart", + "request_headers": { + "nhs-login-nhs-number": "5000000010" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000010", @@ -39,7 +43,7 @@ { "NHS_NUMBER": "5000000010", "ATTRIBUTE_TYPE": "RSV", - "LAST_SUCCESSFUL_DATE": "2023-07-01" + "LAST_SUCCESSFUL_DATE": "20250327" } ] } diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_011.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_011.json index 9626840b3..46f43270a 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_011.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_011.json @@ -1,5 +1,9 @@ { "scenario_name": "RSV - Not Actionable despite to membership of an Age Cohort with reasoning of vaccination given in other setting (e.g. care home)", + "request_headers": { + "nhs-login-nhs-number": "5000000011" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000011", diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_012.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_012.json index b7ff528f2..1c2d8a4a6 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_012.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_012.json @@ -1,5 +1,9 @@ { "scenario_name": "RSV - Not Actionable despite no cohort membership with reasoning of already vaccinated (type 1 includes unknown cohort)", + "request_headers": { + "nhs-login-nhs-number": "5000000012" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000012", diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_013.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_013.json index c0f69b5dc..4f48a06bb 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_013.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_013.json @@ -1,5 +1,9 @@ { "scenario_name": "RSV - Not Actionable despite no cohort membership with reasoning of already vaccinated (type 2 includes no cohorts)", + "request_headers": { + "nhs-login-nhs-number": "5000000013" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000013", @@ -37,7 +41,7 @@ "DE_FLAG": "N" }, { - "NHS_NUMBER": "50000000014", + "NHS_NUMBER": "5000000013", "ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": "<>" } diff --git a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_014.json b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_014.json index 5cd1e21d7..f6945cb28 100644 --- a/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_014.json +++ b/tests/e2e/data/in/dynamoDB/AUTO_RSV_SB_014.json @@ -1,5 +1,9 @@ { "scenario_name": "RSV - Not Eligible", + "request_headers": { + "nhs-login-nhs-number": "5000000014" + }, + "config_filename": "AUTO_RSV_SB_001.json", "data": [ { "NHS_NUMBER": "5000000014", @@ -19,7 +23,7 @@ } }, { - "NHS_NUMBER": "50000000014", + "NHS_NUMBER": "5000000014", "ATTRIBUTE_TYPE": "PERSON", "DATE_OF_BIRTH": "<>", "GENDER": "0", diff --git a/tests/e2e/data/in/dynamoDB/test_data.json b/tests/e2e/data/in/dynamoDB/test_data.json deleted file mode 100644 index f5c80e4b5..000000000 --- a/tests/e2e/data/in/dynamoDB/test_data.json +++ /dev/null @@ -1,220 +0,0 @@ -[ - { - "NHS_NUMBER": "0000000001", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "19960302", - "GENDER": 1, - "POSTCODE": "S18 C1X", - "POSTCODE_SECTOR": "S181", - "POSTCODE_OUTCODE": "S18", - "GP_PRACTICE_CODE": "U69384", - "PCN": "QJ2" - }, - { - "NHS_NUMBER": "0000000001", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "Flu", - "STATUS": "Actionable" - }, - { - "NHS_NUMBER": "0000000001", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "RSV", - "STATUS": "NotEligible" - }, - { - "NHS_NUMBER": "0000000002", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "19890518", - "GENDER": 1, - "POSTCODE": "S18 B1X", - "POSTCODE_SECTOR": "S181", - "POSTCODE_OUTCODE": "S18", - "GP_PRACTICE_CODE": "U33827", - "PCN": "RJ3" - }, - { - "NHS_NUMBER": "0000000002", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "Flu", - "STATUS": "Actionable" - }, - { - "NHS_NUMBER": "0000000002", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "RSV", - "STATUS": "NotActionable" - }, - { - "NHS_NUMBER": "0000000003", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "19780323", - "GENDER": 1, - "POSTCODE": "S18 X9X", - "POSTCODE_SECTOR": "S189", - "POSTCODE_OUTCODE": "S18", - "GP_PRACTICE_CODE": "U90732", - "PCN": "QJ2" - }, - { - "NHS_NUMBER": "0000000003", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "Flu", - "STATUS": "Actionable" - }, - { - "NHS_NUMBER": "0000000004", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "19570120", - "GENDER": 0, - "POSTCODE": "S18 B6X", - "POSTCODE_SECTOR": "S186", - "POSTCODE_OUTCODE": "S18", - "GP_PRACTICE_CODE": "U52353", - "PCN": "LM8" - }, - { - "NHS_NUMBER": "0000000004", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "RSV", - "STATUS": "Actionable" - }, - { - "NHS_NUMBER": "0000000004", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "RSV", - "STATUS": "NotEligible" - }, - { - "NHS_NUMBER": "0000000005", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "19910411", - "GENDER": 1, - "POSTCODE": "S18 B5X", - "POSTCODE_SECTOR": "S185", - "POSTCODE_OUTCODE": "S18", - "GP_PRACTICE_CODE": "U54464", - "PCN": "LM8" - }, - { - "NHS_NUMBER": "0000000005", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "RSV", - "STATUS": "NotActionable" - }, - { - "NHS_NUMBER": "0000000005", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "Covid", - "STATUS": "NotEligible" - }, - { - "NHS_NUMBER": "0000000006", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "19450206", - "GENDER": 1, - "POSTCODE": "S18 B9X", - "POSTCODE_SECTOR": "S189", - "POSTCODE_OUTCODE": "S18", - "GP_PRACTICE_CODE": "U97038", - "PCN": "RJ3" - }, - { - "NHS_NUMBER": "0000000006", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "RSV", - "STATUS": "Actionable" - }, - { - "NHS_NUMBER": "0000000006", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "Flu", - "STATUS": "NotEligible" - }, - { - "NHS_NUMBER": "0000000007", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "19590705", - "GENDER": 0, - "POSTCODE": "S18 B3X", - "POSTCODE_SECTOR": "S183", - "POSTCODE_OUTCODE": "S18", - "GP_PRACTICE_CODE": "U56174", - "PCN": "TK9" - }, - { - "NHS_NUMBER": "0000000007", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "Covid", - "STATUS": "NotEligible" - }, - { - "NHS_NUMBER": "0000000007", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "Covid", - "STATUS": "Actionable" - }, - { - "NHS_NUMBER": "0000000008", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "19990716", - "GENDER": 0, - "POSTCODE": "S18 A5X", - "POSTCODE_SECTOR": "S185", - "POSTCODE_OUTCODE": "S18", - "GP_PRACTICE_CODE": "U33386", - "PCN": "QJ2" - }, - { - "NHS_NUMBER": "0000000008", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "Flu", - "STATUS": "NotActionable" - }, - { - "NHS_NUMBER": "0000000009", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "20100426", - "GENDER": 0, - "POSTCODE": "S18 X4X", - "POSTCODE_SECTOR": "S184", - "POSTCODE_OUTCODE": "S18", - "GP_PRACTICE_CODE": "U94864", - "PCN": "LM8" - }, - { - "NHS_NUMBER": "0000000009", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "Flu", - "STATUS": "Actionable" - }, - { - "NHS_NUMBER": "0000000009", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "Flu", - "STATUS": "NotActionable" - }, - { - "NHS_NUMBER": "0000000010", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "19931007", - "GENDER": 1, - "POSTCODE": "S18 B5X", - "POSTCODE_SECTOR": "S185", - "POSTCODE_OUTCODE": "S18", - "GP_PRACTICE_CODE": "U74914", - "PCN": "LM8" - }, - { - "NHS_NUMBER": "0000000010", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "RSV", - "STATUS": "NotEligible" - }, - { - "NHS_NUMBER": "0000000010", - "ATTRIBUTE_TYPE": "ELIGIBILITY", - "CONDITION": "Flu", - "STATUS": "NotEligible" - } -] diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_001.json b/tests/e2e/data/responses/AUTO_RSV_SB_001.json index a79499e66..a45608ae2 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_001.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_001.json @@ -1,50 +1,30 @@ { "meta": { - "lastUpdated": "2025-07-15T09:44:41.227460+00:00" + "lastUpdated": "2025-07-15T10:11:33.599540+00:00" }, "processedSuggestions": [ { - "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ + "actions": [ { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" + "actionCode": "BookNBS", + "actionType": "ButtonWithAuthLink", + "description": "", + "urlLabel": "Continue to booking", + "urlLink": "http://www.nhs.uk/book-rsv" } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", - "eligibilityCohorts": [ - { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], "condition": "RSV", "eligibilityCohorts": [ { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "cohortCode": "rsv_age_rolling", + "cohortStatus": "Actionable", + "cohortText": "are aged 75 to 79 years old." } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", + "status": "Actionable", + "statusText": "Status.actionable", "suitabilityRules": [] } ], - "responseId": "7a57f8e6-1e7c-457a-af83-9968f68383d6" -} + "responseId": "dfbf7f95-adc3-46e7-be9a-e08104e8806c" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_002.json b/tests/e2e/data/responses/AUTO_RSV_SB_002.json index 600d9aa75..b9ddd62ec 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_002.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_002.json @@ -1,50 +1,30 @@ { "meta": { - "lastUpdated": "2025-07-15T09:44:44.544273+00:00" + "lastUpdated": "2025-07-15T10:11:36.524070+00:00" }, "processedSuggestions": [ { - "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ + "actions": [ { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" + "actionCode": "BookLocal", + "actionType": "InfoText", + "description": "##Getting the vaccine\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "urlLabel": "", + "urlLink": "" } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", - "eligibilityCohorts": [ - { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], "condition": "RSV", "eligibilityCohorts": [ { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "cohortCode": "rsv_age_rolling", + "cohortStatus": "Actionable", + "cohortText": "are aged 75 to 79 years old." } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", + "status": "Actionable", + "statusText": "Status.actionable", "suitabilityRules": [] } ], - "responseId": "a1264578-da65-46fa-92ba-c1a09d498f31" -} + "responseId": "0ec1b335-129a-42cb-b74e-81c42a07bb3d" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_003.json b/tests/e2e/data/responses/AUTO_RSV_SB_003.json index c60b9ad6c..345f4fcf0 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_003.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_003.json @@ -1,50 +1,30 @@ { "meta": { - "lastUpdated": "2025-07-15T09:44:47.658192+00:00" + "lastUpdated": "2025-07-15T10:11:39.467491+00:00" }, "processedSuggestions": [ { - "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ + "actions": [ { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" + "actionCode": "BookLocal", + "actionType": "InfoText", + "description": "##Getting the vaccine\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "urlLabel": "", + "urlLink": "" } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", - "eligibilityCohorts": [ - { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], "condition": "RSV", "eligibilityCohorts": [ { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "cohortCode": "rsv_age_catchup", + "cohortStatus": "Actionable", + "cohortText": "turned 80 between 2nd September 2024 and 31st August 2025" } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", + "status": "Actionable", + "statusText": "Status.actionable", "suitabilityRules": [] } ], - "responseId": "98b7c6ec-13a5-48bc-af50-94ceaaa10893" -} + "responseId": "30de7114-1ff9-410f-be9b-7ff8327e78b3" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_004.json b/tests/e2e/data/responses/AUTO_RSV_SB_004.json index 14743f8cc..3c847a7ca 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_004.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_004.json @@ -1,50 +1,24 @@ { "meta": { - "lastUpdated": "2025-07-15T09:44:50.667999+00:00" + "lastUpdated": "2025-07-15T10:11:42.509859+00:00" }, "processedSuggestions": [ { - "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ + "actions": [ { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" + "actionCode": "AmendNBS", + "actionType": "ButtonWithAuthLink", + "description": "##You have an RSV vaccination appointment\nYou can view, change or cancel your appointment below.", + "urlLabel": "Manage your appointment", + "urlLink": "http://www.nhs.uk/book-rsv" } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", - "eligibilityCohorts": [ - { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], "condition": "RSV", - "eligibilityCohorts": [ - { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", + "eligibilityCohorts": [], + "status": "Actionable", + "statusText": "Status.actionable", "suitabilityRules": [] } ], - "responseId": "317d721a-1ffd-4621-b660-562d8143501d" -} + "responseId": "139f2328-0695-482b-9e30-2223046ca7e2" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_005.json b/tests/e2e/data/responses/AUTO_RSV_SB_005.json index 6ae9d96d6..92bcdd6aa 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_005.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_005.json @@ -1,50 +1,24 @@ { "meta": { - "lastUpdated": "2025-07-15T09:44:53.772820+00:00" + "lastUpdated": "2025-07-15T10:11:49.021445+00:00" }, "processedSuggestions": [ { - "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ + "actions": [ { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" + "actionCode": "ManageLocal", + "actionType": "CardWithText", + "description": "##You have an RSV vaccination appointment\nContact your healthcare provider to change or cancel your appointment.", + "urlLabel": "", + "urlLink": "" } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", - "eligibilityCohorts": [ - { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], "condition": "RSV", - "eligibilityCohorts": [ - { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", + "eligibilityCohorts": [], + "status": "Actionable", + "statusText": "Status.actionable", "suitabilityRules": [] } ], - "responseId": "ac77be57-2406-401d-b195-9728507f7559" -} + "responseId": "d340ebbd-de36-40d9-8da2-54b2c019225b" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_006.json b/tests/e2e/data/responses/AUTO_RSV_SB_006.json index cbfa02300..f7e0be9ab 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_006.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_006.json @@ -1,50 +1,22 @@ { "meta": { - "lastUpdated": "2025-07-15T09:44:56.893166+00:00" + "lastUpdated": "2025-07-15T10:11:52.192023+00:00" }, "processedSuggestions": [ - { - "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ - { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", - "eligibilityCohorts": [ - { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, { "actions": [], "condition": "RSV", - "eligibilityCohorts": [ + "eligibilityCohorts": [], + "status": "NotActionable", + "statusText": "Status.not_actionable", + "suitabilityRules": [ { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "ruleCode": "AlreadyVaccinated", + "ruleText": "##You've had your RSV vaccination\nWe believe you had your vaccination on <>.", + "ruleType": "S" } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] + ] } ], - "responseId": "aef6e6ca-de67-4af5-b439-76381d0d9871" -} + "responseId": "0d64cba6-96af-49e5-8250-d3471a57da97" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_007.json b/tests/e2e/data/responses/AUTO_RSV_SB_007.json index f4a1b6ca0..29ad85800 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_007.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_007.json @@ -1,50 +1,33 @@ { "meta": { - "lastUpdated": "2025-07-15T09:44:59.937916+00:00" + "lastUpdated": "2025-07-15T10:11:55.007429+00:00" }, "processedSuggestions": [ { "actions": [], - "condition": "COVID", + "condition": "RSV", "eligibilityCohorts": [ { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" + "cohortCode": "rsv_age_rolling", + "cohortStatus": "NotActionable", + "cohortText": "are aged 75 to 79 years old." } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", - "eligibilityCohorts": [ + "status": "NotActionable", + "statusText": "Status.not_actionable", + "suitabilityRules": [ { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "RSV", - "eligibilityCohorts": [ + "ruleCode": "NotAvailable", + "ruleText": "NotAvailable|Vaccinations are not currently available.", + "ruleType": "S" + }, { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "ruleCode": "AlreadyVaccinated", + "ruleText": "##You've had your RSV vaccination\nWe believe you had your vaccination on <>.", + "ruleType": "S" } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] + ] } ], - "responseId": "68f50ad9-6bcf-423e-b6af-57c7c0e3b91d" -} + "responseId": "7b302c89-d3c8-4194-91e8-a9717330e91b" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_008.json b/tests/e2e/data/responses/AUTO_RSV_SB_008.json index d6b1b2fba..3d11dcb87 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_008.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_008.json @@ -1,50 +1,30 @@ { "meta": { - "lastUpdated": "2025-07-15T09:45:02.985622+00:00" + "lastUpdated": "2025-07-15T10:11:58.006524+00:00" }, "processedSuggestions": [ { - "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ + "actions": [ { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" + "actionCode": "ManageLocal", + "actionType": "CardWithText", + "description": "##You have an RSV vaccination appointment\nContact your healthcare provider to change or cancel your appointment.", + "urlLabel": "", + "urlLink": "" } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", - "eligibilityCohorts": [ - { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], "condition": "RSV", "eligibilityCohorts": [ { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "cohortCode": "rsv_age_rolling", + "cohortStatus": "Actionable", + "cohortText": "are aged 75 to 79 years old." } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", + "status": "Actionable", + "statusText": "Status.actionable", "suitabilityRules": [] } ], - "responseId": "01ef2722-aea5-44c5-ace5-cc9e47d58bb4" -} + "responseId": "8090f190-cd7c-4f43-aa77-d0e6f074ac3f" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_009.json b/tests/e2e/data/responses/AUTO_RSV_SB_009.json index abc39b615..0ad707d50 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_009.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_009.json @@ -1,50 +1,28 @@ { "meta": { - "lastUpdated": "2025-07-15T09:45:06.346173+00:00" + "lastUpdated": "2025-07-15T10:12:01.690297+00:00" }, "processedSuggestions": [ { "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ - { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", + "condition": "RSV", "eligibilityCohorts": [ { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" + "cohortCode": "rsv_age_rolling", + "cohortStatus": "NotActionable", + "cohortText": "are aged 75 to 79 years old." } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "RSV", - "eligibilityCohorts": [ + "status": "NotActionable", + "statusText": "Status.not_actionable", + "suitabilityRules": [ { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "ruleCode": "NotYetDue", + "ruleText": "NotYetDue|Your next dose is not yet due.", + "ruleType": "S" } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] + ] } ], - "responseId": "99760fc3-0345-4276-aa3d-7e7b6b9171b3" -} + "responseId": "f70b8f89-42f2-4451-8ef5-cabd542618a7" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_010.json b/tests/e2e/data/responses/AUTO_RSV_SB_010.json index 8265f8648..58996d204 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_010.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_010.json @@ -1,50 +1,28 @@ { "meta": { - "lastUpdated": "2025-07-15T09:45:09.324017+00:00" + "lastUpdated": "2025-07-15T10:12:04.565581+00:00" }, "processedSuggestions": [ { "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ - { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", + "condition": "RSV", "eligibilityCohorts": [ { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" + "cohortCode": "rsv_age_rolling", + "cohortStatus": "NotActionable", + "cohortText": "are aged 75 to 79 years old." } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "RSV", - "eligibilityCohorts": [ + "status": "NotActionable", + "statusText": "Status.not_actionable", + "suitabilityRules": [ { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "ruleCode": "TooClose", + "ruleText": "TooClose|Your previous vaccination was less than 91 days ago.", + "ruleType": "S" } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] + ] } ], - "responseId": "f1ad137a-0426-486b-9f07-693a1861e03a" -} + "responseId": "3d3db0f9-ccf8-4e39-a905-37e060317e0b" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_011.json b/tests/e2e/data/responses/AUTO_RSV_SB_011.json index d5065515f..24706fcf3 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_011.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_011.json @@ -1,50 +1,28 @@ { "meta": { - "lastUpdated": "2025-07-15T09:45:12.371500+00:00" + "lastUpdated": "2025-07-15T10:12:07.347052+00:00" }, "processedSuggestions": [ { "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ - { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", + "condition": "RSV", "eligibilityCohorts": [ { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" + "cohortCode": "rsv_age_rolling", + "cohortStatus": "NotActionable", + "cohortText": "are aged 75 to 79 years old." } ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "RSV", - "eligibilityCohorts": [ + "status": "NotActionable", + "statusText": "Status.not_actionable", + "suitabilityRules": [ { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "ruleCode": "OtherSetting", + "ruleText": "OtherSetting|## Getting the vaccine\n\nOur record show you're living in a setting where care is provided.\n\nIf you think you should have the RSV vaccine, speak to a member of staff where you live.", + "ruleType": "S" } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] + ] } ], - "responseId": "facc346b-8b1b-444c-aabf-b1904fc89eee" -} + "responseId": "156cf0b8-e493-4b0d-99cf-805d01930c2e" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_012.json b/tests/e2e/data/responses/AUTO_RSV_SB_012.json index 1a6ce1af0..e6da2c32d 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_012.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_012.json @@ -1,50 +1,22 @@ { "meta": { - "lastUpdated": "2025-07-15T09:45:15.488082+00:00" + "lastUpdated": "2025-07-15T10:12:10.102981+00:00" }, "processedSuggestions": [ - { - "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ - { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", - "eligibilityCohorts": [ - { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, { "actions": [], "condition": "RSV", - "eligibilityCohorts": [ + "eligibilityCohorts": [], + "status": "NotActionable", + "statusText": "Status.not_actionable", + "suitabilityRules": [ { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "ruleCode": "AlreadyVaccinated", + "ruleText": "##You've had your RSV vaccination\nWe believe you had your vaccination on <>.", + "ruleType": "S" } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] + ] } ], - "responseId": "174cd140-275c-4612-a162-3a6b2e6e5cc5" -} + "responseId": "fc41e4b4-818e-4958-a634-31d699592f3a" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_013.json b/tests/e2e/data/responses/AUTO_RSV_SB_013.json index a66ae31e5..249a18794 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_013.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_013.json @@ -1,50 +1,22 @@ { "meta": { - "lastUpdated": "2025-07-15T09:45:18.471060+00:00" + "lastUpdated": "2025-07-15T10:12:13.045041+00:00" }, "processedSuggestions": [ - { - "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ - { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", - "eligibilityCohorts": [ - { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, { "actions": [], "condition": "RSV", - "eligibilityCohorts": [ + "eligibilityCohorts": [], + "status": "NotActionable", + "statusText": "Status.not_actionable", + "suitabilityRules": [ { - "cohortCode": "rsv_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "ruleCode": "AlreadyVaccinated", + "ruleText": "##You've had your RSV vaccination\nWe believe you had your vaccination on <>.", + "ruleType": "S" } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] + ] } ], - "responseId": "aa6774cb-2a42-4969-bd6d-fe6959236fea" -} + "responseId": "a0d01de1-4e56-4b68-a8dc-5cfbca732490" +} \ No newline at end of file diff --git a/tests/e2e/data/responses/AUTO_RSV_SB_014.json b/tests/e2e/data/responses/AUTO_RSV_SB_014.json index 9872adfbe..32c7e0c5f 100644 --- a/tests/e2e/data/responses/AUTO_RSV_SB_014.json +++ b/tests/e2e/data/responses/AUTO_RSV_SB_014.json @@ -1,44 +1,21 @@ { "meta": { - "lastUpdated": "2025-07-15T09:45:21.380241+00:00" + "lastUpdated": "2025-07-15T10:12:15.890666+00:00" }, "processedSuggestions": [ { "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ - { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in a covid cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", + "condition": "RSV", "eligibilityCohorts": [ { - "cohortCode": "mmr_cohort_group", + "cohortCode": "rsv_age_rolling", "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "Status.not_eligible", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "RSV", - "eligibilityCohorts": [ + "cohortText": "are not aged 75 to 79 years old." + }, { - "cohortCode": "rsv_cohort_group", + "cohortCode": "rsv_age_catchup", "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV cohort" + "cohortText": "did not turn 80 between 2nd September 2024 and 31st August 2025" } ], "status": "NotEligible", @@ -46,5 +23,5 @@ "suitabilityRules": [] } ], - "responseId": "fbf62ad8-3160-40a4-a813-9108e5f8bea0" -} + "responseId": "c321bd59-df13-4e06-a61f-b08a82865cca" +} \ No newline at end of file diff --git a/tests/e2e/data/s3/AUTO_RSV_SB_001.json b/tests/e2e/data/s3/AUTO_RSV_SB_001.json new file mode 100644 index 000000000..bcf054503 --- /dev/null +++ b/tests/e2e/data/s3/AUTO_RSV_SB_001.json @@ -0,0 +1,273 @@ +{ + "CampaignConfig": { + "ID": "<>", + "Version": "1", + "Name": "Automation RSV - Smoke Test Config", + "Type": "V", + "Target": "RSV", + "Manager": "person1@nhs.net", + "Approver": "person1@nhs.net", + "Reviewer": "person1@nhs.net", + "IterationFrequency": "X", + "IterationType": "O", + "IterationTime": "07:00:00", + "DefaultCommsRouting": "BOOK_LOCAL", + "StartDate": "20250601", + "EndDate": "20260601", + "ApprovalMinimum": 0, + "ApprovalMaximum": 0, + "Iterations": [ + { + "ID": ",<>", + "DefaultCommsRouting": "BOOK_LOCAL", + "Version": "1", + "Name": "Automation RSV - Smoke Test Config Iteration", + "IterationDate": "20250601", + "IterationNumber": 1, + "CommsType": "I", + "ApprovalMinimum": 0, + "ApprovalMaximum": 0, + "Type": "O", + "IterationCohorts": [ + { + "CohortLabel": "rsv_75_rolling", + "CohortGroup": "rsv_age_rolling", + "PositiveDescription": "are aged 75 to 79 years old.", + "NegativeDescription": "are not aged 75 to 79 years old.", + "Priority": 0 + }, + { + "CohortLabel": "rsv_75to79_2024", + "CohortGroup": "rsv_age_catchup", + "PositiveDescription": "turned 80 between 2nd September 2024 and 31st August 2025", + "NegativeDescription": "did not turn 80 between 2nd September 2024 and 31st August 2025", + "Priority": 10 + }, + { + "CohortLabel": "elid_all_people", + "CohortGroup": "elid_all_people", + "PositiveDescription": "", + "NegativeDescription": "", + "Priority": 20 + }, + { + "CohortLabel": "no_group_description", + "CohortGroup": "", + "PositiveDescription": "", + "NegativeDescription": "", + "Priority": 30 + } + ], + "IterationRules": [ + { + "Type": "F", + "Name": "Assure only already vaccinated taken from magic cohort", + "Description": "Exclude anyone who has NOT been given a dose of RSV Vaccination from the magic cohort", + "Operator": "is_empty", + "Comparator": "", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "elid_all_people", + "Priority": 100 + }, + { + "Type": "F", + "Name": "Under Age - Under 75 Years on day of execution", + "Description": "Ensure anyone who has a PDS date of birth which determines their age to be less than 75 years is filtered out.", + "Priority": 120, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "Y>", + "Comparator": "-75", + "CohortLabel": "rsv_75to79_2024" + }, + { + "Type": "F", + "Name": "Under Age - Under 75 Years on day of execution", + "Description": "Ensure anyone who has a PDS date of birth which determines their age to be less than 75 years is filtered out.", + "Priority": 125, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "Y>", + "Comparator": "-75", + "CohortLabel": "rsv_75_rolling" + }, + { + "Type": "F", + "Name": "Exclude Too OLD", + "Description": "Exclude anyone over 80", + "Priority": 130, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "<", + "Comparator": "-80" + }, + { + "Type": "S", + "Name": "AlreadyVaccinated", + "Description": "##You've had your RSV vaccination\nWe believe you had your vaccination on <>.", + "Priority": 550, + "AttributeLevel": "TARGET", + "AttributeTarget": "RSV", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "Operator": "Y>=", + "Comparator": "-25", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "NotAvailable", + "Description": "NotAvailable|Vaccinations are not currently available.", + "Priority": 510, + "AttributeLevel": "PERSON", + "AttributeName": "ICB", + "Operator": "=", + "Comparator": "SUPPRESSED_ICB" + }, + { + "Type": "S", + "Name": "NotYetDue", + "Description": "NotYetDue|Your next dose is not yet due.", + "Priority": 520, + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "Operator": "=", + "Comparator": "20250326", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "TooClose", + "Description": "TooClose|Your previous vaccination was less than 91 days ago.", + "Priority": 530, + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "Operator": "=", + "Comparator": "20250327", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "OtherSetting", + "Description": "OtherSetting|## Getting the vaccine\n\nOur record show you're living in a setting where care is provided.\n\nIf you think you should have the RSV vaccine, speak to a member of staff where you live.", + "Priority": 540, + "AttributeLevel": "PERSON", + "AttributeName": "CARE_HOME_FLAG", + "Operator": "=", + "Comparator": "Y" + }, + { + "Type": "R", + "Name": "Actionable Not Vaccinated", + "Description": "Book An Appointment", + "Priority": 1010, + "Operator": "is_empty", + "Comparator": "", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CommsRouting": "BOOK_NBS" + }, + { + "Type": "R", + "Name": "Actionable Not Vaccinated", + "Description": "Book An Appointment", + "Priority": 1010, + "Operator": "=", + "Comparator": "LS2", + "AttributeLevel": "PERSON", + "AttributeName": "POSTCODE_SECTOR", + "CommsRouting": "BOOK_NBS" + }, + { + "Type": "R", + "Name": "Actionable Future Booked Appointment", + "Description": "Actionable Future Booked Appointment", + "Priority": 1020, + "Operator": ">=", + "Comparator": "0", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_DATE", + "CommsRouting": "AMEND_NBS" + }, + { + "Type": "R", + "Name": "Actionable Future Booked Appointment", + "Description": "Actionable Future Booked Appointment", + "Priority": 1020, + "Operator": "=", + "Comparator": "NBS", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_PROVIDER", + "CommsRouting": "AMEND_NBS" + }, + { + "Type": "R", + "Name": "Actionable Future Booked Appointment", + "Description": "Actionable Future Booked Appointment", + "Priority": 1030, + "Operator": ">=", + "Comparator": "0", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_DATE", + "CommsRouting": "MANAGE_LOCAL" + }, + { + "Type": "R", + "Name": "Actionable Future Booked Appointment", + "Description": "Actionable Future Booked Appointment", + "Priority": 1030, + "Operator": "!=", + "Comparator": "NBS", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_PROVIDER", + "CommsRouting": "MANAGE_LOCAL" + } + ], + "ActionsMapper": { + "BOOK_NBS": { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking" + }, + "AMEND_NBS": { + "ExternalRoutingCode": "AmendNBS", + "ActionDescription": "##You have an RSV vaccination appointment\nYou can view, change or cancel your appointment below.", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Manage your appointment" + }, + "CONTACT_GP": { + "ExternalRoutingCode": "ContactGP", + "ActionDescription": "Contact your GP", + "ActionType": "InfoText" + }, + "BOOK_LOCAL": { + "ExternalRoutingCode": "BookLocal", + "ActionDescription": "##Getting the vaccine\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "ActionType": "InfoText" + }, + "MANAGE_LOCAL": { + "ExternalRoutingCode": "ManageLocal", + "ActionDescription": "##You have an RSV vaccination appointment\nContact your healthcare provider to change or cancel your appointment.", + "ActionType": "CardWithText" + }, + "CHECK_CORRECT": { + "ExternalRoutingCode": "CheckCorrect", + "ActionDescription": "##If you think this is incorrect\\nIf you have not had this vaccination and you think you should, speak to your healthcare professional.", + "ActionType": "InfoText" + } + } + } + ] + } +} diff --git a/tests/e2e/data/s3/patient_s3_sample.json b/tests/e2e/data/s3/patient_s3_sample.json deleted file mode 100644 index 3e7b93748..000000000 --- a/tests/e2e/data/s3/patient_s3_sample.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "PATIENT": { - "NHS_NUMBER": "9876543210", - "SCENARIO": "SCN005", - "PERSON_ATTRIBUTES": { - "DATE_OF_BIRTH": "19880615", - "GENDER": "1", - "POSTCODE": "M1 1AA", - "POSTCODE_SECTOR": "M1", - "POSTCODE_OUTCODE": "1AA", - "MSOA": "E05000003", - "LSOA": "E01000003", - "GP_PRACTICE_CODE": "C98765", - "PCN": "PCN003", - "ICB": "ICB002", - "COMMISSIONING_REGION": "RegionZ", - "13Q_FLAG": "Y", - "CARE_HOME_FLAG": "N", - "DE_FLAG": "N" - }, - "TARGET_CONDITIONS": { - "COVID": { - "VALID_DOSES_COUNT": "2", - "INVALID_DOSES_COUNT": "0", - "LAST_SUCCESSFUL_DATE": "20241201", - "LAST_VALID_DOSE_DATE": "20241201", - "BOOKED_APPOINTMENT_DATE": "20250110", - "BOOKED_APPOINTMENT_PROVIDER": "Local Clinic", - "LAST_INVITE_DATE": "20241115", - "LAST_INVITE_STATUS": "Accepted", - "OPTOUT": "N" - }, - "FLU": { - "VALID_DOSES_COUNT": "1", - "INVALID_DOSES_COUNT": "0", - "LAST_SUCCESSFUL_DATE": "20241020", - "LAST_VALID_DOSE_DATE": "20241020", - "LAST_INVITE_DATE": "20240901", - "LAST_INVITE_STATUS": "Delivered", - "OPTOUT": "N" - }, - "RSV": { - "LAST_SUCCESSFUL_DATE": "20241105", - "LAST_VALID_DOSE_DATE": "20241105", - "OPTOUT": "N" - } - }, - "COHORT_MEMBERSHIP": [ - { - "cohort_label": "Trial_Group_A", - "date_joined": "20250210" - }, - { - "cohort_label": "Manchester_Residents_2024", - "date_joined": "20240701" - } - ] - } -} From a1f015b8c79fca8caa35adde0408bc005f23a6e7 Mon Sep 17 00:00:00 2001 From: ivano mannella Date: Wed, 16 Jul 2025 13:56:46 +0100 Subject: [PATCH 10/10] Add technical handover documentation and enhance README for E2E Test Automation Framework. +File formatting required --- tests/e2e/HANDOVER.md | 443 ++++++++++++ tests/e2e/README.md | 653 ++++++++++++------ tests/e2e/features/environment.py | 40 +- .../steps/helpers/dynamodb_data_generator.py | 12 +- .../steps/helpers/dynamodb_data_uploader.py | 8 +- 5 files changed, 890 insertions(+), 266 deletions(-) create mode 100644 tests/e2e/HANDOVER.md diff --git a/tests/e2e/HANDOVER.md b/tests/e2e/HANDOVER.md new file mode 100644 index 000000000..91a3ead57 --- /dev/null +++ b/tests/e2e/HANDOVER.md @@ -0,0 +1,443 @@ +# E2E Test Automation Framework - Technical Handover Documentation + +**Document Version**: 1.0 +**Last Updated**: January 2025 +**Framework Version**: 1.0 +**Audience**: QA Engineers, DevOps Engineers, Development Team Leads + +--- + +## ๐Ÿ“‹ Executive Summary + +This document provides technical handover information for the NHS Eligibility Signposting API E2E Test Automation Framework. The framework is production-ready and has been successfully validating API functionality with comprehensive AWS integration. + +### Framework Capabilities + +- โœ… **Automated BDD Testing** with Behave framework +- โœ… **AWS Cloud Integration** (DynamoDB, S3, SSM) +- โœ… **mTLS Authentication** with automatic certificate management +- โœ… **Dynamic Test Data** generation and cleanup +- โœ… **CI/CD Ready** with Jenkins and GitHub Actions support + +### Current Test Coverage + +- **14 comprehensive scenarios** covering RSV vaccination eligibility +- **API validation** with schema verification +- **End-to-end workflow** from data setup to cleanup +- **Multi-environment support** (test, staging configurations) + +--- + +## ๐Ÿ—๏ธ Technical Architecture + +### Framework Components + +```mermaid +graph TB + subgraph "E2E Framework" + A[Behave BDD Runner] --> B[Feature Files] + A --> C[Step Definitions] + A --> D[Environment Hooks] + end + + subgraph "AWS Integration" + E[DynamoDB] --> F[Test Data Storage] + G[S3] --> H[Config Management] + I[SSM] --> J[Certificate Storage] + end + + subgraph "API Testing" + K[mTLS Client] --> L[NHS API Gateway] + L --> M[Eligibility Service] + end + + C --> E + C --> G + D --> I + C --> K +``` + +### Key Technologies + +| Component | Technology | Version | Purpose | +| --------------- | ----------- | ------- | ----------------------- | +| BDD Framework | Behave | Latest | Test scenario execution | +| HTTP Client | Requests | Latest | API communication | +| AWS SDK | Boto3 | Latest | AWS service integration | +| Data Processing | Python JSON | 3.13+ | Test data manipulation | +| Environment | Poetry | Latest | Dependency management | + +### Data Flow Architecture + +```mermaid +sequenceDiagram + participant TR as Test Runner + participant AWS as AWS Services + participant API as NHS API Gateway + participant DB as DynamoDB + + Note over TR,DB: Test Setup Phase + TR->>AWS: Download mTLS certificates (SSM) + TR->>AWS: Generate & upload test data (DynamoDB) + TR->>AWS: Upload configurations (S3) + + Note over TR,DB: Test Execution Phase + TR->>API: Execute eligibility check (mTLS) + API->>DB: Query eligibility data + DB-->>API: Return eligibility results + API-->>TR: Return API response + TR->>TR: Validate response against expected + + Note over TR,DB: Test Cleanup Phase + TR->>AWS: Cleanup test data (DynamoDB) + TR->>AWS: Remove temporary files (S3) +``` + +--- + +## ๐Ÿ”ง Implementation Details + +### Directory Structure and Responsibilities + +``` +tests/e2e/ +โ”œโ”€โ”€ features/ +โ”‚ โ”œโ”€โ”€ eligibility_check/ +โ”‚ โ”‚ โ””โ”€โ”€ eligibility_check.feature # ๐Ÿ“ Gherkin scenarios (14 test cases) +โ”‚ โ”œโ”€โ”€ steps/ +โ”‚ โ”‚ โ”œโ”€โ”€ eligibility_check_steps.py # ๐Ÿ Step implementations +โ”‚ โ”‚ โ””โ”€โ”€ helpers/ +โ”‚ โ”‚ โ”œโ”€โ”€ dynamodb_data_generator.py # ๐Ÿ”„ Data generation with date variables +โ”‚ โ”‚ โ””โ”€โ”€ dynamodb_data_uploader.py # โฌ†๏ธ AWS DynamoDB operations +โ”‚ โ””โ”€โ”€ environment.py # ๐ŸŒ Behave hooks and AWS setup +โ”œโ”€โ”€ data/ +โ”‚ โ”œโ”€โ”€ in/dynamoDB/ # ๐Ÿ“„ Test input data (14 JSON files) +โ”‚ โ”œโ”€โ”€ responses/ # โœ… Expected API responses (14 files) +โ”‚ โ”œโ”€โ”€ s3/ # โš™๏ธ Campaign configurations +โ”‚ โ””โ”€โ”€ configs/ # ๐ŸŒ Global settings +โ”œโ”€โ”€ utils/ +โ”‚ โ””โ”€โ”€ config.py # ๐Ÿ“‹ Environment config & JSON schemas +โ””โ”€โ”€ tests/ # ๐Ÿงช Legacy pytest tests (maintained) +``` + +### Core Implementation Files + +#### 1. Test Scenario Definition + +**File**: [`features/eligibility_check/eligibility_check.feature`](features/eligibility_check/eligibility_check.feature) + +- **Purpose**: Gherkin BDD scenarios for API testing +- **Coverage**: 14 NHS numbers with different eligibility scenarios +- **Format**: Scenario Outline with Examples table +- **Maintainer**: QA Team + +#### 2. Step Implementation + +**File**: [`features/steps/eligibility_check_steps.py`](features/steps/eligibility_check_steps.py) + +- **Purpose**: Python implementation of Gherkin steps +- **Key Functions**: + - mTLS certificate download from SSM + - DynamoDB test data management + - API request execution with authentication + - JSON response validation with diff reporting +- **Dependencies**: boto3, requests, jsonschema +- **Maintainer**: QA/DevOps Team + +#### 3. Data Management + +**Files**: + +- [`helpers/dynamodb_data_generator.py`](features/steps/helpers/dynamodb_data_generator.py) +- [`helpers/dynamodb_data_uploader.py`](features/steps/helpers/dynamodb_data_uploader.py) + +**Capabilities**: + +- **Dynamic Date Variables**: `<>`, `<>` +- **GUID Generation**: `<>` +- **Batch Upload/Delete**: Efficient DynamoDB operations +- **Error Handling**: Graceful failure with detailed logging + +#### 4. Environment Configuration + +**File**: [`features/environment.py`](features/environment.py) + +- **Purpose**: Behave lifecycle hooks +- **Key Functions**: + - AWS credential validation + - S3 file uploads (optional) + - Feature-level data cleanup + - Error handling and graceful failures + +--- + +## ๐Ÿš€ Deployment and Maintenance + +### Infrastructure Requirements + +#### AWS Resources + +| Service | Resource | Purpose | Configuration | +| ----------------------- | ----------------------- | ------------------- | ----------------- | +| **DynamoDB** | `eligibilty_data_store` | Test data storage | On-demand billing | +| **S3** | User-defined bucket | Configuration files | Standard storage | +| **SSM Parameter Store** | `/test/mtls/*` | mTLS certificates | SecureString type | + +#### Required IAM Permissions + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "dynamodb:PutItem", + "dynamodb:DeleteItem", + "dynamodb:Scan", + "dynamodb:BatchWriteItem" + ], + "Resource": "arn:aws:dynamodb:eu-west-2:*:table/eligibilty_data_store" + }, + { + "Effect": "Allow", + "Action": ["ssm:GetParameter"], + "Resource": "arn:aws:ssm:eu-west-2:*:parameter/test/mtls/*" + }, + { + "Effect": "Allow", + "Action": ["s3:PutObject", "s3:DeleteObject"], + "Resource": "arn:aws:s3:::your-bucket/*" + } + ] +} +``` + +### Environment Management + +#### Development Environment + +```bash +# Local development setup +cd tests/e2e +poetry install +cp .env.example .env +# Configure .env with development AWS credentials +poetry run behave +``` + +#### CI/CD Environment + +- **GitHub Actions**: [Workflow example in README](README.md#github-actions-example) +- **Jenkins**: [Pipeline example in README](README.md#jenkins-pipeline-example) +- **Secrets Management**: AWS credentials via CI/CD secrets + +### Monitoring and Logging + +#### Application Logs + +- **Framework logs**: Console output with timestamps +- **AWS operation logs**: Detailed boto3 operation results +- **Test execution logs**: Behave standard output +- **Debug mode**: `--verbose --no-capture` for detailed output + +#### Key Metrics to Monitor + +- **Test execution time**: Average ~2-3 minutes for full suite +- **AWS API call success rate**: Should be >99% +- **Data cleanup success rate**: Should be 100% +- **Certificate refresh frequency**: Daily recommended + +--- + +## ๐Ÿ”„ Maintenance Procedures + +### Regular Maintenance Tasks + +#### Weekly Tasks + +- [ ] **Validate test data**: Ensure all 14 test scenarios pass +- [ ] **Check AWS credentials**: Verify access keys haven't expired +- [ ] **Review logs**: Check for any recurring warnings or errors + +#### Monthly Tasks + +- [ ] **Update dependencies**: `poetry update` and test +- [ ] **Review test coverage**: Add scenarios for new API features +- [ ] **Validate environments**: Test against staging and production configs +- [ ] **Certificate rotation**: Verify mTLS certificates are current + +#### Quarterly Tasks + +- [ ] **Performance review**: Analyze execution times and optimize +- [ ] **Documentation updates**: Keep README and handover docs current +- [ ] **Framework upgrades**: Update Behave, boto3, and other dependencies +- [ ] **Security audit**: Review AWS permissions and access patterns + +### Adding New Test Scenarios + +#### Process for New NHS Numbers + +1. **Create test data file**: Copy and modify existing DynamoDB JSON +2. **Create expected response**: Define expected API response JSON +3. **Update feature file**: Add new row to Examples table +4. **Test locally**: Validate new scenario works +5. **Update documentation**: Record new test case in handover docs + +#### Process for New API Endpoints + +1. **Create new feature file**: Follow existing Gherkin patterns +2. **Implement step definitions**: Reuse existing helpers where possible +3. **Add configuration**: Update [`utils/config.py`](utils/config.py) with new schemas +4. **Create test data**: Generate appropriate DynamoDB and response files +5. **Test end-to-end**: Validate full workflow including cleanup + +### Troubleshooting Common Issues + +#### AWS Authentication Problems + +**Symptoms**: `NoCredentialsError`, `AccessDenied` +**Solutions**: + +1. Verify AWS credentials in `.env` file +2. Check IAM permissions match requirements above +3. Validate session token hasn't expired +4. Test AWS access: `aws sts get-caller-identity` + +#### DynamoDB Operation Failures + +**Symptoms**: `ResourceNotFoundException`, upload/delete errors +**Solutions**: + +1. Confirm table name matches `DYNAMODB_TABLE_NAME` setting +2. Verify table exists in correct AWS region +3. Check table has sufficient capacity (on-demand recommended) +4. Validate JSON test data format + +#### mTLS Certificate Issues + +**Symptoms**: SSL errors, authentication failures +**Solutions**: + +1. Verify SSM parameters exist: `aws ssm get-parameter --name "/test/mtls/api_private_key_cert"` +2. Check certificate expiration dates +3. Validate certificate format (PEM) +4. Ensure proper AWS permissions for SSM access + +#### Test Data Cleanup Problems + +**Symptoms**: Leftover data in DynamoDB, failing subsequent tests +**Solutions**: + +1. Check `KEEP_SEED` setting (should be `false` for CI/CD) +2. Manual cleanup: Review and delete items from DynamoDB console +3. Verify cleanup permissions in IAM policy +4. Run tests with `--verbose` to see cleanup operations + +--- + +## ๐Ÿ“ˆ Performance and Scalability + +### Current Performance Metrics + +- **Full test suite execution**: ~2-3 minutes +- **Single scenario execution**: ~10-15 seconds +- **AWS data upload time**: ~5-10 seconds for 14 records +- **mTLS handshake time**: ~1-2 seconds per request + +### Scaling Considerations + +- **Parallel execution**: Behave supports parallel test runs +- **Data isolation**: Each test scenario uses unique NHS numbers +- **AWS limits**: DynamoDB has 25 request units per second default +- **Certificate caching**: mTLS certificates cached per test session + +### Optimization Opportunities + +1. **Implement data pooling**: Reuse DynamoDB records across tests +2. **Parallel test execution**: Split scenarios across multiple runners +3. **Certificate persistence**: Cache certificates between test runs +4. **Response caching**: Cache API responses for repeated validation + +--- + +## ๐Ÿ” Security Considerations + +### Security Best Practices + +- **Credential Management**: Use AWS IAM roles where possible +- **Certificate Storage**: mTLS certificates stored securely in SSM +- **Test Data**: Contains synthetic NHS numbers only +- **Network Security**: API calls use mTLS encryption +- **Access Control**: Least privilege IAM permissions + +### Compliance Notes + +- **Data Protection**: Test data does not contain real patient information +- **Audit Trail**: All AWS operations logged via CloudTrail +- **Access Logging**: Test execution logged for compliance review +- **Certificate Rotation**: mTLS certificates should be rotated regularly + +--- + +## ๐Ÿ“ž Support and Contacts + +### Team Responsibilities + +| Role | Team/Person | Responsibility | +| ------------------------- | ---------------- | ----------------------------- | +| **Framework Maintenance** | QA Team | Test scenarios, documentation | +| **AWS Infrastructure** | DevOps Team | AWS resources, certificates | +| **API Development** | Development Team | API changes, new endpoints | +| **CI/CD Pipeline** | DevOps Team | Build pipelines, deployment | + +### Escalation Process + +1. **Level 1**: Check README troubleshooting section +2. **Level 2**: Review framework logs and AWS console +3. **Level 3**: Contact QA team for framework issues +4. **Level 4**: Contact DevOps team for infrastructure issues +5. **Level 5**: Contact development team for API-related issues + +## ๐Ÿ“š Reference Documentation + +### Internal Documentation + +- **[Main README](README.md)**: User guide and quick start +- **[API Specification](../../../specification/)**: Eligibility API details +- **[Project README](../../../README.md)**: Overall project documentation + +### External Documentation + +- **[Behave Documentation](https://behave.readthedocs.io/)**: BDD framework guide +- **[Boto3 Documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html)**: AWS SDK reference +- **[NHS API Platform](https://digital.nhs.uk/developer/api-catalogue)**: NHS API guidelines +- **[AWS Testing Best Practices](https://aws.amazon.com/testing/)** + +### Training Resources + +- **BDD Testing**: [Cucumber School](https://school.cucumber.io/) +- **AWS Testing**: [AWS Testing Best Practices](https://aws.amazon.com/testing/) +- **Python Testing**: [Real Python Testing Guide](https://realpython.com/python-testing/) + +### Dependencies to Monitor + +- **Behave**: Framework updates and new features +- **Boto3**: AWS SDK improvements and new services +- **NHS API Platform**: Changes to authentication or standards +- **Python**: Language version updates and compatibility + +--- + +**Document Control** + +- **Created**: July 2025 +- **Last Review**: July 2025 +- **Next Review**: TBD +- **Version**: 1.0 +- **Classification**: Internal Use +- **Distribution**: NHS QA Team + +--- + +_This document contains technical handover information for the NHS Eligibility Signposting API E2E Test Automation Framework. For user guides and quick start information, see the [main README](README.md)._ diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 3804c2863..74da684c2 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,292 +1,505 @@ -# Eligibility Signposting API Test Automation Framework +# Eligibility Signposting API - E2E Test Automation Framework -This repository contains a Python-based test automation framework for the Eligibility Signposting API. The framework uses pytest and requests to implement API tests that were previously executed manually using Postman. It also includes BDD-style tests using Behave (not pytest-bdd). +A comprehensive BDD-based test automation framework for the NHS Eligibility Signposting API using Behave, AWS integration, and mTLS authentication. -## Framework Structure +## ๐Ÿš€ Quick Start Guide (5 Minutes) + +### Prerequisites Check + +Before starting, ensure you have: + +- Python 3.13+ installed +- Poetry installed (`curl -sSL https://install.python-poetry.org | python3 -`) +- AWS CLI installed and configured +- Access to NHS test environment AWS account + +### 1. Environment Setup ```bash -qa-automation/ -โ”œโ”€โ”€ tests/ -โ”‚ โ””โ”€โ”€ eligibility_signposting/ -โ”‚ โ”œโ”€โ”€ test_eligibility_check.py # Tests for eligibility check endpoint -โ”‚ โ”œโ”€โ”€ test_eligibility_check_bdd.py # BDD tests for eligibility check -โ”‚ โ””โ”€โ”€ conftest.py # Pytest fixtures -โ”œโ”€โ”€ features/ -โ”‚ โ”œโ”€โ”€ eligibility_check/ -โ”‚ โ”‚ โ””โ”€โ”€ eligibility_check.feature # Behave feature file -โ”‚ โ”œโ”€โ”€ steps/ -โ”‚ โ”‚ โ””โ”€โ”€ eligibility_check_steps.py # Behave step definitions -โ”‚ โ””โ”€โ”€ conftest.py # Behave fixtures (if needed) -โ”œโ”€โ”€ utils/ -โ”‚ โ”œโ”€โ”€ api_client.py # Reusable HTTP client -โ”‚ โ””โ”€โ”€ config.py # Environment config and schemas -โ”œโ”€โ”€ .env # Environment variables (not in version control) -โ”œโ”€โ”€ pytest.ini # Pytest configuration -โ””โ”€โ”€ pyproject.toml # Poetry project file +# Clone and navigate to the project +cd tests/e2e + +# Install dependencies +poetry install + +# Copy environment template +cp .env.example .env + +# Edit .env with your credentials (see Environment Configuration section) ``` -## Setup and Installation +### 2. First Test Run -1. Clone the repository: +```bash +# Run all tests (includes automatic setup and cleanup) +cd tests/e2e +poetry run behave - ```bash - git clone https://github.com/ivma1-nhs/qa-automation.git - cd qa-automation - ``` +# Run a single scenario for quick validation +poetry run behave features/eligibility_check/eligibility_check.feature --name="Eligibility check returns 2xx response for NHS number queries" +``` -2. Create a virtual environment (optional but recommended): +### 3. Success Indicators - ```bash - python -m venv venv - source venv/bin/activate # On Windows: venv\Scripts\activate - ``` +โœ… **Tests pass**: You should see scenarios marked as "passed" +โœ… **AWS connectivity**: mTLS certificates downloaded from SSM +โœ… **Data setup**: DynamoDB test data uploaded successfully +โœ… **API responses**: 200 status codes with valid JSON responses -3. Install Poetry (if not already installed): +**If tests fail**, check the [Troubleshooting](#troubleshooting) section below. - ```bash - curl -sSL https://install.python-poetry.org | python3 - - # Or see https://python-poetry.org/docs/#installation for details - ``` +--- -4. Install dependencies: +## ๐Ÿ“‹ Framework Overview - ```bash - poetry install - ``` +This framework provides automated end-to-end testing for the NHS Eligibility Signposting API with the following capabilities: + +### Key Features + +- **BDD Testing**: Gherkin scenarios using Behave framework +- **AWS Integration**: Automated DynamoDB test data management and S3 configuration +- **mTLS Authentication**: Automatic certificate retrieval from AWS SSM Parameter Store +- **Dynamic Test Data**: Date-based variable resolution for realistic test scenarios +- **Automated Cleanup**: Configurable test data cleanup after execution + +### Framework Architecture + +``` +tests/e2e/ +โ”œโ”€โ”€ features/ # BDD test scenarios +โ”‚ โ”œโ”€โ”€ eligibility_check/ # Feature files +โ”‚ โ”‚ โ””โ”€โ”€ eligibility_check.feature +โ”‚ โ”œโ”€โ”€ steps/ # Step definitions +โ”‚ โ”‚ โ”œโ”€โ”€ eligibility_check_steps.py +โ”‚ โ”‚ โ””โ”€โ”€ helpers/ # Utility classes +โ”‚ โ”‚ โ”œโ”€โ”€ dynamodb_data_generator.py +โ”‚ โ”‚ โ””โ”€โ”€ dynamodb_data_uploader.py +โ”‚ โ””โ”€โ”€ environment.py # Behave environment hooks +โ”œโ”€โ”€ data/ # Test data files +โ”‚ โ”œโ”€โ”€ in/dynamoDB/ # DynamoDB test records +โ”‚ โ”œโ”€โ”€ responses/ # Expected API responses +โ”‚ โ”œโ”€โ”€ s3/ # S3 configuration files +โ”‚ โ””โ”€โ”€ configs/ # Global configurations +โ”œโ”€โ”€ utils/ # Framework utilities +โ”‚ โ””โ”€โ”€ config.py # Configuration and schemas +โ”œโ”€โ”€ tests/ # pytest-based tests (legacy) +โ”œโ”€โ”€ .env.example # Environment template +โ””โ”€โ”€ README.md # This file +``` + +### Test Data Flow + +```mermaid +graph LR + A[Test Start] --> B[Download mTLS Certs] + B --> C[Generate Test Data] + C --> D[Upload to DynamoDB] + D --> E[Upload Config to S3] + E --> F[Execute API Tests] + F --> G[Validate Responses] + G --> H[Cleanup Data] + H --> I[Test Complete] +``` + +--- + +## โš™๏ธ Environment Configuration + +### Required Environment Variables + +Copy [`tests/e2e/.env.example`](.env.example) to [`tests/e2e/.env`](.env) and configure: + +```bash +# AWS Configuration (REQUIRED) +AWS_REGION=eu-west-2 +AWS_ACCESS_KEY_ID=your_aws_access_key +AWS_SECRET_ACCESS_KEY=your_aws_secret_key +AWS_SESSION_TOKEN=your_aws_session_token + +# DynamoDB Configuration (REQUIRED) +DYNAMODB_TABLE_NAME=eligibilty_data_store + +# S3 Configuration (OPTIONAL) +S3_BUCKET_NAME=your-bucket-name +S3_UPLOAD_DIR=qa/json +S3_JSON_SOURCE_DIR=./data/s3 + +# API Configuration (AUTOMATIC) +BASE_URL=https://test.eligibility-signposting-api.nhs.uk +API_GATEWAY_URL=https://test.eligibility-signposting-api.nhs.uk + +# Test Configuration (OPTIONAL) +ABORT_ON_AWS_FAILURE=false +KEEP_SEED=false # Set to true to keep test data after tests +VALID_NHS_NUMBER=50000000004 +INVALID_NHS_NUMBER=9876543210 +``` + +### AWS Permissions Required + +Your AWS credentials need the following permissions: + +- **SSM**: `GetParameter` (for mTLS certificates) +- **DynamoDB**: `PutItem`, `DeleteItem`, `Scan` (for test data management) +- **S3**: `PutObject`, `DeleteObject` (optional, for configuration files) + +--- + +## ๐Ÿงช Test Execution + +### Running Tests -5. Configure environment variables: - - Copy the `.env.example` file to `.env` (if not already present) - - Update the values in `.env` with your sandbox credentials +**Run all tests:** -## Running Tests +```bash +poetry run behave +``` + +**Run specific feature:** -### Running API (pytest) tests +```bash +poetry run behave features/eligibility_check/eligibility_check.feature +``` -Run all pytest-based tests: +**Run with specific tags (if configured):** ```bash -poetry run pytest +poetry run behave --tags=@smoke ``` -Run a specific pytest test file: +**Debug mode with verbose output:** ```bash -poetry run pytest tests/eligibility_signposting/test_eligibility_check.py +poetry run behave --verbose --no-capture ``` -### Running BDD tests with Behave +### Test Data Management + +The framework automatically manages test data: + +1. **Data Generation**: [`dynamodb_data_generator.py`](features/steps/helpers/dynamodb_data_generator.py) processes JSON templates with date variables +2. **Data Upload**: [`dynamodb_data_uploader.py`](features/steps/helpers/dynamodb_data_uploader.py) handles DynamoDB operations +3. **Data Cleanup**: Automatic cleanup unless `KEEP_SEED=true` -Run all Behave feature tests: +**Manual data operations:** ```bash +# Keep test data after execution (useful for debugging) +export KEEP_SEED=true +poetry run behave + +# Force cleanup (if previous tests left data) +export KEEP_SEED=false poetry run behave ``` -This will discover and run all feature files in the `features/` directory using Behave. +### Available Test Scenarios -## Extending the Framework +Current test scenarios in [`eligibility_check.feature`](features/eligibility_check/eligibility_check.feature): -### Adding New Test Files +| NHS Number | Scenario | Expected Response | +| ---------- | ---------------------------- | ------------------------------------------------------------- | +| 5000000001 | Standard eligibility check | [`AUTO_RSV_SB_001.json`](data/responses/AUTO_RSV_SB_001.json) | +| 5000000002 | Alternative eligibility path | [`AUTO_RSV_SB_002.json`](data/responses/AUTO_RSV_SB_002.json) | +| ... | ... | ... | +| 5000000014 | Complex eligibility scenario | [`AUTO_RSV_SB_014.json`](data/responses/AUTO_RSV_SB_014.json) | -1. Create a new test file in the appropriate directory: +--- - ```python - # tests/eligibility_signposting/test_new_feature.py - import pytest - - @pytest.mark.new_feature - class TestNewFeature: - def test_something(self, api_client): - # Test implementation - pass - ``` +## ๐Ÿ”ง Extending the Framework -2. Add the new marker to pytest.ini if needed: +### Adding New Test Scenarios - ```ini - markers = - new_feature: marks tests related to the new feature - ``` +1. **Create test data files:** -### Adding New BDD Tests (Behave) + ```bash + # Add DynamoDB record + cp data/in/dynamoDB/AUTO_RSV_SB_001.json data/in/dynamoDB/AUTO_RSV_SB_015.json -1. Create a new feature file: + # Add expected response + cp data/responses/AUTO_RSV_SB_001.json data/responses/AUTO_RSV_SB_015.json - ```gherkin - # features/new_feature/new_feature.feature - Feature: New Feature - As a user of the Eligibility Signposting API - I want to use the new feature - So that I can achieve my goal - - Scenario: Successful use of new feature - Given the API is available - When I make a request to the new feature endpoint - Then the response should be successful + # Edit both files with new test data ``` -2. Create step definitions: +2. **Update feature file:** - ```python - # features/steps/new_feature_steps.py - from behave import given, when, then - - @when('I make a request to the new feature endpoint') - def step_impl_make_request(context): - # Implementation - pass - - @then('the response should be successful') - def step_impl_check_success(context): - # Implementation - pass + ```gherkin + # Add new row to Examples table in eligibility_check.feature + | 5000000015 | AUTO_RSV_SB_015.json | ``` -3. Run the BDD tests with: - +3. **Test your changes:** ```bash - poetry run behave + poetry run behave --name="5000000015" ``` -### Adding New API Endpoints +### Adding New Features -1. Update the config.py file with the new endpoint: +1. **Create new feature file:** - ```python - # utils/config.py - NEW_ENDPOINT = '/new-endpoint' + ```bash + mkdir -p features/new_feature + touch features/new_feature/new_feature.feature ``` -2. Add a new method to the ApiClient class: +2. **Create step definitions:** + + ```bash + touch features/steps/new_feature_steps.py + ``` +3. **Use existing helpers or create new ones:** ```python - # utils/api_client.py - def get_new_endpoint(self, param1, param2): - url = f"{self.base_url}/new-endpoint" - params = {"param1": param1, "param2": param2} - response = requests.get(url, headers=self.headers, params=params) - return response + from helpers.dynamodb_data_uploader import DynamoDBDataUploader + from helpers.dynamodb_data_generator import JsonTestDataProcessor ``` -### Adding New Response Schemas +### Dynamic Test Data Variables -1. Add the new schema to config.py: +The framework supports date-based variables in test data: - ```python - # utils/config.py - NEW_ENDPOINT_SCHEMA = { - "type": "object", - "properties": { - # Schema definition - } - } - ``` +```json +{ + "LAST_SUCCESSFUL_DATE": "<>", // 30 days ago + "NEXT_DUE_DATE": "<>", // 90 days from now + "BIRTH_DATE": "<>" // 75 years ago +} +``` -2. Use the schema in your tests: +Supported formats: - ```python - from utils.config import NEW_ENDPOINT_SCHEMA - import jsonschema +- `<>` - Add/subtract N days +- `<>` - Add/subtract N months +- `<>` - Add/subtract N years +- `<>` - Generate random GUID - def test_new_endpoint_schema(self, api_client): - response = api_client.get_new_endpoint("value1", "value2") - response_json = response.json() - jsonschema.validate(instance=response_json, schema=NEW_ENDPOINT_SCHEMA) - ``` +--- -### Adding DynamoDB Integration +## ๐Ÿ” mTLS Configuration -When the DynamoDB-backed API is ready, you can extend the framework by: +The framework automatically handles mTLS authentication: -1. Adding DynamoDB client configuration: +### Certificate Management - ```python - # utils/dynamo_client.py - import boto3 - from utils.config import AWS_REGION, AWS_ACCESS_KEY, AWS_SECRET_KEY - - class DynamoClient: - def __init__(self): - self.client = boto3.client( - 'dynamodb', - region_name=AWS_REGION, - aws_access_key_id=AWS_ACCESS_KEY, - aws_secret_access_key=AWS_SECRET_KEY - ) - - def get_item(self, table_name, key): - response = self.client.get_item( - TableName=table_name, - Key=key - ) - return response - ``` +- **Storage**: Certificates stored in AWS SSM Parameter Store +- **Retrieval**: Automatic download at test startup +- **Paths**: + - Private Key: `/test/mtls/api_private_key_cert` + - Client Cert: `/test/mtls/api_client_cert` + - CA Cert: `/test/mtls/api_ca_cert` -2. Adding fixtures in conftest.py: +### Certificate Setup (For DevOps) - ```python - # tests/eligibility_signposting/conftest.py - from utils.dynamo_client import DynamoClient +```bash +# Store certificates in SSM (DevOps task) +aws ssm put-parameter \ + --name "/test/mtls/api_private_key_cert" \ + --value file://private_key.pem \ + --type SecureString + +aws ssm put-parameter \ + --name "/test/mtls/api_client_cert" \ + --value file://client_cert.pem \ + --type SecureString + +aws ssm put-parameter \ + --name "/test/mtls/api_ca_cert" \ + --value file://ca_cert.pem \ + --type SecureString +``` - @pytest.fixture - def dynamo_client(): - return DynamoClient() - ``` +--- -3. Using the DynamoDB client in tests: +## ๐Ÿ› Troubleshooting - ```python - def test_with_dynamodb(self, api_client, dynamo_client): - # Test implementation using both API and DynamoDB - pass - ``` +### Common Issues and Solutions -## Best Practices - -1. **Test Independence**: Each test should be independent and not rely on the state from other tests. -2. **Fixtures**: Use fixtures for common setup and clean-up operations. -3. **Multiple Test Cases**: Use pytest's parameterize feature for testing multiple scenarios. -4. **Assertions**: Use descriptive assertions to make test failures clear. -5. **Documentation**: Document your tests with docstrings and comments. -6. **Environment Variables**: Use environment variables for sensitive information and configuration. - -## Continuous Integration - -This framework can be integrated with CI/CD pipelines: - -1. Add a GitHub Actions workflow: - - ```yaml - # .github/workflows/test.yml - name: API Tests - - on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - - jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.9' - - name: Install dependencies - run: | - poetry install - - name: Run tests - run: | - poetry run pytest --html=report.html - env: - BASE_URL: ${{ secrets.BASE_URL }} - API_KEY: ${{ secrets.API_KEY }} - - name: Upload test report - uses: actions/upload-artifact@v2 - with: - name: test-report - path: report.html - ``` +**โŒ AWS Authentication Failed** + +```bash +# Check AWS credentials +aws sts get-caller-identity + +# Ensure you have the correct session token +export AWS_SESSION_TOKEN=your_session_token +``` + +**โŒ DynamoDB Access Denied** + +```bash +# Verify table name and permissions +aws dynamodb describe-table --table-name eligibilty_data_store +``` + +**โŒ mTLS Certificate Download Failed** + +```bash +# Check SSM parameters exist +aws ssm get-parameter --name "/test/mtls/api_private_key_cert" +``` + +**โŒ API Connection Timeout** + +```bash +# Check network connectivity +curl -I https://test.eligibility-signposting-api.nhs.uk/health +``` + +**โŒ Test Data Upload Failed** + +- Verify [`DYNAMODB_TABLE_NAME`](.env) matches actual table +- Check your AWS permissions include `dynamodb:PutItem` +- Ensure JSON files in [`data/in/dynamoDB/`](data/in/dynamoDB/) are valid + +**โŒ JSON Response Validation Failed** + +- Check expected response files in [`data/responses/`](data/responses/) +- Verify NHS numbers match between test data and feature file +- Use `--no-capture` flag to see detailed diff output + +### Debug Mode + +```bash +# Run with maximum verbosity +poetry run behave --verbose --no-capture --no-capture-stderr + +# Run single scenario with debug +poetry run behave features/eligibility_check/eligibility_check.feature:19 --verbose +``` + +### Logging + +Framework logs are written to console. Key log messages: + +- `โœ… AWS credentials loaded` +- `โœ… mTLS certificates downloaded` +- `โœ… DynamoDB data uploaded: X items` +- `โœ… Test scenario passed` + +--- + +## ๐Ÿ”„ CI/CD Integration + +### GitHub Actions Example + +```yaml +name: E2E Tests +on: [push, pull_request] + +jobs: + e2e-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.13" + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install dependencies + run: | + cd tests/e2e + poetry install + + - name: Run E2E tests + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }} + AWS_REGION: eu-west-2 + DYNAMODB_TABLE_NAME: eligibilty_data_store + run: | + cd tests/e2e + poetry run behave --junit --junit-directory reports/ + + - name: Upload test results + uses: actions/upload-artifact@v3 + if: always() + with: + name: test-results + path: tests/e2e/reports/ +``` + +--- + +## ๐Ÿ“š Additional Resources + +### Framework Dependencies + +- **[Behave](https://behave.readthedocs.io/)**: BDD framework for Python +- **[Boto3](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html)**: AWS SDK for Python +- **[Requests](https://docs.python-requests.org/)**: HTTP library for API calls +- **[Python-dotenv](https://pypi.org/project/python-dotenv/)**: Environment variable management + +### Related Documentation + +- [NHS API Platform Documentation](https://digital.nhs.uk/developer/api-catalogue) +- [Eligibility Signposting API Specification](../../../specification/) +- [Main Project README](../../../README.md) + +### File Structure Reference + +``` +tests/e2e/ +โ”œโ”€โ”€ ๐Ÿ“ features/ +โ”‚ โ”œโ”€โ”€ ๐Ÿ“ eligibility_check/ +โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“„ eligibility_check.feature # BDD scenarios +โ”‚ โ”œโ”€โ”€ ๐Ÿ“ steps/ +โ”‚ โ”‚ โ”œโ”€โ”€ ๐Ÿ“„ eligibility_check_steps.py # Step implementations +โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“ helpers/ +โ”‚ โ”‚ โ”œโ”€โ”€ ๐Ÿ“„ dynamodb_data_generator.py # Test data processing +โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“„ dynamodb_data_uploader.py # AWS data operations +โ”‚ โ”œโ”€โ”€ ๐Ÿ“„ environment.py # Behave hooks and setup +โ”‚ โ””โ”€โ”€ ๐Ÿ“„ __init__.py +โ”œโ”€โ”€ ๐Ÿ“ data/ +โ”‚ โ”œโ”€โ”€ ๐Ÿ“ in/dynamoDB/ # DynamoDB test records +โ”‚ โ”‚ โ”œโ”€โ”€ ๐Ÿ“„ AUTO_RSV_SB_001.json +โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“„ ... (014 test files) +โ”‚ โ”œโ”€โ”€ ๐Ÿ“ responses/ # Expected API responses +โ”‚ โ”‚ โ”œโ”€โ”€ ๐Ÿ“„ AUTO_RSV_SB_001.json +โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“„ ... (014 response files) +โ”‚ โ”œโ”€โ”€ ๐Ÿ“ s3/ # S3 configuration files +โ”‚ โ”‚ โ””โ”€โ”€ ๐Ÿ“„ AUTO_RSV_SB_001.json +โ”‚ โ””โ”€โ”€ ๐Ÿ“ configs/ # Global configurations +โ”œโ”€โ”€ ๐Ÿ“ utils/ +โ”‚ โ”œโ”€โ”€ ๐Ÿ“„ config.py # Framework configuration +โ”‚ โ””โ”€โ”€ ๐Ÿ“„ __init__.py +โ”œโ”€โ”€ ๐Ÿ“ tests/ # Legacy pytest tests +โ”œโ”€โ”€ ๐Ÿ“„ .env.example # Environment template +โ”œโ”€โ”€ ๐Ÿ“„ .gitignore # Git ignore rules +โ””โ”€โ”€ ๐Ÿ“„ README.md # This documentation +``` + +--- + +## โ“ Support and Contribution + +### Getting Help + +1. **Check this README** for common issues and solutions +2. **Review logs** using `--verbose --no-capture` flags +3. **Validate environment** using the troubleshooting section +4. **Contact the team** if issues persist + +### Contributing + +1. **Follow existing patterns** when adding new tests +2. **Update documentation** when adding new features +3. **Test your changes** before submitting +4. **Keep test data minimal** and realistic + +### Version History + +- **v1.0**: Initial BDD framework with AWS integration +- **Current**: Comprehensive mTLS authentication and data management + +--- + +_Last Updated: July 2025_ +_Framework Version: 1.0_ +_Maintained by: NHS Digital QA Team_ diff --git a/tests/e2e/features/environment.py b/tests/e2e/features/environment.py index 7d81c870e..b02481a38 100644 --- a/tests/e2e/features/environment.py +++ b/tests/e2e/features/environment.py @@ -6,9 +6,7 @@ from botocore.exceptions import BotoCoreError from dotenv import load_dotenv -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") logger = logging.getLogger("behave.environment") @@ -26,19 +24,13 @@ def _load_environment_variables(context): context.aws_access_key_id = os.getenv("AWS_ACCESS_KEY_ID") context.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY") context.aws_session_token = os.getenv("AWS_SESSION_TOKEN") - context.abort_on_aws_error = ( - os.getenv("ABORT_ON_AWS_FAILURE", "false").lower() == "true" - ) + context.abort_on_aws_error = os.getenv("ABORT_ON_AWS_FAILURE", "false").lower() == "true" context.keep_seed = os.getenv("KEEP_SEED", "false").lower() == "true" - context.dynamodb_table_name = os.getenv( - "DYNAMODB_TABLE_NAME", "eligibilty_data_store" - ) + context.dynamodb_table_name = os.getenv("DYNAMODB_TABLE_NAME", "eligibilty_data_store") context.s3_bucket = os.getenv("S3_BUCKET_NAME") context.s3_upload_dir = os.getenv("S3_UPLOAD_DIR", "") context.s3_data_path = Path(os.getenv("S3_JSON_SOURCE_DIR", "./data/s3")).resolve() - context.api_gateway_url = os.getenv( - "API_GATEWAY_URL", "https://test.eligibility-signposting-api.nhs.uk" - ) + context.api_gateway_url = os.getenv("API_GATEWAY_URL", "https://test.eligibility-signposting-api.nhs.uk") logger.info("ABORT_ON_AWS_FAILURE=%s", context.abort_on_aws_error) logger.info("KEEP_SEED=%s", context.keep_seed) @@ -67,16 +59,10 @@ def _setup_s3(context): json_files = list(context.s3_data_path.glob("*.json")) for file_path in json_files: - key = ( - f"{context.s3_upload_dir}/{file_path.name}" - if context.s3_upload_dir - else file_path.name - ) + key = f"{context.s3_upload_dir}/{file_path.name}" if context.s3_upload_dir else file_path.name try: s3_client.upload_file(str(file_path), context.s3_bucket, key) - logger.info( - "Uploaded %s to s3://%s/%s", file_path.name, context.s3_bucket, key - ) + logger.info("Uploaded %s to s3://%s/%s", file_path.name, context.s3_bucket, key) except (Exception, BotoCoreError): logger.exception("Failed to upload %s", file_path.name) except (Exception, BotoCoreError): @@ -142,9 +128,7 @@ def after_feature(context, feature): feature.name, ) except Exception: - logger.exception( - "Failed to cleanup DynamoDB data for feature: %s", feature.name - ) + logger.exception("Failed to cleanup DynamoDB data for feature: %s", feature.name) def after_all(context): @@ -159,17 +143,11 @@ def after_all(context): s3_client = boto3.client("s3", region_name=context.aws_region) json_files = list(context.s3_data_path.glob("*.json")) for file_path in json_files: - key = ( - f"{context.s3_upload_dir}/{file_path.name}" - if context.s3_upload_dir - else file_path.name - ) + key = f"{context.s3_upload_dir}/{file_path.name}" if context.s3_upload_dir else file_path.name try: s3_client.delete_object(Bucket=context.s3_bucket, Key=key) logger.info("Deleted s3://%s/%s", context.s3_bucket, key) except (Exception, BotoCoreError): - logger.exception( - "Failed to delete s3://%s/%s", context.s3_bucket, key - ) + logger.exception("Failed to delete s3://%s/%s", context.s3_bucket, key) except Exception: logger.exception("S3 cleanup failed") diff --git a/tests/e2e/features/steps/helpers/dynamodb_data_generator.py b/tests/e2e/features/steps/helpers/dynamodb_data_generator.py index e572ebf3f..423674f0b 100644 --- a/tests/e2e/features/steps/helpers/dynamodb_data_generator.py +++ b/tests/e2e/features/steps/helpers/dynamodb_data_generator.py @@ -31,25 +31,19 @@ def resolve(self, token: str) -> str: if unit == "week": return (self.today + timedelta(weeks=offset)).strftime(DATE_FORMAT) if unit == "year": - return (self.today.replace(year=self.today.year + offset)).strftime( - DATE_FORMAT - ) + return (self.today.replace(year=self.today.year + offset)).strftime(DATE_FORMAT) if unit == "age": try: birth_date = self.today.replace(year=self.today.year - offset) except ValueError: - birth_date = self.today.replace( - month=2, day=28, year=self.today.year - offset - ) + birth_date = self.today.replace(month=2, day=28, year=self.today.year - offset) return birth_date.strftime(DATE_FORMAT) msg = f"Unsupported unit: {unit}" raise ValueError(msg) class JsonTestDataProcessor: - def __init__( - self, input_dir: Path, output_dir: Path, resolver: DateVariableResolver - ): + def __init__(self, input_dir: Path, output_dir: Path, resolver: DateVariableResolver): self.input_dir = input_dir self.output_dir = output_dir self.resolver = resolver diff --git a/tests/e2e/features/steps/helpers/dynamodb_data_uploader.py b/tests/e2e/features/steps/helpers/dynamodb_data_uploader.py index 6cfdc22b2..46c83bc3b 100644 --- a/tests/e2e/features/steps/helpers/dynamodb_data_uploader.py +++ b/tests/e2e/features/steps/helpers/dynamodb_data_uploader.py @@ -50,11 +50,7 @@ def delete_data(self): attribute_type = item.get("ATTRIBUTE_TYPE") if nhs_number and attribute_type: try: - self.table.delete_item( - Key={"NHS_NUMBER": nhs_number, "ATTRIBUTE_TYPE": attribute_type} - ) + self.table.delete_item(Key={"NHS_NUMBER": nhs_number, "ATTRIBUTE_TYPE": attribute_type}) logger.info("Deleted item: %s - %s", nhs_number, attribute_type) except Exception: - logger.exception( - "Failed to delete item: %s - %s", nhs_number, attribute_type - ) + logger.exception("Failed to delete item: %s - %s", nhs_number, attribute_type)