Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/*

# Copy dependency files
COPY requirements.txt .
COPY backend/requirements.txt .

# Install Python dependencies
# Note: Using build-essential requires the pip install step to happen AFTER build-essential is installed.
RUN pip install --no-cache-dir -r requirements.txt

# Copy source code
COPY src ./src
COPY .env .env
COPY backend/src ./src
COPY backend/.env .env
COPY shared/health_measurements.json ./shared/health_measurements.json

# Expose FastAPI port
EXPOSE 8000
Expand Down
131 changes: 131 additions & 0 deletions backend/src/health_measurements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Supported manual health measurement definitions and validation rules."""
import json
from datetime import date
from decimal import Decimal, InvalidOperation
from pathlib import Path


# The authoritative measurement definitions are in:
# shared/health_measurements.json

# Load the shared health measurement contract.
CONTRACT_PATH = (
Path(__file__).resolve().parents[2]
/ "shared"
/ "health_measurements.json"
)

with CONTRACT_PATH.open(encoding="utf-8") as contract_file:
HEALTH_MEASUREMENT_CONTRACT = json.load(contract_file)

METRIC_RULES = HEALTH_MEASUREMENT_CONTRACT["metrics"]
TEXT_METRIC_RULES = HEALTH_MEASUREMENT_CONTRACT["text_metrics"]
MAX_NOTES_LENGTH = HEALTH_MEASUREMENT_CONTRACT["maximum_notes_length"]


def validate_measurement_value(
metric: str,
value: int | float | str,
unit: str,
) -> None:
"""Validate a numeric measurement against its supported range and unit."""

if metric not in METRIC_RULES:
raise ValueError(f"Unsupported measurement metric: {metric}")

rule = METRIC_RULES[metric]

if "units" in rule:
unit_rule = rule["units"].get(unit)
else:
unit_rule = rule if rule["unit"] == unit else None

if unit_rule is None:
raise ValueError(f"Unsupported unit for {metric}: {unit}")

if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError("Measurement value must be numeric.")

try:
numeric_value = Decimal(str(value))
except (InvalidOperation, ValueError):
raise ValueError("Measurement value must be numeric.")

if not numeric_value.is_finite():
raise ValueError("Measurement value must be finite.")

minimum = Decimal(str(unit_rule["minimum"]))
maximum = Decimal(str(unit_rule["maximum"]))

if numeric_value < minimum or numeric_value > maximum:
raise ValueError(
f"{metric} must be between {minimum} and {maximum} {unit}."
)

decimal_places = max(0, -numeric_value.as_tuple().exponent)

if decimal_places > unit_rule["decimal_places"]:
raise ValueError(
f"{metric} supports at most "
f"{unit_rule['decimal_places']} decimal places."
)


def validate_blood_pressure(systolic: int, diastolic: int) -> None:
"""Validate the relationship between systolic and diastolic pressure."""

validate_measurement_value(
"blood_pressure_systolic", systolic, "mmHg"
)
validate_measurement_value(
"blood_pressure_diastolic", diastolic, "mmHg"
)

if systolic <= diastolic:
raise ValueError(
"Systolic pressure must be greater than diastolic pressure."
)


def validate_text_measurement(metric: str, value: str) -> None:
"""Validate a supported text-based measurement."""

if metric not in TEXT_METRIC_RULES:
raise ValueError(f"Unsupported text measurement: {metric}")

if not isinstance(value, str) or not value.strip():
raise ValueError("This field cannot be blank.")

maximum_length = TEXT_METRIC_RULES[metric]["maximum_length"]

if len(value) > maximum_length:
raise ValueError(
f"{metric} cannot exceed {maximum_length} characters."
)


def validate_measurement_notes(notes: str | None) -> None:
"""Validate optional measurement notes."""

if notes is not None and not isinstance(notes, str):
raise ValueError("Notes must be text.")

if notes is not None and len(notes) > MAX_NOTES_LENGTH:
raise ValueError(
f"Notes cannot exceed {MAX_NOTES_LENGTH} characters."
)

def validate_measurement_date(value: date | str) -> None:
"""Reject invalid measurement dates and dates in the future."""

if isinstance(value, str):
try:
value = date.fromisoformat(value)
except ValueError:
raise ValueError("Measurement date must be a valid ISO date.")

if not isinstance(value, date):
raise ValueError("Measurement date must be a valid date.")

if value > date.today():
raise ValueError("Measurement date cannot be in the future.")
168 changes: 168 additions & 0 deletions backend/tests/test_health_measurements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import math

import pytest

from src.health_measurements import (
METRIC_RULES,
TEXT_METRIC_RULES,
validate_blood_pressure,
validate_measurement_date,
validate_measurement_notes,
validate_measurement_value,
validate_text_measurement,
)

from datetime import date, timedelta

def test_all_numeric_metrics_have_validation_rules():
assert len(METRIC_RULES) == 9


@pytest.mark.parametrize(
"metric,value,unit",
[
("blood_pressure_systolic", 120, "mmHg"),
("blood_pressure_diastolic", 80, "mmHg"),
("weight", 70.5, "kg"),
("weight", 155.5, "lb"),
("heart_rate", 72, "bpm"),
("blood_glucose", 100, "mg/dL"),
("blood_glucose", 5.5, "mmol/L"),
("total_cholesterol", 180, "mg/dL"),
("total_cholesterol", 4.5, "mmol/L"),
("sleep_duration", 8, "hours"),
("mood", 3, "scale"),
("stress", 2, "scale"),
],
)
def test_valid_measurement_values(metric, value, unit):
validate_measurement_value(metric, value, unit)


@pytest.mark.parametrize(
"metric,value,unit",
[
("blood_pressure_systolic", 39, "mmHg"),
("blood_pressure_systolic", 301, "mmHg"),
("blood_pressure_diastolic", 19, "mmHg"),
("blood_pressure_diastolic", 201, "mmHg"),
("weight", 0, "kg"),
("weight", 2205, "lb"),
("heart_rate", 19, "bpm"),
("heart_rate", 301, "bpm"),
("blood_glucose", 0, "mg/dL"),
("blood_glucose", 2001, "mg/dL"),
("total_cholesterol", 0, "mg/dL"),
("total_cholesterol", 1501, "mg/dL"),
("sleep_duration", -1, "hours"),
("sleep_duration", 25, "hours"),
("mood", 0, "scale"),
("mood", 6, "scale"),
("stress", 0, "scale"),
("stress", 6, "scale"),
],
)
def test_out_of_range_values_are_rejected(metric, value, unit):
with pytest.raises(ValueError):
validate_measurement_value(metric, value, unit)


def test_unsupported_metric_is_rejected():
with pytest.raises(ValueError, match="Unsupported measurement metric"):
validate_measurement_value("temperature", 98, "F")


def test_unsupported_unit_is_rejected():
with pytest.raises(ValueError, match="Unsupported unit"):
validate_measurement_value("weight", 70, "stones")


@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
def test_non_finite_values_are_rejected(value):
with pytest.raises(ValueError, match="finite"):
validate_measurement_value("weight", value, "kg")


def test_boolean_values_are_rejected():
with pytest.raises(ValueError, match="numeric"):
validate_measurement_value("heart_rate", True, "bpm")

def test_numeric_strings_are_rejected():
with pytest.raises(ValueError, match="numeric"):
validate_measurement_value("weight", "70", "kg")


def test_excess_decimal_places_are_rejected():
with pytest.raises(ValueError, match="decimal places"):
validate_measurement_value("weight", 70.55, "kg")


def test_blood_pressure_accepts_valid_values():
validate_blood_pressure(120, 80)


def test_blood_pressure_rejects_systolic_equal_to_diastolic():
with pytest.raises(ValueError, match="greater than"):
validate_blood_pressure(80, 80)


def test_blood_pressure_rejects_systolic_below_diastolic():
with pytest.raises(ValueError, match="greater than"):
validate_blood_pressure(70, 90)


def test_valid_symptoms_are_accepted():
validate_text_measurement("symptoms", "Headache")


def test_blank_symptoms_are_rejected():
with pytest.raises(ValueError, match="blank"):
validate_text_measurement("symptoms", " ")


def test_symptoms_over_character_limit_are_rejected():
with pytest.raises(ValueError, match="1000 characters"):
validate_text_measurement("symptoms", "a" * 1001)


def test_symptoms_at_character_limit_are_accepted():
validate_text_measurement("symptoms", "a" * 1000)


def test_optional_notes_can_be_empty():
validate_measurement_notes(None)
validate_measurement_notes("")


def test_notes_over_character_limit_are_rejected():
with pytest.raises(ValueError, match="1000 characters"):
validate_measurement_notes("a" * 1001)

def test_non_text_notes_are_rejected():
with pytest.raises(ValueError, match="Notes must be text"):
validate_measurement_notes(123)


def test_notes_at_character_limit_are_accepted():
validate_measurement_notes("a" * 1000)

def test_measurement_date_accepts_today():
validate_measurement_date(date.today())


def test_measurement_date_accepts_past_date():
validate_measurement_date(date.today() - timedelta(days=1))


def test_measurement_date_rejects_future_date():
with pytest.raises(ValueError, match="cannot be in the future"):
validate_measurement_date(date.today() + timedelta(days=1))


def test_measurement_date_accepts_iso_string():
validate_measurement_date(date.today().isoformat())


def test_measurement_date_rejects_invalid_string():
with pytest.raises(ValueError, match="valid ISO date"):
validate_measurement_date("not-a-date")
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ services:
# FastAPI Backend Service
backend:
build:
context: ./backend
dockerfile: Dockerfile
context: .
dockerfile: backend/Dockerfile
container_name: healthapp_fastapi_backend
restart: always
ports:
Expand Down
Loading
Loading