Skip to content
Empty file.
63 changes: 63 additions & 0 deletions gateway-api/src/gateway_api/common/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
Shared lightweight types and helpers used across the gateway API.
"""

import re
from dataclasses import dataclass

# This project uses JSON request/response bodies as strings in the controller layer.
# The alias is used to make intent clearer in function signatures.
type json_str = str


@dataclass
class FlaskResponse:
"""
Lightweight response container returned by controller entry points.

This mirrors the minimal set of fields used by the surrounding web framework.

:param status_code: HTTP status code for the response (e.g., 200, 400, 404).
:param data: Response body as text, if any.
:param headers: Response headers, if any.
"""

status_code: int
data: str | None = None
headers: dict[str, str] | None = None


def validate_nhs_number(value: str | int) -> bool:
"""
Validate an NHS number using the NHS modulus-11 check digit algorithm.

The input may be a string or integer. Any non-digit separators in string
inputs (spaces, hyphens, etc.) are ignored.

:param value: NHS number as a string or integer. Non-digit characters
are ignored when a string is provided.
:returns: ``True`` if the number is a valid NHS number, otherwise ``False``.
"""
str_value = str(value) # Just in case they passed an integer
digits = re.sub(r"\D", "", str_value or "")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will convert "94347NOT_AN_NHS_NUMBER65919" to "9434765919". Do we want that? I don't think I have come across an NHS number written with anything other than a space, hyphen or digits. And PDS FHIR API expects and returns digits only. May need to confirm expectations for this.

>>> str_value = "46HELLO98194180"
>>> digits = re.sub(r"\D", "", str_value or "")
>>> digits
'4698194180'


if len(digits) != 10:
return False
if not digits.isdigit():
return False

first_nine = [int(ch) for ch in digits[:9]]
provided_check_digit = int(digits[9])

weights = list(range(10, 1, -1))
total = sum(d * w for d, w in zip(first_nine, weights, strict=True))

remainder = total % 11
check = 11 - remainder

if check == 11:
check = 0
if check == 10:
return False # invalid NHS number

return check == provided_check_digit
Empty file.
60 changes: 60 additions & 0 deletions gateway-api/src/gateway_api/common/test_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""
Unit tests for :mod:`gateway_api.common.common`.
"""

from gateway_api.common import common


def test_validate_nhs_number_accepts_valid_number_with_separators() -> None:
"""
Validate that separators (spaces, hyphens) are ignored and valid numbers pass.
"""
assert common.validate_nhs_number("943 476 5919") is True
assert common.validate_nhs_number("943-476-5919") is True
assert common.validate_nhs_number(9434765919) is True
Comment on lines +8 to +14

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having a multiple asserts in a unit test means that the first failing assertion prevents any later assertions. You could convert this to a parameterized test averts this.

Suggested change
def test_validate_nhs_number_accepts_valid_number_with_separators() -> None:
"""
Validate that separators (spaces, hyphens) are ignored and valid numbers pass.
"""
assert common.validate_nhs_number("943 476 5919") is True
assert common.validate_nhs_number("943-476-5919") is True
assert common.validate_nhs_number(9434765919) is True
@pytest.mark.parametrize(
"valid_nhs_number",
[
"9434765919",
"9876543210",
9434765919,
],
)
def test_validate_nhs_number_accepts_valid_number_with_separators(
valid_nhs_number: str | int,
) -> None:
"""
Validate that separators (spaces, hyphens) are ignored and valid numbers pass.
"""
assert common.validate_nhs_number(valid_nhs_number) is True



def test_validate_nhs_number_rejects_wrong_length_and_bad_check_digit() -> None:
"""Validate that incorrect lengths and invalid check digits are rejected."""
assert common.validate_nhs_number("") is False
assert common.validate_nhs_number("943476591") is False # 9 digits
assert common.validate_nhs_number("94347659190") is False # 11 digits
assert common.validate_nhs_number("9434765918") is False # wrong check digit


def test_validate_nhs_number_returns_false_for_non_ten_digits_and_non_numeric() -> None:
"""
validate_nhs_number should return False when:
- The number of digits is not exactly 10.
- The input is not numeric.

Notes:
- The implementation strips non-digit characters before validation, so a fully
non-numeric input becomes an empty digit string and is rejected.
"""
# Not ten digits after stripping -> False
Comment on lines +25 to +35

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your test function name, its doc string and the comments all repeat one another.

Suggested change
def test_validate_nhs_number_returns_false_for_non_ten_digits_and_non_numeric() -> None:
"""
validate_nhs_number should return False when:
- The number of digits is not exactly 10.
- The input is not numeric.
Notes:
- The implementation strips non-digit characters before validation, so a fully
non-numeric input becomes an empty digit string and is rejected.
"""
# Not ten digits after stripping -> False
def test_validate_nhs_number_returns_false_for_non_ten_digits_and_non_numeric() ->

assert common.validate_nhs_number("123456789") is False
assert common.validate_nhs_number("12345678901") is False

# Not numeric -> False (becomes 0 digits after stripping)
assert common.validate_nhs_number("NOT_A_NUMBER") is False

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See previous comment. May wish to add a test for

Suggested change
assert common.validate_nhs_number("NOT_A_NUMBER") is False
assert common.validate_nhs_number("NOT_A_NUMBER") is False
assert common.validate_nhs_number("94347659NOT_A_NUMBER19") is False



def test_validate_nhs_number_check_edge_cases_10_and_11() -> None:
"""
validate_nhs_number should behave correctly when the computed ``check`` value
is 10 or 11.

- If ``check`` computes to 11, it should be treated as 0, so a number with check
digit 0 should validate successfully.
- If ``check`` computes to 10, the number is invalid and validation should return
False.
"""
# All zeros => weighted sum 0 => remainder 0 => check 11 => mapped to 0 => valid
# with check digit 0
assert common.validate_nhs_number("0000000000") is True

# First nine digits produce remainder 1 => check 10 => invalid regardless of
# final digit
# Choose d9=6 and others 0: total = 6*2 = 12 => 12 % 11 = 1 => check = 10
assert common.validate_nhs_number("0000000060") is False
Loading
Loading