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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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"