diff --git a/scripts/run-api-redis-tests.sh b/scripts/run-api-redis-tests.sh index 93832b7..7b42936 100755 --- a/scripts/run-api-redis-tests.sh +++ b/scripts/run-api-redis-tests.sh @@ -109,6 +109,12 @@ echo "" echo "[1/${TOTAL_STEPS}] Listing thresholds" resp="$(request GET "${BASE_URL}/api/v1/alert-thresholds")" assert_status "200" "${resp}" +threshold_count="$(jq -r '.data | length' "${resp}")" +if [[ "${threshold_count}" -lt 12 ]]; then + echo "Expected at least 12 alert thresholds, found ${threshold_count}." + exit 1 +fi +echo "OK: ${threshold_count} threshold(s) configured" threshold_id="$(jq -r '.data[] | select(.observationCode=="HEART_RATE") | .id' "${resp}" | head -n 1)" if [[ -z "${threshold_id}" || "${threshold_id}" == "null" ]]; then echo "Could not find HEART_RATE threshold id." diff --git a/scripts/run-phase10-verification.sh b/scripts/run-phase10-verification.sh new file mode 100755 index 0000000..13a985b --- /dev/null +++ b/scripts/run-phase10-verification.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +COMPOSE_FILE="${COMPOSE_FILE:-${ROOT_DIR}/docker-compose.yml}" + +BASE_URL="${BASE_URL:-http://localhost:5270}" +ES_URL="${ES_URL:-http://localhost:9200}" +REDIS_PORT="${REDIS_PORT:-6382}" +TEST_PROJECT="${TEST_PROJECT:-${ROOT_DIR}/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj}" +TEST_FILTER="${TEST_FILTER:-FullyQualifiedName~ClinicalDemographicsAndObservationTests}" +ES_CONSUMER_GROUP="${ES_CONSUMER_GROUP:-es-indexer}" +INDEX_WAIT_SECS="${INDEX_WAIT_SECS:-60}" +PIPELINE_WAIT_SECS="${PIPELINE_WAIT_SECS:-45}" + +EXPECTED_THRESHOLD_CODES=( + HEART_RATE + TEMP_C + POTASSIUM_MEQ_L + SPO2 + RESP_RATE + WBC_K_UL + SYSTOLIC_BP + DIASTOLIC_BP + LACTATE_MMOL_L + AVPU + SUPPLEMENTAL_O2 + GLUCOSE_MG_DL +) + +NEW_OBSERVATION_CODES=( + SYSTOLIC_BP + DIASTOLIC_BP + LACTATE_MMOL_L + AVPU + SUPPLEMENTAL_O2 +) + +TMP_FILES=() +cleanup() { + local f + for f in "${TMP_FILES[@]}"; do + rm -f "${f}" "${f}.status" 2>/dev/null || true + done +} +trap cleanup EXIT + +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing dependency: $1" + exit 1 + } +} + +need curl +need jq +need dotnet + +if ! command -v docker >/dev/null 2>&1 || [[ ! -f "${COMPOSE_FILE}" ]]; then + echo "Missing dependency: docker compose (${COMPOSE_FILE})" + exit 1 +fi + +compose() { + docker compose -f "${COMPOSE_FILE}" "$@" +} + +redis_cmd() { + if command -v redis-cli >/dev/null 2>&1; then + redis-cli -p "${REDIS_PORT}" "$@" + else + compose exec -T redis redis-cli "$@" + fi +} + +request() { + local method="$1" + local url="$2" + local body="${3:-}" + local tmp + tmp="$(mktemp)" + TMP_FILES+=("${tmp}") + local status + + if [[ -n "${body}" ]]; then + status="$(curl -sS -o "${tmp}" -w "%{http_code}" -X "${method}" "${url}" \ + -H "Content-Type: application/json" -d "${body}")" + else + status="$(curl -sS -o "${tmp}" -w "%{http_code}" -X "${method}" "${url}")" + fi + + echo "${status}" > "${tmp}.status" + echo "${tmp}" +} + +assert_status() { + local expected="$1" + local body_file="$2" + local status + status="$(<"${body_file}.status")" + if [[ "${status}" != "${expected}" ]]; then + echo "Expected HTTP ${expected}, got ${status}" + cat "${body_file}" + echo + exit 1 + fi +} + +indexer_lag() { + compose exec -T kafka /opt/kafka/bin/kafka-consumer-groups.sh \ + --bootstrap-server localhost:9092 \ + --describe \ + --group "${ES_CONSUMER_GROUP}" 2>/dev/null | \ + awk 'NR > 1 && $1 != "" { sum += $6 } END { print sum + 0 }' +} + +wait_for_indexer_lag_zero() { + local elapsed=0 + local lag="unknown" + while (( elapsed < INDEX_WAIT_SECS )); do + lag="$(indexer_lag)" + if [[ "${lag}" == "0" ]]; then + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + echo "es-indexer lag did not reach zero within ${INDEX_WAIT_SECS}s (lag=${lag})" + exit 1 +} + +es_observation_hits() { + local code="$1" + local encounter_id="$2" + local payload + payload="$(jq -nc \ + --arg code "${code}" \ + --arg enc "${encounter_id}" \ + '{query:{bool:{must:[{term:{observationCode:$code}},{term:{encounterId:$enc}}]}},size:0,track_total_hits:true}')" + local body + body="$(curl -sS "${ES_URL}/observations/_search" \ + -H "Content-Type: application/json" \ + -d "${payload}")" + jq -r '.hits.total.value // .hits.total // 0' <<< "${body}" +} + +echo "Phase 10 verification starting..." +echo "Repo root: ${ROOT_DIR}" +echo "API: ${BASE_URL}" + +echo "[1/9] Preflight API, Elasticsearch, and Redis" +api_status="$(curl -sS -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/alert-thresholds" || true)" +es_status="$(curl -sS -o /dev/null -w "%{http_code}" "${ES_URL}/_cluster/health" || true)" +[[ "${api_status}" == "200" ]] || { echo "API not ready (${api_status})"; exit 1; } +[[ "${es_status}" == "200" ]] || { echo "Elasticsearch not ready (${es_status})"; exit 1; } +redis_cmd PING >/dev/null || { echo "Redis not reachable on port ${REDIS_PORT}"; exit 1; } + +echo "[2/9] Verify Redis threshold cache has 12 keys" +mapfile -t redis_keys < <(redis_cmd KEYS 'threshold:*') +threshold_count="${#redis_keys[@]}" +[[ "${threshold_count}" -eq 12 ]] || { + echo "Expected 12 Redis threshold keys, found ${threshold_count}" + printf ' %s\n' "${redis_keys[@]}" + exit 1 +} +for code in "${EXPECTED_THRESHOLD_CODES[@]}"; do + if ! redis_cmd EXISTS "threshold:${code}" | grep -q '^1$'; then + echo "Missing Redis threshold key: threshold:${code}" + exit 1 + fi +done +echo "OK: all 12 threshold keys present in Redis" + +echo "[3/9] Verify seeded patient clinical demographics" +seed_resp="$(request GET "${BASE_URL}/api/v1/patients?q=MRN-000001")" +assert_status "200" "${seed_resp}" +seed_blood="$(jq -r '.data.items[0].bloodType // empty' "${seed_resp}")" +seed_allergies="$(jq -r '.data.items[0].allergies // empty' "${seed_resp}")" +[[ "${seed_blood}" == "A+" ]] || { echo "Expected seeded bloodType A+, got '${seed_blood}'"; exit 1; } +[[ "${seed_allergies}" == "Penicillin" ]] || { echo "Expected seeded allergies Penicillin, got '${seed_allergies}'"; exit 1; } +echo "OK: seeded patient has bloodType and allergies" + +echo "[4/9] Register patient with clinical enrichment fields" +patient_payload='{"firstName":"Phase10","lastName":"Verify","dateOfBirth":"1980-01-01","gender":"M","bloodType":"O+","allergies":"Penicillin","emergencyContactName":"Jane Doe","emergencyContactPhone":"555-0000"}' +patient_resp="$(request POST "${BASE_URL}/api/v1/patients" "${patient_payload}")" +assert_status "201" "${patient_resp}" +patient_id="$(jq -r '.data.id' "${patient_resp}")" +[[ "$(jq -r '.data.bloodType' "${patient_resp}")" == "O+" ]] || { echo "bloodType not returned as O+"; exit 1; } +[[ "$(jq -r '.data.allergies' "${patient_resp}")" == "Penicillin" ]] || { echo "allergies not round-tripped"; exit 1; } +echo "OK: patient id = ${patient_id}" + +echo "[5/9] Open encounter with roomBed and admissionReason" +enc_payload='{"encounterType":"INPATIENT","department":"ICU","attendingPhysician":"Dr. Phase10","roomBed":"ICU-1A","admissionReason":"Chest pain"}' +enc_resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload}")" +assert_status "201" "${enc_resp}" +encounter_id="$(jq -r '.data.id' "${enc_resp}")" +[[ "$(jq -r '.data.roomBed' "${enc_resp}")" == "ICU-1A" ]] || { echo "roomBed not round-tripped"; exit 1; } +[[ "$(jq -r '.data.admissionReason' "${enc_resp}")" == "Chest pain" ]] || { echo "admissionReason not round-tripped"; exit 1; } +echo "OK: encounter id = ${encounter_id}" + +recorded_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +echo "[6/9] Ingest expanded observation codes" +for code in "${NEW_OBSERVATION_CODES[@]}"; do + case "${code}" in + SYSTOLIC_BP) + value=128; unit="mmHg"; source="DEVICE" ;; + DIASTOLIC_BP) + value=82; unit="mmHg"; source="DEVICE" ;; + LACTATE_MMOL_L) + value=1.2; unit="mmol/L"; source="LAB" ;; + AVPU) + value=0; unit="score"; source="MANUAL" ;; + SUPPLEMENTAL_O2) + value=0; unit="flag"; source="MANUAL" ;; + *) + echo "Unhandled observation code ${code}" + exit 1 + ;; + esac + + obs_payload="$(jq -nc \ + --arg code "${code}" \ + --argjson value "${value}" \ + --arg unit "${unit}" \ + --arg source "${source}" \ + --arg recordedAt "${recorded_at}" \ + --arg key "phase10-${code}-${encounter_id}" \ + '{observations:[{observationCode:$code,value:$value,unit:$unit,source:$source,recordedAt:$recordedAt,idempotencyKey:$key}]}')" + obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${obs_payload}")" + assert_status "201" "${obs_resp}" + echo " OK: ingested ${code}" +done + +echo "[7/9] Wait for Kafka relay and Elasticsearch indexer, then verify projections" +sleep "${PIPELINE_WAIT_SECS}" +wait_for_indexer_lag_zero + +for code in "${NEW_OBSERVATION_CODES[@]}"; do + hits="$(es_observation_hits "${code}" "${encounter_id}")" + [[ "${hits}" -ge 1 ]] || { + echo "Expected Elasticsearch observation for ${code} on encounter ${encounter_id}, found ${hits}" + exit 1 + } + echo " OK: Elasticsearch indexed ${code}" +done + +enc_doc="$(curl -sS "${ES_URL}/patient_encounters/_source/${encounter_id}")" +[[ "$(jq -r '.roomBed // empty' <<< "${enc_doc}")" == "ICU-1A" ]] || { + echo "patient_encounters document missing roomBed" + echo "${enc_doc}" + exit 1 +} +[[ "$(jq -r '.admissionReason // empty' <<< "${enc_doc}")" == "Chest pain" ]] || { + echo "patient_encounters document missing admissionReason" + echo "${enc_doc}" + exit 1 +} +echo "OK: patient_encounters document has roomBed and admissionReason" + +echo "[8/9] Verify GLUCOSE_MG_DL critical alert and SUPPLEMENTAL_O2 no-alert behavior" +glucose_payload="$(jq -nc \ + --arg recordedAt "${recorded_at}" \ + --arg key "phase10-glucose-${encounter_id}" \ + '{observations:[{observationCode:"GLUCOSE_MG_DL",value:35,unit:"mg/dL",source:"LAB",recordedAt:$recordedAt,idempotencyKey:$key}]}')" +glucose_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${glucose_payload}")" +assert_status "201" "${glucose_resp}" +[[ "$(jq -r '.data.alertGenerated' "${glucose_resp}")" == "true" ]] || { + echo "Expected alertGenerated=true for critical glucose" + cat "${glucose_resp}" + exit 1 +} +echo "OK: critical glucose generated alert" + +o2_payload="$(jq -nc \ + --arg recordedAt "${recorded_at}" \ + --arg key "phase10-o2-flag-${encounter_id}" \ + '{observations:[{observationCode:"SUPPLEMENTAL_O2",value:1,unit:"flag",source:"MANUAL",recordedAt:$recordedAt,idempotencyKey:$key}]}')" +o2_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${o2_payload}")" +assert_status "201" "${o2_resp}" +[[ "$(jq -r '.data.alertGenerated' "${o2_resp}")" == "false" ]] || { + echo "Expected no alert for SUPPLEMENTAL_O2 value 1" + cat "${o2_resp}" + exit 1 +} +echo "OK: supplemental O2 flag did not generate alert" + +echo "[9/9] Run Phase 10 integration tests" +dotnet test "${TEST_PROJECT}" --filter "${TEST_FILTER}" + +echo +echo "Phase 10 verification checks passed."