From 09ffcff144222189197e39859b065f2dec21ca6b Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:22:18 +0000 Subject: [PATCH 01/58] ELI-578 consumer_id - campaign_config mapping --- .../model/consumer_mapping.py | 11 +++++++ .../repos/consumer_mapping_repo.py | 32 +++++++++++++++++++ .../services/eligibility_services.py | 14 +++++++- .../views/eligibility.py | 1 + 4 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 src/eligibility_signposting_api/model/consumer_mapping.py create mode 100644 src/eligibility_signposting_api/repos/consumer_mapping_repo.py diff --git a/src/eligibility_signposting_api/model/consumer_mapping.py b/src/eligibility_signposting_api/model/consumer_mapping.py new file mode 100644 index 000000000..6903aa187 --- /dev/null +++ b/src/eligibility_signposting_api/model/consumer_mapping.py @@ -0,0 +1,11 @@ +from typing import NewType + +from pydantic import RootModel + +from eligibility_signposting_api.model.campaign_config import CampaignID + +ConsumerId = NewType("ConsumerId", str) + +class ConsumerMapping(RootModel[dict[str, list[CampaignID]]]): + def get(self, key: str, default: list[CampaignID] | None = None) -> list[CampaignID] | None: + return self.root.get(key, default) diff --git a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py new file mode 100644 index 000000000..e6a852c8d --- /dev/null +++ b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py @@ -0,0 +1,32 @@ +import json +from typing import Annotated, NewType + +from botocore.client import BaseClient +from wireup import Inject, service + +from eligibility_signposting_api.model.campaign_config import CampaignID +from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping, ConsumerId + +BucketName = NewType("BucketName", str) + + +@service +class ConsumerMappingRepo: + """Repository class for Campaign Rules, which we can use to calculate a person's eligibility for vaccination. + + These rules are stored as JSON files in AWS S3.""" + + def __init__( + self, + s3_client: Annotated[BaseClient, Inject(qualifier="s3")], + bucket_name: Annotated[BucketName, Inject(param="rules_bucket_name")], + ) -> None: + super().__init__() + self.s3_client = s3_client + self.bucket_name = bucket_name + + def get_sanctioned_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: + consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] + response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") + body = response["Body"].read() + return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 79934e174..8ebee49f9 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -3,7 +3,9 @@ from wireup import service from eligibility_signposting_api.model import eligibility_status +from eligibility_signposting_api.model.campaign_config import CampaignConfig from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo +from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services.calculators import eligibility_calculator as calculator logger = logging.getLogger(__name__) @@ -24,11 +26,13 @@ def __init__( person_repo: PersonRepo, campaign_repo: CampaignRepo, calculator_factory: calculator.EligibilityCalculatorFactory, + consumer_mapping_repo: ConsumerMappingRepo ) -> None: super().__init__() self.person_repo = person_repo self.campaign_repo = campaign_repo self.calculator_factory = calculator_factory + self.consumer_mapping = consumer_mapping_repo def get_eligibility_status( self, @@ -36,16 +40,24 @@ def get_eligibility_status( include_actions: str, conditions: list[str], category: str, + consumer_id: str, ) -> eligibility_status.EligibilityStatus: """Calculate a person's eligibility for vaccination given an NHS number.""" if nhs_number: try: person_data = self.person_repo.get_eligibility_data(nhs_number) campaign_configs = list(self.campaign_repo.get_campaign_configs()) + consumer_mappings = self.consumer_mapping.get_sanctioned_campaign_ids(consumer_id) + sanctioned_campaign_ids: list[CampaignConfig] = [ + campaign for campaign in campaign_configs + if campaign.id in consumer_mappings + ] + except NotFoundError as e: raise UnknownPersonError from e else: - calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, campaign_configs) + calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, + sanctioned_campaign_ids) return calc.get_eligibility_status(include_actions, conditions, category) raise UnknownPersonError # pragma: no cover diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index eb2b706ea..61b9350e7 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -54,6 +54,7 @@ def check_eligibility( query_params["includeActions"], query_params["conditions"], query_params["category"], + request.headers.get("X-Correlation-ID"), ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) From dfcff797107389466cff584be7323b5a4dbea4cb Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:20:45 +0000 Subject: [PATCH 02/58] ELI-578 added dummy consumer id --- .../model/consumer_mapping.py | 4 ++-- .../repos/consumer_mapping_repo.py | 13 ++++++++----- .../services/eligibility_services.py | 8 ++++---- .../views/eligibility.py | 2 +- tests/fixtures/builders/model/rule.py | 2 +- .../in_process/test_eligibility_endpoint.py | 3 ++- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/eligibility_signposting_api/model/consumer_mapping.py b/src/eligibility_signposting_api/model/consumer_mapping.py index 6903aa187..9f75f0a70 100644 --- a/src/eligibility_signposting_api/model/consumer_mapping.py +++ b/src/eligibility_signposting_api/model/consumer_mapping.py @@ -6,6 +6,6 @@ ConsumerId = NewType("ConsumerId", str) -class ConsumerMapping(RootModel[dict[str, list[CampaignID]]]): - def get(self, key: str, default: list[CampaignID] | None = None) -> list[CampaignID] | None: +class ConsumerMapping(RootModel[dict[ConsumerId, list[CampaignID]]]): + def get(self, key: ConsumerId, default: list[CampaignID] | None = None) -> list[CampaignID] | None: return self.root.get(key, default) diff --git a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py index e6a852c8d..d8981149c 100644 --- a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py +++ b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py @@ -25,8 +25,11 @@ def __init__( self.s3_client = s3_client self.bucket_name = bucket_name - def get_sanctioned_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: - consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] - response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") - body = response["Body"].read() - return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) + # def get_permitted_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: + # consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] + # response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") + # body = response["Body"].read() + # return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) + + def get_permitted_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: + return ["324r2"] diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 8ebee49f9..b7c2159a9 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -47,17 +47,17 @@ def get_eligibility_status( try: person_data = self.person_repo.get_eligibility_data(nhs_number) campaign_configs = list(self.campaign_repo.get_campaign_configs()) - consumer_mappings = self.consumer_mapping.get_sanctioned_campaign_ids(consumer_id) - sanctioned_campaign_ids: list[CampaignConfig] = [ + permitted_campaign_ids = self.consumer_mapping.get_permitted_campaign_ids(consumer_id) + permitted_campaign_configs: list[CampaignConfig] = [ campaign for campaign in campaign_configs - if campaign.id in consumer_mappings + if campaign.id in permitted_campaign_ids ] except NotFoundError as e: raise UnknownPersonError from e else: calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, - sanctioned_campaign_ids) + permitted_campaign_configs) return calc.get_eligibility_status(include_actions, conditions, category) raise UnknownPersonError # pragma: no cover diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 61b9350e7..cc39c80c3 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -54,7 +54,7 @@ def check_eligibility( query_params["includeActions"], query_params["conditions"], query_params["category"], - request.headers.get("X-Correlation-ID"), + request.headers.get("Consumer-ID"), ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index bf62de900..c44b038b6 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -93,7 +93,7 @@ class IterationFactory(ModelFactory[Iteration]): class RawCampaignConfigFactory(ModelFactory[CampaignConfig]): iterations = Use(IterationFactory.batch, size=2) - + id = "324r2" start_date = Use(past_date) end_date = Use(future_date) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 4e5cdbfb8..ebe237e55 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -28,7 +28,8 @@ def test_nhs_number_given( secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), + "Consumer-ID":"dummy"} # When response = client.get(f"/patient-check/{persisted_person}", headers=headers) From 18736fab26c04145c1960ccc94c35aa5ddbfcad8 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:48:53 +0000 Subject: [PATCH 03/58] ELI-578 local stack configuration --- .../config/config.py | 3 +++ .../repos/consumer_mapping_repo.py | 13 ++++------ tests/fixtures/builders/model/rule.py | 2 +- tests/integration/conftest.py | 25 ++++++++++++++++++- .../in_process/test_eligibility_endpoint.py | 4 ++- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/eligibility_signposting_api/config/config.py b/src/eligibility_signposting_api/config/config.py index 6be1840aa..52f3111cc 100644 --- a/src/eligibility_signposting_api/config/config.py +++ b/src/eligibility_signposting_api/config/config.py @@ -22,6 +22,7 @@ def config() -> dict[str, Any]: person_table_name = TableName(os.getenv("PERSON_TABLE_NAME", "test_eligibility_datastore")) rules_bucket_name = BucketName(os.getenv("RULES_BUCKET_NAME", "test-rules-bucket")) + consumer_mapping_bucket_name = BucketName(os.getenv("CONSUMER_MAPPING_BUCKET_NAME", "test-consumer-mapping-bucket")) audit_bucket_name = BucketName(os.getenv("AUDIT_BUCKET_NAME", "test-audit-bucket")) hashing_secret_name = HashSecretName(os.getenv("HASHING_SECRET_NAME", "test_secret")) aws_default_region = AwsRegion(os.getenv("AWS_DEFAULT_REGION", "eu-west-1")) @@ -41,6 +42,7 @@ def config() -> dict[str, Any]: "s3_endpoint": None, "rules_bucket_name": rules_bucket_name, "audit_bucket_name": audit_bucket_name, + "consumer_mapping_bucket_name": consumer_mapping_bucket_name, "firehose_endpoint": None, "kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3, "enable_xray_patching": enable_xray_patching, @@ -59,6 +61,7 @@ def config() -> dict[str, Any]: "s3_endpoint": URL(os.getenv("S3_ENDPOINT", local_stack_endpoint)), "rules_bucket_name": rules_bucket_name, "audit_bucket_name": audit_bucket_name, + "consumer_mapping_bucket_name": consumer_mapping_bucket_name, "firehose_endpoint": URL(os.getenv("FIREHOSE_ENDPOINT", local_stack_endpoint)), "kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3, "enable_xray_patching": enable_xray_patching, diff --git a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py index d8981149c..669f4f836 100644 --- a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py +++ b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py @@ -19,17 +19,14 @@ class ConsumerMappingRepo: def __init__( self, s3_client: Annotated[BaseClient, Inject(qualifier="s3")], - bucket_name: Annotated[BucketName, Inject(param="rules_bucket_name")], + bucket_name: Annotated[BucketName, Inject(param="consumer_mapping_bucket_name")], ) -> None: super().__init__() self.s3_client = s3_client self.bucket_name = bucket_name - # def get_permitted_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: - # consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] - # response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") - # body = response["Body"].read() - # return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) - def get_permitted_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: - return ["324r2"] + consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] + response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") + body = response["Body"].read() + return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index c44b038b6..a0de2b5c6 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -93,7 +93,7 @@ class IterationFactory(ModelFactory[Iteration]): class RawCampaignConfigFactory(ModelFactory[CampaignConfig]): iterations = Use(IterationFactory.batch, size=2) - id = "324r2" + id = "42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy" start_date = Use(past_date) end_date = Use(future_date) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1edf35179..7ced44a60 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -28,8 +28,9 @@ RuleText, RuleType, StartDate, - StatusText, + StatusText, CampaignID, ) +from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping, ConsumerId from eligibility_signposting_api.processors.hashing_service import HashingService, HashSecretName from eligibility_signposting_api.repos import SecretRepo from eligibility_signposting_api.repos.campaign_repo import BucketName @@ -661,6 +662,14 @@ def rules_bucket(s3_client: BaseClient) -> Generator[BucketName]: s3_client.delete_bucket(Bucket=bucket_name) +@pytest.fixture(scope="session") +def consumer_mapping_bucket(s3_client: BaseClient) -> Generator[BucketName]: + bucket_name = BucketName(os.getenv("CONSUMER_MAPPING_BUCKET_NAME", "test-consumer-mapping-bucket")) + s3_client.create_bucket(Bucket=bucket_name, CreateBucketConfiguration={"LocationConstraint": AWS_REGION}) + yield bucket_name + s3_client.delete_bucket(Bucket=bucket_name) + + @pytest.fixture(scope="session") def audit_bucket(s3_client: BaseClient) -> Generator[BucketName]: bucket_name = BucketName(os.getenv("AUDIT_BUCKET_NAME", "test-audit-bucket")) @@ -718,6 +727,20 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato yield campaign s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") +@pytest.fixture(scope="class") +def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ + CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy") + ] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, Key=f"consumer_mapping.json", Body=json.dumps(consumer_mapping_data), ContentType="application/json" + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key=f"consumer_mapping.json") + @pytest.fixture def campaign_config_with_rules_having_rule_code( diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index ebe237e55..5911309de 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -14,6 +14,7 @@ ) from eligibility_signposting_api.model.campaign_config import CampaignConfig +from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping from eligibility_signposting_api.model.eligibility_status import ( NHSNumber, ) @@ -25,11 +26,12 @@ def test_nhs_number_given( client: FlaskClient, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given headers = {"nhs-login-nhs-number": str(persisted_person), - "Consumer-ID":"dummy"} + "Consumer-ID": "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}", headers=headers) From b9fe63bf537bb33340856a19876eedb08b1dd45f Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:57:47 +0000 Subject: [PATCH 04/58] ELI-578 wip test --- .../services/eligibility_services.py | 2 +- tests/unit/services/test_eligibility_services.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index b7c2159a9..40ed100cb 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -25,8 +25,8 @@ def __init__( self, person_repo: PersonRepo, campaign_repo: CampaignRepo, + consumer_mapping_repo: ConsumerMappingRepo, calculator_factory: calculator.EligibilityCalculatorFactory, - consumer_mapping_repo: ConsumerMappingRepo ) -> None: super().__init__() self.person_repo = person_repo diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index 504888f12..f30c120a1 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -5,6 +5,7 @@ from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo +from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculatorFactory from tests.fixtures.matchers.eligibility import is_eligibility_status @@ -13,13 +14,14 @@ def test_eligibility_service_returns_from_repo(): # Given person_repo = MagicMock(spec=PersonRepo) + consumer_mapping_repo = MagicMock(spec=ConsumerMappingRepo) campaign_repo = MagicMock(spec=CampaignRepo) person_repo.get_eligibility = MagicMock(return_value=[]) - service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory()) + service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory(), consumer_mapping_repo) # When actual = service.get_eligibility_status( - NHSNumber("1234567890"), include_actions="Y", conditions=["ALL"], category="ALL" + NHSNumber("1234567890"), include_actions="Y", conditions=["ALL"], category="ALL", consumer_id="test_consumer_id" ) # Then From bcd1d8d7974049a5d873c7b3f2cccd3b8b79c24b Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:54:03 +0000 Subject: [PATCH 05/58] ELI-578 unit test fixed --- .../model/consumer_mapping.py | 1 + .../repos/consumer_mapping_repo.py | 2 +- .../services/eligibility_services.py | 28 +++++++++++++------ tests/fixtures/builders/model/rule.py | 2 +- tests/integration/conftest.py | 17 ++++++----- .../in_process/test_eligibility_endpoint.py | 3 +- .../services/test_eligibility_services.py | 15 +++++++--- tests/unit/views/test_eligibility.py | 4 ++- 8 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/eligibility_signposting_api/model/consumer_mapping.py b/src/eligibility_signposting_api/model/consumer_mapping.py index 9f75f0a70..8e86d7e46 100644 --- a/src/eligibility_signposting_api/model/consumer_mapping.py +++ b/src/eligibility_signposting_api/model/consumer_mapping.py @@ -6,6 +6,7 @@ ConsumerId = NewType("ConsumerId", str) + class ConsumerMapping(RootModel[dict[ConsumerId, list[CampaignID]]]): def get(self, key: ConsumerId, default: list[CampaignID] | None = None) -> list[CampaignID] | None: return self.root.get(key, default) diff --git a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py index 669f4f836..140ac74c4 100644 --- a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py +++ b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py @@ -5,7 +5,7 @@ from wireup import Inject, service from eligibility_signposting_api.model.campaign_config import CampaignID -from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping, ConsumerId +from eligibility_signposting_api.model.consumer_mapping import ConsumerId, ConsumerMapping BucketName = NewType("BucketName", str) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 40ed100cb..13b701d61 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -4,6 +4,7 @@ from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.campaign_config import CampaignConfig +from eligibility_signposting_api.model.consumer_mapping import ConsumerId from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services.calculators import eligibility_calculator as calculator @@ -46,18 +47,27 @@ def get_eligibility_status( if nhs_number: try: person_data = self.person_repo.get_eligibility_data(nhs_number) - campaign_configs = list(self.campaign_repo.get_campaign_configs()) - permitted_campaign_ids = self.consumer_mapping.get_permitted_campaign_ids(consumer_id) - permitted_campaign_configs: list[CampaignConfig] = [ - campaign for campaign in campaign_configs - if campaign.id in permitted_campaign_ids - ] - except NotFoundError as e: raise UnknownPersonError from e else: - calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, - permitted_campaign_configs) + campaign_configs: list[CampaignConfig] = list(self.campaign_repo.get_campaign_configs()) + permitted_campaign_configs = self.__collect_permitted_campaign_configs( + campaign_configs, ConsumerId(consumer_id) + ) + calc: calculator.EligibilityCalculator = self.calculator_factory.get( + person_data, permitted_campaign_configs + ) return calc.get_eligibility_status(include_actions, conditions, category) raise UnknownPersonError # pragma: no cover + + def __collect_permitted_campaign_configs( + self, campaign_configs: list[CampaignConfig], consumer_id: ConsumerId + ) -> list[CampaignConfig]: + permitted_campaign_ids = self.consumer_mapping.get_permitted_campaign_ids(ConsumerId(consumer_id)) + if permitted_campaign_ids: + permitted_campaign_configs: list[CampaignConfig] = [ + campaign for campaign in campaign_configs if campaign.id in permitted_campaign_ids + ] + return permitted_campaign_configs + return [] diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index a0de2b5c6..2793ea032 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -93,7 +93,7 @@ class IterationFactory(ModelFactory[Iteration]): class RawCampaignConfigFactory(ModelFactory[CampaignConfig]): iterations = Use(IterationFactory.batch, size=2) - id = "42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy" + id = "42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy" start_date = Use(past_date) end_date = Use(future_date) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7ced44a60..a610e0a2d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -21,6 +21,7 @@ from eligibility_signposting_api.model.campaign_config import ( AvailableAction, CampaignConfig, + CampaignID, EndDate, RuleCode, RuleEntry, @@ -28,9 +29,9 @@ RuleText, RuleType, StartDate, - StatusText, CampaignID, + StatusText, ) -from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping, ConsumerId +from eligibility_signposting_api.model.consumer_mapping import ConsumerId, ConsumerMapping from eligibility_signposting_api.processors.hashing_service import HashingService, HashSecretName from eligibility_signposting_api.repos import SecretRepo from eligibility_signposting_api.repos.campaign_repo import BucketName @@ -727,19 +728,21 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato yield campaign s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") + @pytest.fixture(scope="class") def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ - CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy") - ] + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy")] consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) s3_client.put_object( - Bucket=consumer_mapping_bucket, Key=f"consumer_mapping.json", Body=json.dumps(consumer_mapping_data), ContentType="application/json" + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", ) yield consumer_mapping - s3_client.delete_object(Bucket=consumer_mapping_bucket, Key=f"consumer_mapping.json") + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") @pytest.fixture diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 5911309de..66504c5bb 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -30,8 +30,7 @@ def test_nhs_number_given( secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person), - "Consumer-ID": "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), "Consumer-ID": "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}", headers=headers) diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index f30c120a1..1d351ffaf 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -14,10 +14,10 @@ def test_eligibility_service_returns_from_repo(): # Given person_repo = MagicMock(spec=PersonRepo) - consumer_mapping_repo = MagicMock(spec=ConsumerMappingRepo) campaign_repo = MagicMock(spec=CampaignRepo) + consumer_mapping_repo = MagicMock(spec=ConsumerMappingRepo) person_repo.get_eligibility = MagicMock(return_value=[]) - service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory(), consumer_mapping_repo) + service = EligibilityService(person_repo, campaign_repo, consumer_mapping_repo, EligibilityCalculatorFactory()) # When actual = service.get_eligibility_status( @@ -32,9 +32,16 @@ def test_eligibility_service_for_nonexistent_nhs_number(): # Given person_repo = MagicMock(spec=PersonRepo) campaign_repo = MagicMock(spec=CampaignRepo) + consumer_mapping_repo = MagicMock(spec=ConsumerMappingRepo) person_repo.get_eligibility_data = MagicMock(side_effect=NotFoundError) - service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory()) + service = EligibilityService(person_repo, campaign_repo, consumer_mapping_repo, EligibilityCalculatorFactory()) # When with pytest.raises(UnknownPersonError): - service.get_eligibility_status(NHSNumber("1234567890"), include_actions="Y", conditions=["ALL"], category="ALL") + service.get_eligibility_status( + NHSNumber("1234567890"), + include_actions="Y", + conditions=["ALL"], + category="ALL", + consumer_id="test_consumer_id", + ) diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 5c323a7b2..1f9d3db83 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -60,6 +60,7 @@ def get_eligibility_status( _include_actions: str, _conditions: list[str], _category: str, + _consumer_id: str, ) -> EligibilityStatus: return EligibilityStatusFactory.build() @@ -74,6 +75,7 @@ def get_eligibility_status( _include_actions: str, _conditions: list[str], _category: str, + _consumer_id: str, ) -> EligibilityStatus: raise UnknownPersonError @@ -100,7 +102,7 @@ def test_security_headers_present_on_successful_response(app: Flask, client: Fla get_app_container(app).override.service(AuditService, new=FakeAuditService()), ): # When - headers = {"nhs-login-nhs-number": "9876543210"} + headers = {"nhs-login-nhs-number": "9876543210", "Consumer-Id": "test_consumer_id"} response = client.get("/patient-check/9876543210", headers=headers) # Then From 60b49546433f6972c8dce3969096c8af77ed656d Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:23:14 +0000 Subject: [PATCH 06/58] ELI-578 consumer_id error validation --- .../common/api_error_response.py | 8 ++++++++ .../common/request_validator.py | 11 ++++++++++- .../config/constants.py | 1 + .../views/eligibility.py | 17 +++++++++++++---- .../in_process/test_eligibility_endpoint.py | 3 ++- tests/unit/views/test_eligibility.py | 13 ++++++------- 6 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/eligibility_signposting_api/common/api_error_response.py b/src/eligibility_signposting_api/common/api_error_response.py index 40c1ddcdd..cb1006584 100644 --- a/src/eligibility_signposting_api/common/api_error_response.py +++ b/src/eligibility_signposting_api/common/api_error_response.py @@ -135,3 +135,11 @@ def log_and_generate_response( fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, fhir_display_message="Access has been denied to process this request.", ) + +CONSUMER_ID_NOT_PROVIDED_ERROR = APIErrorResponse( + status_code=HTTPStatus.FORBIDDEN, + fhir_issue_code=FHIRIssueCode.FORBIDDEN, + fhir_issue_severity=FHIRIssueSeverity.ERROR, + fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, + fhir_display_message="Access has been denied to process this request.", +) diff --git a/src/eligibility_signposting_api/common/request_validator.py b/src/eligibility_signposting_api/common/request_validator.py index cd213287a..40416e5ca 100644 --- a/src/eligibility_signposting_api/common/request_validator.py +++ b/src/eligibility_signposting_api/common/request_validator.py @@ -7,12 +7,13 @@ from flask.typing import ResponseReturnValue from eligibility_signposting_api.common.api_error_response import ( + CONSUMER_ID_NOT_PROVIDED_ERROR, INVALID_CATEGORY_ERROR, INVALID_CONDITION_FORMAT_ERROR, INVALID_INCLUDE_ACTIONS_ERROR, NHS_NUMBER_MISMATCH_ERROR, ) -from eligibility_signposting_api.config.constants import NHS_NUMBER_HEADER +from eligibility_signposting_api.config.constants import CONSUMER_ID, NHS_NUMBER_HEADER logger = logging.getLogger(__name__) @@ -56,6 +57,14 @@ def validate_request_params() -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs) -> ResponseReturnValue: # noqa:ANN002,ANN003 + consumer_id = str(request.headers.get(CONSUMER_ID)) + + if not consumer_id: + message = "You are not authorised to request" + return CONSUMER_ID_NOT_PROVIDED_ERROR.log_and_generate_response( + log_message=message, diagnostics=message + ) + path_nhs_number = str(kwargs.get("nhs_number")) header_nhs_no = str(request.headers.get(NHS_NUMBER_HEADER)) diff --git a/src/eligibility_signposting_api/config/constants.py b/src/eligibility_signposting_api/config/constants.py index 3aa45fd35..bdc49e307 100644 --- a/src/eligibility_signposting_api/config/constants.py +++ b/src/eligibility_signposting_api/config/constants.py @@ -3,4 +3,5 @@ URL_PREFIX = "patient-check" RULE_STOP_DEFAULT = False NHS_NUMBER_HEADER = "nhs-login-nhs-number" +CONSUMER_ID = "consumer-id" ALLOWED_CONDITIONS = Literal["COVID", "FLU", "MMR", "RSV"] diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index cc39c80c3..2ae296cdc 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -13,7 +13,8 @@ from eligibility_signposting_api.audit.audit_service import AuditService from eligibility_signposting_api.common.api_error_response import NHS_NUMBER_NOT_FOUND_ERROR from eligibility_signposting_api.common.request_validator import validate_request_params -from eligibility_signposting_api.config.constants import URL_PREFIX +from eligibility_signposting_api.config.constants import CONSUMER_ID, URL_PREFIX +from eligibility_signposting_api.model.consumer_mapping import ConsumerId from eligibility_signposting_api.model.eligibility_status import Condition, EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.views.response_model import eligibility_response @@ -48,13 +49,14 @@ def check_eligibility( ) -> ResponseReturnValue: logger.info("checking nhs_number %r in %r", nhs_number, eligibility_service, extra={"nhs_number": nhs_number}) try: - query_params = get_or_default_query_params() + query_params = _get_or_default_query_params() + consumer_id = _get_consumer_id_from_headers() eligibility_status = eligibility_service.get_eligibility_status( nhs_number, query_params["includeActions"], query_params["conditions"], query_params["category"], - request.headers.get("Consumer-ID"), + consumer_id, ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) @@ -64,7 +66,14 @@ def check_eligibility( return make_response(response.model_dump(by_alias=True, mode="json", exclude_none=True), HTTPStatus.OK) -def get_or_default_query_params() -> dict[str, Any]: +def _get_consumer_id_from_headers() -> ConsumerId: + """ + @validate_request_params() ensures the consumer ID is never null at this stage. + """ + return ConsumerId(request.headers.get(CONSUMER_ID, "")) + + +def _get_or_default_query_params() -> dict[str, Any]: default_query_params = {"category": "ALL", "conditions": ["ALL"], "includeActions": "Y"} if not request.args: diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 66504c5bb..84bb201e4 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -13,6 +13,7 @@ has_key, ) +from eligibility_signposting_api.config.constants import CONSUMER_ID from eligibility_signposting_api.model.campaign_config import CampaignConfig from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping from eligibility_signposting_api.model.eligibility_status import ( @@ -30,7 +31,7 @@ def test_nhs_number_given( secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person), "Consumer-ID": "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}", headers=headers) diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 1f9d3db83..14466c4bc 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -31,8 +31,7 @@ from eligibility_signposting_api.views.eligibility import ( build_actions, build_eligibility_cohorts, - build_suitability_results, - get_or_default_query_params, + build_suitability_results, _get_or_default_query_params, ) from eligibility_signposting_api.views.response_model import eligibility_response from tests.fixtures.builders.model.eligibility import ( @@ -509,7 +508,7 @@ def test_build_response_include_values_that_are_not_null(client: FlaskClient): def test_get_or_default_query_params_with_no_args(app: Flask): with app.test_request_context("/patient-check"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"category": "ALL", "conditions": ["ALL"], "includeActions": "Y"} @@ -518,7 +517,7 @@ def test_get_or_default_query_params_with_no_args(app: Flask): def test_get_or_default_query_params_with_all_args(app: Flask): with app.test_request_context("/patient-check?includeActions=Y&category=VACCINATIONS&conditions=FLU"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"includeActions": "Y", "category": "VACCINATIONS", "conditions": ["FLU"]} @@ -527,7 +526,7 @@ def test_get_or_default_query_params_with_all_args(app: Flask): def test_get_or_default_query_params_with_partial_args(app: Flask): with app.test_request_context("/patient-check?includeActions=N"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"includeActions": "N", "category": "ALL", "conditions": ["ALL"]} @@ -536,13 +535,13 @@ def test_get_or_default_query_params_with_partial_args(app: Flask): def test_get_or_default_query_params_with_lowercase_y(app: Flask): with app.test_request_context("/patient-check?includeActions=y"): - result = get_or_default_query_params() + result = _get_or_default_query_params() assert_that(result["includeActions"], is_("Y")) def test_get_or_default_query_params_missing_include_actions(app: Flask): with app.test_request_context("/patient-check?category=SCREENING&conditions=COVID19,FLU"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"includeActions": "Y", "category": "SCREENING", "conditions": ["COVID19", "FLU"]} From d84038988bc6d4e471f1dfe1ed543eb7f85eb7b6 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:34:01 +0000 Subject: [PATCH 07/58] ELI-578 intergation testing --- .../in_process/test_eligibility_endpoint.py | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 84bb201e4..08b5dfa04 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -83,9 +83,10 @@ def test_not_base_eligible( client: FlaskClient, persisted_person_no_cohorts: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts)} + headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_no_cohorts}?includeActions=Y", headers=headers) @@ -127,9 +128,10 @@ def test_not_eligible_by_rule( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19)} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -171,9 +173,10 @@ def test_not_actionable_and_check_response_when_no_rule_code_given( client: FlaskClient, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -221,8 +224,9 @@ def test_actionable( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -272,9 +276,10 @@ def test_actionable_with_and_rule( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_and_rule: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -326,9 +331,10 @@ def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19)} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -370,9 +376,10 @@ def test_not_actionable_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -420,9 +427,10 @@ def test_actionable_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -474,9 +482,10 @@ def test_not_base_eligible( client: FlaskClient, persisted_person_no_cohorts: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts)} + headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_no_cohorts}?includeActions=Y", headers=headers) @@ -512,9 +521,10 @@ def test_not_eligible_by_rule( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19)} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -550,9 +560,10 @@ def test_not_actionable( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -594,9 +605,10 @@ def test_actionable( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -640,9 +652,10 @@ def test_actionable_no_actions( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=N", headers=headers) @@ -714,9 +727,10 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_absent_but_rule_c client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_rules_having_rule_code: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -764,9 +778,10 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_rules_having_rule_mapper: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) From b7561a0eb243fa1424f439a8fcef44e0e9727e6e Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:01:35 +0000 Subject: [PATCH 08/58] ELI-578 lambda --- tests/integration/conftest.py | 23 ++++++++++++++++ .../lambda/test_app_running_as_lambda.py | 27 ++++++++++++++----- tests/unit/views/test_eligibility.py | 3 ++- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a610e0a2d..0a168ff0f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -745,6 +745,29 @@ def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") +@pytest.fixture(scope="class") +def consumer_mapping_with_various_targets( + s3_client: BaseClient, consumer_mapping_bucket: BucketName +) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ + CampaignID("campaign_start_date"), + CampaignID("campaign_start_date_plus_one_day"), + CampaignID("campaign_today"), + CampaignID("campaign_tomorrow"), + ] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + + @pytest.fixture def campaign_config_with_rules_having_rule_code( s3_client: BaseClient, rules_bucket: BucketName diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 9c473696a..f6725f70c 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -23,7 +23,9 @@ ) from yarl import URL +from eligibility_signposting_api.config.constants import CONSUMER_ID from eligibility_signposting_api.model.campaign_config import CampaignConfig +from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos.campaign_repo import BucketName @@ -35,6 +37,7 @@ def test_install_and_call_lambda_flask( flask_function: str, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 ): """Given lambda installed into localstack, run it via boto3 lambda client""" # Given @@ -49,6 +52,7 @@ def test_install_and_call_lambda_flask( "accept": "application/json", "content-type": "application/json", "nhs-login-nhs-number": str(persisted_person), + CONSUMER_ID: "23-mic7heal-jor6don", }, "pathParameters": {"id": str(persisted_person)}, "requestContext": { @@ -86,6 +90,7 @@ def test_install_and_call_lambda_flask( def test_install_and_call_flask_lambda_over_http( persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 api_gateway_endpoint: URL, ): """Given api-gateway and lambda installed into localstack, run it via http""" @@ -94,7 +99,7 @@ def test_install_and_call_flask_lambda_over_http( invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(persisted_person)}, + headers={"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) @@ -105,10 +110,11 @@ def test_install_and_call_flask_lambda_over_http( ) -def test_install_and_call_flask_lambda_with_unknown_nhs_number( +def test_install_and_call_flask_lambda_with_unknown_nhs_number( # noqa: PLR0913 flask_function: str, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 logs_client: BaseClient, api_gateway_endpoint: URL, ): @@ -120,7 +126,7 @@ def test_install_and_call_flask_lambda_with_unknown_nhs_number( invoke_url = f"{api_gateway_endpoint}/patient-check/{nhs_number}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(nhs_number)}, + headers={"nhs-login-nhs-number": str(nhs_number), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) @@ -182,6 +188,7 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, campaign_config: CampaignConfig, + consumer_mapping: ConsumerMapping, # noqa: ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -195,6 +202,7 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person), + CONSUMER_ID: "23-mic7heal-jor6don", "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -371,6 +379,7 @@ def test_validation_of_query_params_when_all_are_valid( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa:ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 api_gateway_endpoint: URL, ): # Given @@ -378,7 +387,7 @@ def test_validation_of_query_params_when_all_are_valid( invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": persisted_person}, + headers={"nhs-login-nhs-number": persisted_person, CONSUMER_ID: "23-mic7heal-jor6don"}, params={"category": "VACCINATIONS", "conditions": "COVID19", "includeActions": "N"}, timeout=10, ) @@ -411,6 +420,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, multiple_campaign_configs: list[CampaignConfig], + consumer_mapping: ConsumerMapping, # noqa: ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -420,6 +430,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person_all_cohorts), + CONSUMER_ID: "23-mic7heal-jor6don", "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -554,6 +565,7 @@ def test_no_active_iteration_returns_empty_processed_suggestions( lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, inactive_iteration_config: list[CampaignConfig], # noqa:ARG001 + consumer_mapping_with_various_targets: ConsumerMapping, # noqa:ARG001 api_gateway_endpoint: URL, ): invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person_all_cohorts}" @@ -561,6 +573,7 @@ def test_no_active_iteration_returns_empty_processed_suggestions( invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person_all_cohorts), + CONSUMER_ID: "23-mic7heal-jor6don", "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -590,6 +603,7 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, campaign_config_with_tokens: CampaignConfig, # noqa:ARG001 + consumer_mapping: ConsumerMapping, # noqa:ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -599,7 +613,7 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 invoke_url = f"{api_gateway_endpoint}/patient-check/{person_with_all_data}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(person_with_all_data)}, + headers={"nhs-login-nhs-number": str(person_with_all_data), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) @@ -640,6 +654,7 @@ def test_incorrect_token_causes_internal_server_error( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, campaign_config_with_invalid_tokens: CampaignConfig, # noqa:ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -649,7 +664,7 @@ def test_incorrect_token_causes_internal_server_error( # noqa: PLR0913 invoke_url = f"{api_gateway_endpoint}/patient-check/{person_with_all_data}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(person_with_all_data)}, + headers={"nhs-login-nhs-number": str(person_with_all_data), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 14466c4bc..aa76b02db 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -29,9 +29,10 @@ ) from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.views.eligibility import ( + _get_or_default_query_params, build_actions, build_eligibility_cohorts, - build_suitability_results, _get_or_default_query_params, + build_suitability_results, ) from eligibility_signposting_api.views.response_model import eligibility_response from tests.fixtures.builders.model.eligibility import ( From aefbe33b4c26e2e6909c77c4d7c744c109b8db46 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:59:25 +0000 Subject: [PATCH 09/58] ELI-578 integration test --- .../in_process/test_eligibility_endpoint.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 08b5dfa04..4bb6d2811 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -324,6 +324,23 @@ def test_actionable_with_and_rule( ), ) + def test_empty_response_when_no_campaign_mapped_to_consumer( + self, + client: FlaskClient, + persisted_person: NHSNumber, + campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 + ): + # Given + consumer_id_not_having_mapping = "23-jo4hn-ce4na" + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id_not_having_mapping} + + # When + response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) + + # Then + assert_that(response, is_response().with_status_code(HTTPStatus.NOT_FOUND)) + class TestVirtualCohortResponse: def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( From 8f4e28a698d518e3eee140130ca50185a6b4e798 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:20:06 +0000 Subject: [PATCH 10/58] ELI-578 Integration test to check if consumer has campaign mappings --- .../common/api_error_response.py | 8 ++++ .../services/eligibility_services.py | 6 ++- .../views/eligibility.py | 21 +++++++++-- .../in_process/test_eligibility_endpoint.py | 37 ++++++++++--------- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/eligibility_signposting_api/common/api_error_response.py b/src/eligibility_signposting_api/common/api_error_response.py index cb1006584..47308e085 100644 --- a/src/eligibility_signposting_api/common/api_error_response.py +++ b/src/eligibility_signposting_api/common/api_error_response.py @@ -143,3 +143,11 @@ def log_and_generate_response( fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, fhir_display_message="Access has been denied to process this request.", ) + +CONSUMER_HAS_NO_CAMPAIGN_MAPPING = APIErrorResponse( + status_code=HTTPStatus.FORBIDDEN, + fhir_issue_code=FHIRIssueCode.FORBIDDEN, + fhir_issue_severity=FHIRIssueSeverity.ERROR, + fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, + fhir_display_message="Access has been denied to process this request.", +) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 13b701d61..74d3c659c 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -20,6 +20,10 @@ class InvalidQueryParamError(Exception): pass +class NoPermittedCampaignsError(Exception): + pass + + @service class EligibilityService: def __init__( @@ -70,4 +74,4 @@ def __collect_permitted_campaign_configs( campaign for campaign in campaign_configs if campaign.id in permitted_campaign_ids ] return permitted_campaign_configs - return [] + raise NoPermittedCampaignsError diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 2ae296cdc..bf6fe1f36 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -11,12 +11,16 @@ from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.common.api_error_response import NHS_NUMBER_NOT_FOUND_ERROR +from eligibility_signposting_api.common.api_error_response import ( + CONSUMER_HAS_NO_CAMPAIGN_MAPPING, + NHS_NUMBER_NOT_FOUND_ERROR, +) from eligibility_signposting_api.common.request_validator import validate_request_params from eligibility_signposting_api.config.constants import CONSUMER_ID, URL_PREFIX from eligibility_signposting_api.model.consumer_mapping import ConsumerId from eligibility_signposting_api.model.eligibility_status import Condition, EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.services import EligibilityService, UnknownPersonError +from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from eligibility_signposting_api.views.response_model import eligibility_response from eligibility_signposting_api.views.response_model.eligibility_response import ProcessedSuggestion @@ -48,9 +52,11 @@ def check_eligibility( nhs_number: NHSNumber, eligibility_service: Injected[EligibilityService], audit_service: Injected[AuditService] ) -> ResponseReturnValue: logger.info("checking nhs_number %r in %r", nhs_number, eligibility_service, extra={"nhs_number": nhs_number}) + + query_params = _get_or_default_query_params() + consumer_id = _get_consumer_id_from_headers() + try: - query_params = _get_or_default_query_params() - consumer_id = _get_consumer_id_from_headers() eligibility_status = eligibility_service.get_eligibility_status( nhs_number, query_params["includeActions"], @@ -60,6 +66,8 @@ def check_eligibility( ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) + except NoPermittedCampaignsError: + return handle_no_permitted_campaigns_for_the_consumer_error(consumer_id) else: response: eligibility_response.EligibilityResponse = build_eligibility_response(eligibility_status) AuditContext.write_to_firehose(audit_service) @@ -112,6 +120,13 @@ def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue: ) +def handle_no_permitted_campaigns_for_the_consumer_error(consumer_id: ConsumerId) -> ResponseReturnValue: + diagnostics = f"Consumer ID '{consumer_id}' was not recognised by the Eligibility Signposting API" + return CONSUMER_HAS_NO_CAMPAIGN_MAPPING.log_and_generate_response( + log_message=diagnostics, diagnostics=diagnostics, location_param="id" + ) + + def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility_response.EligibilityResponse: """Return an object representing the API response we are going to send, given an evaluation of the person's eligibility.""" diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 4bb6d2811..14bb094dd 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -324,23 +324,6 @@ def test_actionable_with_and_rule( ), ) - def test_empty_response_when_no_campaign_mapped_to_consumer( - self, - client: FlaskClient, - persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 - ): - # Given - consumer_id_not_having_mapping = "23-jo4hn-ce4na" - headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id_not_having_mapping} - - # When - response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) - - # Then - assert_that(response, is_response().with_status_code(HTTPStatus.NOT_FOUND)) - class TestVirtualCohortResponse: def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( @@ -840,3 +823,23 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ) ), ) + + +class TestEligibilityResponseWhenConsumerHasNoMapping: + def test_empty_response_when_no_campaign_mapped_for_the_consumer( + self, + client: FlaskClient, + persisted_person: NHSNumber, + campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 + ): + # Given + consumer_id_not_having_mapping = "23-jo4hn-ce4na" + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id_not_having_mapping} + + # When + response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) + + # Then + assert_that(response, is_response().with_status_code(HTTPStatus.FORBIDDEN)) From 4afb1222a8385e26e87db61dd25b74b21ea506d0 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:18:17 +0000 Subject: [PATCH 11/58] ELI-578 Unit tests --- .../unit/repos/test_consumer_mapping_repo.py | 57 ++++++++++++ .../services/test_eligibility_services.py | 66 ++++++++++++++ tests/unit/views/test_eligibility.py | 86 +++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 tests/unit/repos/test_consumer_mapping_repo.py diff --git a/tests/unit/repos/test_consumer_mapping_repo.py b/tests/unit/repos/test_consumer_mapping_repo.py new file mode 100644 index 000000000..df15f1a10 --- /dev/null +++ b/tests/unit/repos/test_consumer_mapping_repo.py @@ -0,0 +1,57 @@ +import json +import pytest +from unittest.mock import MagicMock + +from eligibility_signposting_api.model.consumer_mapping import ConsumerId +from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo, BucketName + + +class TestConsumerMappingRepo: + @pytest.fixture + def mock_s3_client(self): + return MagicMock() + + @pytest.fixture + def repo(self, mock_s3_client): + return ConsumerMappingRepo( + s3_client=mock_s3_client, + bucket_name=BucketName("test-bucket") + ) + + def test_get_permitted_campaign_ids_success(self, repo, mock_s3_client): + # Given + consumer_id = "user-123" + expected_campaigns = ["flu-2024", "covid-2024"] + mapping_data = { + consumer_id: expected_campaigns + } + + mock_s3_client.list_objects.return_value = { + "Contents": [{"Key": "mappings.json"}] + } + + body_json = json.dumps(mapping_data).encode("utf-8") + mock_s3_client.get_object.return_value = { + "Body": MagicMock(read=lambda: body_json) + } + + # When + result = repo.get_permitted_campaign_ids(ConsumerId(consumer_id)) + + # Then + assert result == expected_campaigns + mock_s3_client.list_objects.assert_called_once_with(Bucket="test-bucket") + mock_s3_client.get_object.assert_called_once_with( + Bucket="test-bucket", + Key="mappings.json" + ) + + def test_get_permitted_campaign_ids_returns_none_when_missing(self, repo, mock_s3_client): + # Setup data where the consumer_id doesn't exist + mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "mappings.json"}]} + body_json = json.dumps({"other-user": ["camp-1"]}).encode("utf-8") + mock_s3_client.get_object.return_value = {"Body": MagicMock(read=lambda: body_json)} + + result = repo.get_permitted_campaign_ids(ConsumerId("missing-user")) + + assert result is None diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index 1d351ffaf..793efc355 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -3,13 +3,32 @@ import pytest from hamcrest import assert_that, empty +from eligibility_signposting_api.model.campaign_config import CampaignID, CampaignConfig from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculatorFactory +from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from tests.fixtures.matchers.eligibility import is_eligibility_status +@pytest.fixture +def mock_repos(): + return { + "person": MagicMock(spec=PersonRepo), + "campaign": MagicMock(spec=CampaignRepo), + "consumer": MagicMock(spec=ConsumerMappingRepo), + "factory": MagicMock(spec=EligibilityCalculatorFactory) + } + +@pytest.fixture +def service(mock_repos): + return EligibilityService( + mock_repos["person"], + mock_repos["campaign"], + mock_repos["consumer"], + mock_repos["factory"] + ) def test_eligibility_service_returns_from_repo(): # Given @@ -45,3 +64,50 @@ def test_eligibility_service_for_nonexistent_nhs_number(): category="ALL", consumer_id="test_consumer_id", ) + + +def test_get_eligibility_status_filters_permitted_campaigns(service, mock_repos): + """Tests that ONLY permitted campaigns reach the calculator factory.""" + # Given + nhs_number = NHSNumber("1234567890") + person_data = {"age": 65, "vulnerable": True} + mock_repos["person"].get_eligibility_data.return_value = person_data + + # Available campaigns in system + camp_a = MagicMock(spec=CampaignConfig, id=CampaignID("CAMP_A")) + camp_b = MagicMock(spec=CampaignConfig, id=CampaignID("CAMP_B")) + mock_repos["campaign"].get_campaign_configs.return_value = [camp_a, camp_b] + + # Consumer is only permitted to see CAMP_B + mock_repos["consumer"].get_permitted_campaign_ids.return_value = [CampaignID("CAMP_B")] + + # Mock calculator behavior + mock_calc = MagicMock() + mock_repos["factory"].get.return_value = mock_calc + mock_calc.get_eligibility_status.return_value = "eligible_result" + + # When + result = service.get_eligibility_status(nhs_number, "Y", ["FLU"], "G1", "consumer_xyz") + + # Then + # Verify the factory was called ONLY with camp_b + mock_repos["factory"].get.assert_called_once_with(person_data, [camp_b]) + assert result == "eligible_result" + +def test_raises_no_permitted_campaigns_error(service, mock_repos): + """Tests the scenario where the consumer mapping exists but returns nothing.""" + mock_repos["person"].get_eligibility_data.return_value = {"data": "exists"} + mock_repos["campaign"].get_campaign_configs.return_value = [MagicMock()] + + # Consumer has no permitted IDs mapped + mock_repos["consumer"].get_permitted_campaign_ids.return_value = [] + + with pytest.raises(NoPermittedCampaignsError): + service.get_eligibility_status(NHSNumber("1"), "Y", [], "", "bad_consumer") + +def test_raises_unknown_person_error_on_repo_not_found(service, mock_repos): + """Tests that NotFoundError from repo is translated to UnknownPersonError.""" + mock_repos["person"].get_eligibility_data.side_effect = NotFoundError + + with pytest.raises(UnknownPersonError): + service.get_eligibility_status(NHSNumber("999"), "Y", [], "", "any") diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index aa76b02db..fedc6d84b 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -28,6 +28,7 @@ UrlLink, ) from eligibility_signposting_api.services import EligibilityService, UnknownPersonError +from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from eligibility_signposting_api.views.eligibility import ( _get_or_default_query_params, build_actions, @@ -93,6 +94,20 @@ def get_eligibility_status( ) -> EligibilityStatus: raise ValueError +class FakeNoPermittedCampaignsService(EligibilityService): + def __init__(self): + pass + + def get_eligibility_status( + self, + _nhs_number: NHSNumber, + _include_actions: str, + _conditions: list[str], + _category: str, + _consumer_id: str, + ) -> EligibilityStatus: + # Simulate the new error scenario + raise NoPermittedCampaignsError def test_security_headers_present_on_successful_response(app: Flask, client: FlaskClient): """Test that security headers are present on successful eligibility check response.""" @@ -583,3 +598,74 @@ def test_status_endpoint(app: Flask, client: FlaskClient): ) ), ) + + +def test_no_permitted_campaigns_for_consumer_error(app: Flask, client: FlaskClient): + """ + Tests that NoPermittedCampaignsError is caught and returns + the correct FHIR OperationOutcome with FORBIDDEN status. + """ + # Given + with ( + get_app_container(app).override.service(EligibilityService, new=FakeNoPermittedCampaignsService()), + get_app_container(app).override.service(AuditService, new=FakeAuditService()), + ): + headers = { + "nhs-login-nhs-number": "9876543210", + "Consumer-Id": "unrecognized_consumer" + } + + # When + response = client.get("/patient-check/9876543210", headers=headers) + + # Then + assert_that( + response, + is_response() + .with_status_code(HTTPStatus.FORBIDDEN) + .with_headers(has_entries({"Content-Type": "application/fhir+json"})) + .and_text( + is_json_that( + has_entries( + resourceType="OperationOutcome", + issue=contains_exactly( + has_entries( + severity="error", + code="forbidden", + diagnostics="Consumer ID 'unrecognized_consumer' was not recognised by the Eligibility Signposting API" + ) + ) + ) + ) + ) + ) + + +def test_consumer_id_is_passed_to_service(app: Flask, client: FlaskClient): + """ + Verifies that the consumer ID from the header is actually passed + to the eligibility service call. + """ + # Given + mock_service = MagicMock(spec=EligibilityService) + mock_service.get_eligibility_status.return_value = EligibilityStatusFactory.build() + + with ( + get_app_container(app).override.service(EligibilityService, new=mock_service), + get_app_container(app).override.service(AuditService, new=FakeAuditService()), + ): + headers = { + "nhs-login-nhs-number": "1234567890", + "Consumer-Id": "specific_consumer_123" + } + + # When + client.get("/patient-check/1234567890", headers=headers) + + # Then + # Verify the 5th positional argument or the keyword argument 'consumer_id' + mock_service.get_eligibility_status.assert_called_once() + args, kwargs = mock_service.get_eligibility_status.call_args + + # Check that 'specific_consumer_123' was the consumer_id passed + assert args[4] == "specific_consumer_123" From 2dacd5e011e89a29f6307fb57d7046a502778eaf Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:34:31 +0000 Subject: [PATCH 12/58] added test : customer requesting for campaign that is not mapped --- .../in_process/test_eligibility_endpoint.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 14bb094dd..a4fe310c3 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -824,6 +824,38 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ), ) + def test_response_when_no_campaign_config_is_present( + self, + client: FlaskClient, + persisted_77yo_person: NHSNumber, + campaign_config_with_rules_having_rule_mapper: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 + ): + # Given + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} + + # When + response = client.get(f'/patient-check/{persisted_77yo_person}?includeActions=N&conditions=FLU', + headers=headers) + + # Then + assert_that( + response, + is_response() + .with_status_code(HTTPStatus.OK) + .and_text( + is_json_that( + has_entry( + "processedSuggestions", + equal_to( + [] + ), + ) + ) + ), + ) + class TestEligibilityResponseWhenConsumerHasNoMapping: def test_empty_response_when_no_campaign_mapped_for_the_consumer( From 72f6d0c7f8d8466b52ef9a479d4ee4546800bd2a Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:40:02 +0000 Subject: [PATCH 13/58] consumer with no campaign mapping is valid --- .../services/eligibility_services.py | 6 +- .../views/eligibility.py | 3 - tests/integration/conftest.py | 206 ++++++++++++++---- .../in_process/test_eligibility_endpoint.py | 132 +++++++---- .../lambda/test_app_running_as_lambda.py | 28 +-- .../unit/repos/test_consumer_mapping_repo.py | 27 +-- .../services/test_eligibility_services.py | 24 +- tests/unit/views/test_eligibility.py | 61 +----- 8 files changed, 288 insertions(+), 199 deletions(-) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 74d3c659c..13b701d61 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -20,10 +20,6 @@ class InvalidQueryParamError(Exception): pass -class NoPermittedCampaignsError(Exception): - pass - - @service class EligibilityService: def __init__( @@ -74,4 +70,4 @@ def __collect_permitted_campaign_configs( campaign for campaign in campaign_configs if campaign.id in permitted_campaign_ids ] return permitted_campaign_configs - raise NoPermittedCampaignsError + return [] diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index bf6fe1f36..0505bc28a 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -20,7 +20,6 @@ from eligibility_signposting_api.model.consumer_mapping import ConsumerId from eligibility_signposting_api.model.eligibility_status import Condition, EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.services import EligibilityService, UnknownPersonError -from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from eligibility_signposting_api.views.response_model import eligibility_response from eligibility_signposting_api.views.response_model.eligibility_response import ProcessedSuggestion @@ -66,8 +65,6 @@ def check_eligibility( ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) - except NoPermittedCampaignsError: - return handle_no_permitted_campaigns_for_the_consumer_error(consumer_id) else: response: eligibility_response.EligibilityResponse = build_eligibility_response(eligibility_status) AuditContext.write_to_firehose(audit_service) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0a168ff0f..7c405b55b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -700,7 +700,7 @@ def firehose_delivery_stream(firehose_client: BaseClient, audit_bucket: BucketNa @pytest.fixture(scope="class") -def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: +def rsv_campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: campaign: CampaignConfig = rule.CampaignConfigFactory.build( target="RSV", iterations=[ @@ -729,45 +729,6 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") -@pytest.fixture(scope="class") -def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: - consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy")] - - consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) - s3_client.put_object( - Bucket=consumer_mapping_bucket, - Key="consumer_mapping.json", - Body=json.dumps(consumer_mapping_data), - ContentType="application/json", - ) - yield consumer_mapping - s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") - - -@pytest.fixture(scope="class") -def consumer_mapping_with_various_targets( - s3_client: BaseClient, consumer_mapping_bucket: BucketName -) -> Generator[ConsumerMapping]: - consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ - CampaignID("campaign_start_date"), - CampaignID("campaign_start_date_plus_one_day"), - CampaignID("campaign_today"), - CampaignID("campaign_tomorrow"), - ] - - consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) - s3_client.put_object( - Bucket=consumer_mapping_bucket, - Key="consumer_mapping.json", - Body=json.dumps(consumer_mapping_data), - ContentType="application/json", - ) - yield consumer_mapping - s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") - - @pytest.fixture def campaign_config_with_rules_having_rule_code( s3_client: BaseClient, rules_bucket: BucketName @@ -1030,7 +991,7 @@ def campaign_config_with_invalid_tokens(s3_client: BaseClient, rules_bucket: Buc s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") -@pytest.fixture(scope="class") +@pytest.fixture(scope="function") def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: """Create and upload multiple campaign configs to S3, then clean up after tests.""" campaigns, campaign_data_keys = [], [] @@ -1053,6 +1014,7 @@ def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) - for i in range(3): campaign = rule.CampaignConfigFactory.build( name=f"campaign_{i}", + id=f"{targets[i]}_campaign_id", target=targets[i], type="V", iterations=[ @@ -1149,6 +1111,168 @@ def campaign_config_with_missing_descriptions_missing_rule_text( yield campaign s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") +@pytest.fixture(scope="function") +def multiple_campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: + """Create and upload multiple campaign configs to S3, then clean up after tests.""" + campaigns, campaign_data_keys = [], [] + + targets = getattr(request, "param", ["RSV", "COVID", "FLU"]) + target_rules_map = { + targets[0]: [ + rule.PersonAgeSuppressionRuleFactory.build(type=RuleType.filter, description="TOO YOUNG"), + rule.PostcodeSuppressionRuleFactory.build(type=RuleType.filter, priority=8, cohort_label="cohort_label4"), + ], + targets[1]: [ + rule.PersonAgeSuppressionRuleFactory.build(description="TOO YOUNG, your icb is: [[PERSON.ICB]]"), + rule.PostcodeSuppressionRuleFactory.build( + priority=12, cohort_label="cohort_label2", description="Your postcode is: [[PERSON.POSTCODE]]" + ), + ], + targets[2]: [rule.ICBRedirectRuleFactory.build()], + } + + for i in range(3): + campaign = rule.CampaignConfigFactory.build( + name=f"campaign_{i}", + id=f"{targets[i]}_campaign_id", + target=targets[i], + type="V", + iterations=[ + rule.IterationFactory.build( + iteration_rules=target_rules_map.get(targets[i]), + iteration_cohorts=[ + rule.IterationCohortFactory.build( + cohort_label=f"cohort_label{i + 1}", + cohort_group=f"cohort_group{i + 1}", + positive_description=f"positive_desc_{i + 1}", + negative_description=f"negative_desc_{i + 1}", + ), + rule.IterationCohortFactory.build( + cohort_label="cohort_label4", + cohort_group="cohort_group4", + positive_description="positive_desc_4", + negative_description="negative_desc_4", + ), + ], + status_text=StatusText( + NotEligible=f"You are not eligible to take {targets[i]} vaccines.", + NotActionable=f"You have taken {targets[i]} vaccine in the last 90 days", + Actionable=f"You can take {targets[i]} vaccine.", + ), + ) + ], + ) + campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} + key = f"{campaign.name}.json" + s3_client.put_object( + Bucket=rules_bucket, Key=key, Body=json.dumps(campaign_data), ContentType="application/json" + ) + campaigns.append(campaign) + campaign_data_keys.append(key) + + yield campaigns + + for key in campaign_data_keys: + s3_client.delete_object(Bucket=rules_bucket, Key=key) + +@pytest.fixture(scope="function") +def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: + """Create and upload multiple campaign configs to S3, then clean up after tests.""" + campaigns, campaign_data_keys = [], [] + + targets = getattr(request, "param", ["RSV", "COVID", "FLU"]) + + for i in range(len(targets)): + campaign: CampaignConfig = rule.CampaignConfigFactory.build( + name=f"campaign_{i}", + id=f"{targets[i]}_campaign_id", + target=targets[i], + type="V", + iterations=[ + rule.IterationFactory.build( + iteration_rules=[ + rule.PostcodeSuppressionRuleFactory.build(type=RuleType.filter), + rule.PersonAgeSuppressionRuleFactory.build(), + rule.PersonAgeSuppressionRuleFactory.build(name="Exclude 76 rolling", description=""), + ], + iteration_cohorts=[ + rule.IterationCohortFactory.build( + cohort_label="cohort1", + cohort_group="cohort_group1", + positive_description="", + negative_description="", + ) + ], + status_text=None, + ) + ], + ) + campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} + key = f"{campaign.name}.json" + s3_client.put_object( + Bucket=rules_bucket, Key=key, Body=json.dumps(campaign_data), ContentType="application/json" + ) + campaigns.append(campaign) + campaign_data_keys.append(key) + + yield campaigns + + for key in campaign_data_keys: + s3_client.delete_object(Bucket=rules_bucket, Key=key) + +@pytest.fixture(scope="class") +def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy")] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + +@pytest.fixture(scope="class") +def consumer_mapping_for_rsv_and_covid(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("consumer-id-mapped-to-rsv-and-covid")] = [CampaignID("RSV_campaign_id"), + CampaignID("COVID_campaign_id")] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + +@pytest.fixture(scope="class") +def consumer_mapping_with_various_targets( + s3_client: BaseClient, consumer_mapping_bucket: BucketName +) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ + CampaignID("campaign_start_date"), + CampaignID("campaign_start_date_plus_one_day"), + CampaignID("campaign_today"), + CampaignID("campaign_tomorrow"), + ] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + # If you put StubSecretRepo in a separate module, import it instead class StubSecretRepo(SecretRepo): diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index a4fe310c3..9a127db99 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -1,5 +1,6 @@ from http import HTTPStatus +import pytest from botocore.client import BaseClient from brunns.matchers.data import json_matching as is_json_that from brunns.matchers.werkzeug import is_werkzeug_response as is_response @@ -10,7 +11,7 @@ equal_to, has_entries, has_entry, - has_key, + has_key, has_item, all_of, has_length, contains, has_items, contains_inanyorder, ) from eligibility_signposting_api.config.constants import CONSUMER_ID @@ -26,7 +27,7 @@ def test_nhs_number_given( self, client: FlaskClient, persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_mapping: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -60,7 +61,7 @@ def test_no_nhs_number_given_but_header_given( self, client: FlaskClient, persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 ): # Given headers = {"nhs-login-nhs-number": str(persisted_person)} @@ -82,7 +83,7 @@ def test_not_base_eligible( self, client: FlaskClient, persisted_person_no_cohorts: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given @@ -127,7 +128,7 @@ def test_not_eligible_by_rule( self, client: FlaskClient, persisted_person_pc_sw19: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given @@ -172,7 +173,7 @@ def test_not_actionable_and_check_response_when_no_rule_code_given( self, client: FlaskClient, persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given @@ -223,7 +224,7 @@ def test_actionable( self, client: FlaskClient, persisted_77yo_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_mapping: ConsumerMapping, # noqa: ARG002 ): headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} @@ -824,22 +825,94 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ), ) - def test_response_when_no_campaign_config_is_present( + + @pytest.mark.parametrize( + "campaign_configs, consumer_mapping_for_rsv_and_covid, consumer_id, requested_conditions, expected_targets", + [ + # Scenario 1: Intersection of mapped targets, requested targets, and active campaigns (Success) + ( + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "ALL", + ["RSV", "COVID"], + ), + # Scenario 2: Explicit request for a single mapped target with an active campaign + ( + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "RSV", + ["RSV"], + ), + # Scenario 3: Request for an active campaign (FLU) that the consumer is NOT mapped to + ( + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "FLU", + [], + ), + # Scenario 4: Request for a target that neither exists in system nor is mapped to consumer + ( + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "HPV", + [], + ), + # Scenario 5: Consumer has no target mappings; requesting ALL should return empty + ( + ["RSV", "COVID", "FLU"], + "consumer-id-mapped-to-rsv-and-covid", + "consumer-id-with-no-mapping", + "ALL", + [], + ), + # Scenario 6: Consumer has no target mappings; requesting specific target should return empty + ( + ["RSV", "COVID", "FLU"], + "consumer-id-mapped-to-rsv-and-covid", + "consumer-id-with-no-mapping", + "RSV", + [], + ), + # Scenario 7: Consumer is mapped to targets (RSV/COVID), but those campaigns aren't active/present + ( + ["MMR"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "ALL", + [], + ), + # Scenario 8: Request for specific mapped target (RSV), but those campaigns aren't active/present + ( + ["MMR"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "RSV", + [], + ), + ], + indirect=["campaign_configs", "consumer_mapping_for_rsv_and_covid"] + ) + def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( self, client: FlaskClient, - persisted_77yo_person: NHSNumber, - campaign_config_with_rules_having_rule_mapper: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + persisted_person: NHSNumber, secretsmanager_client: BaseClient, # noqa: ARG002 + campaign_configs: CampaignConfig, # noqa: ARG002 + consumer_mapping_for_rsv_and_covid: ConsumerMapping, # noqa: ARG002 + consumer_id: str, + requested_conditions: str, + expected_targets: list[str], ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id} # When - response = client.get(f'/patient-check/{persisted_77yo_person}?includeActions=N&conditions=FLU', - headers=headers) + response = client.get(f"/patient-check/{persisted_person}?includeActions=Y&conditions={requested_conditions}", headers=headers) - # Then assert_that( response, is_response() @@ -848,30 +921,11 @@ def test_response_when_no_campaign_config_is_present( is_json_that( has_entry( "processedSuggestions", - equal_to( - [] - ), + # This ensures ONLY these items exist, no extras like FLU + contains_inanyorder( + *[has_entry("condition", i) for i in expected_targets] + ) ) ) - ), + ) ) - - -class TestEligibilityResponseWhenConsumerHasNoMapping: - def test_empty_response_when_no_campaign_mapped_for_the_consumer( - self, - client: FlaskClient, - persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 - secretsmanager_client: BaseClient, # noqa: ARG002 - ): - # Given - consumer_id_not_having_mapping = "23-jo4hn-ce4na" - headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id_not_having_mapping} - - # When - response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) - - # Then - assert_that(response, is_response().with_status_code(HTTPStatus.FORBIDDEN)) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index f6725f70c..1fcfc152d 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -36,7 +36,7 @@ def test_install_and_call_lambda_flask( lambda_client: BaseClient, flask_function: str, persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG001 + rsv_campaign_config: CampaignConfig, # noqa: ARG001 consumer_mapping: ConsumerMapping, # noqa: ARG001 ): """Given lambda installed into localstack, run it via boto3 lambda client""" @@ -89,7 +89,7 @@ def test_install_and_call_lambda_flask( def test_install_and_call_flask_lambda_over_http( persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG001 + rsv_campaign_config: CampaignConfig, # noqa: ARG001 consumer_mapping: ConsumerMapping, # noqa: ARG001 api_gateway_endpoint: URL, ): @@ -113,7 +113,7 @@ def test_install_and_call_flask_lambda_over_http( def test_install_and_call_flask_lambda_with_unknown_nhs_number( # noqa: PLR0913 flask_function: str, persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG001 + rsv_campaign_config: CampaignConfig, # noqa: ARG001 consumer_mapping: ConsumerMapping, # noqa: ARG001 logs_client: BaseClient, api_gateway_endpoint: URL, @@ -187,7 +187,7 @@ def get_log_messages(flask_function: str, logs_client: BaseClient) -> list[str]: def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_if_audited( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - campaign_config: CampaignConfig, + rsv_campaign_config: CampaignConfig, consumer_mapping: ConsumerMapping, # noqa: ARG001 s3_client: BaseClient, audit_bucket: BucketName, @@ -234,13 +234,13 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i expected_conditions = [ { - "campaignId": campaign_config.id, - "campaignVersion": campaign_config.version, - "iterationId": campaign_config.iterations[0].id, - "iterationVersion": campaign_config.iterations[0].version, - "conditionName": campaign_config.target, + "campaignId": rsv_campaign_config.id, + "campaignVersion": rsv_campaign_config.version, + "iterationId": rsv_campaign_config.iterations[0].id, + "iterationVersion": rsv_campaign_config.iterations[0].version, + "conditionName": rsv_campaign_config.target, "status": "not_actionable", - "statusText": f"You should have the {campaign_config.target} vaccine", + "statusText": f"You should have the {rsv_campaign_config.target} vaccine", "eligibilityCohorts": [{"cohortCode": "cohort1", "cohortStatus": "not_actionable"}], "eligibilityCohortGroups": [ { @@ -285,7 +285,7 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_results_in_error_response( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa:ARG001 + rsv_campaign_config: CampaignConfig, # noqa:ARG001 api_gateway_endpoint: URL, ): # Given @@ -332,7 +332,7 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu def test_given_nhs_number_not_present_in_headers_results_in_error_response( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa:ARG001 + rsv_campaign_config: CampaignConfig, # noqa:ARG001 api_gateway_endpoint: URL, ): # Given @@ -378,7 +378,7 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response( def test_validation_of_query_params_when_all_are_valid( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa:ARG001 + rsv_campaign_config: CampaignConfig, # noqa:ARG001 consumer_mapping: ConsumerMapping, # noqa: ARG001 api_gateway_endpoint: URL, ): @@ -399,7 +399,7 @@ def test_validation_of_query_params_when_all_are_valid( def test_validation_of_query_params_when_invalid_conditions_is_specified( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa:ARG001 + rsv_campaign_config: CampaignConfig, # noqa:ARG001 api_gateway_endpoint: URL, ): # Given diff --git a/tests/unit/repos/test_consumer_mapping_repo.py b/tests/unit/repos/test_consumer_mapping_repo.py index df15f1a10..243874482 100644 --- a/tests/unit/repos/test_consumer_mapping_repo.py +++ b/tests/unit/repos/test_consumer_mapping_repo.py @@ -1,9 +1,10 @@ import json -import pytest from unittest.mock import MagicMock +import pytest + from eligibility_signposting_api.model.consumer_mapping import ConsumerId -from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo, BucketName +from eligibility_signposting_api.repos.consumer_mapping_repo import BucketName, ConsumerMappingRepo class TestConsumerMappingRepo: @@ -13,27 +14,18 @@ def mock_s3_client(self): @pytest.fixture def repo(self, mock_s3_client): - return ConsumerMappingRepo( - s3_client=mock_s3_client, - bucket_name=BucketName("test-bucket") - ) + return ConsumerMappingRepo(s3_client=mock_s3_client, bucket_name=BucketName("test-bucket")) def test_get_permitted_campaign_ids_success(self, repo, mock_s3_client): # Given consumer_id = "user-123" expected_campaigns = ["flu-2024", "covid-2024"] - mapping_data = { - consumer_id: expected_campaigns - } + mapping_data = {consumer_id: expected_campaigns} - mock_s3_client.list_objects.return_value = { - "Contents": [{"Key": "mappings.json"}] - } + mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "mappings.json"}]} body_json = json.dumps(mapping_data).encode("utf-8") - mock_s3_client.get_object.return_value = { - "Body": MagicMock(read=lambda: body_json) - } + mock_s3_client.get_object.return_value = {"Body": MagicMock(read=lambda: body_json)} # When result = repo.get_permitted_campaign_ids(ConsumerId(consumer_id)) @@ -41,10 +33,7 @@ def test_get_permitted_campaign_ids_success(self, repo, mock_s3_client): # Then assert result == expected_campaigns mock_s3_client.list_objects.assert_called_once_with(Bucket="test-bucket") - mock_s3_client.get_object.assert_called_once_with( - Bucket="test-bucket", - Key="mappings.json" - ) + mock_s3_client.get_object.assert_called_once_with(Bucket="test-bucket", Key="mappings.json") def test_get_permitted_campaign_ids_returns_none_when_missing(self, repo, mock_s3_client): # Setup data where the consumer_id doesn't exist diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index 793efc355..ab5b85df3 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -3,33 +3,32 @@ import pytest from hamcrest import assert_that, empty -from eligibility_signposting_api.model.campaign_config import CampaignID, CampaignConfig +from eligibility_signposting_api.model.campaign_config import CampaignConfig, CampaignID from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculatorFactory -from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from tests.fixtures.matchers.eligibility import is_eligibility_status + @pytest.fixture def mock_repos(): return { "person": MagicMock(spec=PersonRepo), "campaign": MagicMock(spec=CampaignRepo), "consumer": MagicMock(spec=ConsumerMappingRepo), - "factory": MagicMock(spec=EligibilityCalculatorFactory) + "factory": MagicMock(spec=EligibilityCalculatorFactory), } + @pytest.fixture def service(mock_repos): return EligibilityService( - mock_repos["person"], - mock_repos["campaign"], - mock_repos["consumer"], - mock_repos["factory"] + mock_repos["person"], mock_repos["campaign"], mock_repos["consumer"], mock_repos["factory"] ) + def test_eligibility_service_returns_from_repo(): # Given person_repo = MagicMock(spec=PersonRepo) @@ -94,17 +93,6 @@ def test_get_eligibility_status_filters_permitted_campaigns(service, mock_repos) mock_repos["factory"].get.assert_called_once_with(person_data, [camp_b]) assert result == "eligible_result" -def test_raises_no_permitted_campaigns_error(service, mock_repos): - """Tests the scenario where the consumer mapping exists but returns nothing.""" - mock_repos["person"].get_eligibility_data.return_value = {"data": "exists"} - mock_repos["campaign"].get_campaign_configs.return_value = [MagicMock()] - - # Consumer has no permitted IDs mapped - mock_repos["consumer"].get_permitted_campaign_ids.return_value = [] - - with pytest.raises(NoPermittedCampaignsError): - service.get_eligibility_status(NHSNumber("1"), "Y", [], "", "bad_consumer") - def test_raises_unknown_person_error_on_repo_not_found(service, mock_repos): """Tests that NotFoundError from repo is translated to UnknownPersonError.""" mock_repos["person"].get_eligibility_data.side_effect = NotFoundError diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index fedc6d84b..1a022e42f 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -28,7 +28,6 @@ UrlLink, ) from eligibility_signposting_api.services import EligibilityService, UnknownPersonError -from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from eligibility_signposting_api.views.eligibility import ( _get_or_default_query_params, build_actions, @@ -94,20 +93,6 @@ def get_eligibility_status( ) -> EligibilityStatus: raise ValueError -class FakeNoPermittedCampaignsService(EligibilityService): - def __init__(self): - pass - - def get_eligibility_status( - self, - _nhs_number: NHSNumber, - _include_actions: str, - _conditions: list[str], - _category: str, - _consumer_id: str, - ) -> EligibilityStatus: - # Simulate the new error scenario - raise NoPermittedCampaignsError def test_security_headers_present_on_successful_response(app: Flask, client: FlaskClient): """Test that security headers are present on successful eligibility check response.""" @@ -600,47 +585,6 @@ def test_status_endpoint(app: Flask, client: FlaskClient): ) -def test_no_permitted_campaigns_for_consumer_error(app: Flask, client: FlaskClient): - """ - Tests that NoPermittedCampaignsError is caught and returns - the correct FHIR OperationOutcome with FORBIDDEN status. - """ - # Given - with ( - get_app_container(app).override.service(EligibilityService, new=FakeNoPermittedCampaignsService()), - get_app_container(app).override.service(AuditService, new=FakeAuditService()), - ): - headers = { - "nhs-login-nhs-number": "9876543210", - "Consumer-Id": "unrecognized_consumer" - } - - # When - response = client.get("/patient-check/9876543210", headers=headers) - - # Then - assert_that( - response, - is_response() - .with_status_code(HTTPStatus.FORBIDDEN) - .with_headers(has_entries({"Content-Type": "application/fhir+json"})) - .and_text( - is_json_that( - has_entries( - resourceType="OperationOutcome", - issue=contains_exactly( - has_entries( - severity="error", - code="forbidden", - diagnostics="Consumer ID 'unrecognized_consumer' was not recognised by the Eligibility Signposting API" - ) - ) - ) - ) - ) - ) - - def test_consumer_id_is_passed_to_service(app: Flask, client: FlaskClient): """ Verifies that the consumer ID from the header is actually passed @@ -654,10 +598,7 @@ def test_consumer_id_is_passed_to_service(app: Flask, client: FlaskClient): get_app_container(app).override.service(EligibilityService, new=mock_service), get_app_container(app).override.service(AuditService, new=FakeAuditService()), ): - headers = { - "nhs-login-nhs-number": "1234567890", - "Consumer-Id": "specific_consumer_123" - } + headers = {"nhs-login-nhs-number": "1234567890", "Consumer-Id": "specific_consumer_123"} # When client.get("/patient-check/1234567890", headers=headers) From bcee4b187d2108f3145ee198b3f00b7181d6b76f Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:22:18 +0000 Subject: [PATCH 14/58] ELI-578 consumer_id - campaign_config mapping --- .../model/consumer_mapping.py | 11 +++++++ .../repos/consumer_mapping_repo.py | 32 +++++++++++++++++++ .../services/eligibility_services.py | 14 +++++++- .../views/eligibility.py | 1 + 4 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 src/eligibility_signposting_api/model/consumer_mapping.py create mode 100644 src/eligibility_signposting_api/repos/consumer_mapping_repo.py diff --git a/src/eligibility_signposting_api/model/consumer_mapping.py b/src/eligibility_signposting_api/model/consumer_mapping.py new file mode 100644 index 000000000..6903aa187 --- /dev/null +++ b/src/eligibility_signposting_api/model/consumer_mapping.py @@ -0,0 +1,11 @@ +from typing import NewType + +from pydantic import RootModel + +from eligibility_signposting_api.model.campaign_config import CampaignID + +ConsumerId = NewType("ConsumerId", str) + +class ConsumerMapping(RootModel[dict[str, list[CampaignID]]]): + def get(self, key: str, default: list[CampaignID] | None = None) -> list[CampaignID] | None: + return self.root.get(key, default) diff --git a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py new file mode 100644 index 000000000..e6a852c8d --- /dev/null +++ b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py @@ -0,0 +1,32 @@ +import json +from typing import Annotated, NewType + +from botocore.client import BaseClient +from wireup import Inject, service + +from eligibility_signposting_api.model.campaign_config import CampaignID +from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping, ConsumerId + +BucketName = NewType("BucketName", str) + + +@service +class ConsumerMappingRepo: + """Repository class for Campaign Rules, which we can use to calculate a person's eligibility for vaccination. + + These rules are stored as JSON files in AWS S3.""" + + def __init__( + self, + s3_client: Annotated[BaseClient, Inject(qualifier="s3")], + bucket_name: Annotated[BucketName, Inject(param="rules_bucket_name")], + ) -> None: + super().__init__() + self.s3_client = s3_client + self.bucket_name = bucket_name + + def get_sanctioned_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: + consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] + response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") + body = response["Body"].read() + return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 79934e174..8ebee49f9 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -3,7 +3,9 @@ from wireup import service from eligibility_signposting_api.model import eligibility_status +from eligibility_signposting_api.model.campaign_config import CampaignConfig from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo +from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services.calculators import eligibility_calculator as calculator logger = logging.getLogger(__name__) @@ -24,11 +26,13 @@ def __init__( person_repo: PersonRepo, campaign_repo: CampaignRepo, calculator_factory: calculator.EligibilityCalculatorFactory, + consumer_mapping_repo: ConsumerMappingRepo ) -> None: super().__init__() self.person_repo = person_repo self.campaign_repo = campaign_repo self.calculator_factory = calculator_factory + self.consumer_mapping = consumer_mapping_repo def get_eligibility_status( self, @@ -36,16 +40,24 @@ def get_eligibility_status( include_actions: str, conditions: list[str], category: str, + consumer_id: str, ) -> eligibility_status.EligibilityStatus: """Calculate a person's eligibility for vaccination given an NHS number.""" if nhs_number: try: person_data = self.person_repo.get_eligibility_data(nhs_number) campaign_configs = list(self.campaign_repo.get_campaign_configs()) + consumer_mappings = self.consumer_mapping.get_sanctioned_campaign_ids(consumer_id) + sanctioned_campaign_ids: list[CampaignConfig] = [ + campaign for campaign in campaign_configs + if campaign.id in consumer_mappings + ] + except NotFoundError as e: raise UnknownPersonError from e else: - calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, campaign_configs) + calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, + sanctioned_campaign_ids) return calc.get_eligibility_status(include_actions, conditions, category) raise UnknownPersonError # pragma: no cover diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index eb2b706ea..61b9350e7 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -54,6 +54,7 @@ def check_eligibility( query_params["includeActions"], query_params["conditions"], query_params["category"], + request.headers.get("X-Correlation-ID"), ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) From 602939820f54009db07e63d1ae8912597c9ab917 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:20:45 +0000 Subject: [PATCH 15/58] ELI-578 added dummy consumer id --- .../model/consumer_mapping.py | 4 ++-- .../repos/consumer_mapping_repo.py | 13 ++++++++----- .../services/eligibility_services.py | 8 ++++---- .../views/eligibility.py | 2 +- tests/fixtures/builders/model/rule.py | 2 +- .../in_process/test_eligibility_endpoint.py | 3 ++- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/eligibility_signposting_api/model/consumer_mapping.py b/src/eligibility_signposting_api/model/consumer_mapping.py index 6903aa187..9f75f0a70 100644 --- a/src/eligibility_signposting_api/model/consumer_mapping.py +++ b/src/eligibility_signposting_api/model/consumer_mapping.py @@ -6,6 +6,6 @@ ConsumerId = NewType("ConsumerId", str) -class ConsumerMapping(RootModel[dict[str, list[CampaignID]]]): - def get(self, key: str, default: list[CampaignID] | None = None) -> list[CampaignID] | None: +class ConsumerMapping(RootModel[dict[ConsumerId, list[CampaignID]]]): + def get(self, key: ConsumerId, default: list[CampaignID] | None = None) -> list[CampaignID] | None: return self.root.get(key, default) diff --git a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py index e6a852c8d..d8981149c 100644 --- a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py +++ b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py @@ -25,8 +25,11 @@ def __init__( self.s3_client = s3_client self.bucket_name = bucket_name - def get_sanctioned_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: - consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] - response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") - body = response["Body"].read() - return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) + # def get_permitted_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: + # consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] + # response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") + # body = response["Body"].read() + # return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) + + def get_permitted_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: + return ["324r2"] diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 8ebee49f9..b7c2159a9 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -47,17 +47,17 @@ def get_eligibility_status( try: person_data = self.person_repo.get_eligibility_data(nhs_number) campaign_configs = list(self.campaign_repo.get_campaign_configs()) - consumer_mappings = self.consumer_mapping.get_sanctioned_campaign_ids(consumer_id) - sanctioned_campaign_ids: list[CampaignConfig] = [ + permitted_campaign_ids = self.consumer_mapping.get_permitted_campaign_ids(consumer_id) + permitted_campaign_configs: list[CampaignConfig] = [ campaign for campaign in campaign_configs - if campaign.id in consumer_mappings + if campaign.id in permitted_campaign_ids ] except NotFoundError as e: raise UnknownPersonError from e else: calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, - sanctioned_campaign_ids) + permitted_campaign_configs) return calc.get_eligibility_status(include_actions, conditions, category) raise UnknownPersonError # pragma: no cover diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 61b9350e7..cc39c80c3 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -54,7 +54,7 @@ def check_eligibility( query_params["includeActions"], query_params["conditions"], query_params["category"], - request.headers.get("X-Correlation-ID"), + request.headers.get("Consumer-ID"), ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index bf62de900..c44b038b6 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -93,7 +93,7 @@ class IterationFactory(ModelFactory[Iteration]): class RawCampaignConfigFactory(ModelFactory[CampaignConfig]): iterations = Use(IterationFactory.batch, size=2) - + id = "324r2" start_date = Use(past_date) end_date = Use(future_date) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 4e5cdbfb8..ebe237e55 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -28,7 +28,8 @@ def test_nhs_number_given( secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), + "Consumer-ID":"dummy"} # When response = client.get(f"/patient-check/{persisted_person}", headers=headers) From 714433de4cb09a1cc16b2891232f32ca40de9da6 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:48:53 +0000 Subject: [PATCH 16/58] ELI-578 local stack configuration --- .../config/config.py | 3 +++ .../repos/consumer_mapping_repo.py | 13 ++++------ tests/fixtures/builders/model/rule.py | 2 +- tests/integration/conftest.py | 25 ++++++++++++++++++- .../in_process/test_eligibility_endpoint.py | 4 ++- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/eligibility_signposting_api/config/config.py b/src/eligibility_signposting_api/config/config.py index 6be1840aa..52f3111cc 100644 --- a/src/eligibility_signposting_api/config/config.py +++ b/src/eligibility_signposting_api/config/config.py @@ -22,6 +22,7 @@ def config() -> dict[str, Any]: person_table_name = TableName(os.getenv("PERSON_TABLE_NAME", "test_eligibility_datastore")) rules_bucket_name = BucketName(os.getenv("RULES_BUCKET_NAME", "test-rules-bucket")) + consumer_mapping_bucket_name = BucketName(os.getenv("CONSUMER_MAPPING_BUCKET_NAME", "test-consumer-mapping-bucket")) audit_bucket_name = BucketName(os.getenv("AUDIT_BUCKET_NAME", "test-audit-bucket")) hashing_secret_name = HashSecretName(os.getenv("HASHING_SECRET_NAME", "test_secret")) aws_default_region = AwsRegion(os.getenv("AWS_DEFAULT_REGION", "eu-west-1")) @@ -41,6 +42,7 @@ def config() -> dict[str, Any]: "s3_endpoint": None, "rules_bucket_name": rules_bucket_name, "audit_bucket_name": audit_bucket_name, + "consumer_mapping_bucket_name": consumer_mapping_bucket_name, "firehose_endpoint": None, "kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3, "enable_xray_patching": enable_xray_patching, @@ -59,6 +61,7 @@ def config() -> dict[str, Any]: "s3_endpoint": URL(os.getenv("S3_ENDPOINT", local_stack_endpoint)), "rules_bucket_name": rules_bucket_name, "audit_bucket_name": audit_bucket_name, + "consumer_mapping_bucket_name": consumer_mapping_bucket_name, "firehose_endpoint": URL(os.getenv("FIREHOSE_ENDPOINT", local_stack_endpoint)), "kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3, "enable_xray_patching": enable_xray_patching, diff --git a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py index d8981149c..669f4f836 100644 --- a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py +++ b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py @@ -19,17 +19,14 @@ class ConsumerMappingRepo: def __init__( self, s3_client: Annotated[BaseClient, Inject(qualifier="s3")], - bucket_name: Annotated[BucketName, Inject(param="rules_bucket_name")], + bucket_name: Annotated[BucketName, Inject(param="consumer_mapping_bucket_name")], ) -> None: super().__init__() self.s3_client = s3_client self.bucket_name = bucket_name - # def get_permitted_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: - # consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] - # response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") - # body = response["Body"].read() - # return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) - def get_permitted_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: - return ["324r2"] + consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] + response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") + body = response["Body"].read() + return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index c44b038b6..a0de2b5c6 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -93,7 +93,7 @@ class IterationFactory(ModelFactory[Iteration]): class RawCampaignConfigFactory(ModelFactory[CampaignConfig]): iterations = Use(IterationFactory.batch, size=2) - id = "324r2" + id = "42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy" start_date = Use(past_date) end_date = Use(future_date) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 4af1223a2..de20fd130 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -28,8 +28,9 @@ RuleText, RuleType, StartDate, - StatusText, + StatusText, CampaignID, ) +from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping, ConsumerId from eligibility_signposting_api.processors.hashing_service import HashingService, HashSecretName from eligibility_signposting_api.repos import SecretRepo from eligibility_signposting_api.repos.campaign_repo import BucketName @@ -661,6 +662,14 @@ def rules_bucket(s3_client: BaseClient) -> Generator[BucketName]: s3_client.delete_bucket(Bucket=bucket_name) +@pytest.fixture(scope="session") +def consumer_mapping_bucket(s3_client: BaseClient) -> Generator[BucketName]: + bucket_name = BucketName(os.getenv("CONSUMER_MAPPING_BUCKET_NAME", "test-consumer-mapping-bucket")) + s3_client.create_bucket(Bucket=bucket_name, CreateBucketConfiguration={"LocationConstraint": AWS_REGION}) + yield bucket_name + s3_client.delete_bucket(Bucket=bucket_name) + + @pytest.fixture(scope="session") def audit_bucket(s3_client: BaseClient) -> Generator[BucketName]: bucket_name = BucketName(os.getenv("AUDIT_BUCKET_NAME", "test-audit-bucket")) @@ -718,6 +727,20 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato yield campaign s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") +@pytest.fixture(scope="class") +def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ + CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy") + ] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, Key=f"consumer_mapping.json", Body=json.dumps(consumer_mapping_data), ContentType="application/json" + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key=f"consumer_mapping.json") + @pytest.fixture def campaign_config_with_rules_having_rule_code( diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index ebe237e55..5911309de 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -14,6 +14,7 @@ ) from eligibility_signposting_api.model.campaign_config import CampaignConfig +from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping from eligibility_signposting_api.model.eligibility_status import ( NHSNumber, ) @@ -25,11 +26,12 @@ def test_nhs_number_given( client: FlaskClient, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given headers = {"nhs-login-nhs-number": str(persisted_person), - "Consumer-ID":"dummy"} + "Consumer-ID": "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}", headers=headers) From fcc68be57ec347627064bf23d076cabe98df1c31 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:57:47 +0000 Subject: [PATCH 17/58] ELI-578 wip test --- .../services/eligibility_services.py | 2 +- tests/unit/services/test_eligibility_services.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index b7c2159a9..40ed100cb 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -25,8 +25,8 @@ def __init__( self, person_repo: PersonRepo, campaign_repo: CampaignRepo, + consumer_mapping_repo: ConsumerMappingRepo, calculator_factory: calculator.EligibilityCalculatorFactory, - consumer_mapping_repo: ConsumerMappingRepo ) -> None: super().__init__() self.person_repo = person_repo diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index 504888f12..f30c120a1 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -5,6 +5,7 @@ from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo +from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculatorFactory from tests.fixtures.matchers.eligibility import is_eligibility_status @@ -13,13 +14,14 @@ def test_eligibility_service_returns_from_repo(): # Given person_repo = MagicMock(spec=PersonRepo) + consumer_mapping_repo = MagicMock(spec=ConsumerMappingRepo) campaign_repo = MagicMock(spec=CampaignRepo) person_repo.get_eligibility = MagicMock(return_value=[]) - service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory()) + service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory(), consumer_mapping_repo) # When actual = service.get_eligibility_status( - NHSNumber("1234567890"), include_actions="Y", conditions=["ALL"], category="ALL" + NHSNumber("1234567890"), include_actions="Y", conditions=["ALL"], category="ALL", consumer_id="test_consumer_id" ) # Then From 510df8dedd3650e2440903e15014f47f7ee216cd Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:54:03 +0000 Subject: [PATCH 18/58] ELI-578 unit test fixed --- .../model/consumer_mapping.py | 1 + .../repos/consumer_mapping_repo.py | 2 +- .../services/eligibility_services.py | 28 +++++++++++++------ tests/fixtures/builders/model/rule.py | 2 +- tests/integration/conftest.py | 17 ++++++----- .../in_process/test_eligibility_endpoint.py | 3 +- .../services/test_eligibility_services.py | 15 +++++++--- tests/unit/views/test_eligibility.py | 4 ++- 8 files changed, 47 insertions(+), 25 deletions(-) diff --git a/src/eligibility_signposting_api/model/consumer_mapping.py b/src/eligibility_signposting_api/model/consumer_mapping.py index 9f75f0a70..8e86d7e46 100644 --- a/src/eligibility_signposting_api/model/consumer_mapping.py +++ b/src/eligibility_signposting_api/model/consumer_mapping.py @@ -6,6 +6,7 @@ ConsumerId = NewType("ConsumerId", str) + class ConsumerMapping(RootModel[dict[ConsumerId, list[CampaignID]]]): def get(self, key: ConsumerId, default: list[CampaignID] | None = None) -> list[CampaignID] | None: return self.root.get(key, default) diff --git a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py index 669f4f836..140ac74c4 100644 --- a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py +++ b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py @@ -5,7 +5,7 @@ from wireup import Inject, service from eligibility_signposting_api.model.campaign_config import CampaignID -from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping, ConsumerId +from eligibility_signposting_api.model.consumer_mapping import ConsumerId, ConsumerMapping BucketName = NewType("BucketName", str) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 40ed100cb..13b701d61 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -4,6 +4,7 @@ from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.campaign_config import CampaignConfig +from eligibility_signposting_api.model.consumer_mapping import ConsumerId from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services.calculators import eligibility_calculator as calculator @@ -46,18 +47,27 @@ def get_eligibility_status( if nhs_number: try: person_data = self.person_repo.get_eligibility_data(nhs_number) - campaign_configs = list(self.campaign_repo.get_campaign_configs()) - permitted_campaign_ids = self.consumer_mapping.get_permitted_campaign_ids(consumer_id) - permitted_campaign_configs: list[CampaignConfig] = [ - campaign for campaign in campaign_configs - if campaign.id in permitted_campaign_ids - ] - except NotFoundError as e: raise UnknownPersonError from e else: - calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, - permitted_campaign_configs) + campaign_configs: list[CampaignConfig] = list(self.campaign_repo.get_campaign_configs()) + permitted_campaign_configs = self.__collect_permitted_campaign_configs( + campaign_configs, ConsumerId(consumer_id) + ) + calc: calculator.EligibilityCalculator = self.calculator_factory.get( + person_data, permitted_campaign_configs + ) return calc.get_eligibility_status(include_actions, conditions, category) raise UnknownPersonError # pragma: no cover + + def __collect_permitted_campaign_configs( + self, campaign_configs: list[CampaignConfig], consumer_id: ConsumerId + ) -> list[CampaignConfig]: + permitted_campaign_ids = self.consumer_mapping.get_permitted_campaign_ids(ConsumerId(consumer_id)) + if permitted_campaign_ids: + permitted_campaign_configs: list[CampaignConfig] = [ + campaign for campaign in campaign_configs if campaign.id in permitted_campaign_ids + ] + return permitted_campaign_configs + return [] diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index a0de2b5c6..2793ea032 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -93,7 +93,7 @@ class IterationFactory(ModelFactory[Iteration]): class RawCampaignConfigFactory(ModelFactory[CampaignConfig]): iterations = Use(IterationFactory.batch, size=2) - id = "42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy" + id = "42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy" start_date = Use(past_date) end_date = Use(future_date) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index de20fd130..2775cc27d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -21,6 +21,7 @@ from eligibility_signposting_api.model.campaign_config import ( AvailableAction, CampaignConfig, + CampaignID, EndDate, RuleCode, RuleEntry, @@ -28,9 +29,9 @@ RuleText, RuleType, StartDate, - StatusText, CampaignID, + StatusText, ) -from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping, ConsumerId +from eligibility_signposting_api.model.consumer_mapping import ConsumerId, ConsumerMapping from eligibility_signposting_api.processors.hashing_service import HashingService, HashSecretName from eligibility_signposting_api.repos import SecretRepo from eligibility_signposting_api.repos.campaign_repo import BucketName @@ -727,19 +728,21 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato yield campaign s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") + @pytest.fixture(scope="class") def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ - CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy") - ] + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy")] consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) s3_client.put_object( - Bucket=consumer_mapping_bucket, Key=f"consumer_mapping.json", Body=json.dumps(consumer_mapping_data), ContentType="application/json" + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", ) yield consumer_mapping - s3_client.delete_object(Bucket=consumer_mapping_bucket, Key=f"consumer_mapping.json") + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") @pytest.fixture diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 5911309de..66504c5bb 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -30,8 +30,7 @@ def test_nhs_number_given( secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person), - "Consumer-ID": "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), "Consumer-ID": "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}", headers=headers) diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index f30c120a1..1d351ffaf 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -14,10 +14,10 @@ def test_eligibility_service_returns_from_repo(): # Given person_repo = MagicMock(spec=PersonRepo) - consumer_mapping_repo = MagicMock(spec=ConsumerMappingRepo) campaign_repo = MagicMock(spec=CampaignRepo) + consumer_mapping_repo = MagicMock(spec=ConsumerMappingRepo) person_repo.get_eligibility = MagicMock(return_value=[]) - service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory(), consumer_mapping_repo) + service = EligibilityService(person_repo, campaign_repo, consumer_mapping_repo, EligibilityCalculatorFactory()) # When actual = service.get_eligibility_status( @@ -32,9 +32,16 @@ def test_eligibility_service_for_nonexistent_nhs_number(): # Given person_repo = MagicMock(spec=PersonRepo) campaign_repo = MagicMock(spec=CampaignRepo) + consumer_mapping_repo = MagicMock(spec=ConsumerMappingRepo) person_repo.get_eligibility_data = MagicMock(side_effect=NotFoundError) - service = EligibilityService(person_repo, campaign_repo, EligibilityCalculatorFactory()) + service = EligibilityService(person_repo, campaign_repo, consumer_mapping_repo, EligibilityCalculatorFactory()) # When with pytest.raises(UnknownPersonError): - service.get_eligibility_status(NHSNumber("1234567890"), include_actions="Y", conditions=["ALL"], category="ALL") + service.get_eligibility_status( + NHSNumber("1234567890"), + include_actions="Y", + conditions=["ALL"], + category="ALL", + consumer_id="test_consumer_id", + ) diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 5c323a7b2..1f9d3db83 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -60,6 +60,7 @@ def get_eligibility_status( _include_actions: str, _conditions: list[str], _category: str, + _consumer_id: str, ) -> EligibilityStatus: return EligibilityStatusFactory.build() @@ -74,6 +75,7 @@ def get_eligibility_status( _include_actions: str, _conditions: list[str], _category: str, + _consumer_id: str, ) -> EligibilityStatus: raise UnknownPersonError @@ -100,7 +102,7 @@ def test_security_headers_present_on_successful_response(app: Flask, client: Fla get_app_container(app).override.service(AuditService, new=FakeAuditService()), ): # When - headers = {"nhs-login-nhs-number": "9876543210"} + headers = {"nhs-login-nhs-number": "9876543210", "Consumer-Id": "test_consumer_id"} response = client.get("/patient-check/9876543210", headers=headers) # Then From 91a2a09d6769d2d594c379af84b326f6e39023ce Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:23:14 +0000 Subject: [PATCH 19/58] ELI-578 consumer_id error validation --- .../common/api_error_response.py | 8 ++++++++ .../common/request_validator.py | 11 ++++++++++- .../config/constants.py | 1 + .../views/eligibility.py | 17 +++++++++++++---- .../in_process/test_eligibility_endpoint.py | 3 ++- tests/unit/views/test_eligibility.py | 13 ++++++------- 6 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/eligibility_signposting_api/common/api_error_response.py b/src/eligibility_signposting_api/common/api_error_response.py index 40c1ddcdd..cb1006584 100644 --- a/src/eligibility_signposting_api/common/api_error_response.py +++ b/src/eligibility_signposting_api/common/api_error_response.py @@ -135,3 +135,11 @@ def log_and_generate_response( fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, fhir_display_message="Access has been denied to process this request.", ) + +CONSUMER_ID_NOT_PROVIDED_ERROR = APIErrorResponse( + status_code=HTTPStatus.FORBIDDEN, + fhir_issue_code=FHIRIssueCode.FORBIDDEN, + fhir_issue_severity=FHIRIssueSeverity.ERROR, + fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, + fhir_display_message="Access has been denied to process this request.", +) diff --git a/src/eligibility_signposting_api/common/request_validator.py b/src/eligibility_signposting_api/common/request_validator.py index cd213287a..40416e5ca 100644 --- a/src/eligibility_signposting_api/common/request_validator.py +++ b/src/eligibility_signposting_api/common/request_validator.py @@ -7,12 +7,13 @@ from flask.typing import ResponseReturnValue from eligibility_signposting_api.common.api_error_response import ( + CONSUMER_ID_NOT_PROVIDED_ERROR, INVALID_CATEGORY_ERROR, INVALID_CONDITION_FORMAT_ERROR, INVALID_INCLUDE_ACTIONS_ERROR, NHS_NUMBER_MISMATCH_ERROR, ) -from eligibility_signposting_api.config.constants import NHS_NUMBER_HEADER +from eligibility_signposting_api.config.constants import CONSUMER_ID, NHS_NUMBER_HEADER logger = logging.getLogger(__name__) @@ -56,6 +57,14 @@ def validate_request_params() -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs) -> ResponseReturnValue: # noqa:ANN002,ANN003 + consumer_id = str(request.headers.get(CONSUMER_ID)) + + if not consumer_id: + message = "You are not authorised to request" + return CONSUMER_ID_NOT_PROVIDED_ERROR.log_and_generate_response( + log_message=message, diagnostics=message + ) + path_nhs_number = str(kwargs.get("nhs_number")) header_nhs_no = str(request.headers.get(NHS_NUMBER_HEADER)) diff --git a/src/eligibility_signposting_api/config/constants.py b/src/eligibility_signposting_api/config/constants.py index 3aa45fd35..bdc49e307 100644 --- a/src/eligibility_signposting_api/config/constants.py +++ b/src/eligibility_signposting_api/config/constants.py @@ -3,4 +3,5 @@ URL_PREFIX = "patient-check" RULE_STOP_DEFAULT = False NHS_NUMBER_HEADER = "nhs-login-nhs-number" +CONSUMER_ID = "consumer-id" ALLOWED_CONDITIONS = Literal["COVID", "FLU", "MMR", "RSV"] diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index cc39c80c3..2ae296cdc 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -13,7 +13,8 @@ from eligibility_signposting_api.audit.audit_service import AuditService from eligibility_signposting_api.common.api_error_response import NHS_NUMBER_NOT_FOUND_ERROR from eligibility_signposting_api.common.request_validator import validate_request_params -from eligibility_signposting_api.config.constants import URL_PREFIX +from eligibility_signposting_api.config.constants import CONSUMER_ID, URL_PREFIX +from eligibility_signposting_api.model.consumer_mapping import ConsumerId from eligibility_signposting_api.model.eligibility_status import Condition, EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.views.response_model import eligibility_response @@ -48,13 +49,14 @@ def check_eligibility( ) -> ResponseReturnValue: logger.info("checking nhs_number %r in %r", nhs_number, eligibility_service, extra={"nhs_number": nhs_number}) try: - query_params = get_or_default_query_params() + query_params = _get_or_default_query_params() + consumer_id = _get_consumer_id_from_headers() eligibility_status = eligibility_service.get_eligibility_status( nhs_number, query_params["includeActions"], query_params["conditions"], query_params["category"], - request.headers.get("Consumer-ID"), + consumer_id, ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) @@ -64,7 +66,14 @@ def check_eligibility( return make_response(response.model_dump(by_alias=True, mode="json", exclude_none=True), HTTPStatus.OK) -def get_or_default_query_params() -> dict[str, Any]: +def _get_consumer_id_from_headers() -> ConsumerId: + """ + @validate_request_params() ensures the consumer ID is never null at this stage. + """ + return ConsumerId(request.headers.get(CONSUMER_ID, "")) + + +def _get_or_default_query_params() -> dict[str, Any]: default_query_params = {"category": "ALL", "conditions": ["ALL"], "includeActions": "Y"} if not request.args: diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 66504c5bb..84bb201e4 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -13,6 +13,7 @@ has_key, ) +from eligibility_signposting_api.config.constants import CONSUMER_ID from eligibility_signposting_api.model.campaign_config import CampaignConfig from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping from eligibility_signposting_api.model.eligibility_status import ( @@ -30,7 +31,7 @@ def test_nhs_number_given( secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person), "Consumer-ID": "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}", headers=headers) diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 1f9d3db83..14466c4bc 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -31,8 +31,7 @@ from eligibility_signposting_api.views.eligibility import ( build_actions, build_eligibility_cohorts, - build_suitability_results, - get_or_default_query_params, + build_suitability_results, _get_or_default_query_params, ) from eligibility_signposting_api.views.response_model import eligibility_response from tests.fixtures.builders.model.eligibility import ( @@ -509,7 +508,7 @@ def test_build_response_include_values_that_are_not_null(client: FlaskClient): def test_get_or_default_query_params_with_no_args(app: Flask): with app.test_request_context("/patient-check"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"category": "ALL", "conditions": ["ALL"], "includeActions": "Y"} @@ -518,7 +517,7 @@ def test_get_or_default_query_params_with_no_args(app: Flask): def test_get_or_default_query_params_with_all_args(app: Flask): with app.test_request_context("/patient-check?includeActions=Y&category=VACCINATIONS&conditions=FLU"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"includeActions": "Y", "category": "VACCINATIONS", "conditions": ["FLU"]} @@ -527,7 +526,7 @@ def test_get_or_default_query_params_with_all_args(app: Flask): def test_get_or_default_query_params_with_partial_args(app: Flask): with app.test_request_context("/patient-check?includeActions=N"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"includeActions": "N", "category": "ALL", "conditions": ["ALL"]} @@ -536,13 +535,13 @@ def test_get_or_default_query_params_with_partial_args(app: Flask): def test_get_or_default_query_params_with_lowercase_y(app: Flask): with app.test_request_context("/patient-check?includeActions=y"): - result = get_or_default_query_params() + result = _get_or_default_query_params() assert_that(result["includeActions"], is_("Y")) def test_get_or_default_query_params_missing_include_actions(app: Flask): with app.test_request_context("/patient-check?category=SCREENING&conditions=COVID19,FLU"): - result = get_or_default_query_params() + result = _get_or_default_query_params() expected = {"includeActions": "Y", "category": "SCREENING", "conditions": ["COVID19", "FLU"]} From 4089abc00daa150e20a27e52f031ae6c07a22948 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:34:01 +0000 Subject: [PATCH 20/58] ELI-578 intergation testing --- .../in_process/test_eligibility_endpoint.py | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 84bb201e4..08b5dfa04 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -83,9 +83,10 @@ def test_not_base_eligible( client: FlaskClient, persisted_person_no_cohorts: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts)} + headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_no_cohorts}?includeActions=Y", headers=headers) @@ -127,9 +128,10 @@ def test_not_eligible_by_rule( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19)} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -171,9 +173,10 @@ def test_not_actionable_and_check_response_when_no_rule_code_given( client: FlaskClient, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -221,8 +224,9 @@ def test_actionable( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -272,9 +276,10 @@ def test_actionable_with_and_rule( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_and_rule: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -326,9 +331,10 @@ def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19)} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -370,9 +376,10 @@ def test_not_actionable_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -420,9 +427,10 @@ def test_actionable_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -474,9 +482,10 @@ def test_not_base_eligible( client: FlaskClient, persisted_person_no_cohorts: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts)} + headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_no_cohorts}?includeActions=Y", headers=headers) @@ -512,9 +521,10 @@ def test_not_eligible_by_rule( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19)} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -550,9 +560,10 @@ def test_not_actionable( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -594,9 +605,10 @@ def test_actionable( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -640,9 +652,10 @@ def test_actionable_no_actions( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person)} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=N", headers=headers) @@ -714,9 +727,10 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_absent_but_rule_c client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_rules_having_rule_code: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -764,9 +778,10 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_rules_having_rule_mapper: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person)} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) From 1279f2defc29233464c8117ed666fd3f3590e8a3 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:01:35 +0000 Subject: [PATCH 21/58] ELI-578 lambda --- tests/integration/conftest.py | 23 ++++++++++++++++ .../lambda/test_app_running_as_lambda.py | 27 ++++++++++++++----- tests/unit/views/test_eligibility.py | 3 ++- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 2775cc27d..09c8617e6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -745,6 +745,29 @@ def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") +@pytest.fixture(scope="class") +def consumer_mapping_with_various_targets( + s3_client: BaseClient, consumer_mapping_bucket: BucketName +) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ + CampaignID("campaign_start_date"), + CampaignID("campaign_start_date_plus_one_day"), + CampaignID("campaign_today"), + CampaignID("campaign_tomorrow"), + ] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + + @pytest.fixture def campaign_config_with_rules_having_rule_code( s3_client: BaseClient, rules_bucket: BucketName diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 9c473696a..f6725f70c 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -23,7 +23,9 @@ ) from yarl import URL +from eligibility_signposting_api.config.constants import CONSUMER_ID from eligibility_signposting_api.model.campaign_config import CampaignConfig +from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos.campaign_repo import BucketName @@ -35,6 +37,7 @@ def test_install_and_call_lambda_flask( flask_function: str, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 ): """Given lambda installed into localstack, run it via boto3 lambda client""" # Given @@ -49,6 +52,7 @@ def test_install_and_call_lambda_flask( "accept": "application/json", "content-type": "application/json", "nhs-login-nhs-number": str(persisted_person), + CONSUMER_ID: "23-mic7heal-jor6don", }, "pathParameters": {"id": str(persisted_person)}, "requestContext": { @@ -86,6 +90,7 @@ def test_install_and_call_lambda_flask( def test_install_and_call_flask_lambda_over_http( persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 api_gateway_endpoint: URL, ): """Given api-gateway and lambda installed into localstack, run it via http""" @@ -94,7 +99,7 @@ def test_install_and_call_flask_lambda_over_http( invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(persisted_person)}, + headers={"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) @@ -105,10 +110,11 @@ def test_install_and_call_flask_lambda_over_http( ) -def test_install_and_call_flask_lambda_with_unknown_nhs_number( +def test_install_and_call_flask_lambda_with_unknown_nhs_number( # noqa: PLR0913 flask_function: str, persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa: ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 logs_client: BaseClient, api_gateway_endpoint: URL, ): @@ -120,7 +126,7 @@ def test_install_and_call_flask_lambda_with_unknown_nhs_number( invoke_url = f"{api_gateway_endpoint}/patient-check/{nhs_number}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(nhs_number)}, + headers={"nhs-login-nhs-number": str(nhs_number), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) @@ -182,6 +188,7 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, campaign_config: CampaignConfig, + consumer_mapping: ConsumerMapping, # noqa: ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -195,6 +202,7 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person), + CONSUMER_ID: "23-mic7heal-jor6don", "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -371,6 +379,7 @@ def test_validation_of_query_params_when_all_are_valid( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, campaign_config: CampaignConfig, # noqa:ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 api_gateway_endpoint: URL, ): # Given @@ -378,7 +387,7 @@ def test_validation_of_query_params_when_all_are_valid( invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": persisted_person}, + headers={"nhs-login-nhs-number": persisted_person, CONSUMER_ID: "23-mic7heal-jor6don"}, params={"category": "VACCINATIONS", "conditions": "COVID19", "includeActions": "N"}, timeout=10, ) @@ -411,6 +420,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, multiple_campaign_configs: list[CampaignConfig], + consumer_mapping: ConsumerMapping, # noqa: ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -420,6 +430,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person_all_cohorts), + CONSUMER_ID: "23-mic7heal-jor6don", "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -554,6 +565,7 @@ def test_no_active_iteration_returns_empty_processed_suggestions( lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, inactive_iteration_config: list[CampaignConfig], # noqa:ARG001 + consumer_mapping_with_various_targets: ConsumerMapping, # noqa:ARG001 api_gateway_endpoint: URL, ): invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person_all_cohorts}" @@ -561,6 +573,7 @@ def test_no_active_iteration_returns_empty_processed_suggestions( invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person_all_cohorts), + CONSUMER_ID: "23-mic7heal-jor6don", "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -590,6 +603,7 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, campaign_config_with_tokens: CampaignConfig, # noqa:ARG001 + consumer_mapping: ConsumerMapping, # noqa:ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -599,7 +613,7 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 invoke_url = f"{api_gateway_endpoint}/patient-check/{person_with_all_data}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(person_with_all_data)}, + headers={"nhs-login-nhs-number": str(person_with_all_data), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) @@ -640,6 +654,7 @@ def test_incorrect_token_causes_internal_server_error( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, campaign_config_with_invalid_tokens: CampaignConfig, # noqa:ARG001 + consumer_mapping: ConsumerMapping, # noqa: ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -649,7 +664,7 @@ def test_incorrect_token_causes_internal_server_error( # noqa: PLR0913 invoke_url = f"{api_gateway_endpoint}/patient-check/{person_with_all_data}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(person_with_all_data)}, + headers={"nhs-login-nhs-number": str(person_with_all_data), CONSUMER_ID: "23-mic7heal-jor6don"}, timeout=10, ) diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 14466c4bc..aa76b02db 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -29,9 +29,10 @@ ) from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.views.eligibility import ( + _get_or_default_query_params, build_actions, build_eligibility_cohorts, - build_suitability_results, _get_or_default_query_params, + build_suitability_results, ) from eligibility_signposting_api.views.response_model import eligibility_response from tests.fixtures.builders.model.eligibility import ( From 425f6e79ad09c2a5300536055fdcddf1be111571 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:59:25 +0000 Subject: [PATCH 22/58] ELI-578 integration test --- .../in_process/test_eligibility_endpoint.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 08b5dfa04..4bb6d2811 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -324,6 +324,23 @@ def test_actionable_with_and_rule( ), ) + def test_empty_response_when_no_campaign_mapped_to_consumer( + self, + client: FlaskClient, + persisted_person: NHSNumber, + campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 + ): + # Given + consumer_id_not_having_mapping = "23-jo4hn-ce4na" + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id_not_having_mapping} + + # When + response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) + + # Then + assert_that(response, is_response().with_status_code(HTTPStatus.NOT_FOUND)) + class TestVirtualCohortResponse: def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( From 3b58fc9c5b8b85747522aaa053377b575aa271c2 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:20:06 +0000 Subject: [PATCH 23/58] ELI-578 Integration test to check if consumer has campaign mappings --- .../common/api_error_response.py | 8 ++++ .../services/eligibility_services.py | 6 ++- .../views/eligibility.py | 21 +++++++++-- .../in_process/test_eligibility_endpoint.py | 37 ++++++++++--------- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/eligibility_signposting_api/common/api_error_response.py b/src/eligibility_signposting_api/common/api_error_response.py index cb1006584..47308e085 100644 --- a/src/eligibility_signposting_api/common/api_error_response.py +++ b/src/eligibility_signposting_api/common/api_error_response.py @@ -143,3 +143,11 @@ def log_and_generate_response( fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, fhir_display_message="Access has been denied to process this request.", ) + +CONSUMER_HAS_NO_CAMPAIGN_MAPPING = APIErrorResponse( + status_code=HTTPStatus.FORBIDDEN, + fhir_issue_code=FHIRIssueCode.FORBIDDEN, + fhir_issue_severity=FHIRIssueSeverity.ERROR, + fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, + fhir_display_message="Access has been denied to process this request.", +) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 13b701d61..74d3c659c 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -20,6 +20,10 @@ class InvalidQueryParamError(Exception): pass +class NoPermittedCampaignsError(Exception): + pass + + @service class EligibilityService: def __init__( @@ -70,4 +74,4 @@ def __collect_permitted_campaign_configs( campaign for campaign in campaign_configs if campaign.id in permitted_campaign_ids ] return permitted_campaign_configs - return [] + raise NoPermittedCampaignsError diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 2ae296cdc..bf6fe1f36 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -11,12 +11,16 @@ from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.common.api_error_response import NHS_NUMBER_NOT_FOUND_ERROR +from eligibility_signposting_api.common.api_error_response import ( + CONSUMER_HAS_NO_CAMPAIGN_MAPPING, + NHS_NUMBER_NOT_FOUND_ERROR, +) from eligibility_signposting_api.common.request_validator import validate_request_params from eligibility_signposting_api.config.constants import CONSUMER_ID, URL_PREFIX from eligibility_signposting_api.model.consumer_mapping import ConsumerId from eligibility_signposting_api.model.eligibility_status import Condition, EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.services import EligibilityService, UnknownPersonError +from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from eligibility_signposting_api.views.response_model import eligibility_response from eligibility_signposting_api.views.response_model.eligibility_response import ProcessedSuggestion @@ -48,9 +52,11 @@ def check_eligibility( nhs_number: NHSNumber, eligibility_service: Injected[EligibilityService], audit_service: Injected[AuditService] ) -> ResponseReturnValue: logger.info("checking nhs_number %r in %r", nhs_number, eligibility_service, extra={"nhs_number": nhs_number}) + + query_params = _get_or_default_query_params() + consumer_id = _get_consumer_id_from_headers() + try: - query_params = _get_or_default_query_params() - consumer_id = _get_consumer_id_from_headers() eligibility_status = eligibility_service.get_eligibility_status( nhs_number, query_params["includeActions"], @@ -60,6 +66,8 @@ def check_eligibility( ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) + except NoPermittedCampaignsError: + return handle_no_permitted_campaigns_for_the_consumer_error(consumer_id) else: response: eligibility_response.EligibilityResponse = build_eligibility_response(eligibility_status) AuditContext.write_to_firehose(audit_service) @@ -112,6 +120,13 @@ def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue: ) +def handle_no_permitted_campaigns_for_the_consumer_error(consumer_id: ConsumerId) -> ResponseReturnValue: + diagnostics = f"Consumer ID '{consumer_id}' was not recognised by the Eligibility Signposting API" + return CONSUMER_HAS_NO_CAMPAIGN_MAPPING.log_and_generate_response( + log_message=diagnostics, diagnostics=diagnostics, location_param="id" + ) + + def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility_response.EligibilityResponse: """Return an object representing the API response we are going to send, given an evaluation of the person's eligibility.""" diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 4bb6d2811..14bb094dd 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -324,23 +324,6 @@ def test_actionable_with_and_rule( ), ) - def test_empty_response_when_no_campaign_mapped_to_consumer( - self, - client: FlaskClient, - persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 - ): - # Given - consumer_id_not_having_mapping = "23-jo4hn-ce4na" - headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id_not_having_mapping} - - # When - response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) - - # Then - assert_that(response, is_response().with_status_code(HTTPStatus.NOT_FOUND)) - class TestVirtualCohortResponse: def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( @@ -840,3 +823,23 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ) ), ) + + +class TestEligibilityResponseWhenConsumerHasNoMapping: + def test_empty_response_when_no_campaign_mapped_for_the_consumer( + self, + client: FlaskClient, + persisted_person: NHSNumber, + campaign_config: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 + ): + # Given + consumer_id_not_having_mapping = "23-jo4hn-ce4na" + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id_not_having_mapping} + + # When + response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) + + # Then + assert_that(response, is_response().with_status_code(HTTPStatus.FORBIDDEN)) From 7812be0eaa04d7ad74dfe816a563cddcde25c1a1 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 22 Dec 2025 18:18:17 +0000 Subject: [PATCH 24/58] ELI-578 Unit tests --- .../unit/repos/test_consumer_mapping_repo.py | 57 ++++++++++++ .../services/test_eligibility_services.py | 66 ++++++++++++++ tests/unit/views/test_eligibility.py | 86 +++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 tests/unit/repos/test_consumer_mapping_repo.py diff --git a/tests/unit/repos/test_consumer_mapping_repo.py b/tests/unit/repos/test_consumer_mapping_repo.py new file mode 100644 index 000000000..df15f1a10 --- /dev/null +++ b/tests/unit/repos/test_consumer_mapping_repo.py @@ -0,0 +1,57 @@ +import json +import pytest +from unittest.mock import MagicMock + +from eligibility_signposting_api.model.consumer_mapping import ConsumerId +from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo, BucketName + + +class TestConsumerMappingRepo: + @pytest.fixture + def mock_s3_client(self): + return MagicMock() + + @pytest.fixture + def repo(self, mock_s3_client): + return ConsumerMappingRepo( + s3_client=mock_s3_client, + bucket_name=BucketName("test-bucket") + ) + + def test_get_permitted_campaign_ids_success(self, repo, mock_s3_client): + # Given + consumer_id = "user-123" + expected_campaigns = ["flu-2024", "covid-2024"] + mapping_data = { + consumer_id: expected_campaigns + } + + mock_s3_client.list_objects.return_value = { + "Contents": [{"Key": "mappings.json"}] + } + + body_json = json.dumps(mapping_data).encode("utf-8") + mock_s3_client.get_object.return_value = { + "Body": MagicMock(read=lambda: body_json) + } + + # When + result = repo.get_permitted_campaign_ids(ConsumerId(consumer_id)) + + # Then + assert result == expected_campaigns + mock_s3_client.list_objects.assert_called_once_with(Bucket="test-bucket") + mock_s3_client.get_object.assert_called_once_with( + Bucket="test-bucket", + Key="mappings.json" + ) + + def test_get_permitted_campaign_ids_returns_none_when_missing(self, repo, mock_s3_client): + # Setup data where the consumer_id doesn't exist + mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "mappings.json"}]} + body_json = json.dumps({"other-user": ["camp-1"]}).encode("utf-8") + mock_s3_client.get_object.return_value = {"Body": MagicMock(read=lambda: body_json)} + + result = repo.get_permitted_campaign_ids(ConsumerId("missing-user")) + + assert result is None diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index 1d351ffaf..793efc355 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -3,13 +3,32 @@ import pytest from hamcrest import assert_that, empty +from eligibility_signposting_api.model.campaign_config import CampaignID, CampaignConfig from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculatorFactory +from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from tests.fixtures.matchers.eligibility import is_eligibility_status +@pytest.fixture +def mock_repos(): + return { + "person": MagicMock(spec=PersonRepo), + "campaign": MagicMock(spec=CampaignRepo), + "consumer": MagicMock(spec=ConsumerMappingRepo), + "factory": MagicMock(spec=EligibilityCalculatorFactory) + } + +@pytest.fixture +def service(mock_repos): + return EligibilityService( + mock_repos["person"], + mock_repos["campaign"], + mock_repos["consumer"], + mock_repos["factory"] + ) def test_eligibility_service_returns_from_repo(): # Given @@ -45,3 +64,50 @@ def test_eligibility_service_for_nonexistent_nhs_number(): category="ALL", consumer_id="test_consumer_id", ) + + +def test_get_eligibility_status_filters_permitted_campaigns(service, mock_repos): + """Tests that ONLY permitted campaigns reach the calculator factory.""" + # Given + nhs_number = NHSNumber("1234567890") + person_data = {"age": 65, "vulnerable": True} + mock_repos["person"].get_eligibility_data.return_value = person_data + + # Available campaigns in system + camp_a = MagicMock(spec=CampaignConfig, id=CampaignID("CAMP_A")) + camp_b = MagicMock(spec=CampaignConfig, id=CampaignID("CAMP_B")) + mock_repos["campaign"].get_campaign_configs.return_value = [camp_a, camp_b] + + # Consumer is only permitted to see CAMP_B + mock_repos["consumer"].get_permitted_campaign_ids.return_value = [CampaignID("CAMP_B")] + + # Mock calculator behavior + mock_calc = MagicMock() + mock_repos["factory"].get.return_value = mock_calc + mock_calc.get_eligibility_status.return_value = "eligible_result" + + # When + result = service.get_eligibility_status(nhs_number, "Y", ["FLU"], "G1", "consumer_xyz") + + # Then + # Verify the factory was called ONLY with camp_b + mock_repos["factory"].get.assert_called_once_with(person_data, [camp_b]) + assert result == "eligible_result" + +def test_raises_no_permitted_campaigns_error(service, mock_repos): + """Tests the scenario where the consumer mapping exists but returns nothing.""" + mock_repos["person"].get_eligibility_data.return_value = {"data": "exists"} + mock_repos["campaign"].get_campaign_configs.return_value = [MagicMock()] + + # Consumer has no permitted IDs mapped + mock_repos["consumer"].get_permitted_campaign_ids.return_value = [] + + with pytest.raises(NoPermittedCampaignsError): + service.get_eligibility_status(NHSNumber("1"), "Y", [], "", "bad_consumer") + +def test_raises_unknown_person_error_on_repo_not_found(service, mock_repos): + """Tests that NotFoundError from repo is translated to UnknownPersonError.""" + mock_repos["person"].get_eligibility_data.side_effect = NotFoundError + + with pytest.raises(UnknownPersonError): + service.get_eligibility_status(NHSNumber("999"), "Y", [], "", "any") diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index aa76b02db..fedc6d84b 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -28,6 +28,7 @@ UrlLink, ) from eligibility_signposting_api.services import EligibilityService, UnknownPersonError +from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from eligibility_signposting_api.views.eligibility import ( _get_or_default_query_params, build_actions, @@ -93,6 +94,20 @@ def get_eligibility_status( ) -> EligibilityStatus: raise ValueError +class FakeNoPermittedCampaignsService(EligibilityService): + def __init__(self): + pass + + def get_eligibility_status( + self, + _nhs_number: NHSNumber, + _include_actions: str, + _conditions: list[str], + _category: str, + _consumer_id: str, + ) -> EligibilityStatus: + # Simulate the new error scenario + raise NoPermittedCampaignsError def test_security_headers_present_on_successful_response(app: Flask, client: FlaskClient): """Test that security headers are present on successful eligibility check response.""" @@ -583,3 +598,74 @@ def test_status_endpoint(app: Flask, client: FlaskClient): ) ), ) + + +def test_no_permitted_campaigns_for_consumer_error(app: Flask, client: FlaskClient): + """ + Tests that NoPermittedCampaignsError is caught and returns + the correct FHIR OperationOutcome with FORBIDDEN status. + """ + # Given + with ( + get_app_container(app).override.service(EligibilityService, new=FakeNoPermittedCampaignsService()), + get_app_container(app).override.service(AuditService, new=FakeAuditService()), + ): + headers = { + "nhs-login-nhs-number": "9876543210", + "Consumer-Id": "unrecognized_consumer" + } + + # When + response = client.get("/patient-check/9876543210", headers=headers) + + # Then + assert_that( + response, + is_response() + .with_status_code(HTTPStatus.FORBIDDEN) + .with_headers(has_entries({"Content-Type": "application/fhir+json"})) + .and_text( + is_json_that( + has_entries( + resourceType="OperationOutcome", + issue=contains_exactly( + has_entries( + severity="error", + code="forbidden", + diagnostics="Consumer ID 'unrecognized_consumer' was not recognised by the Eligibility Signposting API" + ) + ) + ) + ) + ) + ) + + +def test_consumer_id_is_passed_to_service(app: Flask, client: FlaskClient): + """ + Verifies that the consumer ID from the header is actually passed + to the eligibility service call. + """ + # Given + mock_service = MagicMock(spec=EligibilityService) + mock_service.get_eligibility_status.return_value = EligibilityStatusFactory.build() + + with ( + get_app_container(app).override.service(EligibilityService, new=mock_service), + get_app_container(app).override.service(AuditService, new=FakeAuditService()), + ): + headers = { + "nhs-login-nhs-number": "1234567890", + "Consumer-Id": "specific_consumer_123" + } + + # When + client.get("/patient-check/1234567890", headers=headers) + + # Then + # Verify the 5th positional argument or the keyword argument 'consumer_id' + mock_service.get_eligibility_status.assert_called_once() + args, kwargs = mock_service.get_eligibility_status.call_args + + # Check that 'specific_consumer_123' was the consumer_id passed + assert args[4] == "specific_consumer_123" From 569dc5ffea20b9b995b52b43a653348a678e0cd6 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:34:31 +0000 Subject: [PATCH 25/58] added test : customer requesting for campaign that is not mapped --- .../in_process/test_eligibility_endpoint.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 14bb094dd..a4fe310c3 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -824,6 +824,38 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ), ) + def test_response_when_no_campaign_config_is_present( + self, + client: FlaskClient, + persisted_77yo_person: NHSNumber, + campaign_config_with_rules_having_rule_mapper: CampaignConfig, # noqa: ARG002 + consumer_mapping: ConsumerMapping, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 + ): + # Given + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} + + # When + response = client.get(f'/patient-check/{persisted_77yo_person}?includeActions=N&conditions=FLU', + headers=headers) + + # Then + assert_that( + response, + is_response() + .with_status_code(HTTPStatus.OK) + .and_text( + is_json_that( + has_entry( + "processedSuggestions", + equal_to( + [] + ), + ) + ) + ), + ) + class TestEligibilityResponseWhenConsumerHasNoMapping: def test_empty_response_when_no_campaign_mapped_for_the_consumer( From fbb902d2a8e17530d701c13d6ffd8feca7a4c896 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:40:02 +0000 Subject: [PATCH 26/58] consumer with no campaign mapping is valid --- .../services/eligibility_services.py | 6 +- .../views/eligibility.py | 3 - tests/integration/conftest.py | 206 ++++++++++++++---- .../in_process/test_eligibility_endpoint.py | 132 +++++++---- .../lambda/test_app_running_as_lambda.py | 28 +-- .../unit/repos/test_consumer_mapping_repo.py | 27 +-- .../services/test_eligibility_services.py | 24 +- tests/unit/views/test_eligibility.py | 61 +----- 8 files changed, 288 insertions(+), 199 deletions(-) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 74d3c659c..13b701d61 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -20,10 +20,6 @@ class InvalidQueryParamError(Exception): pass -class NoPermittedCampaignsError(Exception): - pass - - @service class EligibilityService: def __init__( @@ -74,4 +70,4 @@ def __collect_permitted_campaign_configs( campaign for campaign in campaign_configs if campaign.id in permitted_campaign_ids ] return permitted_campaign_configs - raise NoPermittedCampaignsError + return [] diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index bf6fe1f36..0505bc28a 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -20,7 +20,6 @@ from eligibility_signposting_api.model.consumer_mapping import ConsumerId from eligibility_signposting_api.model.eligibility_status import Condition, EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.services import EligibilityService, UnknownPersonError -from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from eligibility_signposting_api.views.response_model import eligibility_response from eligibility_signposting_api.views.response_model.eligibility_response import ProcessedSuggestion @@ -66,8 +65,6 @@ def check_eligibility( ) except UnknownPersonError: return handle_unknown_person_error(nhs_number) - except NoPermittedCampaignsError: - return handle_no_permitted_campaigns_for_the_consumer_error(consumer_id) else: response: eligibility_response.EligibilityResponse = build_eligibility_response(eligibility_status) AuditContext.write_to_firehose(audit_service) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 09c8617e6..d06d7a2dd 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -700,7 +700,7 @@ def firehose_delivery_stream(firehose_client: BaseClient, audit_bucket: BucketNa @pytest.fixture(scope="class") -def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: +def rsv_campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: campaign: CampaignConfig = rule.CampaignConfigFactory.build( target="RSV", iterations=[ @@ -729,45 +729,6 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") -@pytest.fixture(scope="class") -def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: - consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy")] - - consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) - s3_client.put_object( - Bucket=consumer_mapping_bucket, - Key="consumer_mapping.json", - Body=json.dumps(consumer_mapping_data), - ContentType="application/json", - ) - yield consumer_mapping - s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") - - -@pytest.fixture(scope="class") -def consumer_mapping_with_various_targets( - s3_client: BaseClient, consumer_mapping_bucket: BucketName -) -> Generator[ConsumerMapping]: - consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ - CampaignID("campaign_start_date"), - CampaignID("campaign_start_date_plus_one_day"), - CampaignID("campaign_today"), - CampaignID("campaign_tomorrow"), - ] - - consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) - s3_client.put_object( - Bucket=consumer_mapping_bucket, - Key="consumer_mapping.json", - Body=json.dumps(consumer_mapping_data), - ContentType="application/json", - ) - yield consumer_mapping - s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") - - @pytest.fixture def campaign_config_with_rules_having_rule_code( s3_client: BaseClient, rules_bucket: BucketName @@ -1030,7 +991,7 @@ def campaign_config_with_invalid_tokens(s3_client: BaseClient, rules_bucket: Buc s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") -@pytest.fixture(scope="class") +@pytest.fixture(scope="function") def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: """Create and upload multiple campaign configs to S3, then clean up after tests.""" campaigns, campaign_data_keys = [], [] @@ -1053,6 +1014,7 @@ def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) - for i in range(3): campaign = rule.CampaignConfigFactory.build( name=f"campaign_{i}", + id=f"{targets[i]}_campaign_id", target=targets[i], type="V", iterations=[ @@ -1149,6 +1111,168 @@ def campaign_config_with_missing_descriptions_missing_rule_text( yield campaign s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") +@pytest.fixture(scope="function") +def multiple_campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: + """Create and upload multiple campaign configs to S3, then clean up after tests.""" + campaigns, campaign_data_keys = [], [] + + targets = getattr(request, "param", ["RSV", "COVID", "FLU"]) + target_rules_map = { + targets[0]: [ + rule.PersonAgeSuppressionRuleFactory.build(type=RuleType.filter, description="TOO YOUNG"), + rule.PostcodeSuppressionRuleFactory.build(type=RuleType.filter, priority=8, cohort_label="cohort_label4"), + ], + targets[1]: [ + rule.PersonAgeSuppressionRuleFactory.build(description="TOO YOUNG, your icb is: [[PERSON.ICB]]"), + rule.PostcodeSuppressionRuleFactory.build( + priority=12, cohort_label="cohort_label2", description="Your postcode is: [[PERSON.POSTCODE]]" + ), + ], + targets[2]: [rule.ICBRedirectRuleFactory.build()], + } + + for i in range(3): + campaign = rule.CampaignConfigFactory.build( + name=f"campaign_{i}", + id=f"{targets[i]}_campaign_id", + target=targets[i], + type="V", + iterations=[ + rule.IterationFactory.build( + iteration_rules=target_rules_map.get(targets[i]), + iteration_cohorts=[ + rule.IterationCohortFactory.build( + cohort_label=f"cohort_label{i + 1}", + cohort_group=f"cohort_group{i + 1}", + positive_description=f"positive_desc_{i + 1}", + negative_description=f"negative_desc_{i + 1}", + ), + rule.IterationCohortFactory.build( + cohort_label="cohort_label4", + cohort_group="cohort_group4", + positive_description="positive_desc_4", + negative_description="negative_desc_4", + ), + ], + status_text=StatusText( + NotEligible=f"You are not eligible to take {targets[i]} vaccines.", + NotActionable=f"You have taken {targets[i]} vaccine in the last 90 days", + Actionable=f"You can take {targets[i]} vaccine.", + ), + ) + ], + ) + campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} + key = f"{campaign.name}.json" + s3_client.put_object( + Bucket=rules_bucket, Key=key, Body=json.dumps(campaign_data), ContentType="application/json" + ) + campaigns.append(campaign) + campaign_data_keys.append(key) + + yield campaigns + + for key in campaign_data_keys: + s3_client.delete_object(Bucket=rules_bucket, Key=key) + +@pytest.fixture(scope="function") +def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: + """Create and upload multiple campaign configs to S3, then clean up after tests.""" + campaigns, campaign_data_keys = [], [] + + targets = getattr(request, "param", ["RSV", "COVID", "FLU"]) + + for i in range(len(targets)): + campaign: CampaignConfig = rule.CampaignConfigFactory.build( + name=f"campaign_{i}", + id=f"{targets[i]}_campaign_id", + target=targets[i], + type="V", + iterations=[ + rule.IterationFactory.build( + iteration_rules=[ + rule.PostcodeSuppressionRuleFactory.build(type=RuleType.filter), + rule.PersonAgeSuppressionRuleFactory.build(), + rule.PersonAgeSuppressionRuleFactory.build(name="Exclude 76 rolling", description=""), + ], + iteration_cohorts=[ + rule.IterationCohortFactory.build( + cohort_label="cohort1", + cohort_group="cohort_group1", + positive_description="", + negative_description="", + ) + ], + status_text=None, + ) + ], + ) + campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} + key = f"{campaign.name}.json" + s3_client.put_object( + Bucket=rules_bucket, Key=key, Body=json.dumps(campaign_data), ContentType="application/json" + ) + campaigns.append(campaign) + campaign_data_keys.append(key) + + yield campaigns + + for key in campaign_data_keys: + s3_client.delete_object(Bucket=rules_bucket, Key=key) + +@pytest.fixture(scope="class") +def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy")] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + +@pytest.fixture(scope="class") +def consumer_mapping_for_rsv_and_covid(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("consumer-id-mapped-to-rsv-and-covid")] = [CampaignID("RSV_campaign_id"), + CampaignID("COVID_campaign_id")] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + +@pytest.fixture(scope="class") +def consumer_mapping_with_various_targets( + s3_client: BaseClient, consumer_mapping_bucket: BucketName +) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ + CampaignID("campaign_start_date"), + CampaignID("campaign_start_date_plus_one_day"), + CampaignID("campaign_today"), + CampaignID("campaign_tomorrow"), + ] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + # If you put StubSecretRepo in a separate module, import it instead class StubSecretRepo(SecretRepo): diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index a4fe310c3..9a127db99 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -1,5 +1,6 @@ from http import HTTPStatus +import pytest from botocore.client import BaseClient from brunns.matchers.data import json_matching as is_json_that from brunns.matchers.werkzeug import is_werkzeug_response as is_response @@ -10,7 +11,7 @@ equal_to, has_entries, has_entry, - has_key, + has_key, has_item, all_of, has_length, contains, has_items, contains_inanyorder, ) from eligibility_signposting_api.config.constants import CONSUMER_ID @@ -26,7 +27,7 @@ def test_nhs_number_given( self, client: FlaskClient, persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_mapping: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -60,7 +61,7 @@ def test_no_nhs_number_given_but_header_given( self, client: FlaskClient, persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 ): # Given headers = {"nhs-login-nhs-number": str(persisted_person)} @@ -82,7 +83,7 @@ def test_not_base_eligible( self, client: FlaskClient, persisted_person_no_cohorts: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given @@ -127,7 +128,7 @@ def test_not_eligible_by_rule( self, client: FlaskClient, persisted_person_pc_sw19: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given @@ -172,7 +173,7 @@ def test_not_actionable_and_check_response_when_no_rule_code_given( self, client: FlaskClient, persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_mapping: ConsumerMapping, # noqa: ARG002 ): # Given @@ -223,7 +224,7 @@ def test_actionable( self, client: FlaskClient, persisted_77yo_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 + rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_mapping: ConsumerMapping, # noqa: ARG002 ): headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} @@ -824,22 +825,94 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ), ) - def test_response_when_no_campaign_config_is_present( + + @pytest.mark.parametrize( + "campaign_configs, consumer_mapping_for_rsv_and_covid, consumer_id, requested_conditions, expected_targets", + [ + # Scenario 1: Intersection of mapped targets, requested targets, and active campaigns (Success) + ( + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "ALL", + ["RSV", "COVID"], + ), + # Scenario 2: Explicit request for a single mapped target with an active campaign + ( + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "RSV", + ["RSV"], + ), + # Scenario 3: Request for an active campaign (FLU) that the consumer is NOT mapped to + ( + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "FLU", + [], + ), + # Scenario 4: Request for a target that neither exists in system nor is mapped to consumer + ( + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "HPV", + [], + ), + # Scenario 5: Consumer has no target mappings; requesting ALL should return empty + ( + ["RSV", "COVID", "FLU"], + "consumer-id-mapped-to-rsv-and-covid", + "consumer-id-with-no-mapping", + "ALL", + [], + ), + # Scenario 6: Consumer has no target mappings; requesting specific target should return empty + ( + ["RSV", "COVID", "FLU"], + "consumer-id-mapped-to-rsv-and-covid", + "consumer-id-with-no-mapping", + "RSV", + [], + ), + # Scenario 7: Consumer is mapped to targets (RSV/COVID), but those campaigns aren't active/present + ( + ["MMR"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "ALL", + [], + ), + # Scenario 8: Request for specific mapped target (RSV), but those campaigns aren't active/present + ( + ["MMR"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "RSV", + [], + ), + ], + indirect=["campaign_configs", "consumer_mapping_for_rsv_and_covid"] + ) + def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( self, client: FlaskClient, - persisted_77yo_person: NHSNumber, - campaign_config_with_rules_having_rule_mapper: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + persisted_person: NHSNumber, secretsmanager_client: BaseClient, # noqa: ARG002 + campaign_configs: CampaignConfig, # noqa: ARG002 + consumer_mapping_for_rsv_and_covid: ConsumerMapping, # noqa: ARG002 + consumer_id: str, + requested_conditions: str, + expected_targets: list[str], ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id} # When - response = client.get(f'/patient-check/{persisted_77yo_person}?includeActions=N&conditions=FLU', - headers=headers) + response = client.get(f"/patient-check/{persisted_person}?includeActions=Y&conditions={requested_conditions}", headers=headers) - # Then assert_that( response, is_response() @@ -848,30 +921,11 @@ def test_response_when_no_campaign_config_is_present( is_json_that( has_entry( "processedSuggestions", - equal_to( - [] - ), + # This ensures ONLY these items exist, no extras like FLU + contains_inanyorder( + *[has_entry("condition", i) for i in expected_targets] + ) ) ) - ), + ) ) - - -class TestEligibilityResponseWhenConsumerHasNoMapping: - def test_empty_response_when_no_campaign_mapped_for_the_consumer( - self, - client: FlaskClient, - persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 - secretsmanager_client: BaseClient, # noqa: ARG002 - ): - # Given - consumer_id_not_having_mapping = "23-jo4hn-ce4na" - headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id_not_having_mapping} - - # When - response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) - - # Then - assert_that(response, is_response().with_status_code(HTTPStatus.FORBIDDEN)) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index f6725f70c..1fcfc152d 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -36,7 +36,7 @@ def test_install_and_call_lambda_flask( lambda_client: BaseClient, flask_function: str, persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG001 + rsv_campaign_config: CampaignConfig, # noqa: ARG001 consumer_mapping: ConsumerMapping, # noqa: ARG001 ): """Given lambda installed into localstack, run it via boto3 lambda client""" @@ -89,7 +89,7 @@ def test_install_and_call_lambda_flask( def test_install_and_call_flask_lambda_over_http( persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG001 + rsv_campaign_config: CampaignConfig, # noqa: ARG001 consumer_mapping: ConsumerMapping, # noqa: ARG001 api_gateway_endpoint: URL, ): @@ -113,7 +113,7 @@ def test_install_and_call_flask_lambda_over_http( def test_install_and_call_flask_lambda_with_unknown_nhs_number( # noqa: PLR0913 flask_function: str, persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa: ARG001 + rsv_campaign_config: CampaignConfig, # noqa: ARG001 consumer_mapping: ConsumerMapping, # noqa: ARG001 logs_client: BaseClient, api_gateway_endpoint: URL, @@ -187,7 +187,7 @@ def get_log_messages(flask_function: str, logs_client: BaseClient) -> list[str]: def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_if_audited( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - campaign_config: CampaignConfig, + rsv_campaign_config: CampaignConfig, consumer_mapping: ConsumerMapping, # noqa: ARG001 s3_client: BaseClient, audit_bucket: BucketName, @@ -234,13 +234,13 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i expected_conditions = [ { - "campaignId": campaign_config.id, - "campaignVersion": campaign_config.version, - "iterationId": campaign_config.iterations[0].id, - "iterationVersion": campaign_config.iterations[0].version, - "conditionName": campaign_config.target, + "campaignId": rsv_campaign_config.id, + "campaignVersion": rsv_campaign_config.version, + "iterationId": rsv_campaign_config.iterations[0].id, + "iterationVersion": rsv_campaign_config.iterations[0].version, + "conditionName": rsv_campaign_config.target, "status": "not_actionable", - "statusText": f"You should have the {campaign_config.target} vaccine", + "statusText": f"You should have the {rsv_campaign_config.target} vaccine", "eligibilityCohorts": [{"cohortCode": "cohort1", "cohortStatus": "not_actionable"}], "eligibilityCohortGroups": [ { @@ -285,7 +285,7 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_results_in_error_response( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa:ARG001 + rsv_campaign_config: CampaignConfig, # noqa:ARG001 api_gateway_endpoint: URL, ): # Given @@ -332,7 +332,7 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu def test_given_nhs_number_not_present_in_headers_results_in_error_response( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa:ARG001 + rsv_campaign_config: CampaignConfig, # noqa:ARG001 api_gateway_endpoint: URL, ): # Given @@ -378,7 +378,7 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response( def test_validation_of_query_params_when_all_are_valid( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa:ARG001 + rsv_campaign_config: CampaignConfig, # noqa:ARG001 consumer_mapping: ConsumerMapping, # noqa: ARG001 api_gateway_endpoint: URL, ): @@ -399,7 +399,7 @@ def test_validation_of_query_params_when_all_are_valid( def test_validation_of_query_params_when_invalid_conditions_is_specified( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - campaign_config: CampaignConfig, # noqa:ARG001 + rsv_campaign_config: CampaignConfig, # noqa:ARG001 api_gateway_endpoint: URL, ): # Given diff --git a/tests/unit/repos/test_consumer_mapping_repo.py b/tests/unit/repos/test_consumer_mapping_repo.py index df15f1a10..243874482 100644 --- a/tests/unit/repos/test_consumer_mapping_repo.py +++ b/tests/unit/repos/test_consumer_mapping_repo.py @@ -1,9 +1,10 @@ import json -import pytest from unittest.mock import MagicMock +import pytest + from eligibility_signposting_api.model.consumer_mapping import ConsumerId -from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo, BucketName +from eligibility_signposting_api.repos.consumer_mapping_repo import BucketName, ConsumerMappingRepo class TestConsumerMappingRepo: @@ -13,27 +14,18 @@ def mock_s3_client(self): @pytest.fixture def repo(self, mock_s3_client): - return ConsumerMappingRepo( - s3_client=mock_s3_client, - bucket_name=BucketName("test-bucket") - ) + return ConsumerMappingRepo(s3_client=mock_s3_client, bucket_name=BucketName("test-bucket")) def test_get_permitted_campaign_ids_success(self, repo, mock_s3_client): # Given consumer_id = "user-123" expected_campaigns = ["flu-2024", "covid-2024"] - mapping_data = { - consumer_id: expected_campaigns - } + mapping_data = {consumer_id: expected_campaigns} - mock_s3_client.list_objects.return_value = { - "Contents": [{"Key": "mappings.json"}] - } + mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "mappings.json"}]} body_json = json.dumps(mapping_data).encode("utf-8") - mock_s3_client.get_object.return_value = { - "Body": MagicMock(read=lambda: body_json) - } + mock_s3_client.get_object.return_value = {"Body": MagicMock(read=lambda: body_json)} # When result = repo.get_permitted_campaign_ids(ConsumerId(consumer_id)) @@ -41,10 +33,7 @@ def test_get_permitted_campaign_ids_success(self, repo, mock_s3_client): # Then assert result == expected_campaigns mock_s3_client.list_objects.assert_called_once_with(Bucket="test-bucket") - mock_s3_client.get_object.assert_called_once_with( - Bucket="test-bucket", - Key="mappings.json" - ) + mock_s3_client.get_object.assert_called_once_with(Bucket="test-bucket", Key="mappings.json") def test_get_permitted_campaign_ids_returns_none_when_missing(self, repo, mock_s3_client): # Setup data where the consumer_id doesn't exist diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index 793efc355..ab5b85df3 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -3,33 +3,32 @@ import pytest from hamcrest import assert_that, empty -from eligibility_signposting_api.model.campaign_config import CampaignID, CampaignConfig +from eligibility_signposting_api.model.campaign_config import CampaignConfig, CampaignID from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo from eligibility_signposting_api.repos.consumer_mapping_repo import ConsumerMappingRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculatorFactory -from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from tests.fixtures.matchers.eligibility import is_eligibility_status + @pytest.fixture def mock_repos(): return { "person": MagicMock(spec=PersonRepo), "campaign": MagicMock(spec=CampaignRepo), "consumer": MagicMock(spec=ConsumerMappingRepo), - "factory": MagicMock(spec=EligibilityCalculatorFactory) + "factory": MagicMock(spec=EligibilityCalculatorFactory), } + @pytest.fixture def service(mock_repos): return EligibilityService( - mock_repos["person"], - mock_repos["campaign"], - mock_repos["consumer"], - mock_repos["factory"] + mock_repos["person"], mock_repos["campaign"], mock_repos["consumer"], mock_repos["factory"] ) + def test_eligibility_service_returns_from_repo(): # Given person_repo = MagicMock(spec=PersonRepo) @@ -94,17 +93,6 @@ def test_get_eligibility_status_filters_permitted_campaigns(service, mock_repos) mock_repos["factory"].get.assert_called_once_with(person_data, [camp_b]) assert result == "eligible_result" -def test_raises_no_permitted_campaigns_error(service, mock_repos): - """Tests the scenario where the consumer mapping exists but returns nothing.""" - mock_repos["person"].get_eligibility_data.return_value = {"data": "exists"} - mock_repos["campaign"].get_campaign_configs.return_value = [MagicMock()] - - # Consumer has no permitted IDs mapped - mock_repos["consumer"].get_permitted_campaign_ids.return_value = [] - - with pytest.raises(NoPermittedCampaignsError): - service.get_eligibility_status(NHSNumber("1"), "Y", [], "", "bad_consumer") - def test_raises_unknown_person_error_on_repo_not_found(service, mock_repos): """Tests that NotFoundError from repo is translated to UnknownPersonError.""" mock_repos["person"].get_eligibility_data.side_effect = NotFoundError diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index fedc6d84b..1a022e42f 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -28,7 +28,6 @@ UrlLink, ) from eligibility_signposting_api.services import EligibilityService, UnknownPersonError -from eligibility_signposting_api.services.eligibility_services import NoPermittedCampaignsError from eligibility_signposting_api.views.eligibility import ( _get_or_default_query_params, build_actions, @@ -94,20 +93,6 @@ def get_eligibility_status( ) -> EligibilityStatus: raise ValueError -class FakeNoPermittedCampaignsService(EligibilityService): - def __init__(self): - pass - - def get_eligibility_status( - self, - _nhs_number: NHSNumber, - _include_actions: str, - _conditions: list[str], - _category: str, - _consumer_id: str, - ) -> EligibilityStatus: - # Simulate the new error scenario - raise NoPermittedCampaignsError def test_security_headers_present_on_successful_response(app: Flask, client: FlaskClient): """Test that security headers are present on successful eligibility check response.""" @@ -600,47 +585,6 @@ def test_status_endpoint(app: Flask, client: FlaskClient): ) -def test_no_permitted_campaigns_for_consumer_error(app: Flask, client: FlaskClient): - """ - Tests that NoPermittedCampaignsError is caught and returns - the correct FHIR OperationOutcome with FORBIDDEN status. - """ - # Given - with ( - get_app_container(app).override.service(EligibilityService, new=FakeNoPermittedCampaignsService()), - get_app_container(app).override.service(AuditService, new=FakeAuditService()), - ): - headers = { - "nhs-login-nhs-number": "9876543210", - "Consumer-Id": "unrecognized_consumer" - } - - # When - response = client.get("/patient-check/9876543210", headers=headers) - - # Then - assert_that( - response, - is_response() - .with_status_code(HTTPStatus.FORBIDDEN) - .with_headers(has_entries({"Content-Type": "application/fhir+json"})) - .and_text( - is_json_that( - has_entries( - resourceType="OperationOutcome", - issue=contains_exactly( - has_entries( - severity="error", - code="forbidden", - diagnostics="Consumer ID 'unrecognized_consumer' was not recognised by the Eligibility Signposting API" - ) - ) - ) - ) - ) - ) - - def test_consumer_id_is_passed_to_service(app: Flask, client: FlaskClient): """ Verifies that the consumer ID from the header is actually passed @@ -654,10 +598,7 @@ def test_consumer_id_is_passed_to_service(app: Flask, client: FlaskClient): get_app_container(app).override.service(EligibilityService, new=mock_service), get_app_container(app).override.service(AuditService, new=FakeAuditService()), ): - headers = { - "nhs-login-nhs-number": "1234567890", - "Consumer-Id": "specific_consumer_123" - } + headers = {"nhs-login-nhs-number": "1234567890", "Consumer-Id": "specific_consumer_123"} # When client.get("/patient-check/1234567890", headers=headers) From d30b4df3335d5cdfcc5a978315143de30095e4c9 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:13:48 +0000 Subject: [PATCH 27/58] revert --- tests/integration/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d06d7a2dd..ee9ff04b3 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1014,7 +1014,6 @@ def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) - for i in range(3): campaign = rule.CampaignConfigFactory.build( name=f"campaign_{i}", - id=f"{targets[i]}_campaign_id", target=targets[i], type="V", iterations=[ From bca621538067d40a8355ddca123110c137b5b567 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 5 Jan 2026 19:28:10 +0000 Subject: [PATCH 28/58] linting --- tests/integration/conftest.py | 81 ++----------- .../in_process/test_eligibility_endpoint.py | 106 +++++++++--------- .../lambda/test_app_running_as_lambda.py | 1 + .../services/test_eligibility_services.py | 53 ++++----- tests/unit/views/test_eligibility.py | 2 +- 5 files changed, 97 insertions(+), 146 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d06d7a2dd..025a2a031 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -991,7 +991,7 @@ def campaign_config_with_invalid_tokens(s3_client: BaseClient, rules_bucket: Buc s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") -@pytest.fixture(scope="function") +@pytest.fixture def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: """Create and upload multiple campaign configs to S3, then clean up after tests.""" campaigns, campaign_data_keys = [], [] @@ -1014,7 +1014,6 @@ def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) - for i in range(3): campaign = rule.CampaignConfigFactory.build( name=f"campaign_{i}", - id=f"{targets[i]}_campaign_id", target=targets[i], type="V", iterations=[ @@ -1111,71 +1110,8 @@ def campaign_config_with_missing_descriptions_missing_rule_text( yield campaign s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") -@pytest.fixture(scope="function") -def multiple_campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: - """Create and upload multiple campaign configs to S3, then clean up after tests.""" - campaigns, campaign_data_keys = [], [] - - targets = getattr(request, "param", ["RSV", "COVID", "FLU"]) - target_rules_map = { - targets[0]: [ - rule.PersonAgeSuppressionRuleFactory.build(type=RuleType.filter, description="TOO YOUNG"), - rule.PostcodeSuppressionRuleFactory.build(type=RuleType.filter, priority=8, cohort_label="cohort_label4"), - ], - targets[1]: [ - rule.PersonAgeSuppressionRuleFactory.build(description="TOO YOUNG, your icb is: [[PERSON.ICB]]"), - rule.PostcodeSuppressionRuleFactory.build( - priority=12, cohort_label="cohort_label2", description="Your postcode is: [[PERSON.POSTCODE]]" - ), - ], - targets[2]: [rule.ICBRedirectRuleFactory.build()], - } - - for i in range(3): - campaign = rule.CampaignConfigFactory.build( - name=f"campaign_{i}", - id=f"{targets[i]}_campaign_id", - target=targets[i], - type="V", - iterations=[ - rule.IterationFactory.build( - iteration_rules=target_rules_map.get(targets[i]), - iteration_cohorts=[ - rule.IterationCohortFactory.build( - cohort_label=f"cohort_label{i + 1}", - cohort_group=f"cohort_group{i + 1}", - positive_description=f"positive_desc_{i + 1}", - negative_description=f"negative_desc_{i + 1}", - ), - rule.IterationCohortFactory.build( - cohort_label="cohort_label4", - cohort_group="cohort_group4", - positive_description="positive_desc_4", - negative_description="negative_desc_4", - ), - ], - status_text=StatusText( - NotEligible=f"You are not eligible to take {targets[i]} vaccines.", - NotActionable=f"You have taken {targets[i]} vaccine in the last 90 days", - Actionable=f"You can take {targets[i]} vaccine.", - ), - ) - ], - ) - campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} - key = f"{campaign.name}.json" - s3_client.put_object( - Bucket=rules_bucket, Key=key, Body=json.dumps(campaign_data), ContentType="application/json" - ) - campaigns.append(campaign) - campaign_data_keys.append(key) - - yield campaigns - for key in campaign_data_keys: - s3_client.delete_object(Bucket=rules_bucket, Key=key) - -@pytest.fixture(scope="function") +@pytest.fixture def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: """Create and upload multiple campaign configs to S3, then clean up after tests.""" campaigns, campaign_data_keys = [], [] @@ -1220,6 +1156,7 @@ def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) - for key in campaign_data_keys: s3_client.delete_object(Bucket=rules_bucket, Key=key) + @pytest.fixture(scope="class") def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: consumer_mapping = ConsumerMapping.model_validate({}) @@ -1235,11 +1172,16 @@ def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + @pytest.fixture(scope="class") -def consumer_mapping_for_rsv_and_covid(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: +def consumer_mapping_for_rsv_and_covid( + s3_client: BaseClient, consumer_mapping_bucket: BucketName +) -> Generator[ConsumerMapping]: consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId("consumer-id-mapped-to-rsv-and-covid")] = [CampaignID("RSV_campaign_id"), - CampaignID("COVID_campaign_id")] + consumer_mapping.root[ConsumerId("consumer-id-mapped-to-rsv-and-covid")] = [ + CampaignID("RSV_campaign_id"), + CampaignID("COVID_campaign_id"), + ] consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) s3_client.put_object( @@ -1251,6 +1193,7 @@ def consumer_mapping_for_rsv_and_covid(s3_client: BaseClient, consumer_mapping_b yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + @pytest.fixture(scope="class") def consumer_mapping_with_various_targets( s3_client: BaseClient, consumer_mapping_bucket: BucketName diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 9a127db99..c40b2bbfc 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -8,10 +8,11 @@ from hamcrest import ( assert_that, contains_exactly, + contains_inanyorder, equal_to, has_entries, has_entry, - has_key, has_item, all_of, has_length, contains, has_items, contains_inanyorder, + has_key, ) from eligibility_signposting_api.config.constants import CONSUMER_ID @@ -825,78 +826,83 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ), ) - @pytest.mark.parametrize( - "campaign_configs, consumer_mapping_for_rsv_and_covid, consumer_id, requested_conditions, expected_targets", + ( + "campaign_configs", + "consumer_mapping_for_rsv_and_covid", + "consumer_id", + "requested_conditions", + "expected_targets", + ), [ # Scenario 1: Intersection of mapped targets, requested targets, and active campaigns (Success) ( - ["RSV", "COVID", "FLU"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", - "ALL", - ["RSV", "COVID"], + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "ALL", + ["RSV", "COVID"], ), # Scenario 2: Explicit request for a single mapped target with an active campaign ( - ["RSV", "COVID", "FLU"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", - "RSV", - ["RSV"], + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "RSV", + ["RSV"], ), # Scenario 3: Request for an active campaign (FLU) that the consumer is NOT mapped to ( - ["RSV", "COVID", "FLU"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", - "FLU", - [], + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "FLU", + [], ), # Scenario 4: Request for a target that neither exists in system nor is mapped to consumer ( - ["RSV", "COVID", "FLU"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", - "HPV", - [], + ["RSV", "COVID", "FLU"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "HPV", + [], ), # Scenario 5: Consumer has no target mappings; requesting ALL should return empty ( - ["RSV", "COVID", "FLU"], - "consumer-id-mapped-to-rsv-and-covid", - "consumer-id-with-no-mapping", - "ALL", - [], + ["RSV", "COVID", "FLU"], + "consumer-id-mapped-to-rsv-and-covid", + "consumer-id-with-no-mapping", + "ALL", + [], ), # Scenario 6: Consumer has no target mappings; requesting specific target should return empty ( - ["RSV", "COVID", "FLU"], - "consumer-id-mapped-to-rsv-and-covid", - "consumer-id-with-no-mapping", - "RSV", - [], + ["RSV", "COVID", "FLU"], + "consumer-id-mapped-to-rsv-and-covid", + "consumer-id-with-no-mapping", + "RSV", + [], ), # Scenario 7: Consumer is mapped to targets (RSV/COVID), but those campaigns aren't active/present ( - ["MMR"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", - "ALL", - [], + ["MMR"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "ALL", + [], ), # Scenario 8: Request for specific mapped target (RSV), but those campaigns aren't active/present ( - ["MMR"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", - "RSV", - [], + ["MMR"], + "consumer_mapping_for_rsv_and_covid", + "consumer-id-mapped-to-rsv-and-covid", + "RSV", + [], ), ], - indirect=["campaign_configs", "consumer_mapping_for_rsv_and_covid"] + indirect=["campaign_configs", "consumer_mapping_for_rsv_and_covid"], ) - def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( + def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # noqa: PLR0913 self, client: FlaskClient, persisted_person: NHSNumber, @@ -911,7 +917,9 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id} # When - response = client.get(f"/patient-check/{persisted_person}?includeActions=Y&conditions={requested_conditions}", headers=headers) + response = client.get( + f"/patient-check/{persisted_person}?includeActions=Y&conditions={requested_conditions}", headers=headers + ) assert_that( response, @@ -922,10 +930,8 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( has_entry( "processedSuggestions", # This ensures ONLY these items exist, no extras like FLU - contains_inanyorder( - *[has_entry("condition", i) for i in expected_targets] - ) + contains_inanyorder(*[has_entry("condition", i) for i in expected_targets]), ) ) - ) + ), ) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 1fcfc152d..26363af46 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -424,6 +424,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, + secretsmanager_client: BaseClient, # noqa: ARG001 ): invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person_all_cohorts}" response = httpx.get( diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index ab5b85df3..3d3b787cd 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -66,32 +66,33 @@ def test_eligibility_service_for_nonexistent_nhs_number(): def test_get_eligibility_status_filters_permitted_campaigns(service, mock_repos): - """Tests that ONLY permitted campaigns reach the calculator factory.""" - # Given - nhs_number = NHSNumber("1234567890") - person_data = {"age": 65, "vulnerable": True} - mock_repos["person"].get_eligibility_data.return_value = person_data - - # Available campaigns in system - camp_a = MagicMock(spec=CampaignConfig, id=CampaignID("CAMP_A")) - camp_b = MagicMock(spec=CampaignConfig, id=CampaignID("CAMP_B")) - mock_repos["campaign"].get_campaign_configs.return_value = [camp_a, camp_b] - - # Consumer is only permitted to see CAMP_B - mock_repos["consumer"].get_permitted_campaign_ids.return_value = [CampaignID("CAMP_B")] - - # Mock calculator behavior - mock_calc = MagicMock() - mock_repos["factory"].get.return_value = mock_calc - mock_calc.get_eligibility_status.return_value = "eligible_result" - - # When - result = service.get_eligibility_status(nhs_number, "Y", ["FLU"], "G1", "consumer_xyz") - - # Then - # Verify the factory was called ONLY with camp_b - mock_repos["factory"].get.assert_called_once_with(person_data, [camp_b]) - assert result == "eligible_result" + """Tests that ONLY permitted campaigns reach the calculator factory.""" + # Given + nhs_number = NHSNumber("1234567890") + person_data = {"age": 65, "vulnerable": True} + mock_repos["person"].get_eligibility_data.return_value = person_data + + # Available campaigns in system + camp_a = MagicMock(spec=CampaignConfig, id=CampaignID("CAMP_A")) + camp_b = MagicMock(spec=CampaignConfig, id=CampaignID("CAMP_B")) + mock_repos["campaign"].get_campaign_configs.return_value = [camp_a, camp_b] + + # Consumer is only permitted to see CAMP_B + mock_repos["consumer"].get_permitted_campaign_ids.return_value = [CampaignID("CAMP_B")] + + # Mock calculator behavior + mock_calc = MagicMock() + mock_repos["factory"].get.return_value = mock_calc + mock_calc.get_eligibility_status.return_value = "eligible_result" + + # When + result = service.get_eligibility_status(nhs_number, "Y", ["FLU"], "G1", "consumer_xyz") + + # Then + # Verify the factory was called ONLY with camp_b + mock_repos["factory"].get.assert_called_once_with(person_data, [camp_b]) + assert result == "eligible_result" + def test_raises_unknown_person_error_on_repo_not_found(service, mock_repos): """Tests that NotFoundError from repo is translated to UnknownPersonError.""" diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 1a022e42f..210b72e26 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -606,7 +606,7 @@ def test_consumer_id_is_passed_to_service(app: Flask, client: FlaskClient): # Then # Verify the 5th positional argument or the keyword argument 'consumer_id' mock_service.get_eligibility_status.assert_called_once() - args, kwargs = mock_service.get_eligibility_status.call_args + args, _kwargs = mock_service.get_eligibility_status.call_args # Check that 'specific_consumer_123' was the consumer_id passed assert args[4] == "specific_consumer_123" From 92dd24e0b5419cfa39df6f96bd69135637495f0f Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:57:00 +0000 Subject: [PATCH 29/58] removed unused code --- .../common/api_error_response.py | 16 ---------------- .../common/request_validator.py | 1 - .../views/eligibility.py | 8 -------- 3 files changed, 25 deletions(-) diff --git a/src/eligibility_signposting_api/common/api_error_response.py b/src/eligibility_signposting_api/common/api_error_response.py index 47308e085..40c1ddcdd 100644 --- a/src/eligibility_signposting_api/common/api_error_response.py +++ b/src/eligibility_signposting_api/common/api_error_response.py @@ -135,19 +135,3 @@ def log_and_generate_response( fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, fhir_display_message="Access has been denied to process this request.", ) - -CONSUMER_ID_NOT_PROVIDED_ERROR = APIErrorResponse( - status_code=HTTPStatus.FORBIDDEN, - fhir_issue_code=FHIRIssueCode.FORBIDDEN, - fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, - fhir_display_message="Access has been denied to process this request.", -) - -CONSUMER_HAS_NO_CAMPAIGN_MAPPING = APIErrorResponse( - status_code=HTTPStatus.FORBIDDEN, - fhir_issue_code=FHIRIssueCode.FORBIDDEN, - fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, - fhir_display_message="Access has been denied to process this request.", -) diff --git a/src/eligibility_signposting_api/common/request_validator.py b/src/eligibility_signposting_api/common/request_validator.py index 40416e5ca..76f4eefbb 100644 --- a/src/eligibility_signposting_api/common/request_validator.py +++ b/src/eligibility_signposting_api/common/request_validator.py @@ -7,7 +7,6 @@ from flask.typing import ResponseReturnValue from eligibility_signposting_api.common.api_error_response import ( - CONSUMER_ID_NOT_PROVIDED_ERROR, INVALID_CATEGORY_ERROR, INVALID_CONDITION_FORMAT_ERROR, INVALID_INCLUDE_ACTIONS_ERROR, diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 0505bc28a..b935678f6 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -12,7 +12,6 @@ from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.audit.audit_service import AuditService from eligibility_signposting_api.common.api_error_response import ( - CONSUMER_HAS_NO_CAMPAIGN_MAPPING, NHS_NUMBER_NOT_FOUND_ERROR, ) from eligibility_signposting_api.common.request_validator import validate_request_params @@ -117,13 +116,6 @@ def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue: ) -def handle_no_permitted_campaigns_for_the_consumer_error(consumer_id: ConsumerId) -> ResponseReturnValue: - diagnostics = f"Consumer ID '{consumer_id}' was not recognised by the Eligibility Signposting API" - return CONSUMER_HAS_NO_CAMPAIGN_MAPPING.log_and_generate_response( - log_message=diagnostics, diagnostics=diagnostics, location_param="id" - ) - - def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility_response.EligibilityResponse: """Return an object representing the API response we are going to send, given an evaluation of the person's eligibility.""" From f25e29e8208862ce7c10329e0166f56a5c38f8a0 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:08:30 +0000 Subject: [PATCH 30/58] added CONSUMER_ID_NOT_PROVIDED_ERROR back --- .../common/api_error_response.py | 8 ++++++++ .../common/request_validator.py | 1 + 2 files changed, 9 insertions(+) diff --git a/src/eligibility_signposting_api/common/api_error_response.py b/src/eligibility_signposting_api/common/api_error_response.py index 40c1ddcdd..cb1006584 100644 --- a/src/eligibility_signposting_api/common/api_error_response.py +++ b/src/eligibility_signposting_api/common/api_error_response.py @@ -135,3 +135,11 @@ def log_and_generate_response( fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, fhir_display_message="Access has been denied to process this request.", ) + +CONSUMER_ID_NOT_PROVIDED_ERROR = APIErrorResponse( + status_code=HTTPStatus.FORBIDDEN, + fhir_issue_code=FHIRIssueCode.FORBIDDEN, + fhir_issue_severity=FHIRIssueSeverity.ERROR, + fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, + fhir_display_message="Access has been denied to process this request.", +) diff --git a/src/eligibility_signposting_api/common/request_validator.py b/src/eligibility_signposting_api/common/request_validator.py index 76f4eefbb..40416e5ca 100644 --- a/src/eligibility_signposting_api/common/request_validator.py +++ b/src/eligibility_signposting_api/common/request_validator.py @@ -7,6 +7,7 @@ from flask.typing import ResponseReturnValue from eligibility_signposting_api.common.api_error_response import ( + CONSUMER_ID_NOT_PROVIDED_ERROR, INVALID_CATEGORY_ERROR, INVALID_CONDITION_FORMAT_ERROR, INVALID_INCLUDE_ACTIONS_ERROR, From 80704feea8f4ce9fc1240d05f752211580f5ab6a Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:35:30 +0000 Subject: [PATCH 31/58] more test cases --- tests/integration/conftest.py | 6 +- .../in_process/test_eligibility_endpoint.py | 62 ++++++++++++------- 2 files changed, 42 insertions(+), 26 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 025a2a031..d12eb89fa 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1174,10 +1174,10 @@ def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) @pytest.fixture(scope="class") -def consumer_mapping_for_rsv_and_covid( - s3_client: BaseClient, consumer_mapping_bucket: BucketName +def consumer_mappings( + request, s3_client: BaseClient, consumer_mapping_bucket: BucketName ) -> Generator[ConsumerMapping]: - consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping = ConsumerMapping.model_validate(getattr(request, "param", {})) consumer_mapping.root[ConsumerId("consumer-id-mapped-to-rsv-and-covid")] = [ CampaignID("RSV_campaign_id"), CampaignID("COVID_campaign_id"), diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index c40b2bbfc..3208713ae 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -829,7 +829,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( @pytest.mark.parametrize( ( "campaign_configs", - "consumer_mapping_for_rsv_and_covid", + "consumer_mappings", "consumer_id", "requested_conditions", "expected_targets", @@ -838,69 +838,85 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( # Scenario 1: Intersection of mapped targets, requested targets, and active campaigns (Success) ( ["RSV", "COVID", "FLU"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", "ALL", ["RSV", "COVID"], ), # Scenario 2: Explicit request for a single mapped target with an active campaign ( ["RSV", "COVID", "FLU"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", "RSV", ["RSV"], ), # Scenario 3: Request for an active campaign (FLU) that the consumer is NOT mapped to ( ["RSV", "COVID", "FLU"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", "FLU", [], ), # Scenario 4: Request for a target that neither exists in system nor is mapped to consumer ( ["RSV", "COVID", "FLU"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", "HPV", [], ), - # Scenario 5: Consumer has no target mappings; requesting ALL should return empty + # Scenario 5: No mappings at all; requesting ALL should return empty ( ["RSV", "COVID", "FLU"], - "consumer-id-mapped-to-rsv-and-covid", - "consumer-id-with-no-mapping", + {}, + "consumer-id", "ALL", [], ), - # Scenario 6: Consumer has no target mappings; requesting specific target should return empty + # Scenario 6: No mappings at all; requesting RSV should return empty ( ["RSV", "COVID", "FLU"], - "consumer-id-mapped-to-rsv-and-covid", - "consumer-id-with-no-mapping", + {}, + "consumer-id", "RSV", [], ), - # Scenario 7: Consumer is mapped to targets (RSV/COVID), but those campaigns aren't active/present + # Scenario 7: Consumer has no target mappings; requesting ALL should return empty + ( + ["RSV", "COVID", "FLU"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "another-consumer-id", + "ALL", + [], + ), + # Scenario 8: Consumer has no target mappings; requesting specific target should return empty + ( + ["RSV", "COVID", "FLU"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "another-consumer-id", + "RSV", + [], + ), + # Scenario 9: Consumer is mapped to targets (RSV/COVID), but those campaigns aren't active/present ( ["MMR"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", "ALL", [], ), - # Scenario 8: Request for specific mapped target (RSV), but those campaigns aren't active/present + # Scenario 10: Request for specific mapped target (RSV), but those campaigns aren't active/present ( ["MMR"], - "consumer_mapping_for_rsv_and_covid", - "consumer-id-mapped-to-rsv-and-covid", + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", "RSV", [], ), ], - indirect=["campaign_configs", "consumer_mapping_for_rsv_and_covid"], + indirect=["campaign_configs", "consumer_mappings"], ) def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # noqa: PLR0913 self, @@ -908,7 +924,7 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no persisted_person: NHSNumber, secretsmanager_client: BaseClient, # noqa: ARG002 campaign_configs: CampaignConfig, # noqa: ARG002 - consumer_mapping_for_rsv_and_covid: ConsumerMapping, # noqa: ARG002 + consumer_mappings: ConsumerMapping, # noqa: ARG002 consumer_id: str, requested_conditions: str, expected_targets: list[str], From 54078208063aea142c3a44b184988143778c42fe Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:46:01 +0000 Subject: [PATCH 32/58] hardcodes values are converted to fixtures --- tests/integration/conftest.py | 78 ++++++++++++++-- .../in_process/test_eligibility_endpoint.py | 91 +++++++++++++------ 2 files changed, 133 insertions(+), 36 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d12eb89fa..67b514ac4 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1158,9 +1158,14 @@ def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) - @pytest.fixture(scope="class") -def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) -> Generator[ConsumerMapping]: +def consumer_id() -> ConsumerId: + return ConsumerId("23-mic7heal-jor6don") + + +@pytest.fixture(scope="class") +def consumer_mapping_with_rsv(s3_client: BaseClient, consumer_mapping_bucket: BucketName, rsv_campaign_config:CampaignConfig, consumer_id:ConsumerId) -> Generator[ConsumerMapping]: consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [CampaignID("42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy")] + consumer_mapping.root[ConsumerId(consumer_id)] = [rsv_campaign_config.id] consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) s3_client.put_object( @@ -1172,17 +1177,76 @@ def consumer_mapping(s3_client: BaseClient, consumer_mapping_bucket: BucketName) yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") +@pytest.fixture +def consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text( + s3_client, consumer_mapping_bucket, campaign_config_with_missing_descriptions_missing_rule_text, consumer_id +): + mapping = ConsumerMapping.model_validate({}) + mapping.root[consumer_id] = [campaign_config_with_missing_descriptions_missing_rule_text.id] + + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(mapping.model_dump(by_alias=True)), + ContentType="application/json", + ) + yield mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + +@pytest.fixture +def consumer_mapping_with_campaign_config_with_rules_having_rule_code( + s3_client, consumer_mapping_bucket, campaign_config_with_rules_having_rule_code, consumer_id +): + mapping = ConsumerMapping.model_validate({}) + mapping.root[consumer_id] = [campaign_config_with_rules_having_rule_code.id] + + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(mapping.model_dump(by_alias=True)), + ContentType="application/json", + ) + yield mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + +@pytest.fixture +def consumer_mapping_with_campaign_config_with_rules_having_rule_mapper( + s3_client, consumer_mapping_bucket, campaign_config_with_rules_having_rule_mapper, consumer_id +): + mapping = ConsumerMapping.model_validate({}) + mapping.root[consumer_id] = [campaign_config_with_rules_having_rule_mapper.id] + + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(mapping.model_dump(by_alias=True)), + ContentType="application/json", + ) + yield mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + + +@pytest.fixture +def consumer_mapping_with_only_virtual_cohort( + s3_client, consumer_mapping_bucket, campaign_config_with_virtual_cohort, consumer_id +): + mapping = ConsumerMapping.model_validate({}) + mapping.root[consumer_id] = [campaign_config_with_virtual_cohort.id] + + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(mapping.model_dump(by_alias=True)), + ContentType="application/json", + ) + yield mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") @pytest.fixture(scope="class") def consumer_mappings( request, s3_client: BaseClient, consumer_mapping_bucket: BucketName ) -> Generator[ConsumerMapping]: consumer_mapping = ConsumerMapping.model_validate(getattr(request, "param", {})) - consumer_mapping.root[ConsumerId("consumer-id-mapped-to-rsv-and-covid")] = [ - CampaignID("RSV_campaign_id"), - CampaignID("COVID_campaign_id"), - ] - consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) s3_client.put_object( Bucket=consumer_mapping_bucket, diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 3208713ae..6b42f510a 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -17,7 +17,7 @@ from eligibility_signposting_api.config.constants import CONSUMER_ID from eligibility_signposting_api.model.campaign_config import CampaignConfig -from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping +from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping, ConsumerId from eligibility_signposting_api.model.eligibility_status import ( NHSNumber, ) @@ -29,11 +29,12 @@ def test_nhs_number_given( client: FlaskClient, persisted_person: NHSNumber, rsv_campaign_config: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_id: ConsumerId, + consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person}", headers=headers) @@ -85,10 +86,12 @@ def test_not_base_eligible( client: FlaskClient, persisted_person_no_cohorts: NHSNumber, rsv_campaign_config: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_id: ConsumerId, + consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person_no_cohorts}?includeActions=Y", headers=headers) @@ -130,10 +133,12 @@ def test_not_eligible_by_rule( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, rsv_campaign_config: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_id: ConsumerId, + consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -175,10 +180,12 @@ def test_not_actionable_and_check_response_when_no_rule_code_given( client: FlaskClient, persisted_person: NHSNumber, rsv_campaign_config: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_id: ConsumerId, + consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -226,9 +233,11 @@ def test_actionable( client: FlaskClient, persisted_77yo_person: NHSNumber, rsv_campaign_config: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_id: ConsumerId, + consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): - headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -278,10 +287,12 @@ def test_actionable_with_and_rule( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_and_rule: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_id: ConsumerId, + consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -333,10 +344,12 @@ def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 + consumer_id: ConsumerId, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -378,10 +391,12 @@ def test_not_actionable_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 + consumer_id: ConsumerId, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -429,10 +444,12 @@ def test_actionable_when_only_virtual_cohort_is_present( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 + consumer_id: ConsumerId, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -484,10 +501,12 @@ def test_not_base_eligible( client: FlaskClient, persisted_person_no_cohorts: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 + consumer_id: ConsumerId, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person_no_cohorts), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person_no_cohorts}?includeActions=Y", headers=headers) @@ -523,10 +542,13 @@ def test_not_eligible_by_rule( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, + # noqa: ARG002 + consumer_id: ConsumerId, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person_pc_sw19), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person_pc_sw19}?includeActions=Y", headers=headers) @@ -562,10 +584,13 @@ def test_not_actionable( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, + # noqa: ARG002 + consumer_id: ConsumerId, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -607,7 +632,9 @@ def test_actionable( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping,# noqa: ARG002 + consumer_id: ConsumerId, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} @@ -654,7 +681,9 @@ def test_actionable_no_actions( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping,# noqa: ARG002 + consumer_id: ConsumerId, # noqa: ARG002 + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} @@ -729,7 +758,9 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_absent_but_rule_c client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_rules_having_rule_code: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_mapping_with_campaign_config_with_rules_having_rule_code: ConsumerMapping, + consumer_id: ConsumerId, + secretsmanager_client: BaseClient, ): # Given headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} @@ -780,7 +811,9 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_rules_having_rule_mapper: CampaignConfig, # noqa: ARG002 - consumer_mapping: ConsumerMapping, # noqa: ARG002 + consumer_mapping_with_campaign_config_with_rules_having_rule_mapper: ConsumerMapping, + consumer_id: ConsumerId, + secretsmanager_client: BaseClient, ): # Given headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} From ca0aef840bab7b3bfe3ea9396c99b9ec9cbaa441 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:08:44 +0000 Subject: [PATCH 33/58] fixtures --- tests/integration/conftest.py | 75 ++++++++++++++++++- .../in_process/test_eligibility_endpoint.py | 20 +++-- .../lambda/test_app_running_as_lambda.py | 39 ++++++---- 3 files changed, 106 insertions(+), 28 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 67b514ac4..fa54c11a2 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -991,7 +991,7 @@ def campaign_config_with_invalid_tokens(s3_client: BaseClient, rules_bucket: Buc s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") -@pytest.fixture +@pytest.fixture(scope="class") def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: """Create and upload multiple campaign configs to S3, then clean up after tests.""" campaigns, campaign_data_keys = [], [] @@ -1163,7 +1163,54 @@ def consumer_id() -> ConsumerId: @pytest.fixture(scope="class") -def consumer_mapping_with_rsv(s3_client: BaseClient, consumer_mapping_bucket: BucketName, rsv_campaign_config:CampaignConfig, consumer_id:ConsumerId) -> Generator[ConsumerMapping]: +def consumer_mapping_with_campaign_config_with_invalid_tokens( + s3_client: BaseClient, + consumer_mapping_bucket: BucketName, + campaign_config_with_invalid_tokens: CampaignConfig, + consumer_id: ConsumerId, +) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId(consumer_id)] = [campaign_config_with_invalid_tokens.id] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + + +@pytest.fixture(scope="class") +def consumer_mapping_with_campaign_config_with_tokens( + s3_client: BaseClient, + consumer_mapping_bucket: BucketName, + campaign_config_with_tokens: CampaignConfig, + consumer_id: ConsumerId, +) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId(consumer_id)] = [campaign_config_with_tokens.id] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + + +@pytest.fixture(scope="class") +def consumer_mapping_with_rsv( + s3_client: BaseClient, + consumer_mapping_bucket: BucketName, + rsv_campaign_config: CampaignConfig, + consumer_id: ConsumerId, +) -> Generator[ConsumerMapping]: consumer_mapping = ConsumerMapping.model_validate({}) consumer_mapping.root[ConsumerId(consumer_id)] = [rsv_campaign_config.id] @@ -1177,6 +1224,7 @@ def consumer_mapping_with_rsv(s3_client: BaseClient, consumer_mapping_bucket: Bu yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + @pytest.fixture def consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text( s3_client, consumer_mapping_bucket, campaign_config_with_missing_descriptions_missing_rule_text, consumer_id @@ -1193,6 +1241,7 @@ def consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule yield mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + @pytest.fixture def consumer_mapping_with_campaign_config_with_rules_having_rule_code( s3_client, consumer_mapping_bucket, campaign_config_with_rules_having_rule_code, consumer_id @@ -1209,6 +1258,7 @@ def consumer_mapping_with_campaign_config_with_rules_having_rule_code( yield mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + @pytest.fixture def consumer_mapping_with_campaign_config_with_rules_having_rule_mapper( s3_client, consumer_mapping_bucket, campaign_config_with_rules_having_rule_mapper, consumer_id @@ -1242,6 +1292,27 @@ def consumer_mapping_with_only_virtual_cohort( yield mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + +@pytest.fixture(scope="class") +def consumer_mapping_with_multiple_campaign_configs( + multiple_campaign_configs: list[CampaignConfig], + consumer_id: ConsumerId, + s3_client: BaseClient, + consumer_mapping_bucket: BucketName, +) -> Generator[ConsumerMapping]: + mapping = ConsumerMapping.model_validate({}) + mapping.root[consumer_id] = [cc.id for cc in multiple_campaign_configs] + + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(mapping.model_dump(by_alias=True)), + ContentType="application/json", + ) + yield mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + + @pytest.fixture(scope="class") def consumer_mappings( request, s3_client: BaseClient, consumer_mapping_bucket: BucketName diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 6b42f510a..3c70be74e 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -17,7 +17,7 @@ from eligibility_signposting_api.config.constants import CONSUMER_ID from eligibility_signposting_api.model.campaign_config import CampaignConfig -from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping, ConsumerId +from eligibility_signposting_api.model.consumer_mapping import ConsumerId, ConsumerMapping from eligibility_signposting_api.model.eligibility_status import ( NHSNumber, ) @@ -345,7 +345,7 @@ def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( persisted_person_pc_sw19: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 - consumer_id: ConsumerId, # noqa: ARG002 + consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -392,7 +392,7 @@ def test_not_actionable_when_only_virtual_cohort_is_present( persisted_person: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 - consumer_id: ConsumerId, # noqa: ARG002 + consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -445,7 +445,7 @@ def test_actionable_when_only_virtual_cohort_is_present( persisted_77yo_person: NHSNumber, campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 - consumer_id: ConsumerId, # noqa: ARG002 + consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -502,7 +502,7 @@ def test_not_base_eligible( persisted_person_no_cohorts: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 - consumer_id: ConsumerId, # noqa: ARG002 + consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -543,8 +543,7 @@ def test_not_eligible_by_rule( persisted_person_pc_sw19: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, - # noqa: ARG002 - consumer_id: ConsumerId, # noqa: ARG002 + consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -585,8 +584,7 @@ def test_not_actionable( persisted_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, - # noqa: ARG002 - consumer_id: ConsumerId, # noqa: ARG002 + consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -632,7 +630,7 @@ def test_actionable( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 - consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping,# noqa: ARG002 + consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -681,7 +679,7 @@ def test_actionable_no_actions( client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 - consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping,# noqa: ARG002 + consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 26363af46..73891c94f 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -25,7 +25,7 @@ from eligibility_signposting_api.config.constants import CONSUMER_ID from eligibility_signposting_api.model.campaign_config import CampaignConfig -from eligibility_signposting_api.model.consumer_mapping import ConsumerMapping +from eligibility_signposting_api.model.consumer_mapping import ConsumerId, ConsumerMapping from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos.campaign_repo import BucketName @@ -37,7 +37,8 @@ def test_install_and_call_lambda_flask( flask_function: str, persisted_person: NHSNumber, rsv_campaign_config: CampaignConfig, # noqa: ARG001 - consumer_mapping: ConsumerMapping, # noqa: ARG001 + consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 + consumer_id: ConsumerId, ): """Given lambda installed into localstack, run it via boto3 lambda client""" # Given @@ -52,7 +53,7 @@ def test_install_and_call_lambda_flask( "accept": "application/json", "content-type": "application/json", "nhs-login-nhs-number": str(persisted_person), - CONSUMER_ID: "23-mic7heal-jor6don", + CONSUMER_ID: consumer_id, }, "pathParameters": {"id": str(persisted_person)}, "requestContext": { @@ -90,7 +91,8 @@ def test_install_and_call_lambda_flask( def test_install_and_call_flask_lambda_over_http( persisted_person: NHSNumber, rsv_campaign_config: CampaignConfig, # noqa: ARG001 - consumer_mapping: ConsumerMapping, # noqa: ARG001 + consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 + consumer_id: ConsumerId, api_gateway_endpoint: URL, ): """Given api-gateway and lambda installed into localstack, run it via http""" @@ -99,7 +101,7 @@ def test_install_and_call_flask_lambda_over_http( invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"}, + headers={"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id}, timeout=10, ) @@ -114,7 +116,8 @@ def test_install_and_call_flask_lambda_with_unknown_nhs_number( # noqa: PLR0913 flask_function: str, persisted_person: NHSNumber, rsv_campaign_config: CampaignConfig, # noqa: ARG001 - consumer_mapping: ConsumerMapping, # noqa: ARG001 + consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 + consumer_id: ConsumerId, logs_client: BaseClient, api_gateway_endpoint: URL, ): @@ -188,7 +191,8 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, rsv_campaign_config: CampaignConfig, - consumer_mapping: ConsumerMapping, # noqa: ARG001 + consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 + consumer_id: ConsumerId, s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -379,7 +383,8 @@ def test_validation_of_query_params_when_all_are_valid( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, rsv_campaign_config: CampaignConfig, # noqa:ARG001 - consumer_mapping: ConsumerMapping, # noqa: ARG001 + consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 + consumer_id: ConsumerId, api_gateway_endpoint: URL, ): # Given @@ -420,7 +425,8 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, multiple_campaign_configs: list[CampaignConfig], - consumer_mapping: ConsumerMapping, # noqa: ARG001 + consumer_mapping_with_multiple_campaign_configs: ConsumerMapping, # noqa: ARG001 + consumer_id: ConsumerId, s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -431,7 +437,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person_all_cohorts), - CONSUMER_ID: "23-mic7heal-jor6don", + CONSUMER_ID: consumer_id, "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -567,6 +573,7 @@ def test_no_active_iteration_returns_empty_processed_suggestions( persisted_person_all_cohorts: NHSNumber, inactive_iteration_config: list[CampaignConfig], # noqa:ARG001 consumer_mapping_with_various_targets: ConsumerMapping, # noqa:ARG001 + consumer_id: ConsumerId, api_gateway_endpoint: URL, ): invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person_all_cohorts}" @@ -574,7 +581,7 @@ def test_no_active_iteration_returns_empty_processed_suggestions( invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person_all_cohorts), - CONSUMER_ID: "23-mic7heal-jor6don", + CONSUMER_ID: consumer_id, "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -604,7 +611,8 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, campaign_config_with_tokens: CampaignConfig, # noqa:ARG001 - consumer_mapping: ConsumerMapping, # noqa:ARG001 + consumer_mapping_with_campaign_config_with_tokens: ConsumerMapping, # noqa: ARG001 + consumer_id: ConsumerId, s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -614,7 +622,7 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 invoke_url = f"{api_gateway_endpoint}/patient-check/{person_with_all_data}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(person_with_all_data), CONSUMER_ID: "23-mic7heal-jor6don"}, + headers={"nhs-login-nhs-number": str(person_with_all_data), CONSUMER_ID: consumer_id}, timeout=10, ) @@ -655,7 +663,8 @@ def test_incorrect_token_causes_internal_server_error( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, campaign_config_with_invalid_tokens: CampaignConfig, # noqa:ARG001 - consumer_mapping: ConsumerMapping, # noqa: ARG001 + consumer_mapping_with_campaign_config_with_invalid_tokens: ConsumerMapping, # noqa: ARG001 + consumer_id: ConsumerId, s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, @@ -665,7 +674,7 @@ def test_incorrect_token_causes_internal_server_error( # noqa: PLR0913 invoke_url = f"{api_gateway_endpoint}/patient-check/{person_with_all_data}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(person_with_all_data), CONSUMER_ID: "23-mic7heal-jor6don"}, + headers={"nhs-login-nhs-number": str(person_with_all_data), CONSUMER_ID: consumer_id}, timeout=10, ) From 3f305021bc1da865256e62b0876bab56111c5e81 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:48:43 +0000 Subject: [PATCH 34/58] fixtures --- .../in_process/test_eligibility_endpoint.py | 56 +++++++++---------- .../lambda/test_app_running_as_lambda.py | 16 +----- 2 files changed, 31 insertions(+), 41 deletions(-) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 3c70be74e..dc99953c3 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -24,7 +24,7 @@ class TestBaseLine: - def test_nhs_number_given( + def test_nhs_number_given( # noqa: PLR0913 self, client: FlaskClient, persisted_person: NHSNumber, @@ -81,7 +81,7 @@ def test_no_nhs_number_given_but_header_given( class TestStandardResponse: - def test_not_base_eligible( + def test_not_base_eligible( # noqa: PLR0913 self, client: FlaskClient, persisted_person_no_cohorts: NHSNumber, @@ -128,7 +128,7 @@ def test_not_base_eligible( ), ) - def test_not_eligible_by_rule( + def test_not_eligible_by_rule( # noqa: PLR0913 self, client: FlaskClient, persisted_person_pc_sw19: NHSNumber, @@ -175,7 +175,7 @@ def test_not_eligible_by_rule( ), ) - def test_not_actionable_and_check_response_when_no_rule_code_given( + def test_not_actionable_and_check_response_when_no_rule_code_given( # noqa: PLR0913 self, client: FlaskClient, persisted_person: NHSNumber, @@ -228,7 +228,7 @@ def test_not_actionable_and_check_response_when_no_rule_code_given( ), ) - def test_actionable( + def test_actionable( # noqa: PLR0913 self, client: FlaskClient, persisted_77yo_person: NHSNumber, @@ -282,7 +282,7 @@ def test_actionable( ), ) - def test_actionable_with_and_rule( + def test_actionable_with_and_rule( # noqa: PLR0913 self, client: FlaskClient, persisted_person: NHSNumber, @@ -339,7 +339,7 @@ def test_actionable_with_and_rule( class TestVirtualCohortResponse: - def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( + def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( # noqa: PLR0913 self, client: FlaskClient, persisted_person_pc_sw19: NHSNumber, @@ -386,7 +386,7 @@ def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( ), ) - def test_not_actionable_when_only_virtual_cohort_is_present( + def test_not_actionable_when_only_virtual_cohort_is_present( # noqa: PLR0913 self, client: FlaskClient, persisted_person: NHSNumber, @@ -439,7 +439,7 @@ def test_not_actionable_when_only_virtual_cohort_is_present( ), ) - def test_actionable_when_only_virtual_cohort_is_present( + def test_actionable_when_only_virtual_cohort_is_present( # noqa: PLR0913 self, client: FlaskClient, persisted_77yo_person: NHSNumber, @@ -496,7 +496,7 @@ def test_actionable_when_only_virtual_cohort_is_present( class TestResponseOnMissingAttributes: - def test_not_base_eligible( + def test_not_base_eligible( # noqa: PLR0913 self, client: FlaskClient, persisted_person_no_cohorts: NHSNumber, @@ -537,12 +537,12 @@ def test_not_base_eligible( ), ) - def test_not_eligible_by_rule( + def test_not_eligible_by_rule( # noqa: PLR0913 self, client: FlaskClient, persisted_person_pc_sw19: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 - consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, + consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -578,12 +578,12 @@ def test_not_eligible_by_rule( ), ) - def test_not_actionable( + def test_not_actionable( # noqa: PLR0913 self, client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 - consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, + consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -625,17 +625,17 @@ def test_not_actionable( ), ) - def test_actionable( + def test_actionable( # noqa: PLR0913 self, client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 - consumer_id: ConsumerId, # noqa: ARG002 + consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=Y", headers=headers) @@ -674,17 +674,17 @@ def test_actionable( ), ) - def test_actionable_no_actions( + def test_actionable_no_actions( # noqa: PLR0913 self, client: FlaskClient, persisted_77yo_person: NHSNumber, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 - consumer_id: ConsumerId, # noqa: ARG002 + consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_77yo_person}?includeActions=N", headers=headers) @@ -751,17 +751,17 @@ def test_status_endpoint(self, client: FlaskClient): class TestEligibilityResponseWithVariousInputs: - def test_not_actionable_and_check_response_when_rule_mapper_is_absent_but_rule_code_given( + def test_not_actionable_and_check_response_when_rule_mapper_is_absent_but_rule_code_given( # noqa: PLR0913 self, client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_rules_having_rule_code: CampaignConfig, # noqa: ARG002 - consumer_mapping_with_campaign_config_with_rules_having_rule_code: ConsumerMapping, + consumer_mapping_with_campaign_config_with_rules_having_rule_code: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, - secretsmanager_client: BaseClient, + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) @@ -804,17 +804,17 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_absent_but_rule_c ), ) - def test_not_actionable_and_check_response_when_rule_mapper_is_given( + def test_not_actionable_and_check_response_when_rule_mapper_is_given( # noqa: PLR0913 self, client: FlaskClient, persisted_person: NHSNumber, campaign_config_with_rules_having_rule_mapper: CampaignConfig, # noqa: ARG002 - consumer_mapping_with_campaign_config_with_rules_having_rule_mapper: ConsumerMapping, + consumer_mapping_with_campaign_config_with_rules_having_rule_mapper: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, - secretsmanager_client: BaseClient, + secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given - headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: "23-mic7heal-jor6don"} + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id} # When response = client.get(f"/patient-check/{persisted_person}?includeActions=Y", headers=headers) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 73891c94f..1b882ec93 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -36,7 +36,6 @@ def test_install_and_call_lambda_flask( lambda_client: BaseClient, flask_function: str, persisted_person: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa: ARG001 consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, ): @@ -90,7 +89,6 @@ def test_install_and_call_lambda_flask( def test_install_and_call_flask_lambda_over_http( persisted_person: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa: ARG001 consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, api_gateway_endpoint: URL, @@ -115,7 +113,6 @@ def test_install_and_call_flask_lambda_over_http( def test_install_and_call_flask_lambda_with_unknown_nhs_number( # noqa: PLR0913 flask_function: str, persisted_person: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa: ARG001 consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, logs_client: BaseClient, @@ -129,7 +126,7 @@ def test_install_and_call_flask_lambda_with_unknown_nhs_number( # noqa: PLR0913 invoke_url = f"{api_gateway_endpoint}/patient-check/{nhs_number}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": str(nhs_number), CONSUMER_ID: "23-mic7heal-jor6don"}, + headers={"nhs-login-nhs-number": str(nhs_number), CONSUMER_ID: consumer_id}, timeout=10, ) @@ -206,7 +203,7 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i invoke_url, headers={ "nhs-login-nhs-number": str(persisted_person), - CONSUMER_ID: "23-mic7heal-jor6don", + CONSUMER_ID: consumer_id, "x_request_id": "x_request_id", "x_correlation_id": "x_correlation_id", "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", @@ -289,7 +286,6 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_results_in_error_response( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa:ARG001 api_gateway_endpoint: URL, ): # Given @@ -336,7 +332,6 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu def test_given_nhs_number_not_present_in_headers_results_in_error_response( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa:ARG001 api_gateway_endpoint: URL, ): # Given @@ -382,7 +377,6 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response( def test_validation_of_query_params_when_all_are_valid( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa:ARG001 consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, api_gateway_endpoint: URL, @@ -392,7 +386,7 @@ def test_validation_of_query_params_when_all_are_valid( invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": persisted_person, CONSUMER_ID: "23-mic7heal-jor6don"}, + headers={"nhs-login-nhs-number": persisted_person, CONSUMER_ID: consumer_id}, params={"category": "VACCINATIONS", "conditions": "COVID19", "includeActions": "N"}, timeout=10, ) @@ -404,7 +398,6 @@ def test_validation_of_query_params_when_all_are_valid( def test_validation_of_query_params_when_invalid_conditions_is_specified( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa:ARG001 api_gateway_endpoint: URL, ): # Given @@ -571,7 +564,6 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # def test_no_active_iteration_returns_empty_processed_suggestions( lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, - inactive_iteration_config: list[CampaignConfig], # noqa:ARG001 consumer_mapping_with_various_targets: ConsumerMapping, # noqa:ARG001 consumer_id: ConsumerId, api_gateway_endpoint: URL, @@ -610,7 +602,6 @@ def test_no_active_iteration_returns_empty_processed_suggestions( def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, - campaign_config_with_tokens: CampaignConfig, # noqa:ARG001 consumer_mapping_with_campaign_config_with_tokens: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, s3_client: BaseClient, @@ -662,7 +653,6 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 def test_incorrect_token_causes_internal_server_error( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, - campaign_config_with_invalid_tokens: CampaignConfig, # noqa:ARG001 consumer_mapping_with_campaign_config_with_invalid_tokens: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, s3_client: BaseClient, From 7751ae831ff5b6d7cfd05ce13e52808917441377 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:08:47 +0000 Subject: [PATCH 35/58] linting --- tests/integration/conftest.py | 61 +++++++++++++++++-- .../in_process/test_eligibility_endpoint.py | 51 ++++++---------- .../lambda/test_app_running_as_lambda.py | 2 +- 3 files changed, 75 insertions(+), 39 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index fa54c11a2..1b835ef2f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1225,9 +1225,33 @@ def consumer_mapping_with_rsv( s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") +@pytest.fixture(scope="class") +def consumer_mapping_with_campaign_config_with_and_rule( + s3_client: BaseClient, + consumer_mapping_bucket: BucketName, + campaign_config_with_and_rule: CampaignConfig, + consumer_id: ConsumerId, +) -> Generator[ConsumerMapping]: + consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId(consumer_id)] = [campaign_config_with_and_rule.id] + + consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(consumer_mapping_data), + ContentType="application/json", + ) + yield consumer_mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + + @pytest.fixture def consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text( - s3_client, consumer_mapping_bucket, campaign_config_with_missing_descriptions_missing_rule_text, consumer_id + s3_client: BaseClient, + consumer_mapping_bucket: ConsumerMapping, + campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, + consumer_id: ConsumerId, ): mapping = ConsumerMapping.model_validate({}) mapping.root[consumer_id] = [campaign_config_with_missing_descriptions_missing_rule_text.id] @@ -1244,7 +1268,10 @@ def consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule @pytest.fixture def consumer_mapping_with_campaign_config_with_rules_having_rule_code( - s3_client, consumer_mapping_bucket, campaign_config_with_rules_having_rule_code, consumer_id + s3_client: BaseClient, + consumer_mapping_bucket: ConsumerMapping, + campaign_config_with_rules_having_rule_code: CampaignConfig, + consumer_id: ConsumerId, ): mapping = ConsumerMapping.model_validate({}) mapping.root[consumer_id] = [campaign_config_with_rules_having_rule_code.id] @@ -1261,7 +1288,10 @@ def consumer_mapping_with_campaign_config_with_rules_having_rule_code( @pytest.fixture def consumer_mapping_with_campaign_config_with_rules_having_rule_mapper( - s3_client, consumer_mapping_bucket, campaign_config_with_rules_having_rule_mapper, consumer_id + s3_client: BaseClient, + consumer_mapping_bucket: ConsumerMapping, + campaign_config_with_rules_having_rule_mapper: CampaignConfig, + consumer_id: ConsumerId, ): mapping = ConsumerMapping.model_validate({}) mapping.root[consumer_id] = [campaign_config_with_rules_having_rule_mapper.id] @@ -1278,7 +1308,10 @@ def consumer_mapping_with_campaign_config_with_rules_having_rule_mapper( @pytest.fixture def consumer_mapping_with_only_virtual_cohort( - s3_client, consumer_mapping_bucket, campaign_config_with_virtual_cohort, consumer_id + s3_client: BaseClient, + consumer_mapping_bucket: ConsumerMapping, + campaign_config_with_virtual_cohort: CampaignConfig, + consumer_id: ConsumerId, ): mapping = ConsumerMapping.model_validate({}) mapping.root[consumer_id] = [campaign_config_with_virtual_cohort.id] @@ -1293,6 +1326,26 @@ def consumer_mapping_with_only_virtual_cohort( s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") +@pytest.fixture +def consumer_mapping_with_inactive_iteration_config( + s3_client: BaseClient, + consumer_mapping_bucket: ConsumerMapping, + inactive_iteration_config: list[CampaignConfig], + consumer_id: ConsumerId, +): + mapping = ConsumerMapping.model_validate({}) + mapping.root[consumer_id] = [cc.id for cc in inactive_iteration_config] + + s3_client.put_object( + Bucket=consumer_mapping_bucket, + Key="consumer_mapping.json", + Body=json.dumps(mapping.model_dump(by_alias=True)), + ContentType="application/json", + ) + yield mapping + s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") + + @pytest.fixture(scope="class") def consumer_mapping_with_multiple_campaign_configs( multiple_campaign_configs: list[CampaignConfig], diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index dc99953c3..782ab14bf 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -24,11 +24,10 @@ class TestBaseLine: - def test_nhs_number_given( # noqa: PLR0913 + def test_nhs_number_given( self, client: FlaskClient, persisted_person: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_id: ConsumerId, consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 @@ -63,7 +62,6 @@ def test_no_nhs_number_given_but_header_given( self, client: FlaskClient, persisted_person: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa: ARG002 ): # Given headers = {"nhs-login-nhs-number": str(persisted_person)} @@ -81,11 +79,10 @@ def test_no_nhs_number_given_but_header_given( class TestStandardResponse: - def test_not_base_eligible( # noqa: PLR0913 + def test_not_base_eligible( self, client: FlaskClient, persisted_person_no_cohorts: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_id: ConsumerId, consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 @@ -128,11 +125,10 @@ def test_not_base_eligible( # noqa: PLR0913 ), ) - def test_not_eligible_by_rule( # noqa: PLR0913 + def test_not_eligible_by_rule( self, client: FlaskClient, persisted_person_pc_sw19: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_id: ConsumerId, consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 @@ -175,11 +171,10 @@ def test_not_eligible_by_rule( # noqa: PLR0913 ), ) - def test_not_actionable_and_check_response_when_no_rule_code_given( # noqa: PLR0913 + def test_not_actionable_and_check_response_when_no_rule_code_given( self, client: FlaskClient, persisted_person: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_id: ConsumerId, consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 @@ -228,11 +223,10 @@ def test_not_actionable_and_check_response_when_no_rule_code_given( # noqa: PLR ), ) - def test_actionable( # noqa: PLR0913 + def test_actionable( self, client: FlaskClient, persisted_77yo_person: NHSNumber, - rsv_campaign_config: CampaignConfig, # noqa: ARG002 consumer_id: ConsumerId, consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 @@ -282,13 +276,12 @@ def test_actionable( # noqa: PLR0913 ), ) - def test_actionable_with_and_rule( # noqa: PLR0913 + def test_actionable_with_and_rule( self, client: FlaskClient, persisted_person: NHSNumber, - campaign_config_with_and_rule: CampaignConfig, # noqa: ARG002 consumer_id: ConsumerId, - consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 + consumer_mapping_with_campaign_config_with_and_rule: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -339,11 +332,10 @@ def test_actionable_with_and_rule( # noqa: PLR0913 class TestVirtualCohortResponse: - def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( # noqa: PLR0913 + def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( self, client: FlaskClient, persisted_person_pc_sw19: NHSNumber, - campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 @@ -386,11 +378,10 @@ def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( # noqa: PLR0 ), ) - def test_not_actionable_when_only_virtual_cohort_is_present( # noqa: PLR0913 + def test_not_actionable_when_only_virtual_cohort_is_present( self, client: FlaskClient, persisted_person: NHSNumber, - campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 @@ -439,11 +430,10 @@ def test_not_actionable_when_only_virtual_cohort_is_present( # noqa: PLR0913 ), ) - def test_actionable_when_only_virtual_cohort_is_present( # noqa: PLR0913 + def test_actionable_when_only_virtual_cohort_is_present( self, client: FlaskClient, persisted_77yo_person: NHSNumber, - campaign_config_with_virtual_cohort: CampaignConfig, # noqa: ARG002 consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 @@ -496,11 +486,10 @@ def test_actionable_when_only_virtual_cohort_is_present( # noqa: PLR0913 class TestResponseOnMissingAttributes: - def test_not_base_eligible( # noqa: PLR0913 + def test_not_base_eligible( self, client: FlaskClient, persisted_person_no_cohorts: NHSNumber, - campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 @@ -537,11 +526,10 @@ def test_not_base_eligible( # noqa: PLR0913 ), ) - def test_not_eligible_by_rule( # noqa: PLR0913 + def test_not_eligible_by_rule( self, client: FlaskClient, persisted_person_pc_sw19: NHSNumber, - campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 @@ -578,11 +566,10 @@ def test_not_eligible_by_rule( # noqa: PLR0913 ), ) - def test_not_actionable( # noqa: PLR0913 + def test_not_actionable( self, client: FlaskClient, persisted_person: NHSNumber, - campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 @@ -625,11 +612,10 @@ def test_not_actionable( # noqa: PLR0913 ), ) - def test_actionable( # noqa: PLR0913 + def test_actionable( self, client: FlaskClient, persisted_77yo_person: NHSNumber, - campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 @@ -674,11 +660,10 @@ def test_actionable( # noqa: PLR0913 ), ) - def test_actionable_no_actions( # noqa: PLR0913 + def test_actionable_no_actions( self, client: FlaskClient, persisted_77yo_person: NHSNumber, - campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 @@ -751,11 +736,10 @@ def test_status_endpoint(self, client: FlaskClient): class TestEligibilityResponseWithVariousInputs: - def test_not_actionable_and_check_response_when_rule_mapper_is_absent_but_rule_code_given( # noqa: PLR0913 + def test_not_actionable_and_check_response_when_rule_mapper_is_absent_but_rule_code_given( self, client: FlaskClient, persisted_person: NHSNumber, - campaign_config_with_rules_having_rule_code: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_rules_having_rule_code: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 @@ -804,11 +788,10 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_absent_but_rule_c ), ) - def test_not_actionable_and_check_response_when_rule_mapper_is_given( # noqa: PLR0913 + def test_not_actionable_and_check_response_when_rule_mapper_is_given( self, client: FlaskClient, persisted_person: NHSNumber, - campaign_config_with_rules_having_rule_mapper: CampaignConfig, # noqa: ARG002 consumer_mapping_with_campaign_config_with_rules_having_rule_mapper: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 1b882ec93..7e94176bf 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -564,7 +564,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # def test_no_active_iteration_returns_empty_processed_suggestions( lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, - consumer_mapping_with_various_targets: ConsumerMapping, # noqa:ARG001 + consumer_mapping_with_inactive_iteration_config: ConsumerMapping, # noqa:ARG001 consumer_id: ConsumerId, api_gateway_endpoint: URL, ): From 4fea2dc7c16510dc599a14d98a9db5b982ed7053 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Wed, 7 Jan 2026 09:51:36 +0000 Subject: [PATCH 36/58] more scenarios --- .../in_process/test_eligibility_endpoint.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 782ab14bf..80addddc1 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -857,7 +857,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( "ALL", ["RSV", "COVID"], ), - # Scenario 2: Explicit request for a single mapped target with an active campaign + # Scenario 2a: Explicit request for a single mapped target with an active campaign ( ["RSV", "COVID", "FLU"], {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, @@ -865,6 +865,14 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( "RSV", ["RSV"], ), + # Scenario 2b: Explicit request for a single mapped target with an active campaign + ( + ["RSV", "COVID", "FLU"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "RSV,COVID", + ["RSV", "COVID"], + ), # Scenario 3: Request for an active campaign (FLU) that the consumer is NOT mapped to ( ["RSV", "COVID", "FLU"], From 34827d101e70e0cb778a671b5aefcb264fed5bca Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:16:53 +0000 Subject: [PATCH 37/58] added consumer-id header to the requests in unit tests --- .../common/request_validator.py | 3 +- .../lambda/test_app_running_as_lambda.py | 5 +- tests/unit/common/test_request_validator.py | 56 ++++++++++++++++++- tests/unit/views/test_eligibility.py | 12 ++-- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/eligibility_signposting_api/common/request_validator.py b/src/eligibility_signposting_api/common/request_validator.py index 40416e5ca..796b4239a 100644 --- a/src/eligibility_signposting_api/common/request_validator.py +++ b/src/eligibility_signposting_api/common/request_validator.py @@ -57,8 +57,7 @@ def validate_request_params() -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs) -> ResponseReturnValue: # noqa:ANN002,ANN003 - consumer_id = str(request.headers.get(CONSUMER_ID)) - + consumer_id = request.headers.get(CONSUMER_ID) if not consumer_id: message = "You are not authorised to request" return CONSUMER_ID_NOT_PROVIDED_ERROR.log_and_generate_response( diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 7e94176bf..3239a3db9 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -293,7 +293,7 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": f"123{persisted_person!s}"}, + headers={"nhs-login-nhs-number": f"123{persisted_person!s}", "consumer-id":"test_consumer_id"}, timeout=10, ) @@ -339,6 +339,7 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response( invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, + headers={"consumer-id": "test_consumer_id"}, timeout=10, ) @@ -405,7 +406,7 @@ def test_validation_of_query_params_when_invalid_conditions_is_specified( invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": persisted_person}, + headers={"nhs-login-nhs-number": persisted_person, "consumer-id":"test_consumer_id"}, params={"category": "ALL", "conditions": "23-097"}, timeout=10, ) diff --git a/tests/unit/common/test_request_validator.py b/tests/unit/common/test_request_validator.py index 7de1c776a..c19977dc9 100644 --- a/tests/unit/common/test_request_validator.py +++ b/tests/unit/common/test_request_validator.py @@ -48,7 +48,7 @@ def test_validate_request_params_success(self, app, caplog): with app.test_request_context( "/dummy?id=1234567890", - headers={"nhs-login-nhs-number": "1234567890"}, + headers={"nhs-login-nhs-number": "1234567890", "consumer-id": "test_consumer_id"}, method="GET", ): with caplog.at_level(logging.INFO): @@ -66,7 +66,7 @@ def test_validate_request_params_nhs_mismatch(self, app, caplog): with app.test_request_context( "/dummy?id=1234567890", - headers={"nhs-login-nhs-number": "0987654321"}, + headers={"nhs-login-nhs-number": "0987654321", "consumer-id": "test_consumer_id"}, method="GET", ): with caplog.at_level(logging.INFO): @@ -84,6 +84,58 @@ def test_validate_request_params_nhs_mismatch(self, app, caplog): assert issue["diagnostics"] == "You are not authorised to request information for the supplied NHS Number" assert response.headers["Content-Type"] == "application/fhir+json" + def test_validate_request_params_consumer_id_present(self, app, caplog): + mock_api = MagicMock(return_value="ok") + + decorator = request_validator.validate_request_params() + dummy_route = decorator(mock_api) + + with ( + app.test_request_context( + "/dummy?id=1234567890", + headers={ + "consumer-id": "some-consumer", + "nhs-login-nhs-number": "1234567890", + }, + method="GET", + ), + caplog.at_level(logging.INFO), + ): + response = dummy_route(nhs_number=request.args.get("id")) + + mock_api.assert_called_once() + assert response == "ok" + assert not any(record.levelname == "ERROR" for record in caplog.records) + + def test_validate_request_params_missing_consumer_id(self, app, caplog): + mock_api = MagicMock() + + decorator = request_validator.validate_request_params() + dummy_route = decorator(mock_api) + + with ( + app.test_request_context( + "/dummy?id=1234567890", + headers={"nhs-login-nhs-number": "1234567890"}, # no consumer ID + method="GET", + ), + caplog.at_level(logging.ERROR), + ): + response = dummy_route(nhs_number=request.args.get("id")) + + mock_api.assert_not_called() + + assert response is not None + assert response.status_code == HTTPStatus.FORBIDDEN + response_json = response.json + + issue = response_json["issue"][0] + assert issue["code"] == "forbidden" + assert issue["details"]["coding"][0]["code"] == "ACCESS_DENIED" + assert issue["details"]["coding"][0]["display"] == "Access has been denied to process this request." + assert issue["diagnostics"] == "You are not authorised to request" + assert response.headers["Content-Type"] == "application/fhir+json" + class TestValidateQueryParameters: @pytest.mark.parametrize( diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 210b72e26..122cd5373 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -130,7 +130,7 @@ def test_security_headers_present_on_error_response(app: Flask, client: FlaskCli get_app_container(app).override.service(AuditService, new=FakeAuditService()), ): # When - headers = {"nhs-login-nhs-number": "9876543210"} + headers = {"nhs-login-nhs-number": "9876543210", "consumer-id":"test_customer_id"} response = client.get("/patient-check/9876543210", headers=headers) # Then @@ -179,7 +179,7 @@ def test_nhs_number_given(app: Flask, client: FlaskClient): get_app_container(app).override.service(AuditService, new=FakeAuditService()), ): # Given - headers = {"nhs-login-nhs-number": str(12345)} + headers = {"nhs-login-nhs-number": str(12345), "consumer-id":"test_customer_id"} # When response = client.get("/patient-check/12345", headers=headers) @@ -192,7 +192,7 @@ def test_no_nhs_number_given(app: Flask, client: FlaskClient): # Given with get_app_container(app).override.service(EligibilityService, new=FakeUnknownPersonEligibilityService()): # Given - headers = {"nhs-login-nhs-number": str(12345)} + headers = {"nhs-login-nhs-number": str(12345), "consumer-id":"test_customer_id"} # When response = client.get("/patient-check/", headers=headers) @@ -231,7 +231,7 @@ def test_no_nhs_number_given(app: Flask, client: FlaskClient): def test_unexpected_error(app: Flask, client: FlaskClient): # Given - headers = {"nhs-login-nhs-number": str(12345)} + headers = {"nhs-login-nhs-number": str(12345), "consumer-id":"test_customer_id"} with get_app_container(app).override.service(EligibilityService, new=FakeUnexpectedErrorEligibilityService()): response = client.get("/patient-check/12345", headers=headers) @@ -441,7 +441,7 @@ def test_excludes_nulls_via_build_response(client: FlaskClient): return_value=mocked_response, ), ): - response = client.get("/patient-check/12345", headers={"nhs-login-nhs-number": str(12345)}) + response = client.get("/patient-check/12345", headers={"nhs-login-nhs-number": str(12345), "consumer-id":"test_customer_id"}) assert response.status_code == HTTPStatus.OK payload = json.loads(response.data) @@ -493,7 +493,7 @@ def test_build_response_include_values_that_are_not_null(client: FlaskClient): return_value=mocked_response, ), ): - response = client.get("/patient-check/12345", headers={"nhs-login-nhs-number": str(12345)}) + response = client.get("/patient-check/12345", headers={"nhs-login-nhs-number": str(12345), "consumer-id":"test_customer_id"}) assert response.status_code == HTTPStatus.OK payload = json.loads(response.data) From 3d1b9707aafe8028ac550a160cf561c2467d56a7 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:17:45 +0000 Subject: [PATCH 38/58] linting --- .../lambda/test_app_running_as_lambda.py | 4 ++-- tests/unit/views/test_eligibility.py | 16 ++++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 3239a3db9..6a74546a7 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -293,7 +293,7 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": f"123{persisted_person!s}", "consumer-id":"test_consumer_id"}, + headers={"nhs-login-nhs-number": f"123{persisted_person!s}", "consumer-id": "test_consumer_id"}, timeout=10, ) @@ -406,7 +406,7 @@ def test_validation_of_query_params_when_invalid_conditions_is_specified( invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person}" response = httpx.get( invoke_url, - headers={"nhs-login-nhs-number": persisted_person, "consumer-id":"test_consumer_id"}, + headers={"nhs-login-nhs-number": persisted_person, "consumer-id": "test_consumer_id"}, params={"category": "ALL", "conditions": "23-097"}, timeout=10, ) diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 122cd5373..cecd26c38 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -130,7 +130,7 @@ def test_security_headers_present_on_error_response(app: Flask, client: FlaskCli get_app_container(app).override.service(AuditService, new=FakeAuditService()), ): # When - headers = {"nhs-login-nhs-number": "9876543210", "consumer-id":"test_customer_id"} + headers = {"nhs-login-nhs-number": "9876543210", "consumer-id": "test_customer_id"} response = client.get("/patient-check/9876543210", headers=headers) # Then @@ -179,7 +179,7 @@ def test_nhs_number_given(app: Flask, client: FlaskClient): get_app_container(app).override.service(AuditService, new=FakeAuditService()), ): # Given - headers = {"nhs-login-nhs-number": str(12345), "consumer-id":"test_customer_id"} + headers = {"nhs-login-nhs-number": str(12345), "consumer-id": "test_customer_id"} # When response = client.get("/patient-check/12345", headers=headers) @@ -192,7 +192,7 @@ def test_no_nhs_number_given(app: Flask, client: FlaskClient): # Given with get_app_container(app).override.service(EligibilityService, new=FakeUnknownPersonEligibilityService()): # Given - headers = {"nhs-login-nhs-number": str(12345), "consumer-id":"test_customer_id"} + headers = {"nhs-login-nhs-number": str(12345), "consumer-id": "test_customer_id"} # When response = client.get("/patient-check/", headers=headers) @@ -231,7 +231,7 @@ def test_no_nhs_number_given(app: Flask, client: FlaskClient): def test_unexpected_error(app: Flask, client: FlaskClient): # Given - headers = {"nhs-login-nhs-number": str(12345), "consumer-id":"test_customer_id"} + headers = {"nhs-login-nhs-number": str(12345), "consumer-id": "test_customer_id"} with get_app_container(app).override.service(EligibilityService, new=FakeUnexpectedErrorEligibilityService()): response = client.get("/patient-check/12345", headers=headers) @@ -441,7 +441,9 @@ def test_excludes_nulls_via_build_response(client: FlaskClient): return_value=mocked_response, ), ): - response = client.get("/patient-check/12345", headers={"nhs-login-nhs-number": str(12345), "consumer-id":"test_customer_id"}) + response = client.get( + "/patient-check/12345", headers={"nhs-login-nhs-number": str(12345), "consumer-id": "test_customer_id"} + ) assert response.status_code == HTTPStatus.OK payload = json.loads(response.data) @@ -493,7 +495,9 @@ def test_build_response_include_values_that_are_not_null(client: FlaskClient): return_value=mocked_response, ), ): - response = client.get("/patient-check/12345", headers={"nhs-login-nhs-number": str(12345), "consumer-id":"test_customer_id"}) + response = client.get( + "/patient-check/12345", headers={"nhs-login-nhs-number": str(12345), "consumer-id": "test_customer_id"} + ) assert response.status_code == HTTPStatus.OK payload = json.loads(response.data) From a14db731f18669cf9017cfaf944ea8f4d9a08f2f Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:12:12 +0000 Subject: [PATCH 39/58] Added vacc request placeholder in tests. --- .../in_process/test_eligibility_endpoint.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 80addddc1..beb04c489 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -840,12 +840,14 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ), ) + @pytest.mark.parametrize( ( "campaign_configs", "consumer_mappings", "consumer_id", "requested_conditions", + "requested_category", "expected_targets", ), [ @@ -855,6 +857,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, "consumer-id", "ALL", + "VACCINATIONS", ["RSV", "COVID"], ), # Scenario 2a: Explicit request for a single mapped target with an active campaign @@ -863,6 +866,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, "consumer-id", "RSV", + "VACCINATIONS", ["RSV"], ), # Scenario 2b: Explicit request for a single mapped target with an active campaign @@ -871,6 +875,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, "consumer-id", "RSV,COVID", + "VACCINATIONS", ["RSV", "COVID"], ), # Scenario 3: Request for an active campaign (FLU) that the consumer is NOT mapped to @@ -879,6 +884,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, "consumer-id", "FLU", + "VACCINATIONS", [], ), # Scenario 4: Request for a target that neither exists in system nor is mapped to consumer @@ -887,6 +893,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, "consumer-id", "HPV", + "VACCINATIONS", [], ), # Scenario 5: No mappings at all; requesting ALL should return empty @@ -895,6 +902,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( {}, "consumer-id", "ALL", + "VACCINATIONS", [], ), # Scenario 6: No mappings at all; requesting RSV should return empty @@ -903,6 +911,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( {}, "consumer-id", "RSV", + "VACCINATIONS", [], ), # Scenario 7: Consumer has no target mappings; requesting ALL should return empty @@ -911,6 +920,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, "another-consumer-id", "ALL", + "VACCINATIONS", [], ), # Scenario 8: Consumer has no target mappings; requesting specific target should return empty @@ -919,6 +929,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, "another-consumer-id", "RSV", + "VACCINATIONS", [], ), # Scenario 9: Consumer is mapped to targets (RSV/COVID), but those campaigns aren't active/present @@ -927,6 +938,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, "consumer-id", "ALL", + "VACCINATIONS", [], ), # Scenario 10: Request for specific mapped target (RSV), but those campaigns aren't active/present @@ -935,6 +947,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, "consumer-id", "RSV", + "VACCINATIONS", [], ), ], @@ -949,6 +962,7 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no consumer_mappings: ConsumerMapping, # noqa: ARG002 consumer_id: str, requested_conditions: str, + requested_category: str, expected_targets: list[str], ): # Given @@ -956,7 +970,7 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no # When response = client.get( - f"/patient-check/{persisted_person}?includeActions=Y&conditions={requested_conditions}", headers=headers + f"/patient-check/{persisted_person}?includeActions=Y&category={requested_category}&conditions={requested_conditions}", headers=headers ) assert_that( From 1b10f8534fd6f0ba2bf87ecb9004347664809d13 Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:23:17 +0000 Subject: [PATCH 40/58] fixed linting. --- tests/integration/in_process/test_eligibility_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index beb04c489..8469047dd 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -840,7 +840,6 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ), ) - @pytest.mark.parametrize( ( "campaign_configs", @@ -970,7 +969,8 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no # When response = client.get( - f"/patient-check/{persisted_person}?includeActions=Y&category={requested_category}&conditions={requested_conditions}", headers=headers + f"/patient-check/{persisted_person}?includeActions=Y&category={requested_category}&conditions={requested_conditions}", + headers=headers, ) assert_that( From 45522f8cfbe1b79c4a779ee4208bf51728020a1e Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:31:51 +0000 Subject: [PATCH 41/58] added sample consumer-mapping file --- .../test_consumer_mapping_config.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json diff --git a/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json b/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json new file mode 100644 index 000000000..3d86ca01e --- /dev/null +++ b/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json @@ -0,0 +1,10 @@ +{ + "consumer-id-123": [ + "RSV_campaign_id", + "COVID_campaign_id" + ], + "consumer-id-456": [ + "RSV_campaign_id", + "COVID_campaign_id" + ] +} From 926edc811486fb19c104c4f6f18b9d7c2a334db4 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:07:20 +0000 Subject: [PATCH 42/58] test_valid_response_when_consumer_has_a_valid_campaign_config_mapping parameterise are categorised --- .../in_process/test_eligibility_endpoint.py | 205 +++++++++++------- 1 file changed, 122 insertions(+), 83 deletions(-) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 8469047dd..e3cd0c192 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -842,112 +842,151 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( @pytest.mark.parametrize( ( - "campaign_configs", - "consumer_mappings", - "consumer_id", - "requested_conditions", - "requested_category", - "expected_targets", + "campaign_configs", + "consumer_mappings", + "consumer_id", + "requested_conditions", + "requested_category", + "expected_targets", ), [ - # Scenario 1: Intersection of mapped targets, requested targets, and active campaigns (Success) + # ============================================================ + # Group 1: Consumer is mapped, campaign exists in S3, requesting + # ============================================================ + + # 1.1 Consumer is mapped; multiple active campaigns exist; requesting ALL + # → Return intersection of mapped campaigns and active campaigns ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "ALL", - "VACCINATIONS", - ["RSV", "COVID"], + ["RSV", "COVID", "FLU"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "ALL", + "VACCINATIONS", + ["RSV", "COVID"], ), - # Scenario 2a: Explicit request for a single mapped target with an active campaign + + # 1.2 Consumer is mapped; requested single campaign exists and is mapped + # → Return requested campaign ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "RSV", - "VACCINATIONS", - ["RSV"], + ["RSV", "COVID", "FLU"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "RSV", + "VACCINATIONS", + ["RSV"], ), - # Scenario 2b: Explicit request for a single mapped target with an active campaign + + # 1.3 Consumer is mapped; requested multiple campaigns exist and are mapped + # → Return requested campaigns ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "RSV,COVID", - "VACCINATIONS", - ["RSV", "COVID"], + ["RSV", "COVID", "FLU"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "RSV,COVID", + "VACCINATIONS", + ["RSV", "COVID"], ), - # Scenario 3: Request for an active campaign (FLU) that the consumer is NOT mapped to + + # ============================================================ + # Group 2: Consumer is mapped, campaign does NOT exist in S3 + # ============================================================ + + # 2.1 Consumer is mapped; requested campaign exists in S3 + # but is NOT mapped to the consumer + # → Return empty ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "FLU", - "VACCINATIONS", - [], + ["RSV", "COVID", "FLU"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "FLU", + "VACCINATIONS", + [], ), - # Scenario 4: Request for a target that neither exists in system nor is mapped to consumer + + # 2.2 Consumer is mapped, but none of the mapped campaigns exist in S3 + # → Return empty ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "HPV", - "VACCINATIONS", - [], + ["MMR"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "ALL", + "VACCINATIONS", + [], ), - # Scenario 5: No mappings at all; requesting ALL should return empty + + # 2.3 Consumer is mapped; requested specific mapped campaign, + # but campaign does not exist in S3 + # → Return empty ( - ["RSV", "COVID", "FLU"], - {}, - "consumer-id", - "ALL", - "VACCINATIONS", - [], + ["MMR"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "RSV", + "VACCINATIONS", + [], ), - # Scenario 6: No mappings at all; requesting RSV should return empty + + # ============================================================ + # Group 3: Consumer is NOT mapped, campaign exists in S3 + # ============================================================ + + # 3.1 Consumer is not mapped; campaigns exist in S3; requesting ALL + # → Return empty ( - ["RSV", "COVID", "FLU"], - {}, - "consumer-id", - "RSV", - "VACCINATIONS", - [], + ["RSV", "COVID", "FLU"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "another-consumer-id", + "ALL", + "VACCINATIONS", + [], ), - # Scenario 7: Consumer has no target mappings; requesting ALL should return empty + + # 3.2 Consumer is not mapped; requested specific campaign exists in S3 + # → Return empty ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "another-consumer-id", - "ALL", - "VACCINATIONS", - [], + ["RSV", "COVID", "FLU"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "another-consumer-id", + "RSV", + "VACCINATIONS", + [], ), - # Scenario 8: Consumer has no target mappings; requesting specific target should return empty + + # ============================================================ + # Group 4: Consumer is NOT mapped, campaign does NOT exist in S3 + # ============================================================ + + # 4.1 Consumer is mapped; requested campaign does not exist in system + # → Return empty ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "another-consumer-id", - "RSV", - "VACCINATIONS", - [], + ["RSV", "COVID", "FLU"], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "HPV", + "VACCINATIONS", + [], ), - # Scenario 9: Consumer is mapped to targets (RSV/COVID), but those campaigns aren't active/present + + # 4.2 No consumer mappings exist; requesting ALL + # → Return empty ( - ["MMR"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "ALL", - "VACCINATIONS", - [], + ["RSV", "COVID", "FLU"], + {}, + "consumer-id", + "ALL", + "VACCINATIONS", + [], ), - # Scenario 10: Request for specific mapped target (RSV), but those campaigns aren't active/present + + # 4.3 No consumer mappings exist; requesting specific campaign + # → Return empty ( - ["MMR"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "RSV", - "VACCINATIONS", - [], + ["RSV", "COVID", "FLU"], + {}, + "consumer-id", + "RSV", + "VACCINATIONS", + [], ), ], indirect=["campaign_configs", "consumer_mappings"], From cbbae6f793e39cdb10970e1964956f9bdd31d258 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:51:37 +0000 Subject: [PATCH 43/58] multiple campaigns for same target --- tests/integration/conftest.py | 15 +- .../in_process/test_eligibility_endpoint.py | 280 +++++++++++------- 2 files changed, 183 insertions(+), 112 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1b835ef2f..5cfad77fe 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1114,14 +1114,19 @@ def campaign_config_with_missing_descriptions_missing_rule_text( @pytest.fixture def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: """Create and upload multiple campaign configs to S3, then clean up after tests.""" - campaigns, campaign_data_keys = [], [] + campaign_data_keys = [], [] + + raw = getattr( + request, "param", [("RSV", "RSV_campaign_id"), ("COVID", "COVID_campaign_id"), ("FLU", "FLU_campaign_id")] + ) - targets = getattr(request, "param", ["RSV", "COVID", "FLU"]) + targets = [t for t, _id in raw] + campaign_id = [_id for t, _id in raw] for i in range(len(targets)): campaign: CampaignConfig = rule.CampaignConfigFactory.build( name=f"campaign_{i}", - id=f"{targets[i]}_campaign_id", + id=campaign_id[i], target=targets[i], type="V", iterations=[ @@ -1148,10 +1153,10 @@ def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) - s3_client.put_object( Bucket=rules_bucket, Key=key, Body=json.dumps(campaign_data), ContentType="application/json" ) - campaigns.append(campaign) + campaign_id.append(campaign) campaign_data_keys.append(key) - yield campaigns + yield campaign_id for key in campaign_data_keys: s3_client.delete_object(Bucket=rules_bucket, Key=key) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index e3cd0c192..7669813dd 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -1,3 +1,4 @@ +import json from http import HTTPStatus import pytest @@ -21,6 +22,7 @@ from eligibility_signposting_api.model.eligibility_status import ( NHSNumber, ) +from eligibility_signposting_api.repos.campaign_repo import BucketName class TestBaseLine: @@ -842,151 +844,164 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( @pytest.mark.parametrize( ( - "campaign_configs", - "consumer_mappings", - "consumer_id", - "requested_conditions", - "requested_category", - "expected_targets", + "campaign_configs", + "consumer_mappings", + "consumer_id", + "requested_conditions", + "requested_category", + "expected_targets", ), [ # ============================================================ # Group 1: Consumer is mapped, campaign exists in S3, requesting # ============================================================ - # 1.1 Consumer is mapped; multiple active campaigns exist; requesting ALL - # → Return intersection of mapped campaigns and active campaigns ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "ALL", - "VACCINATIONS", - ["RSV", "COVID"], + [ + ("RSV", "RSV_campaign_id"), + ("COVID", "COVID_campaign_id"), + ("FLU", "FLU_campaign_id"), + ], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "ALL", + "VACCINATIONS", + ["RSV", "COVID"], ), - # 1.2 Consumer is mapped; requested single campaign exists and is mapped - # → Return requested campaign ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "RSV", - "VACCINATIONS", - ["RSV"], + [ + ("RSV", "RSV_campaign_id"), + ("COVID", "COVID_campaign_id"), + ("FLU", "FLU_campaign_id"), + ], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "RSV", + "VACCINATIONS", + ["RSV"], ), - # 1.3 Consumer is mapped; requested multiple campaigns exist and are mapped - # → Return requested campaigns ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "RSV,COVID", - "VACCINATIONS", - ["RSV", "COVID"], + [ + ("RSV", "RSV_campaign_id"), + ("COVID", "COVID_campaign_id"), + ("FLU", "FLU_campaign_id"), + ], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "RSV,COVID", + "VACCINATIONS", + ["RSV", "COVID"], ), - # ============================================================ # Group 2: Consumer is mapped, campaign does NOT exist in S3 # ============================================================ - - # 2.1 Consumer is mapped; requested campaign exists in S3 - # but is NOT mapped to the consumer - # → Return empty + # 2.1 Consumer is mapped; requested campaign exists in S3 but not mapped ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "FLU", - "VACCINATIONS", - [], + [ + ("RSV", "RSV_campaign_id"), + ("COVID", "COVID_campaign_id"), + ("FLU", "FLU_campaign_id"), + ], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "FLU", + "VACCINATIONS", + [], ), - # 2.2 Consumer is mapped, but none of the mapped campaigns exist in S3 - # → Return empty ( - ["MMR"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "ALL", - "VACCINATIONS", - [], + [ + ("MMR", "MMR_campaign_id"), + ], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "ALL", + "VACCINATIONS", + [], ), - - # 2.3 Consumer is mapped; requested specific mapped campaign, - # but campaign does not exist in S3 - # → Return empty + # 2.3 Consumer is mapped; requested mapped campaign does not exist in S3 ( - ["MMR"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "RSV", - "VACCINATIONS", - [], + [ + ("MMR", "MMR_campaign_id"), + ], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "RSV", + "VACCINATIONS", + [], ), - # ============================================================ # Group 3: Consumer is NOT mapped, campaign exists in S3 # ============================================================ - - # 3.1 Consumer is not mapped; campaigns exist in S3; requesting ALL - # → Return empty + # 3.1 Consumer not mapped; requesting ALL ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "another-consumer-id", - "ALL", - "VACCINATIONS", - [], + [ + ("RSV", "RSV_campaign_id"), + ("COVID", "COVID_campaign_id"), + ("FLU", "FLU_campaign_id"), + ], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "another-consumer-id", + "ALL", + "VACCINATIONS", + [], ), - - # 3.2 Consumer is not mapped; requested specific campaign exists in S3 - # → Return empty + # 3.2 Consumer not mapped; requesting specific campaign ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "another-consumer-id", - "RSV", - "VACCINATIONS", - [], + [ + ("RSV", "RSV_campaign_id"), + ("COVID", "COVID_campaign_id"), + ("FLU", "FLU_campaign_id"), + ], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "another-consumer-id", + "RSV", + "VACCINATIONS", + [], ), - # ============================================================ - # Group 4: Consumer is NOT mapped, campaign does NOT exist in S3 + # Group 4: Consumer NOT mapped, campaign does NOT exist in S3 # ============================================================ - - # 4.1 Consumer is mapped; requested campaign does not exist in system - # → Return empty + # 4.1 Consumer mapped; requested campaign does not exist ( - ["RSV", "COVID", "FLU"], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, - "consumer-id", - "HPV", - "VACCINATIONS", - [], + [ + ("RSV", "RSV_campaign_id"), + ("COVID", "COVID_campaign_id"), + ("FLU", "FLU_campaign_id"), + ], + {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + "consumer-id", + "HPV", + "VACCINATIONS", + [], ), - - # 4.2 No consumer mappings exist; requesting ALL - # → Return empty + # 4.2 No consumer mappings; requesting ALL ( - ["RSV", "COVID", "FLU"], - {}, - "consumer-id", - "ALL", - "VACCINATIONS", - [], + [ + ("RSV", "RSV_campaign_id"), + ("COVID", "COVID_campaign_id"), + ("FLU", "FLU_campaign_id"), + ], + {}, + "consumer-id", + "ALL", + "VACCINATIONS", + [], ), - - # 4.3 No consumer mappings exist; requesting specific campaign - # → Return empty + # 4.3 No consumer mappings; requesting specific campaign ( - ["RSV", "COVID", "FLU"], - {}, - "consumer-id", - "RSV", - "VACCINATIONS", - [], + [ + ("RSV", "RSV_campaign_id"), + ("COVID", "COVID_campaign_id"), + ("FLU", "FLU_campaign_id"), + ], + {}, + "consumer-id", + "RSV", + "VACCINATIONS", + [], ), ], indirect=["campaign_configs", "consumer_mappings"], @@ -1026,3 +1041,54 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no ) ), ) + + @pytest.mark.parametrize( + ("consumer_id", "expected_campaign_id"), + [ + ("consumer-id-1", "RSV_campaign_id_1"), + ("consumer-id-2", "RSV_campaign_id_2"), + ], + ) + @pytest.mark.parametrize( + ("campaign_configs", "consumer_mappings", "requested_conditions", "requested_category"), + [ + ( + [("RSV", "RSV_campaign_id_1"), ("RSV", "RSV_campaign_id_2")], + {"consumer-id-1": ["RSV_campaign_id_1"], "consumer-id-2": ["RSV_campaign_id_2"]}, + "RSV", + "VACCINATIONS", + ) + ], + indirect=["campaign_configs", "consumer_mappings"], + ) + def test_if_correct_campaign_is_chosen_for_the_consumer_if_there_exists_multiple_campaign_per_target( # noqa: PLR0913 + self, + client: FlaskClient, + persisted_person: NHSNumber, + secretsmanager_client: BaseClient, # noqa: ARG002 + audit_bucket: BucketName, + s3_client: BaseClient, + campaign_configs: CampaignConfig, # noqa: ARG002 + consumer_mappings: ConsumerMapping, # noqa: ARG002 + consumer_id: str, + requested_conditions: str, + requested_category: str, + expected_campaign_id: list[str], + ): + # Given + headers = {"nhs-login-nhs-number": str(persisted_person), CONSUMER_ID: consumer_id} + + # When + client.get( + f"/patient-check/{persisted_person}?includeActions=Y&category={requested_category}&conditions={requested_conditions}", + headers=headers, + ) + + objects = s3_client.list_objects_v2(Bucket=audit_bucket).get("Contents", []) + object_keys = [obj["Key"] for obj in objects] + latest_key = sorted(object_keys)[-1] + audit_data = json.loads(s3_client.get_object(Bucket=audit_bucket, Key=latest_key)["Body"].read()) + + # Then + assert_that(len(audit_data["response"]["condition"]), equal_to(1)) + assert_that(audit_data["response"]["condition"][0].get("campaignId"), equal_to(expected_campaign_id)) From f525d01f4f6a6be2b42f6c18381930d440deed0e Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:02:00 +0000 Subject: [PATCH 44/58] multiple campaigns for same target --- tests/integration/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5cfad77fe..832f149d5 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1114,7 +1114,7 @@ def campaign_config_with_missing_descriptions_missing_rule_text( @pytest.fixture def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: """Create and upload multiple campaign configs to S3, then clean up after tests.""" - campaign_data_keys = [], [] + campaigns, campaign_data_keys = [], [] raw = getattr( request, "param", [("RSV", "RSV_campaign_id"), ("COVID", "COVID_campaign_id"), ("FLU", "FLU_campaign_id")] @@ -1371,7 +1371,7 @@ def consumer_mapping_with_multiple_campaign_configs( s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") -@pytest.fixture(scope="class") +@pytest.fixture def consumer_mappings( request, s3_client: BaseClient, consumer_mapping_bucket: BucketName ) -> Generator[ConsumerMapping]: From fa80df6aa1d28b61ef7b842b13433eedbc306aaa Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:52:19 +0000 Subject: [PATCH 45/58] consumer config structure modification --- .../model/consumer_mapping.py | 11 +- .../repos/consumer_mapping_repo.py | 17 +- tests/integration/conftest.py | 148 +++++++----------- .../in_process/test_eligibility_endpoint.py | 100 +++++++++--- .../lambda/test_app_running_as_lambda.py | 19 +-- .../test_consumer_mapping_config.json | 28 +++- .../unit/repos/test_consumer_mapping_repo.py | 26 ++- 7 files changed, 206 insertions(+), 143 deletions(-) diff --git a/src/eligibility_signposting_api/model/consumer_mapping.py b/src/eligibility_signposting_api/model/consumer_mapping.py index 8e86d7e46..730153228 100644 --- a/src/eligibility_signposting_api/model/consumer_mapping.py +++ b/src/eligibility_signposting_api/model/consumer_mapping.py @@ -1,12 +1,17 @@ from typing import NewType -from pydantic import RootModel +from pydantic import BaseModel, RootModel from eligibility_signposting_api.model.campaign_config import CampaignID ConsumerId = NewType("ConsumerId", str) -class ConsumerMapping(RootModel[dict[ConsumerId, list[CampaignID]]]): - def get(self, key: ConsumerId, default: list[CampaignID] | None = None) -> list[CampaignID] | None: +class ConsumerCampaign(BaseModel): + campaign: CampaignID + description: str | None = None + + +class ConsumerMapping(RootModel[dict[ConsumerId, list[ConsumerCampaign]]]): + def get(self, key: ConsumerId, default: list[ConsumerCampaign] | None = None) -> list[ConsumerCampaign] | None: return self.root.get(key, default) diff --git a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py index 140ac74c4..583acb4d5 100644 --- a/src/eligibility_signposting_api/repos/consumer_mapping_repo.py +++ b/src/eligibility_signposting_api/repos/consumer_mapping_repo.py @@ -26,7 +26,18 @@ def __init__( self.bucket_name = bucket_name def get_permitted_campaign_ids(self, consumer_id: ConsumerId) -> list[CampaignID] | None: - consumer_mappings = self.s3_client.list_objects(Bucket=self.bucket_name)["Contents"][0] - response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{consumer_mappings['Key']}") + objects = self.s3_client.list_objects(Bucket=self.bucket_name).get("Contents") + + if not objects: + return None + + consumer_mappings_obj = objects[0] + response = self.s3_client.get_object(Bucket=self.bucket_name, Key=consumer_mappings_obj["Key"]) body = response["Body"].read() - return ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) + + mapping_result = ConsumerMapping.model_validate(json.loads(body)).get(consumer_id) + + if mapping_result is None: + return None + + return [item.campaign for item in mapping_result] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 832f149d5..efd5e697d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -31,7 +31,7 @@ StartDate, StatusText, ) -from eligibility_signposting_api.model.consumer_mapping import ConsumerId, ConsumerMapping +from eligibility_signposting_api.model.consumer_mapping import ConsumerCampaign, ConsumerId, ConsumerMapping from eligibility_signposting_api.processors.hashing_service import HashingService, HashSecretName from eligibility_signposting_api.repos import SecretRepo from eligibility_signposting_api.repos.campaign_repo import BucketName @@ -1114,7 +1114,7 @@ def campaign_config_with_missing_descriptions_missing_rule_text( @pytest.fixture def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: """Create and upload multiple campaign configs to S3, then clean up after tests.""" - campaigns, campaign_data_keys = [], [] + campaigns, campaign_data_keys = [], [] # noqa: F841 raw = getattr( request, "param", [("RSV", "RSV_campaign_id"), ("COVID", "COVID_campaign_id"), ("FLU", "FLU_campaign_id")] @@ -1167,16 +1167,13 @@ def consumer_id() -> ConsumerId: return ConsumerId("23-mic7heal-jor6don") -@pytest.fixture(scope="class") -def consumer_mapping_with_campaign_config_with_invalid_tokens( - s3_client: BaseClient, - consumer_mapping_bucket: BucketName, - campaign_config_with_invalid_tokens: CampaignConfig, - consumer_id: ConsumerId, -) -> Generator[ConsumerMapping]: +def create_and_put_consumer_mapping_in_s3( + campaign_config: CampaignConfig, consumer_id: str, consumer_mapping_bucket, s3_client +) -> ConsumerMapping: consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId(consumer_id)] = [campaign_config_with_invalid_tokens.id] + campaign_entry = ConsumerCampaign(campaign=campaign_config.id, description="Test description for campaign mapping") + consumer_mapping.root[ConsumerId(consumer_id)] = [campaign_entry] consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) s3_client.put_object( Bucket=consumer_mapping_bucket, @@ -1184,162 +1181,132 @@ def consumer_mapping_with_campaign_config_with_invalid_tokens( Body=json.dumps(consumer_mapping_data), ContentType="application/json", ) + return consumer_mapping + + +@pytest.fixture(scope="class") +def consumer_mapped_to_campaign_having_invalid_tokens( + s3_client: BaseClient, + consumer_mapping_bucket: BucketName, + campaign_config_with_invalid_tokens: CampaignConfig, + consumer_id: ConsumerId, +) -> Generator[ConsumerMapping]: + consumer_mapping = create_and_put_consumer_mapping_in_s3( + campaign_config_with_invalid_tokens, consumer_id, consumer_mapping_bucket, s3_client + ) yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") @pytest.fixture(scope="class") -def consumer_mapping_with_campaign_config_with_tokens( +def consumer_mapped_to_campaign_having_tokens( s3_client: BaseClient, consumer_mapping_bucket: BucketName, campaign_config_with_tokens: CampaignConfig, consumer_id: ConsumerId, ) -> Generator[ConsumerMapping]: - consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId(consumer_id)] = [campaign_config_with_tokens.id] - - consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) - s3_client.put_object( - Bucket=consumer_mapping_bucket, - Key="consumer_mapping.json", - Body=json.dumps(consumer_mapping_data), - ContentType="application/json", + consumer_mapping = create_and_put_consumer_mapping_in_s3( + campaign_config_with_tokens, consumer_id, consumer_mapping_bucket, s3_client ) yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") @pytest.fixture(scope="class") -def consumer_mapping_with_rsv( +def consumer_mapped_to_rsv_campaign( s3_client: BaseClient, consumer_mapping_bucket: BucketName, rsv_campaign_config: CampaignConfig, consumer_id: ConsumerId, ) -> Generator[ConsumerMapping]: - consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId(consumer_id)] = [rsv_campaign_config.id] - - consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) - s3_client.put_object( - Bucket=consumer_mapping_bucket, - Key="consumer_mapping.json", - Body=json.dumps(consumer_mapping_data), - ContentType="application/json", + consumer_mapping = create_and_put_consumer_mapping_in_s3( + rsv_campaign_config, consumer_id, consumer_mapping_bucket, s3_client ) yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") @pytest.fixture(scope="class") -def consumer_mapping_with_campaign_config_with_and_rule( +def consumer_mapped_to_campaign_having_and_rule( s3_client: BaseClient, consumer_mapping_bucket: BucketName, campaign_config_with_and_rule: CampaignConfig, consumer_id: ConsumerId, ) -> Generator[ConsumerMapping]: - consumer_mapping = ConsumerMapping.model_validate({}) - consumer_mapping.root[ConsumerId(consumer_id)] = [campaign_config_with_and_rule.id] - - consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) - s3_client.put_object( - Bucket=consumer_mapping_bucket, - Key="consumer_mapping.json", - Body=json.dumps(consumer_mapping_data), - ContentType="application/json", + consumer_mapping = create_and_put_consumer_mapping_in_s3( + campaign_config_with_and_rule, consumer_id, consumer_mapping_bucket, s3_client ) yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") @pytest.fixture -def consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text( +def consumer_mapped_to_campaign_missing_descriptions_and_rule_text( s3_client: BaseClient, consumer_mapping_bucket: ConsumerMapping, campaign_config_with_missing_descriptions_missing_rule_text: CampaignConfig, consumer_id: ConsumerId, ): - mapping = ConsumerMapping.model_validate({}) - mapping.root[consumer_id] = [campaign_config_with_missing_descriptions_missing_rule_text.id] - - s3_client.put_object( - Bucket=consumer_mapping_bucket, - Key="consumer_mapping.json", - Body=json.dumps(mapping.model_dump(by_alias=True)), - ContentType="application/json", + consumer_mapping = create_and_put_consumer_mapping_in_s3( + campaign_config_with_missing_descriptions_missing_rule_text, consumer_id, consumer_mapping_bucket, s3_client ) - yield mapping + yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") @pytest.fixture -def consumer_mapping_with_campaign_config_with_rules_having_rule_code( +def consumer_mapped_to_campaign_having_rules_with_rule_code( s3_client: BaseClient, consumer_mapping_bucket: ConsumerMapping, campaign_config_with_rules_having_rule_code: CampaignConfig, consumer_id: ConsumerId, ): - mapping = ConsumerMapping.model_validate({}) - mapping.root[consumer_id] = [campaign_config_with_rules_having_rule_code.id] - - s3_client.put_object( - Bucket=consumer_mapping_bucket, - Key="consumer_mapping.json", - Body=json.dumps(mapping.model_dump(by_alias=True)), - ContentType="application/json", + consumer_mapping = create_and_put_consumer_mapping_in_s3( + campaign_config_with_rules_having_rule_code, consumer_id, consumer_mapping_bucket, s3_client ) - yield mapping + yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") @pytest.fixture -def consumer_mapping_with_campaign_config_with_rules_having_rule_mapper( +def consumer_mapped_to_campaign_having_rules_with_rule_mapper( s3_client: BaseClient, consumer_mapping_bucket: ConsumerMapping, campaign_config_with_rules_having_rule_mapper: CampaignConfig, consumer_id: ConsumerId, ): - mapping = ConsumerMapping.model_validate({}) - mapping.root[consumer_id] = [campaign_config_with_rules_having_rule_mapper.id] - - s3_client.put_object( - Bucket=consumer_mapping_bucket, - Key="consumer_mapping.json", - Body=json.dumps(mapping.model_dump(by_alias=True)), - ContentType="application/json", + consumer_mapping = create_and_put_consumer_mapping_in_s3( + campaign_config_with_rules_having_rule_mapper, consumer_id, consumer_mapping_bucket, s3_client ) - yield mapping + yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") @pytest.fixture -def consumer_mapping_with_only_virtual_cohort( +def consumer_mapped_to_campaign_having_only_virtual_cohort( s3_client: BaseClient, consumer_mapping_bucket: ConsumerMapping, campaign_config_with_virtual_cohort: CampaignConfig, consumer_id: ConsumerId, ): - mapping = ConsumerMapping.model_validate({}) - mapping.root[consumer_id] = [campaign_config_with_virtual_cohort.id] - - s3_client.put_object( - Bucket=consumer_mapping_bucket, - Key="consumer_mapping.json", - Body=json.dumps(mapping.model_dump(by_alias=True)), - ContentType="application/json", + consumer_mapping = create_and_put_consumer_mapping_in_s3( + campaign_config_with_virtual_cohort, consumer_id, consumer_mapping_bucket, s3_client ) - yield mapping + yield consumer_mapping s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json") @pytest.fixture -def consumer_mapping_with_inactive_iteration_config( +def consumer_mapped_to_campaign_having_inactive_iteration_config( s3_client: BaseClient, consumer_mapping_bucket: ConsumerMapping, inactive_iteration_config: list[CampaignConfig], consumer_id: ConsumerId, ): mapping = ConsumerMapping.model_validate({}) - mapping.root[consumer_id] = [cc.id for cc in inactive_iteration_config] + mapping.root[consumer_id] = [ + ConsumerCampaign(campaign=cc.id, description=f"Description for {cc.id}") for cc in inactive_iteration_config + ] s3_client.put_object( Bucket=consumer_mapping_bucket, @@ -1352,14 +1319,16 @@ def consumer_mapping_with_inactive_iteration_config( @pytest.fixture(scope="class") -def consumer_mapping_with_multiple_campaign_configs( +def consumer_mapped_to_multiple_campaign_configs( multiple_campaign_configs: list[CampaignConfig], consumer_id: ConsumerId, s3_client: BaseClient, consumer_mapping_bucket: BucketName, ) -> Generator[ConsumerMapping]: mapping = ConsumerMapping.model_validate({}) - mapping.root[consumer_id] = [cc.id for cc in multiple_campaign_configs] + mapping.root[consumer_id] = [ + ConsumerCampaign(campaign=cc.id, description=f"Description for {cc.id}") for cc in multiple_campaign_configs + ] s3_client.put_object( Bucket=consumer_mapping_bucket, @@ -1388,15 +1357,16 @@ def consumer_mappings( @pytest.fixture(scope="class") -def consumer_mapping_with_various_targets( +def consumer_mapped_to_with_various_targets( s3_client: BaseClient, consumer_mapping_bucket: BucketName ) -> Generator[ConsumerMapping]: consumer_mapping = ConsumerMapping.model_validate({}) + consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ - CampaignID("campaign_start_date"), - CampaignID("campaign_start_date_plus_one_day"), - CampaignID("campaign_today"), - CampaignID("campaign_tomorrow"), + ConsumerCampaign(campaign=CampaignID("campaign_start_date")), + ConsumerCampaign(campaign=CampaignID("campaign_start_date_plus_one_day")), + ConsumerCampaign(campaign=CampaignID("campaign_today")), + ConsumerCampaign(campaign=CampaignID("campaign_tomorrow")), ] consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 7669813dd..04ec35801 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -31,7 +31,7 @@ def test_nhs_number_given( client: FlaskClient, persisted_person: NHSNumber, consumer_id: ConsumerId, - consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_rsv_campaign: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -86,7 +86,7 @@ def test_not_base_eligible( client: FlaskClient, persisted_person_no_cohorts: NHSNumber, consumer_id: ConsumerId, - consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_rsv_campaign: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -132,7 +132,7 @@ def test_not_eligible_by_rule( client: FlaskClient, persisted_person_pc_sw19: NHSNumber, consumer_id: ConsumerId, - consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_rsv_campaign: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -178,7 +178,7 @@ def test_not_actionable_and_check_response_when_no_rule_code_given( client: FlaskClient, persisted_person: NHSNumber, consumer_id: ConsumerId, - consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_rsv_campaign: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -230,7 +230,7 @@ def test_actionable( client: FlaskClient, persisted_77yo_person: NHSNumber, consumer_id: ConsumerId, - consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_rsv_campaign: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): headers = {"nhs-login-nhs-number": str(persisted_77yo_person), CONSUMER_ID: consumer_id} @@ -283,7 +283,7 @@ def test_actionable_with_and_rule( client: FlaskClient, persisted_person: NHSNumber, consumer_id: ConsumerId, - consumer_mapping_with_campaign_config_with_and_rule: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_campaign_having_and_rule: ConsumerMapping, # noqa: ARG002 secretsmanager_client: BaseClient, # noqa: ARG002 ): # Given @@ -338,7 +338,7 @@ def test_not_eligible_by_rule_when_only_virtual_cohort_is_present( self, client: FlaskClient, persisted_person_pc_sw19: NHSNumber, - consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_campaign_having_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -384,7 +384,7 @@ def test_not_actionable_when_only_virtual_cohort_is_present( self, client: FlaskClient, persisted_person: NHSNumber, - consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_campaign_having_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -436,7 +436,7 @@ def test_actionable_when_only_virtual_cohort_is_present( self, client: FlaskClient, persisted_77yo_person: NHSNumber, - consumer_mapping_with_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_campaign_having_only_virtual_cohort: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -492,7 +492,7 @@ def test_not_base_eligible( self, client: FlaskClient, persisted_person_no_cohorts: NHSNumber, - consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_campaign_missing_descriptions_and_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -532,7 +532,7 @@ def test_not_eligible_by_rule( self, client: FlaskClient, persisted_person_pc_sw19: NHSNumber, - consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_campaign_missing_descriptions_and_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -572,7 +572,7 @@ def test_not_actionable( self, client: FlaskClient, persisted_person: NHSNumber, - consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_campaign_missing_descriptions_and_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -618,7 +618,7 @@ def test_actionable( self, client: FlaskClient, persisted_77yo_person: NHSNumber, - consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_campaign_missing_descriptions_and_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -666,7 +666,7 @@ def test_actionable_no_actions( self, client: FlaskClient, persisted_77yo_person: NHSNumber, - consumer_mapping_with_campaign_config_with_missing_descriptions_missing_rule_text: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_campaign_missing_descriptions_and_rule_text: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -742,7 +742,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_absent_but_rule_c self, client: FlaskClient, persisted_person: NHSNumber, - consumer_mapping_with_campaign_config_with_rules_having_rule_code: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_campaign_having_rules_with_rule_code: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -794,7 +794,7 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( self, client: FlaskClient, persisted_person: NHSNumber, - consumer_mapping_with_campaign_config_with_rules_having_rule_mapper: ConsumerMapping, # noqa: ARG002 + consumer_mapped_to_campaign_having_rules_with_rule_mapper: ConsumerMapping, # noqa: ARG002 consumer_id: ConsumerId, secretsmanager_client: BaseClient, # noqa: ARG002 ): @@ -862,7 +862,12 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ("COVID", "COVID_campaign_id"), ("FLU", "FLU_campaign_id"), ], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + { + "consumer-id": [ + {"campaign": "RSV_campaign_id"}, + {"campaign": "COVID_campaign_id"}, + ] + }, "consumer-id", "ALL", "VACCINATIONS", @@ -875,7 +880,12 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ("COVID", "COVID_campaign_id"), ("FLU", "FLU_campaign_id"), ], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + { + "consumer-id": [ + {"campaign": "RSV_campaign_id"}, + {"campaign": "COVID_campaign_id"}, + ] + }, "consumer-id", "RSV", "VACCINATIONS", @@ -888,7 +898,12 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ("COVID", "COVID_campaign_id"), ("FLU", "FLU_campaign_id"), ], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + { + "consumer-id": [ + {"campaign": "RSV_campaign_id"}, + {"campaign": "COVID_campaign_id"}, + ] + }, "consumer-id", "RSV,COVID", "VACCINATIONS", @@ -904,7 +919,12 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ("COVID", "COVID_campaign_id"), ("FLU", "FLU_campaign_id"), ], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + { + "consumer-id": [ + {"campaign": "RSV_campaign_id"}, + {"campaign": "COVID_campaign_id"}, + ] + }, "consumer-id", "FLU", "VACCINATIONS", @@ -915,7 +935,12 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( [ ("MMR", "MMR_campaign_id"), ], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + { + "consumer-id": [ + {"campaign": "RSV_campaign_id"}, + {"campaign": "COVID_campaign_id"}, + ] + }, "consumer-id", "ALL", "VACCINATIONS", @@ -926,7 +951,12 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( [ ("MMR", "MMR_campaign_id"), ], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + { + "consumer-id": [ + {"campaign": "RSV_campaign_id"}, + {"campaign": "COVID_campaign_id"}, + ] + }, "consumer-id", "RSV", "VACCINATIONS", @@ -942,7 +972,12 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ("COVID", "COVID_campaign_id"), ("FLU", "FLU_campaign_id"), ], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + { + "consumer-id": [ + {"campaign": "RSV_campaign_id"}, + {"campaign": "COVID_campaign_id"}, + ] + }, "another-consumer-id", "ALL", "VACCINATIONS", @@ -955,7 +990,12 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ("COVID", "COVID_campaign_id"), ("FLU", "FLU_campaign_id"), ], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + { + "consumer-id": [ + {"campaign": "RSV_campaign_id"}, + {"campaign": "COVID_campaign_id"}, + ] + }, "another-consumer-id", "RSV", "VACCINATIONS", @@ -971,7 +1011,12 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ("COVID", "COVID_campaign_id"), ("FLU", "FLU_campaign_id"), ], - {"consumer-id": ["RSV_campaign_id", "COVID_campaign_id"]}, + { + "consumer-id": [ + {"campaign": "RSV_campaign_id"}, + {"campaign": "COVID_campaign_id"}, + ] + }, "consumer-id", "HPV", "VACCINATIONS", @@ -1054,7 +1099,10 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no [ ( [("RSV", "RSV_campaign_id_1"), ("RSV", "RSV_campaign_id_2")], - {"consumer-id-1": ["RSV_campaign_id_1"], "consumer-id-2": ["RSV_campaign_id_2"]}, + { + "consumer-id-1": [{"campaign": "RSV_campaign_id_1"}], + "consumer-id-2": [{"campaign": "RSV_campaign_id_2"}], + }, "RSV", "VACCINATIONS", ) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 6a74546a7..5ee7c2fdb 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -36,7 +36,7 @@ def test_install_and_call_lambda_flask( lambda_client: BaseClient, flask_function: str, persisted_person: NHSNumber, - consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 + consumer_mapped_to_rsv_campaign: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, ): """Given lambda installed into localstack, run it via boto3 lambda client""" @@ -89,7 +89,7 @@ def test_install_and_call_lambda_flask( def test_install_and_call_flask_lambda_over_http( persisted_person: NHSNumber, - consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 + consumer_mapped_to_rsv_campaign: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, api_gateway_endpoint: URL, ): @@ -113,10 +113,11 @@ def test_install_and_call_flask_lambda_over_http( def test_install_and_call_flask_lambda_with_unknown_nhs_number( # noqa: PLR0913 flask_function: str, persisted_person: NHSNumber, - consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 + consumer_mapped_to_rsv_campaign: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, logs_client: BaseClient, api_gateway_endpoint: URL, + secretsmanager_client: BaseClient, # noqa: ARG001 ): """Given lambda installed into localstack, run it via http, with a nonexistent NHS number specified""" # Given @@ -188,7 +189,7 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, rsv_campaign_config: CampaignConfig, - consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 + consumer_mapped_to_rsv_campaign: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, s3_client: BaseClient, audit_bucket: BucketName, @@ -378,7 +379,7 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response( def test_validation_of_query_params_when_all_are_valid( lambda_client: BaseClient, # noqa:ARG001 persisted_person: NHSNumber, - consumer_mapping_with_rsv: ConsumerMapping, # noqa: ARG001 + consumer_mapped_to_rsv_campaign: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, api_gateway_endpoint: URL, ): @@ -419,7 +420,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, multiple_campaign_configs: list[CampaignConfig], - consumer_mapping_with_multiple_campaign_configs: ConsumerMapping, # noqa: ARG001 + consumer_mapped_to_multiple_campaign_configs: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, s3_client: BaseClient, audit_bucket: BucketName, @@ -565,7 +566,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # def test_no_active_iteration_returns_empty_processed_suggestions( lambda_client: BaseClient, # noqa:ARG001 persisted_person_all_cohorts: NHSNumber, - consumer_mapping_with_inactive_iteration_config: ConsumerMapping, # noqa:ARG001 + consumer_mapped_to_campaign_having_inactive_iteration_config: ConsumerMapping, # noqa:ARG001 consumer_id: ConsumerId, api_gateway_endpoint: URL, ): @@ -603,7 +604,7 @@ def test_no_active_iteration_returns_empty_processed_suggestions( def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, - consumer_mapping_with_campaign_config_with_tokens: ConsumerMapping, # noqa: ARG001 + consumer_mapped_to_campaign_having_tokens: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, s3_client: BaseClient, audit_bucket: BucketName, @@ -654,7 +655,7 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 def test_incorrect_token_causes_internal_server_error( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, - consumer_mapping_with_campaign_config_with_invalid_tokens: ConsumerMapping, # noqa: ARG001 + consumer_mapped_to_campaign_having_invalid_tokens: ConsumerMapping, # noqa: ARG001 consumer_id: ConsumerId, s3_client: BaseClient, audit_bucket: BucketName, diff --git a/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json b/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json index 3d86ca01e..332671db0 100644 --- a/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json +++ b/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json @@ -1,10 +1,22 @@ { - "consumer-id-123": [ - "RSV_campaign_id", - "COVID_campaign_id" - ], - "consumer-id-456": [ - "RSV_campaign_id", - "COVID_campaign_id" - ] + "consumer-id-123": [ + { + "campaign": "RSV_campaign_id", + "description": "RSV Ongoing for My Vaccines" + }, + { + "campaign": "COVID_campaign_id", + "description": "COVID Ongoing for My Vaccines" + } + ], + "consumer-id-456": [ + { + "campaign": "RSV_campaign_id_NBS", + "description": "RSV Ongoing for NBS" + }, + { + "campaign": "COVID_campaign_id_NBS", + "description": "RSV Ongoing for NBS" + } + ] } diff --git a/tests/unit/repos/test_consumer_mapping_repo.py b/tests/unit/repos/test_consumer_mapping_repo.py index 243874482..acc9d29e2 100644 --- a/tests/unit/repos/test_consumer_mapping_repo.py +++ b/tests/unit/repos/test_consumer_mapping_repo.py @@ -19,8 +19,17 @@ def repo(self, mock_s3_client): def test_get_permitted_campaign_ids_success(self, repo, mock_s3_client): # Given consumer_id = "user-123" - expected_campaigns = ["flu-2024", "covid-2024"] - mapping_data = {consumer_id: expected_campaigns} + + # The expected output is just the IDs + expected_campaign_ids = ["flu-2024", "covid-2024"] + + # The mocked S3 data must match the new schema (objects with description) + mapping_data = { + consumer_id: [ + {"campaign": "flu-2024", "description": "Flu Shot Description"}, + {"campaign": "covid-2024", "description": "Covid Shot Description"}, + ] + } mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "mappings.json"}]} @@ -31,16 +40,23 @@ def test_get_permitted_campaign_ids_success(self, repo, mock_s3_client): result = repo.get_permitted_campaign_ids(ConsumerId(consumer_id)) # Then - assert result == expected_campaigns + assert result == expected_campaign_ids mock_s3_client.list_objects.assert_called_once_with(Bucket="test-bucket") mock_s3_client.get_object.assert_called_once_with(Bucket="test-bucket", Key="mappings.json") def test_get_permitted_campaign_ids_returns_none_when_missing(self, repo, mock_s3_client): - # Setup data where the consumer_id doesn't exist + """ + Setup data where the consumer_id doesn't exist + We must still use the valid schema (dicts inside the list) to pass Pydantic validation + """ + valid_schema_data = {"other-user": [{"campaign": "camp-1", "description": "Some description"}]} + mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "mappings.json"}]} - body_json = json.dumps({"other-user": ["camp-1"]}).encode("utf-8") + body_json = json.dumps(valid_schema_data).encode("utf-8") mock_s3_client.get_object.return_value = {"Body": MagicMock(read=lambda: body_json)} + # When result = repo.get_permitted_campaign_ids(ConsumerId("missing-user")) + # Then assert result is None From 82ee079091bfede5f9967a51ca3cbe653fe00ca9 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:22:32 +0000 Subject: [PATCH 46/58] lint fix --- .../test_consumer_mapping_config.json | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json b/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json index 332671db0..ee5c5c01d 100644 --- a/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json +++ b/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json @@ -1,22 +1,23 @@ + { - "consumer-id-123": [ - { - "campaign": "RSV_campaign_id", - "description": "RSV Ongoing for My Vaccines" - }, - { - "campaign": "COVID_campaign_id", - "description": "COVID Ongoing for My Vaccines" - } - ], - "consumer-id-456": [ - { - "campaign": "RSV_campaign_id_NBS", - "description": "RSV Ongoing for NBS" - }, - { - "campaign": "COVID_campaign_id_NBS", - "description": "RSV Ongoing for NBS" - } - ] + "consumer-id-123": [ + { + "campaign": "RSV_campaign_id", + "description": "RSV Ongoing for My Vaccines" + }, + { + "campaign": "COVID_campaign_id", + "description": "COVID Ongoing for My Vaccines" + } + ], + "consumer-id-456": [ + { + "campaign": "RSV_campaign_id_NBS", + "description": "RSV Ongoing for NBS" + }, + { + "campaign": "COVID_campaign_id_NBS", + "description": "RSV Ongoing for NBS" + } + ] } From 2045e080424171eeddc721e82ba591a36f1ba0ff Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:59:03 +0000 Subject: [PATCH 47/58] consumer mapping - schema --- .../model/consumer_mapping.py | 6 +-- tests/integration/conftest.py | 14 +++---- .../in_process/test_eligibility_endpoint.py | 40 +++++++++---------- .../test_consumer_mapping_config.json | 16 ++++---- .../unit/repos/test_consumer_mapping_repo.py | 6 +-- 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/eligibility_signposting_api/model/consumer_mapping.py b/src/eligibility_signposting_api/model/consumer_mapping.py index 730153228..839b05d8e 100644 --- a/src/eligibility_signposting_api/model/consumer_mapping.py +++ b/src/eligibility_signposting_api/model/consumer_mapping.py @@ -1,6 +1,6 @@ from typing import NewType -from pydantic import BaseModel, RootModel +from pydantic import BaseModel, RootModel, Field from eligibility_signposting_api.model.campaign_config import CampaignID @@ -8,8 +8,8 @@ class ConsumerCampaign(BaseModel): - campaign: CampaignID - description: str | None = None + campaign: CampaignID = Field(alias="Campaign") + description: str | None = Field(default=None, alias="Description") class ConsumerMapping(RootModel[dict[ConsumerId, list[ConsumerCampaign]]]): diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index efd5e697d..b2f699957 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1171,7 +1171,7 @@ def create_and_put_consumer_mapping_in_s3( campaign_config: CampaignConfig, consumer_id: str, consumer_mapping_bucket, s3_client ) -> ConsumerMapping: consumer_mapping = ConsumerMapping.model_validate({}) - campaign_entry = ConsumerCampaign(campaign=campaign_config.id, description="Test description for campaign mapping") + campaign_entry = ConsumerCampaign(Campaign=campaign_config.id, Description="Test description for campaign mapping") consumer_mapping.root[ConsumerId(consumer_id)] = [campaign_entry] consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) @@ -1305,7 +1305,7 @@ def consumer_mapped_to_campaign_having_inactive_iteration_config( ): mapping = ConsumerMapping.model_validate({}) mapping.root[consumer_id] = [ - ConsumerCampaign(campaign=cc.id, description=f"Description for {cc.id}") for cc in inactive_iteration_config + ConsumerCampaign(Campaign=cc.id, Description=f"Description for {cc.id}") for cc in inactive_iteration_config ] s3_client.put_object( @@ -1327,7 +1327,7 @@ def consumer_mapped_to_multiple_campaign_configs( ) -> Generator[ConsumerMapping]: mapping = ConsumerMapping.model_validate({}) mapping.root[consumer_id] = [ - ConsumerCampaign(campaign=cc.id, description=f"Description for {cc.id}") for cc in multiple_campaign_configs + ConsumerCampaign(Campaign=cc.id, Description=f"Description for {cc.id}") for cc in multiple_campaign_configs ] s3_client.put_object( @@ -1363,10 +1363,10 @@ def consumer_mapped_to_with_various_targets( consumer_mapping = ConsumerMapping.model_validate({}) consumer_mapping.root[ConsumerId("23-mic7heal-jor6don")] = [ - ConsumerCampaign(campaign=CampaignID("campaign_start_date")), - ConsumerCampaign(campaign=CampaignID("campaign_start_date_plus_one_day")), - ConsumerCampaign(campaign=CampaignID("campaign_today")), - ConsumerCampaign(campaign=CampaignID("campaign_tomorrow")), + ConsumerCampaign(Campaign=CampaignID("campaign_start_date")), + ConsumerCampaign(Campaign=CampaignID("campaign_start_date_plus_one_day")), + ConsumerCampaign(Campaign=CampaignID("campaign_today")), + ConsumerCampaign(Campaign=CampaignID("campaign_tomorrow")), ] consumer_mapping_data = consumer_mapping.model_dump(by_alias=True) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 04ec35801..ac27fac7a 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -864,8 +864,8 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ], { "consumer-id": [ - {"campaign": "RSV_campaign_id"}, - {"campaign": "COVID_campaign_id"}, + {"Campaign": "RSV_campaign_id"}, + {"Campaign": "COVID_campaign_id"}, ] }, "consumer-id", @@ -882,8 +882,8 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ], { "consumer-id": [ - {"campaign": "RSV_campaign_id"}, - {"campaign": "COVID_campaign_id"}, + {"Campaign": "RSV_campaign_id"}, + {"Campaign": "COVID_campaign_id"}, ] }, "consumer-id", @@ -900,8 +900,8 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ], { "consumer-id": [ - {"campaign": "RSV_campaign_id"}, - {"campaign": "COVID_campaign_id"}, + {"Campaign": "RSV_campaign_id"}, + {"Campaign": "COVID_campaign_id"}, ] }, "consumer-id", @@ -921,8 +921,8 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ], { "consumer-id": [ - {"campaign": "RSV_campaign_id"}, - {"campaign": "COVID_campaign_id"}, + {"Campaign": "RSV_campaign_id"}, + {"Campaign": "COVID_campaign_id"}, ] }, "consumer-id", @@ -937,8 +937,8 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ], { "consumer-id": [ - {"campaign": "RSV_campaign_id"}, - {"campaign": "COVID_campaign_id"}, + {"Campaign": "RSV_campaign_id"}, + {"Campaign": "COVID_campaign_id"}, ] }, "consumer-id", @@ -953,8 +953,8 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ], { "consumer-id": [ - {"campaign": "RSV_campaign_id"}, - {"campaign": "COVID_campaign_id"}, + {"Campaign": "RSV_campaign_id"}, + {"Campaign": "COVID_campaign_id"}, ] }, "consumer-id", @@ -974,8 +974,8 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ], { "consumer-id": [ - {"campaign": "RSV_campaign_id"}, - {"campaign": "COVID_campaign_id"}, + {"Campaign": "RSV_campaign_id"}, + {"Campaign": "COVID_campaign_id"}, ] }, "another-consumer-id", @@ -992,8 +992,8 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ], { "consumer-id": [ - {"campaign": "RSV_campaign_id"}, - {"campaign": "COVID_campaign_id"}, + {"Campaign": "RSV_campaign_id"}, + {"Campaign": "COVID_campaign_id"}, ] }, "another-consumer-id", @@ -1013,8 +1013,8 @@ def test_not_actionable_and_check_response_when_rule_mapper_is_given( ], { "consumer-id": [ - {"campaign": "RSV_campaign_id"}, - {"campaign": "COVID_campaign_id"}, + {"Campaign": "RSV_campaign_id"}, + {"Campaign": "COVID_campaign_id"}, ] }, "consumer-id", @@ -1100,8 +1100,8 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no ( [("RSV", "RSV_campaign_id_1"), ("RSV", "RSV_campaign_id_2")], { - "consumer-id-1": [{"campaign": "RSV_campaign_id_1"}], - "consumer-id-2": [{"campaign": "RSV_campaign_id_2"}], + "consumer-id-1": [{"Campaign": "RSV_campaign_id_1"}], + "consumer-id-2": [{"Campaign": "RSV_campaign_id_2"}], }, "RSV", "VACCINATIONS", diff --git a/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json b/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json index ee5c5c01d..29127b19c 100644 --- a/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json +++ b/tests/test_data/test_consumer_mapping/test_consumer_mapping_config.json @@ -2,22 +2,22 @@ { "consumer-id-123": [ { - "campaign": "RSV_campaign_id", - "description": "RSV Ongoing for My Vaccines" + "Campaign": "RSV_campaign_id", + "Description": "RSV Ongoing for My Vaccines" }, { - "campaign": "COVID_campaign_id", - "description": "COVID Ongoing for My Vaccines" + "Campaign": "COVID_campaign_id", + "Description": "COVID Ongoing for My Vaccines" } ], "consumer-id-456": [ { - "campaign": "RSV_campaign_id_NBS", - "description": "RSV Ongoing for NBS" + "Campaign": "RSV_campaign_id_NBS", + "Description": "RSV Ongoing for NBS" }, { - "campaign": "COVID_campaign_id_NBS", - "description": "RSV Ongoing for NBS" + "Campaign": "COVID_campaign_id_NBS", + "Description": "RSV Ongoing for NBS" } ] } diff --git a/tests/unit/repos/test_consumer_mapping_repo.py b/tests/unit/repos/test_consumer_mapping_repo.py index acc9d29e2..057042e27 100644 --- a/tests/unit/repos/test_consumer_mapping_repo.py +++ b/tests/unit/repos/test_consumer_mapping_repo.py @@ -26,8 +26,8 @@ def test_get_permitted_campaign_ids_success(self, repo, mock_s3_client): # The mocked S3 data must match the new schema (objects with description) mapping_data = { consumer_id: [ - {"campaign": "flu-2024", "description": "Flu Shot Description"}, - {"campaign": "covid-2024", "description": "Covid Shot Description"}, + {"Campaign": "flu-2024", "Description": "Flu Shot Description"}, + {"Campaign": "covid-2024", "Description": "Covid Shot Description"}, ] } @@ -49,7 +49,7 @@ def test_get_permitted_campaign_ids_returns_none_when_missing(self, repo, mock_s Setup data where the consumer_id doesn't exist We must still use the valid schema (dicts inside the list) to pass Pydantic validation """ - valid_schema_data = {"other-user": [{"campaign": "camp-1", "description": "Some description"}]} + valid_schema_data = {"other-user": [{"Campaign": "camp-1", "Description": "Some description"}]} mock_s3_client.list_objects.return_value = {"Contents": [{"Key": "mappings.json"}]} body_json = json.dumps(valid_schema_data).encode("utf-8") From 5fff049315dfd9f2abf56cecf4f400d8dc88947e Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:32:03 +0000 Subject: [PATCH 48/58] Terraform consumer mapping bucket --- infrastructure/modules/lambda/lambda.tf | 17 ++++---- infrastructure/modules/lambda/variables.tf | 5 +++ infrastructure/stacks/api-layer/lambda.tf | 41 ++++++++++--------- infrastructure/stacks/api-layer/s3_buckets.tf | 9 ++++ 4 files changed, 44 insertions(+), 28 deletions(-) diff --git a/infrastructure/modules/lambda/lambda.tf b/infrastructure/modules/lambda/lambda.tf index 889cd6d2a..6485be675 100644 --- a/infrastructure/modules/lambda/lambda.tf +++ b/infrastructure/modules/lambda/lambda.tf @@ -17,14 +17,15 @@ resource "aws_lambda_function" "eligibility_signposting_lambda" { environment { variables = { - PERSON_TABLE_NAME = var.eligibility_status_table_name, - RULES_BUCKET_NAME = var.eligibility_rules_bucket_name, - KINESIS_AUDIT_STREAM_TO_S3 = var.kinesis_audit_stream_to_s3_name - ENV = var.environment - LOG_LEVEL = var.log_level - ENABLE_XRAY_PATCHING = var.enable_xray_patching - API_DOMAIN_NAME = var.api_domain_name - HASHING_SECRET_NAME = var.hashing_secret_name + PERSON_TABLE_NAME = var.eligibility_status_table_name, + RULES_BUCKET_NAME = var.eligibility_rules_bucket_name, + CONSUMER_MAPPING_BUCKET_NAME = var.eligibility_consumer_mappings_bucket_name, + KINESIS_AUDIT_STREAM_TO_S3 = var.kinesis_audit_stream_to_s3_name + ENV = var.environment + LOG_LEVEL = var.log_level + ENABLE_XRAY_PATCHING = var.enable_xray_patching + API_DOMAIN_NAME = var.api_domain_name + HASHING_SECRET_NAME = var.hashing_secret_name } } diff --git a/infrastructure/modules/lambda/variables.tf b/infrastructure/modules/lambda/variables.tf index 85b639862..6f238e149 100644 --- a/infrastructure/modules/lambda/variables.tf +++ b/infrastructure/modules/lambda/variables.tf @@ -44,6 +44,11 @@ variable "eligibility_rules_bucket_name" { type = string } +variable "eligibility_consumer_mappings_bucket_name" { + description = "consumer mappings bucket name" + type = string +} + variable "eligibility_status_table_name" { description = "eligibility datastore table name" type = string diff --git a/infrastructure/stacks/api-layer/lambda.tf b/infrastructure/stacks/api-layer/lambda.tf index 9b31fee49..f87c36588 100644 --- a/infrastructure/stacks/api-layer/lambda.tf +++ b/infrastructure/stacks/api-layer/lambda.tf @@ -11,27 +11,28 @@ data "aws_subnet" "private_subnets" { } module "eligibility_signposting_lambda_function" { - source = "../../modules/lambda" - eligibility_lambda_role_arn = aws_iam_role.eligibility_lambda_role.arn - eligibility_lambda_role_name = aws_iam_role.eligibility_lambda_role.name - workspace = local.workspace - environment = var.environment - runtime = "python3.13" - lambda_func_name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}eligibility_signposting_api" + source = "../../modules/lambda" + eligibility_lambda_role_arn = aws_iam_role.eligibility_lambda_role.arn + eligibility_lambda_role_name = aws_iam_role.eligibility_lambda_role.name + workspace = local.workspace + environment = var.environment + runtime = "python3.13" + lambda_func_name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}eligibility_signposting_api" security_group_ids = [data.aws_security_group.main_sg.id] - vpc_intra_subnets = [for v in data.aws_subnet.private_subnets : v.id] - file_name = "../../../dist/lambda.zip" - handler = "eligibility_signposting_api.app.lambda_handler" - eligibility_rules_bucket_name = module.s3_rules_bucket.storage_bucket_name - eligibility_status_table_name = module.eligibility_status_table.table_name - kinesis_audit_stream_to_s3_name = module.eligibility_audit_firehose_delivery_stream.firehose_stream_name - hashing_secret_name = module.secrets_manager.aws_hashing_secret_name - lambda_insights_extension_version = 38 - log_level = "INFO" - enable_xray_patching = "true" - stack_name = local.stack_name - provisioned_concurrency_count = 5 - api_domain_name = local.api_domain_name + vpc_intra_subnets = [for v in data.aws_subnet.private_subnets : v.id] + file_name = "../../../dist/lambda.zip" + handler = "eligibility_signposting_api.app.lambda_handler" + eligibility_rules_bucket_name = module.s3_rules_bucket.storage_bucket_name + eligibility_consumer_mappings_bucket_name = module.s3_consumer_mappings_bucket.storage_bucket_name + eligibility_status_table_name = module.eligibility_status_table.table_name + kinesis_audit_stream_to_s3_name = module.eligibility_audit_firehose_delivery_stream.firehose_stream_name + hashing_secret_name = module.secrets_manager.aws_hashing_secret_name + lambda_insights_extension_version = 38 + log_level = "INFO" + enable_xray_patching = "true" + stack_name = local.stack_name + provisioned_concurrency_count = 5 + api_domain_name = local.api_domain_name } # ----------------------------------------------------------------------------- diff --git a/infrastructure/stacks/api-layer/s3_buckets.tf b/infrastructure/stacks/api-layer/s3_buckets.tf index 1a94f7284..df32b9eaf 100644 --- a/infrastructure/stacks/api-layer/s3_buckets.tf +++ b/infrastructure/stacks/api-layer/s3_buckets.tf @@ -7,6 +7,15 @@ module "s3_rules_bucket" { workspace = terraform.workspace } +module "s3_consumer_mappings_bucket" { + source = "../../modules/s3" + bucket_name = "eli-consumer-mappings" + environment = var.environment + project_name = var.project_name + stack_name = local.stack_name + workspace = terraform.workspace +} + module "s3_audit_bucket" { source = "../../modules/s3" bucket_name = "eli-audit" From bcadb02d9e8541ee8e264ed97c1d547141a01df9 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:46:20 +0000 Subject: [PATCH 49/58] Terraform consumer mapping bucket policy --- .../stacks/api-layer/iam_policies.tf | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 0fd67e453..6726fb492 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -104,6 +104,60 @@ data "aws_iam_policy_document" "rules_s3_bucket_policy" { } } +# Policy doc for S3 Consumer Mappings bucket +data "aws_iam_policy_document" "s3_consumer_mapping_bucket_policy" { + statement { + sid = "AllowSSLRequestsOnly" + actions = [ + "s3:GetObject", + "s3:ListBucket", + ] + resources = [ + module.s3_consumer_mappings_bucket.storage_bucket_arn, + "${module.s3_consumer_mappings_bucket.storage_bucket_arn}/*", + ] + condition { + test = "Bool" + values = ["true"] + variable = "aws:SecureTransport" + } + } +} + +# ensure only secure transport is allowed + +resource "aws_s3_bucket_policy" "consumer_mapping_s3_bucket" { + bucket = module.s3_consumer_mappings_bucket.storage_bucket_id + policy = data.aws_iam_policy_document.s3_consumer_mapping_bucket_policy.json +} + +data "aws_iam_policy_document" "consumer_mapping_s3_bucket_policy" { + statement { + sid = "AllowSslRequestsOnly" + actions = [ + "s3:*", + ] + effect = "Deny" + resources = [ + module.s3_consumer_mappings_bucket.storage_bucket_arn, + "${module.s3_consumer_mappings_bucket.storage_bucket_arn}/*", + ] + principals { + type = "*" + identifiers = ["*"] + } + condition { + test = "Bool" + values = [ + "false", + ] + + variable = "aws:SecureTransport" + } + } +} + +# audit bucket resource "aws_s3_bucket_policy" "audit_s3_bucket" { bucket = module.s3_audit_bucket.storage_bucket_id policy = data.aws_iam_policy_document.audit_s3_bucket_policy.json @@ -136,12 +190,18 @@ data "aws_iam_policy_document" "audit_s3_bucket_policy" { } # Attach s3 read policy to Lambda role -resource "aws_iam_role_policy" "lambda_s3_read_policy" { - name = "S3ReadAccess" +resource "aws_iam_role_policy" "lambda_s3_rules_read_policy" { + name = "S3RulesReadAccess" role = aws_iam_role.eligibility_lambda_role.id policy = data.aws_iam_policy_document.s3_rules_bucket_policy.json } +resource "aws_iam_role_policy" "lambda_s3_mapping_read_policy" { + name = "S3ConsumerMappingReadAccess" + role = aws_iam_role.eligibility_lambda_role.id + policy = data.aws_iam_policy_document.s3_consumer_mapping_bucket_policy.json +} + # Attach s3 write policy to kinesis firehose role resource "aws_iam_role_policy" "kinesis_firehose_s3_write_policy" { name = "S3WriteAccess" From 6eaca3046b5211fe0ba4cfcbd86e96be2a877cf2 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:21:50 +0000 Subject: [PATCH 50/58] more linting --- src/eligibility_signposting_api/model/consumer_mapping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eligibility_signposting_api/model/consumer_mapping.py b/src/eligibility_signposting_api/model/consumer_mapping.py index 839b05d8e..046aa9fee 100644 --- a/src/eligibility_signposting_api/model/consumer_mapping.py +++ b/src/eligibility_signposting_api/model/consumer_mapping.py @@ -1,6 +1,6 @@ from typing import NewType -from pydantic import BaseModel, RootModel, Field +from pydantic import BaseModel, Field, RootModel from eligibility_signposting_api.model.campaign_config import CampaignID From a1ad01833af7e29782a7930e6738f39fab05aacf Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:55:00 +0000 Subject: [PATCH 51/58] fix s3 --- infrastructure/stacks/api-layer/iam_policies.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 6726fb492..9a54878cf 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -128,7 +128,7 @@ data "aws_iam_policy_document" "s3_consumer_mapping_bucket_policy" { resource "aws_s3_bucket_policy" "consumer_mapping_s3_bucket" { bucket = module.s3_consumer_mappings_bucket.storage_bucket_id - policy = data.aws_iam_policy_document.s3_consumer_mapping_bucket_policy.json + policy = data.aws_iam_policy_document.consumer_mapping_s3_bucket_policy.json } data "aws_iam_policy_document" "consumer_mapping_s3_bucket_policy" { From 3d6b1272fe7d087a76bbd48e06bc68d87cba03fc Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:15:12 +0000 Subject: [PATCH 52/58] fix s3 --- infrastructure/stacks/api-layer/s3_buckets.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/stacks/api-layer/s3_buckets.tf b/infrastructure/stacks/api-layer/s3_buckets.tf index df32b9eaf..276e71354 100644 --- a/infrastructure/stacks/api-layer/s3_buckets.tf +++ b/infrastructure/stacks/api-layer/s3_buckets.tf @@ -9,7 +9,7 @@ module "s3_rules_bucket" { module "s3_consumer_mappings_bucket" { source = "../../modules/s3" - bucket_name = "eli-consumer-mappings" + bucket_name = "eli-consumer-map" environment = var.environment project_name = var.project_name stack_name = local.stack_name From 8afefa95460ddd997d47260767fd4938b1af606e Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:23:27 +0000 Subject: [PATCH 53/58] revert to S3ReadAccess --- infrastructure/stacks/api-layer/iam_policies.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 9a54878cf..f7dfa8d86 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -191,7 +191,7 @@ data "aws_iam_policy_document" "audit_s3_bucket_policy" { # Attach s3 read policy to Lambda role resource "aws_iam_role_policy" "lambda_s3_rules_read_policy" { - name = "S3RulesReadAccess" + name = "S3ReadAccess" role = aws_iam_role.eligibility_lambda_role.id policy = data.aws_iam_policy_document.s3_rules_bucket_policy.json } From 34073c632ab347b77fa65a4231aecd6018ff79be Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:54:15 +0000 Subject: [PATCH 54/58] iam permissions --- .../stacks/api-layer/iam_policies.tf | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index f7dfa8d86..28eb0ac2c 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -350,6 +350,38 @@ resource "aws_kms_key_policy" "s3_rules_kms_key" { policy = data.aws_iam_policy_document.s3_rules_kms_key_policy.json } +data "aws_iam_policy_document" "s3_consumer_mapping_kms_key_policy" { + #checkov:skip=CKV_AWS_111: Root user needs full KMS key management + #checkov:skip=CKV_AWS_356: Root user needs full KMS key management + #checkov:skip=CKV_AWS_109: Root user needs full KMS key management + statement { + sid = "EnableIamUserPermissions" + effect = "Allow" + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] + } + actions = ["kms:*"] + resources = ["*"] + } + + statement { + sid = "AllowLambdaDecrypt" + effect = "Allow" + principals { + type = "AWS" + identifiers = [aws_iam_role.eligibility_lambda_role.arn] + } + actions = ["kms:Decrypt"] + resources = ["*"] + } +} + +resource "aws_kms_key_policy" "s3_consumer_mapping_kms_key" { + key_id = module.s3_consumer_mappings_bucket.storage_bucket_kms_key_id + policy = data.aws_iam_policy_document.s3_consumer_mapping_kms_key_policy.json +} + resource "aws_iam_role_policy" "splunk_firehose_policy" { #checkov:skip=CKV_AWS_290: Firehose requires write access to dynamic log streams without static constraints #checkov:skip=CKV_AWS_355: Firehose logging requires wildcard resource for CloudWatch log groups/streams From bd8b5c2b2910aba5c62b81099fe238fe54edf4bf Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:14:20 +0000 Subject: [PATCH 55/58] more tests --- tests/integration/conftest.py | 14 +++++++-- .../in_process/test_eligibility_endpoint.py | 29 ++++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b2f699957..840af8b04 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1120,8 +1120,14 @@ def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) - request, "param", [("RSV", "RSV_campaign_id"), ("COVID", "COVID_campaign_id"), ("FLU", "FLU_campaign_id")] ) - targets = [t for t, _id in raw] - campaign_id = [_id for t, _id in raw] + targets = [] + campaign_id = [] + status = [] + + for t, _id, *rest in raw: + targets.append(t) + campaign_id.append(_id) + status.append(rest[0] if rest else None) for i in range(len(targets)): campaign: CampaignConfig = rule.CampaignConfigFactory.build( @@ -1148,6 +1154,10 @@ def campaign_configs(request, s3_client: BaseClient, rules_bucket: BucketName) - ) ], ) + + if status[i] == "inactive": + campaign.iterations[0].iteration_date = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(days=7) + campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} key = f"{campaign.name}.json" s3_client.put_object( diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index ac27fac7a..621d6f5d1 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -1090,18 +1090,36 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no @pytest.mark.parametrize( ("consumer_id", "expected_campaign_id"), [ + # Consumer is mapped only to RSV_campaign_id_1 ("consumer-id-1", "RSV_campaign_id_1"), + # Consumer is mapped only to RSV_campaign_id_2 ("consumer-id-2", "RSV_campaign_id_2"), + # Edge-case : Consumer-id-3 is mapped to multiple active campaigns, so the first one is only considered. + ("consumer-id-3", "RSV_campaign_id_3"), + # Edge-case : Consumer is mapped to inactive RSV_campaign_id_5 and active RSV_campaign_id_6 + ("consumer-id-4", "RSV_campaign_id_6"), + # Edge-case : Consumer is mapped only to inactive RSV_campaign_id_5 + ("consumer-id-5", None), ], ) @pytest.mark.parametrize( ("campaign_configs", "consumer_mappings", "requested_conditions", "requested_category"), [ ( - [("RSV", "RSV_campaign_id_1"), ("RSV", "RSV_campaign_id_2")], + [ + ("RSV", "RSV_campaign_id_1"), + ("RSV", "RSV_campaign_id_2"), + ("RSV", "RSV_campaign_id_3"), + ("RSV", "RSV_campaign_id_4"), + ("RSV", "RSV_campaign_id_5", "inactive"), # inactive iteration + ("RSV", "RSV_campaign_id_6"), + ], { "consumer-id-1": [{"Campaign": "RSV_campaign_id_1"}], "consumer-id-2": [{"Campaign": "RSV_campaign_id_2"}], + "consumer-id-3": [{"Campaign": "RSV_campaign_id_3"}, {"Campaign": "RSV_campaign_id_4"}], + "consumer-id-4": [{"Campaign": "RSV_campaign_id_5"}, {"Campaign": "RSV_campaign_id_6"}], + "consumer-id-5": [{"Campaign": "RSV_campaign_id_5"}], }, "RSV", "VACCINATIONS", @@ -1109,7 +1127,7 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no ], indirect=["campaign_configs", "consumer_mappings"], ) - def test_if_correct_campaign_is_chosen_for_the_consumer_if_there_exists_multiple_campaign_per_target( # noqa: PLR0913 + def test_if_correct_campaign_is_chosen_for_the_consumer_if_there_exists_multiple_campaign_per_target( # noqa : PLR0913 self, client: FlaskClient, persisted_person: NHSNumber, @@ -1138,5 +1156,8 @@ def test_if_correct_campaign_is_chosen_for_the_consumer_if_there_exists_multiple audit_data = json.loads(s3_client.get_object(Bucket=audit_bucket, Key=latest_key)["Body"].read()) # Then - assert_that(len(audit_data["response"]["condition"]), equal_to(1)) - assert_that(audit_data["response"]["condition"][0].get("campaignId"), equal_to(expected_campaign_id)) + if expected_campaign_id is not None: + assert_that(len(audit_data["response"]["condition"]), equal_to(1)) + assert_that(audit_data["response"]["condition"][0].get("campaignId"), equal_to(expected_campaign_id)) + else: + assert_that(len(audit_data["response"]["condition"]), equal_to(0)) From 0e6c7841b06e21be17375882d24b41a9c53eb3f4 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:33:14 +0000 Subject: [PATCH 56/58] name correction --- tests/integration/in_process/test_eligibility_endpoint.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 621d6f5d1..8d95e13c8 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -1096,7 +1096,7 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no ("consumer-id-2", "RSV_campaign_id_2"), # Edge-case : Consumer-id-3 is mapped to multiple active campaigns, so the first one is only considered. ("consumer-id-3", "RSV_campaign_id_3"), - # Edge-case : Consumer is mapped to inactive RSV_campaign_id_5 and active RSV_campaign_id_6 + # Edge-case : Consumer is mapped to inactive inactive_RSV_campaign_id_5 and active RSV_campaign_id_6 ("consumer-id-4", "RSV_campaign_id_6"), # Edge-case : Consumer is mapped only to inactive RSV_campaign_id_5 ("consumer-id-5", None), @@ -1111,15 +1111,15 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no ("RSV", "RSV_campaign_id_2"), ("RSV", "RSV_campaign_id_3"), ("RSV", "RSV_campaign_id_4"), - ("RSV", "RSV_campaign_id_5", "inactive"), # inactive iteration + ("RSV", "inactive_RSV_campaign_id_5", "inactive"), # inactive iteration ("RSV", "RSV_campaign_id_6"), ], { "consumer-id-1": [{"Campaign": "RSV_campaign_id_1"}], "consumer-id-2": [{"Campaign": "RSV_campaign_id_2"}], "consumer-id-3": [{"Campaign": "RSV_campaign_id_3"}, {"Campaign": "RSV_campaign_id_4"}], - "consumer-id-4": [{"Campaign": "RSV_campaign_id_5"}, {"Campaign": "RSV_campaign_id_6"}], - "consumer-id-5": [{"Campaign": "RSV_campaign_id_5"}], + "consumer-id-4": [{"Campaign": "inactive_RSV_campaign_id_5"}, {"Campaign": "RSV_campaign_id_6"}], + "consumer-id-5": [{"Campaign": "inactive_RSV_campaign_id_5"}], }, "RSV", "VACCINATIONS", From fe9a2da299b2557df51c75ea5f1ee93fae2e436d Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:44:41 +0000 Subject: [PATCH 57/58] one more test scenario --- .../integration/in_process/test_eligibility_endpoint.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 8d95e13c8..e262101c6 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -1094,8 +1094,10 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no ("consumer-id-1", "RSV_campaign_id_1"), # Consumer is mapped only to RSV_campaign_id_2 ("consumer-id-2", "RSV_campaign_id_2"), - # Edge-case : Consumer-id-3 is mapped to multiple active campaigns, so the first one is only considered. - ("consumer-id-3", "RSV_campaign_id_3"), + # Edge-case : Consumer-id-3a is mapped to multiple active campaigns, so the first one in S3 is only taken. + ("consumer-id-3a", "RSV_campaign_id_3"), + # Edge-case : Consumer-id-3b is mapped to multiple active campaigns, so the first one in S3 is only taken. + ("consumer-id-3b", "RSV_campaign_id_3"), # Edge-case : Consumer is mapped to inactive inactive_RSV_campaign_id_5 and active RSV_campaign_id_6 ("consumer-id-4", "RSV_campaign_id_6"), # Edge-case : Consumer is mapped only to inactive RSV_campaign_id_5 @@ -1117,7 +1119,8 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no { "consumer-id-1": [{"Campaign": "RSV_campaign_id_1"}], "consumer-id-2": [{"Campaign": "RSV_campaign_id_2"}], - "consumer-id-3": [{"Campaign": "RSV_campaign_id_3"}, {"Campaign": "RSV_campaign_id_4"}], + "consumer-id-3a": [{"Campaign": "RSV_campaign_id_3"}, {"Campaign": "RSV_campaign_id_4"}], + "consumer-id-3b": [{"Campaign": "RSV_campaign_id_4"}, {"Campaign": "RSV_campaign_id_3"}], "consumer-id-4": [{"Campaign": "inactive_RSV_campaign_id_5"}, {"Campaign": "RSV_campaign_id_6"}], "consumer-id-5": [{"Campaign": "inactive_RSV_campaign_id_5"}], }, From e7d88622fd64e392680043956564e540a20bbdfe Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:48:35 +0000 Subject: [PATCH 58/58] one more test scenario --- tests/integration/in_process/test_eligibility_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index e262101c6..3f6310534 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -1094,9 +1094,9 @@ def test_valid_response_when_consumer_has_a_valid_campaign_config_mapping( # no ("consumer-id-1", "RSV_campaign_id_1"), # Consumer is mapped only to RSV_campaign_id_2 ("consumer-id-2", "RSV_campaign_id_2"), - # Edge-case : Consumer-id-3a is mapped to multiple active campaigns, so the first one in S3 is only taken. + # Edge-case : Consumer-id-3a is mapped to multiple active campaigns, so only one taken. ("consumer-id-3a", "RSV_campaign_id_3"), - # Edge-case : Consumer-id-3b is mapped to multiple active campaigns, so the first one in S3 is only taken. + # Edge-case : Consumer-id-3b is mapped to multiple active campaigns, so only one taken. ("consumer-id-3b", "RSV_campaign_id_3"), # Edge-case : Consumer is mapped to inactive inactive_RSV_campaign_id_5 and active RSV_campaign_id_6 ("consumer-id-4", "RSV_campaign_id_6"),