Skip to content

Commit 1ad2f62

Browse files
committed
eli-579 - handling 'unknown' targets when used in derivation functions
1 parent 6e3ae34 commit 1ad2f62

4 files changed

Lines changed: 309 additions & 5 deletions

File tree

src/eligibility_signposting_api/services/processors/token_processor.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ def get_token_replacement(token: str, person_data: list[dict], present_attribute
9494
if parsed_token.function_name:
9595
return TokenProcessor.get_derived_value(parsed_token, person_data, present_attributes, token)
9696

97+
# For non-derived tokens, validate that target attributes are allowed
98+
TokenProcessor.validate_target_attribute(parsed_token, token)
99+
97100
found_attribute, key_to_replace = TokenProcessor.find_matching_attribute(parsed_token, person_data)
98101

99102
if not found_attribute or not key_to_replace:
@@ -139,17 +142,16 @@ def get_derived_value(
139142
# For TARGET level tokens, validate the condition is allowed
140143
if parsed_token.attribute_level == TARGET_ATTRIBUTE_LEVEL:
141144
is_allowed_condition = parsed_token.attribute_name in ALLOWED_CONDITIONS.__args__
142-
is_allowed_target_attr = parsed_token.attribute_value in ALLOWED_TARGET_ATTRIBUTES
143145

144146
# If condition is not allowed, raise error
145147
if not is_allowed_condition:
146148
TokenProcessor.handle_token_not_found(parsed_token, token)
147149

148-
# If vaccine type is not in person data but is allowed, return empty
150+
# If vaccine type is not in person data, return empty string
151+
# For derived values, any target attribute name is allowed (e.g., NEXT_BOOKING_AVAILABLE)
152+
# since it's just a placeholder that may be surfaced in the future
149153
if parsed_token.attribute_name not in present_attributes:
150-
if is_allowed_target_attr:
151-
return ""
152-
TokenProcessor.handle_token_not_found(parsed_token, token)
154+
return ""
153155

154156
try:
155157
target_attribute = parsed_token.attribute_value or parsed_token.attribute_name
@@ -185,6 +187,26 @@ def should_replace_with_empty(parsed_token: ParsedToken, present_attributes: set
185187

186188
return all([is_target_level, is_allowed_condition, is_allowed_target_attr, is_attr_not_present])
187189

190+
@staticmethod
191+
def validate_target_attribute(parsed_token: ParsedToken, token: str) -> None:
192+
"""Validate that target attribute is allowed for non-derived tokens.
193+
194+
For regular (non-derived) tokens, only allow known target attributes.
195+
Derived values with functions can use any custom target attribute name.
196+
197+
Args:
198+
parsed_token: The parsed token to validate
199+
token: The original token string for error messages
200+
201+
Raises:
202+
ValueError: If the target attribute is not in ALLOWED_TARGET_ATTRIBUTES
203+
"""
204+
if (
205+
parsed_token.attribute_level == TARGET_ATTRIBUTE_LEVEL
206+
and parsed_token.attribute_value not in ALLOWED_TARGET_ATTRIBUTES
207+
):
208+
TokenProcessor.handle_token_not_found(parsed_token, token)
209+
188210
@staticmethod
189211
def find_matching_attribute(parsed_token: ParsedToken, person_data: list[dict]) -> tuple[dict | None, str | None]:
190212
attribute_level_map = {

tests/integration/conftest.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,48 @@ def campaign_config_with_multiple_add_days(
11621162
s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json")
11631163

11641164

1165+
@pytest.fixture
1166+
def campaign_config_with_custom_target_attributes(
1167+
s3_client: BaseClient, rules_bucket: BucketName
1168+
) -> Generator[CampaignConfig]:
1169+
"""Campaign config with custom target attribute names for derived values."""
1170+
campaign: CampaignConfig = rule.CampaignConfigFactory.build(
1171+
target="COVID",
1172+
iterations=[
1173+
rule.IterationFactory.build(
1174+
default_comms_routing="CUSTOM_BOOKING_DATE",
1175+
actions_mapper=rule.ActionsMapperFactory.build(
1176+
root={
1177+
"CUSTOM_BOOKING_DATE": AvailableAction(
1178+
ActionType="DataValue",
1179+
ExternalRoutingCode="NextBookingAvailable",
1180+
ActionDescription=(
1181+
"[[TARGET.COVID.NEXT_BOOKING_AVAILABLE:ADD_DAYS(71, LAST_SUCCESSFUL_DATE):"
1182+
"DATE(%d %B %Y)]]"
1183+
),
1184+
),
1185+
}
1186+
),
1187+
iteration_rules=[],
1188+
iteration_cohorts=[
1189+
rule.IterationCohortFactory.build(
1190+
cohort_label="cohort_label1",
1191+
cohort_group="cohort_group1",
1192+
positive_description="Positive Description",
1193+
negative_description="Negative Description",
1194+
)
1195+
],
1196+
)
1197+
],
1198+
)
1199+
campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)}
1200+
s3_client.put_object(
1201+
Bucket=rules_bucket, Key=f"{campaign.name}.json", Body=json.dumps(campaign_data), ContentType="application/json"
1202+
)
1203+
yield campaign
1204+
s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json")
1205+
1206+
11651207
@pytest.fixture(scope="class")
11661208
def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]:
11671209
"""Create and upload multiple campaign configs to S3, then clean up after tests."""
@@ -1521,6 +1563,20 @@ def consumer_to_active_campaign_config_with_multiple_add_days_mapping(
15211563
s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json")
15221564

15231565

1566+
@pytest.fixture
1567+
def consumer_to_active_campaign_config_with_custom_target_attributes_mapping(
1568+
s3_client: BaseClient,
1569+
consumer_mapping_bucket: ConsumerMapping,
1570+
campaign_config_with_custom_target_attributes: CampaignConfig,
1571+
consumer_id: ConsumerId,
1572+
):
1573+
consumer_mapping = create_and_put_consumer_mapping_in_s3(
1574+
campaign_config_with_custom_target_attributes, consumer_id, consumer_mapping_bucket, s3_client
1575+
)
1576+
yield consumer_mapping
1577+
s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json")
1578+
1579+
15241580
@pytest.fixture
15251581
def consumer_to_campaign_having_inactive_iteration_mapping(
15261582
s3_client: BaseClient,

tests/integration/in_process/test_derived_values.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,65 @@ def test_multiple_actions_with_different_add_days_parameters(
246246
has_item(has_entries(actionCode="DateOfNextDoseAt61Days", description="20260330")),
247247
),
248248
)
249+
250+
251+
class TestCustomTargetAttributeNames:
252+
"""Test that custom target attribute names work with derived values in integration."""
253+
254+
def test_custom_target_attribute_with_derived_value(
255+
self,
256+
client: FlaskClient,
257+
person_with_covid_vaccination: NHSNumber,
258+
consumer_id: ConsumerId,
259+
consumer_to_active_campaign_config_with_custom_target_attributes_mapping: ConsumerMapping, # noqa: ARG002
260+
secretsmanager_client: BaseClient, # noqa: ARG002
261+
):
262+
"""
263+
Test that custom target attribute names like NEXT_BOOKING_AVAILABLE work with derived values.
264+
265+
This tests the issue reported in production where:
266+
[[TARGET.COVID.NEXT_BOOKING_AVAILABLE:ADD_DAYS(71, LAST_SUCCESSFUL_DATE):DATE(%d %B %Y)]]
267+
was raising a ValueError.
268+
269+
Given:
270+
- A person with COVID vaccination on 2026-01-28
271+
- A campaign config using a custom target attribute: NEXT_BOOKING_AVAILABLE
272+
- The token: [[TARGET.COVID.NEXT_BOOKING_AVAILABLE:ADD_DAYS(71, LAST_SUCCESSFUL_DATE):DATE(%d %B %Y)]]
273+
274+
Expected:
275+
- Should calculate 2026-01-28 + 71 days = 2026-04-09
276+
- Should format as "09 April 2026"
277+
- Should NOT raise ValueError about invalid attribute name
278+
"""
279+
# Given
280+
headers = {"nhs-login-nhs-number": str(person_with_covid_vaccination), UNIQUE_CONSUMER_HEADER: str(consumer_id)}
281+
282+
# When
283+
response = client.get(
284+
f"/patient-check/{person_with_covid_vaccination}?includeActions=Y",
285+
headers=headers,
286+
)
287+
288+
# Then
289+
assert_that(
290+
response,
291+
is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_key("processedSuggestions"))),
292+
)
293+
294+
body = response.get_json()
295+
assert_that(body, is_not(none()))
296+
processed_suggestions = body.get("processedSuggestions", [])
297+
298+
covid_suggestion = next(
299+
(s for s in processed_suggestions if s.get("condition") == "COVID"),
300+
None,
301+
)
302+
assert_that(covid_suggestion, is_not(none()))
303+
304+
actions = covid_suggestion.get("actions", []) # type: ignore[union-attr]
305+
306+
# Verify the custom target attribute with derived value works correctly
307+
assert_that(
308+
actions,
309+
has_item(has_entries(actionCode="NextBookingAvailable", description="09 April 2026")),
310+
)

tests/unit/services/processors/test_token_processor.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,3 +444,167 @@ def test_token_replace_is_case_insensitive(self, token: str, expected: str):
444444

445445
assert result.status_text == f"Your DOB is: {expected}."
446446
assert result.condition_name == f"FLU vaccine on: {expected}."
447+
448+
449+
class TestCustomTargetAttributeNames:
450+
"""Test that custom target attribute names work with derived values."""
451+
452+
def test_custom_target_attribute_with_add_days_when_data_present(self):
453+
"""Test that custom target attributes like NEXT_BOOKING_AVAILABLE work when data is present."""
454+
person = Person(
455+
[
456+
{"ATTRIBUTE_TYPE": "COVID", "LAST_SUCCESSFUL_DATE": "20260128"},
457+
]
458+
)
459+
460+
condition = Condition(
461+
condition_name=ConditionName("Test"),
462+
status=Status.actionable,
463+
status_text=StatusText(
464+
"Next booking: [[TARGET.COVID.NEXT_BOOKING_AVAILABLE:ADD_DAYS(71, LAST_SUCCESSFUL_DATE)]]"
465+
),
466+
cohort_results=[],
467+
suitability_rules=[],
468+
actions=[],
469+
)
470+
471+
result = TokenProcessor.find_and_replace_tokens(person, condition)
472+
473+
# 2026-01-28 + 71 days = 2026-04-09
474+
assert result.status_text == "Next booking: 20260409"
475+
476+
def test_custom_target_attribute_with_add_days_and_formatting(self):
477+
"""Test that custom target attributes work with both ADD_DAYS and DATE formatting."""
478+
person = Person(
479+
[
480+
{"ATTRIBUTE_TYPE": "COVID", "LAST_SUCCESSFUL_DATE": "20260128"},
481+
]
482+
)
483+
484+
condition = Condition(
485+
condition_name=ConditionName("Test"),
486+
status=Status.actionable,
487+
status_text=StatusText(
488+
"Date: [[TARGET.COVID.NEXT_BOOKING_AVAILABLE:ADD_DAYS(71, LAST_SUCCESSFUL_DATE):DATE(%d %B %Y)]]"
489+
),
490+
cohort_results=[],
491+
suitability_rules=[],
492+
actions=[],
493+
)
494+
495+
result = TokenProcessor.find_and_replace_tokens(person, condition)
496+
497+
# 2026-01-28 + 71 days = 2026-04-09, formatted as "09 April 2026"
498+
assert result.status_text == "Date: 09 April 2026"
499+
500+
def test_custom_target_attribute_returns_empty_when_condition_not_present(self):
501+
"""Test that custom target attributes return empty string when condition data not present."""
502+
person = Person(
503+
[
504+
{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"},
505+
# No COVID data present
506+
]
507+
)
508+
509+
condition = Condition(
510+
condition_name=ConditionName("Test"),
511+
status=Status.actionable,
512+
status_text=StatusText(
513+
"Next booking: [[TARGET.COVID.NEXT_BOOKING_AVAILABLE:ADD_DAYS(71, LAST_SUCCESSFUL_DATE)]]"
514+
),
515+
cohort_results=[],
516+
suitability_rules=[],
517+
actions=[],
518+
)
519+
520+
result = TokenProcessor.find_and_replace_tokens(person, condition)
521+
522+
# Should return empty string when condition data is not present
523+
assert result.status_text == "Next booking: "
524+
525+
def test_multiple_custom_target_attributes_with_different_functions(self):
526+
"""Test multiple custom target attributes with different parameters."""
527+
person = Person(
528+
[
529+
{"ATTRIBUTE_TYPE": "COVID", "LAST_SUCCESSFUL_DATE": "20260128"},
530+
]
531+
)
532+
533+
condition = Condition(
534+
condition_name=ConditionName("Test"),
535+
status=Status.actionable,
536+
status_text=StatusText(
537+
"First: [[TARGET.COVID.CUSTOM_FIELD_A:ADD_DAYS(30, LAST_SUCCESSFUL_DATE)]] "
538+
"Second: [[TARGET.COVID.CUSTOM_FIELD_B:ADD_DAYS(60, LAST_SUCCESSFUL_DATE)]]"
539+
),
540+
cohort_results=[],
541+
suitability_rules=[],
542+
actions=[],
543+
)
544+
545+
result = TokenProcessor.find_and_replace_tokens(person, condition)
546+
547+
# 2026-01-28 + 30 = 2026-02-27, + 60 = 2026-03-29
548+
assert result.status_text == "First: 20260227 Second: 20260329"
549+
550+
def test_custom_target_attribute_raises_error_for_invalid_condition(self):
551+
"""Test that invalid condition names still raise errors even with custom target attributes."""
552+
person = Person(
553+
[
554+
{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"},
555+
]
556+
)
557+
558+
condition = Condition(
559+
condition_name=ConditionName("Test"),
560+
status=Status.actionable,
561+
status_text=StatusText("Invalid: [[TARGET.INVALID_CONDITION.CUSTOM_FIELD:ADD_DAYS(30)]]"),
562+
cohort_results=[],
563+
suitability_rules=[],
564+
actions=[],
565+
)
566+
567+
with pytest.raises(ValueError, match="Invalid attribute name 'CUSTOM_FIELD'"):
568+
TokenProcessor.find_and_replace_tokens(person, condition)
569+
570+
def test_non_derived_token_with_invalid_target_attribute_raises_error(self):
571+
"""Test that non-derived tokens (without functions) validate target attributes strictly."""
572+
person = Person(
573+
[
574+
{"ATTRIBUTE_TYPE": "COVID", "LAST_SUCCESSFUL_DATE": "20260128"},
575+
]
576+
)
577+
578+
condition = Condition(
579+
condition_name=ConditionName("Test"),
580+
status=Status.actionable,
581+
status_text=StatusText("Invalid: [[TARGET.COVID.CUSTOM_INVALID_FIELD]]"),
582+
cohort_results=[],
583+
suitability_rules=[],
584+
actions=[],
585+
)
586+
587+
# Non-derived tokens should only allow ALLOWED_TARGET_ATTRIBUTES
588+
with pytest.raises(ValueError, match="Invalid attribute name 'CUSTOM_INVALID_FIELD'"):
589+
TokenProcessor.find_and_replace_tokens(person, condition)
590+
591+
def test_non_derived_token_with_valid_target_attribute_works(self):
592+
"""Test that non-derived tokens with valid target attributes work correctly."""
593+
person = Person(
594+
[
595+
{"ATTRIBUTE_TYPE": "COVID", "LAST_SUCCESSFUL_DATE": "20260128"},
596+
]
597+
)
598+
599+
condition = Condition(
600+
condition_name=ConditionName("Test"),
601+
status=Status.actionable,
602+
status_text=StatusText("Last date: [[TARGET.COVID.LAST_SUCCESSFUL_DATE]]"),
603+
cohort_results=[],
604+
suitability_rules=[],
605+
actions=[],
606+
)
607+
608+
result = TokenProcessor.find_and_replace_tokens(person, condition)
609+
610+
assert result.status_text == "Last date: 20260128"

0 commit comments

Comments
 (0)