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

import re
from dataclasses import dataclass
from typing import cast

# 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"[\s-]", "", str_value or "")

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


def coerce_nhs_number_to_int(value: str | int) -> int:
"""
Coerce an NHS number to an integer with basic validation.

Notes:
- NHS numbers are 10 digits.
- Input may include whitespace (e.g., ``"943 476 5919"``).

:param value: NHS number value, as a string or integer.
:returns: The coerced NHS number as an integer.
:raises ValueError: If the NHS number is non-numeric, the wrong length, or fails
validation.
"""
try:
stripped = cast("str", value).strip().replace(" ", "")
except AttributeError:
nhs_number_int = cast("int", value)
else:
if not stripped.isdigit():
raise ValueError("NHS number must be numeric")
nhs_number_int = int(stripped)

if len(str(nhs_number_int)) != 10:
# If you need to accept test numbers of different length, relax this.
raise ValueError("NHS number must be 10 digits")

if not validate_nhs_number(nhs_number_int):
raise ValueError("NHS number is invalid")

return nhs_number_int
Empty file.
86 changes: 86 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,86 @@
"""
Unit tests for :mod:`gateway_api.common.common`.
"""

from typing import Any

import pytest

from gateway_api.common import common


@pytest.mark.parametrize(
("nhs_number", "expected"),
[
("9434765919", True), # Just a number
("943 476 5919", True), # Spaces are permitted
("987-654-3210", True), # Hyphens are permitted
(9434765919, True), # Integer input is permitted
("", False), # Empty string is invalid
("943476591", False), # 9 digits
("94347659190", False), # 11 digits
("9434765918", False), # wrong check digit
("NOT_A_NUMBER", False), # non-numeric
("943SOME_LETTERS4765919", False), # non-numeric in a valid NHS number
],
)
def test_validate_nhs_number(nhs_number: str | int, expected: bool) -> None:
"""
Validate that separators (spaces, hyphens) are ignored and valid numbers pass.
"""
assert common.validate_nhs_number(nhs_number) is expected


@pytest.mark.parametrize(
("nhs_number", "expected"),
[
# All zeros => weighted sum 0 => remainder 0 => check 11 => mapped to 0 => valid
("0000000000", True),
# First 9 digits produce remainder 1 => check 10 => invalid
("0000000060", False),
],
)
def test_validate_nhs_number_check_edge_cases_10_and_11(
nhs_number: str | int, expected: bool
) -> 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(nhs_number) is expected


def test__coerce_nhs_number_to_int_accepts_spaces_and_validates() -> None:
"""
Validate that whitespace separators are accepted and the number is validated.
"""
# Use real validator logic by default; 9434765919 is algorithmically valid.
assert common.coerce_nhs_number_to_int("943 476 5919") == 9434765919


@pytest.mark.parametrize("value", ["not-a-number", "943476591", "94347659190"])
def test__coerce_nhs_number_to_int_rejects_bad_inputs(value: Any) -> None:
"""
Validate that non-numeric and incorrect-length values are rejected.

:param value: Parameterized input value.
"""
with pytest.raises(ValueError): # noqa: PT011 (ValueError is correct here)
common.coerce_nhs_number_to_int(value)


def test__coerce_nhs_number_to_int_accepts_integer_value() -> None:
"""
Ensure ``_coerce_nhs_number_to_int`` accepts an integer input
and returns it unchanged.

:returns: None
"""
assert common.coerce_nhs_number_to_int(9434765919) == 9434765919
Loading