Skip to content

Commit f703f91

Browse files
authored
VED-872 Tighten given name validation (#915)
1 parent a2a06b6 commit f703f91

File tree

5 files changed

+124
-42
lines changed

5 files changed

+124
-42
lines changed

backend/src/models/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ class Constants:
4848

4949
ALLOWED_CONTAINED_RESOURCES = {"Practitioner", "Patient"}
5050

51+
# As per Personal Demographics Service FHIR API, the maximum length of a given name element or surname is 35 chars.
52+
# Given name is a list with a maximum 5 elements. For more info see:
53+
# https://digital.nhs.uk/developer/api-catalogue/personal-demographics-service-fhir#post-/Patient
54+
PERSON_NAME_ELEMENT_MAX_LENGTH = 35
55+
5156
SUPPLIER_PERMISSIONS_KEY = "supplier_permissions"
5257
VACCINE_TYPE_TO_DISEASES_HASH_KEY = "vacc_to_diseases"
5358
DISEASES_TO_VACCINE_TYPE_HASH_KEY = "diseases_to_vacc"

backend/src/models/fhir_immunization_pre_validators.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -297,22 +297,26 @@ def pre_validate_patient_name(self, values: dict) -> dict:
297297
except (KeyError, IndexError):
298298
pass
299299

300-
def pre_validate_patient_name_given(self, values: dict) -> dict:
300+
def pre_validate_patient_name_given(self, values: dict) -> None:
301301
"""
302302
Pre-validate that, if contained[?(@.resourceType=='Patient')].name[{index}].given index dynamically determined
303-
(legacy CSV field name:PERSON_FORENAME) exists, then it is a an array containing a single non-empty string
303+
(legacy CSV field name:PERSON_FORENAME) exists, then it is an array containing a single non-empty string
304304
"""
305305
field_location = patient_name_given_field_location(values)
306306

307307
try:
308308
field_value, _ = patient_and_practitioner_value_and_index(values, "given", "Patient")
309-
PreValidation.for_list(field_value, field_location, elements_are_strings=True)
309+
PreValidation.for_list(
310+
field_value,
311+
field_location,
312+
elements_are_strings=True,
313+
max_length=5,
314+
string_element_max_length=Constants.PERSON_NAME_ELEMENT_MAX_LENGTH,
315+
)
310316
except (KeyError, IndexError, AttributeError):
311317
pass
312318

313-
PERSON_SURNAME_MAX_LENGTH = 35
314-
315-
def pre_validate_patient_name_family(self, values: dict) -> dict:
319+
def pre_validate_patient_name_family(self, values: dict) -> None:
316320
"""
317321
Pre-validate that, if a contained[?(@.resourceType=='Patient')].name[{index}].family (legacy CSV field name:
318322
PERSON_SURNAME) exists, index dynamically determined then it is a non-empty string of maximum length
@@ -321,7 +325,7 @@ def pre_validate_patient_name_family(self, values: dict) -> dict:
321325
field_location = patient_name_family_field_location(values)
322326
try:
323327
field_value, _ = patient_and_practitioner_value_and_index(values, "family", "Patient")
324-
PreValidation.for_string(field_value, field_location, max_length=self.PERSON_SURNAME_MAX_LENGTH)
328+
PreValidation.for_string(field_value, field_location, max_length=Constants.PERSON_NAME_ELEMENT_MAX_LENGTH)
325329
except (KeyError, IndexError, AttributeError):
326330
pass
327331

@@ -489,8 +493,8 @@ def pre_validate_practitioner_name(self, values: dict) -> dict:
489493
def pre_validate_practitioner_name_given(self, values: dict) -> dict:
490494
"""
491495
Pre-validate that, if contained[?(@.resourceType=='Practitioner')].name[{index}].given index dynamically
492-
determined (legacy CSV field name:PERSON_FORENAME) exists,
493-
then it is a an array containing a single non-empty string
496+
determined (legacy CSV field name:PERSON_FORENAME) exists, then it is an array containing a single non-empty
497+
string
494498
"""
495499
field_location = practitioner_name_given_field_location(values)
496500
try:

backend/src/models/utils/pre_validator_utils.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from datetime import date, datetime
22
from decimal import Decimal
3-
from typing import Union
3+
from typing import Optional, Union
44

55
from .generic_utils import is_valid_simple_snomed, nhs_number_mod11_check
66

@@ -49,14 +49,15 @@ def for_string(
4949
def for_list(
5050
field_value: list,
5151
field_location: str,
52-
defined_length: int = None,
52+
defined_length: Optional[int] = None,
53+
max_length: Optional[int] = None,
5354
elements_are_strings: bool = False,
55+
string_element_max_length: Optional[int] = None,
5456
elements_are_dicts: bool = False,
5557
):
5658
"""
57-
Apply pre-validation to a list field to ensure it is a non-empty list which meets the length
58-
requirements and requirements, if applicable, for each list element to be a non-empty string
59-
or non-empty dictionary
59+
Apply pre-validation to a list field to ensure it is a non-empty list which meets the length requirements and
60+
requirements, if applicable, for each list element to be a non-empty string or non-empty dictionary
6061
"""
6162
if not isinstance(field_value, list):
6263
raise TypeError(f"{field_location} must be an array")
@@ -68,12 +69,12 @@ def for_list(
6869
if len(field_value) == 0:
6970
raise ValueError(f"{field_location} must be a non-empty array")
7071

72+
if max_length is not None and len(field_value) > max_length:
73+
raise ValueError(f"{field_location} must be an array of maximum length {max_length}")
74+
7175
if elements_are_strings:
72-
for element in field_value:
73-
if not isinstance(element, str):
74-
raise TypeError(f"{field_location} must be an array of strings")
75-
if len(element) == 0:
76-
raise ValueError(f"{field_location} must be an array of non-empty strings")
76+
for idx, element in enumerate(field_value):
77+
PreValidation.for_string(element, f"{field_location}[{idx}]", max_length=string_element_max_length)
7778

7879
if elements_are_dicts:
7980
for element in field_value:

backend/tests/test_immunization_pre_validator.py

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from jsonpath_ng.ext import parse
99

10+
from models.constants import Constants
1011
from models.fhir_immunization import ImmunizationValidator
1112
from models.fhir_immunization_pre_validators import PreValidators
1213
from models.utils.generic_utils import (
@@ -487,18 +488,55 @@ def test_pre_validate_patient_name(self):
487488
valid_list_element=[{"family": "Test", "given": ["TestA"]}],
488489
)
489490

490-
def test_pre_validate_patient_name_given(self):
491-
"""Test pre_validate_patient_name_given accepts valid values and rejects invalid values"""
492-
valid_json_data = deepcopy(self.json_data)
493-
# invalid_json
491+
def test_pre_validate_patient_name_given_rejects_invalid_input(self):
492+
"""Test pre_validate_patient_name_given rejects invalid values"""
493+
given_name_field_loc = patient_name_given_field_location(self.json_data)
494+
495+
test_cases = [
496+
([None], "contained[?(@.resourceType=='Patient')].name[0].given[0] must be a string"),
497+
([], "contained[?(@.resourceType=='Patient')].name[0].given must be a non-empty array"),
498+
([""], "contained[?(@.resourceType=='Patient')].name[0].given[0] must be a non-empty string"),
499+
([" \n"], "contained[?(@.resourceType=='Patient')].name[0].given[0] must be a non-empty string"),
500+
(["Test", " "], "contained[?(@.resourceType=='Patient')].name[0].given[1] must be a non-empty string"),
501+
(
502+
["Too", "many", "items", "1", "2", "5"],
503+
"contained[?(@.resourceType=='Patient')].name[0].given must be an array of maximum length 5",
504+
),
505+
(
506+
["Stringtoolongeruti olgkriahfyrtoiuhg"],
507+
"contained[?(@.resourceType=='Patient')].name[0].given[0] must be 35 or fewer characters",
508+
),
509+
]
494510

495-
ValidatorModelTests.test_list_value(
496-
self,
497-
field_location=patient_name_given_field_location(valid_json_data),
498-
valid_lists_to_test=[["Test"], ["Test test"]],
499-
valid_list_element="Test",
500-
is_list_of_strings=True,
501-
)
511+
for invalid_data, expected_error in test_cases:
512+
with self.subTest():
513+
invalid_json_data = deepcopy(self.json_data)
514+
parse(given_name_field_loc).update(invalid_json_data, invalid_data)
515+
516+
with self.assertRaises(Exception) as error:
517+
PreValidators(invalid_json_data).pre_validate_patient_name_given(values=invalid_json_data)
518+
519+
self.assertEqual(str(error.exception), expected_error)
520+
self.assertIsInstance(error.exception, (ValueError, TypeError))
521+
522+
def test_pre_validate_patient_name_given_accepts_valid_input(self):
523+
"""Test pre_validate_patient_name_given accepts valid values"""
524+
given_name_field_loc = patient_name_given_field_location(self.json_data)
525+
526+
test_cases = [
527+
["Test"],
528+
["Multiple test", "values"],
529+
["One", "Two", "Three", "Four", "Five"],
530+
[" Can have spaces "],
531+
["Exactlythirtyfivecharactersuiopasdf"],
532+
]
533+
534+
for valid_input in test_cases:
535+
with self.subTest():
536+
valid_json_data = deepcopy(self.json_data)
537+
parse(given_name_field_loc).update(valid_json_data, valid_input)
538+
539+
PreValidators(valid_json_data).pre_validate_patient_name_given(values=valid_json_data)
502540

503541
def test_pre_validate_patient_name_family(self):
504542
"""Test pre_validate_patient_name_family accepts valid values and rejects invalid values"""
@@ -507,7 +545,7 @@ def test_pre_validate_patient_name_family(self):
507545
self,
508546
field_location=patient_name_family_field_location(valid_json_data),
509547
valid_strings_to_test=["test", "Quitelongsurname", "Surnamewithjustthirtyfivecharacters"],
510-
max_length=PreValidators.PERSON_SURNAME_MAX_LENGTH,
548+
max_length=Constants.PERSON_NAME_ELEMENT_MAX_LENGTH,
511549
invalid_length_strings_to_test=["Surnamethathasgotthirtysixcharacters"],
512550
)
513551

@@ -666,16 +704,50 @@ def test_pre_validate_practitioner_name(self):
666704
valid_list_element={"family": "Test"},
667705
)
668706

669-
def test_pre_validate_practitioner_name_given(self):
670-
"""Test pre_validate_practitioner_name_given accepts valid values and rejects invalid values"""
671-
valid_json_data = deepcopy(self.json_data)
672-
ValidatorModelTests.test_list_value(
673-
self,
674-
field_location=practitioner_name_given_field_location(valid_json_data),
675-
valid_lists_to_test=[["Test"], ["Test test"]],
676-
valid_list_element="Test",
677-
is_list_of_strings=True,
678-
)
707+
def test_pre_validate_practitioner_name_given_rejects_invalid_data(self):
708+
"""Test pre_validate_practitioner_name_given rejects invalid values"""
709+
practitioner_name_field_loc = practitioner_name_given_field_location(self.json_data)
710+
711+
test_cases = [
712+
([None], "contained[?(@.resourceType=='Practitioner')].name[0].given[0] must be a string"),
713+
([123, 456], "contained[?(@.resourceType=='Practitioner')].name[0].given[0] must be a string"),
714+
([], "contained[?(@.resourceType=='Practitioner')].name[0].given must be a non-empty array"),
715+
([""], "contained[?(@.resourceType=='Practitioner')].name[0].given[0] must be a non-empty string"),
716+
([" \n"], "contained[?(@.resourceType=='Practitioner')].name[0].given[0] must be a non-empty string"),
717+
(
718+
["Test", " "],
719+
"contained[?(@.resourceType=='Practitioner')].name[0].given[1] must be a non-empty string",
720+
),
721+
]
722+
723+
for invalid_data, expected_error in test_cases:
724+
with self.subTest():
725+
invalid_json_data = deepcopy(self.json_data)
726+
parse(practitioner_name_field_loc).update(invalid_json_data, invalid_data)
727+
728+
with self.assertRaises(Exception) as error:
729+
PreValidators(invalid_json_data).pre_validate_practitioner_name_given(values=invalid_json_data)
730+
731+
self.assertEqual(str(error.exception), expected_error)
732+
self.assertIsInstance(error.exception, (ValueError, TypeError))
733+
734+
def test_pre_validate_practitioner_name_given_accepts_valid_input(self):
735+
"""Test pre_validate_practitioner_name_given accepts valid values"""
736+
practitioner_name_field_loc = practitioner_name_given_field_location(self.json_data)
737+
738+
test_cases = [
739+
["Test"],
740+
["Multiple test", "values"],
741+
["One", "Two", "Three", "Four", "Five", "Very many not restricted by length"],
742+
[" Can have spaces "],
743+
]
744+
745+
for valid_input in test_cases:
746+
with self.subTest():
747+
valid_json_data = deepcopy(self.json_data)
748+
parse(practitioner_name_field_loc).update(valid_json_data, valid_input)
749+
750+
PreValidators(valid_json_data).pre_validate_practitioner_name_given(values=valid_json_data)
679751

680752
def test_pre_validate_practitioner_name_family(self):
681753
"""Test pre_validate_practitioner_name_family accepts valid values and rejects invalid values"""

backend/tests/testing_utils/pre_validation_test_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def test_list_value(
158158
that a valid list element must be supplied when a predefined list length is given as
159159
the valid element will be used to populate lists of incorrect length to ensure
160160
that the error is being raised due to length, not due to use of an invalid list element)
161-
* If there is no predfined list length: Empty list
161+
* If there is no predefined list length: Empty list
162162
* If is a list of strings: Lists with non-string or empty string elements
163163
* If is a list of dicts: Lists with non-dict or empty dict elements
164164

0 commit comments

Comments
 (0)