#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" COMPOSE_FILE="${COMPOSE_FILE:-${SCRIPT_DIR}/../docker-compose.yml}" BASE_URL="${BASE_URL:-http://localhost:5270}" REDIS_PORT="${REDIS_PORT:-6382}" REDIS_KEY="${REDIS_KEY:-threshold:HEART_RATE}" RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" TMP_FILES=() cleanup() { local f for f in "${TMP_FILES[@]}"; do rm -f "${f}" "${f}.status" done } trap cleanup EXIT if ! command -v curl >/dev/null 2>&1; then echo "Missing dependency: curl" exit 1 fi if ! command -v jq >/dev/null 2>&1; then echo "Missing dependency: jq" exit 1 fi redis_cmd() { if command -v redis-cli >/dev/null 2>&1; then redis-cli -p "${REDIS_PORT}" "$@" elif command -v docker >/dev/null 2>&1 && [[ -f "${COMPOSE_FILE}" ]]; then docker compose -f "${COMPOSE_FILE}" exec -T redis redis-cli "$@" else echo "Missing dependency: redis-cli (or docker compose with redis service)" exit 1 fi } request() { local method="$1" local url="$2" local body="${3:-}" local tmp_body tmp_body="$(mktemp)" TMP_FILES+=("${tmp_body}") local status if [[ -n "${body}" ]]; then status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}" \ -H "Content-Type: application/json" -d "${body}")" else status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}")" fi echo "${status}" > "${tmp_body}.status" echo "${tmp_body}" } assert_status() { local expected="$1" local body_file="$2" local status status="$(cat "${body_file}.status")" if [[ "${status}" != "${expected}" ]]; then echo "Expected HTTP ${expected}, got ${status}" echo "Response body:" cat "${body_file}" echo return 1 fi } assert_json() { local body_file="$1" local jq_expr="$2" local expected="$3" local actual actual="$(jq -r "${jq_expr}" "${body_file}")" if [[ "${actual}" != "${expected}" ]]; then echo "Expected ${jq_expr} = ${expected}, got ${actual}" echo "Response body:" cat "${body_file}" echo return 1 fi } TOTAL_STEPS=16 echo "Running API + Redis verification against ${BASE_URL}" echo "Recorded-at timestamp: ${RECORDED_AT}" echo "" echo "[0/${TOTAL_STEPS}] Preflight — API reachable" preflight_status="$(curl -sS -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/alert-thresholds" || true)" if [[ "${preflight_status}" != "200" ]]; then echo "API not reachable at ${BASE_URL} (HTTP ${preflight_status})." echo "Start the API with: dotnet run --project VigilCareClinicalAPI" exit 1 fi echo "OK: API is up" 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." exit 1 fi echo "OK: HEART_RATE threshold id = ${threshold_id}" echo "" echo "[2/${TOTAL_STEPS}] Registering patient" patient_payload='{"firstName":"Test","lastName":"Runner","dateOfBirth":"1988-01-10","gender":"F"}' resp="$(request POST "${BASE_URL}/api/v1/patients" "${patient_payload}")" assert_status "201" "${resp}" patient_id="$(jq -r '.data.id' "${resp}")" if [[ -z "${patient_id}" || "${patient_id}" == "null" ]]; then echo "Could not parse patient id." exit 1 fi echo "OK: patient id = ${patient_id}" echo "" echo "[3/${TOTAL_STEPS}] Opening encounter" enc_payload='{"encounterType":"Inpatient","department":"ICU","attendingPhysician":"Dr. Script"}' resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload}")" assert_status "201" "${resp}" encounter_id="$(jq -r '.data.id' "${resp}")" if [[ -z "${encounter_id}" || "${encounter_id}" == "null" ]]; then echo "Could not parse encounter id." exit 1 fi echo "OK: encounter id = ${encounter_id}" echo "" echo "[4/${TOTAL_STEPS}] Verifying illegal status transition returns 409" transition_payload='{"status":"Scheduled"}' resp="$(request PATCH "${BASE_URL}/api/v1/encounters/${encounter_id}/status" "${transition_payload}")" assert_status "409" "${resp}" error_code="$(jq -r '.error.code' "${resp}")" echo "OK: illegal transition blocked (${error_code})" echo "" echo "[5/${TOTAL_STEPS}] Updating HEART_RATE threshold" update_payload='{"observationCode":"HEART_RATE","displayName":"Heart Rate","unit":"bpm","criticalLow":30,"warningLow":50,"warningHigh":110,"criticalHigh":160}' resp="$(request PUT "${BASE_URL}/api/v1/alert-thresholds/${threshold_id}" "${update_payload}")" assert_status "200" "${resp}" echo "OK: threshold update accepted" echo "" echo "[6/${TOTAL_STEPS}] Verifying Redis invalidation" redis_value="$(redis_cmd GET "${REDIS_KEY}" | tr -d '\r')" if [[ "${redis_value}" != "(nil)" && -n "${redis_value}" ]]; then echo "Expected Redis key ${REDIS_KEY} to be invalidated, but found value." echo "Value: ${redis_value}" exit 1 fi echo "OK: Redis key invalidated (${REDIS_KEY})" echo "" echo "[7/${TOTAL_STEPS}] Listing patient" resp="$(request GET "${BASE_URL}/api/v1/patients/${patient_id}")" assert_status "200" "${resp}" echo "OK: patient lookup succeeds" echo "" echo "[8/${TOTAL_STEPS}] Ingesting normal observation (no alert)" normal_obs_payload="$(jq -nc \ --arg recordedAt "${RECORDED_AT}" \ '{observations:[{observationCode:"HEART_RATE",value:78,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt}]}')" resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${normal_obs_payload}")" assert_status "201" "${resp}" assert_json "${resp}" '.data.alertGenerated' 'false' echo "OK: normal observation ingested without alert" echo "" echo "[9/${TOTAL_STEPS}] Ingesting critical potassium (alert expected)" critical_obs_payload="$(jq -nc \ --arg recordedAt "${RECORDED_AT}" \ '{observations:[{observationCode:"POTASSIUM_MEQ_L",value:2.1,unit:"mEq/L",source:"LAB",recordedAt:$recordedAt,idempotencyKey:"script-critical-001"}]}')" resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${critical_obs_payload}")" assert_status "201" "${resp}" assert_json "${resp}" '.data.alertGenerated' 'true' alert_id="$(jq -r '.data.alertId' "${resp}")" if [[ -z "${alert_id}" || "${alert_id}" == "null" ]]; then echo "Could not parse alert id from critical ingest." exit 1 fi echo "OK: critical alert generated (alert id = ${alert_id})" echo "" echo "[10/${TOTAL_STEPS}] Fetching observation history" resp="$(request GET "${BASE_URL}/api/v1/encounters/${encounter_id}/observations?code=HEART_RATE&limit=10")" assert_status "200" "${resp}" history_count="$(jq -r '.data.items | length' "${resp}")" if [[ "${history_count}" -lt 1 ]]; then echo "Expected at least one HEART_RATE observation in history, got ${history_count}" exit 1 fi echo "OK: observation history returned ${history_count} item(s)" echo "" echo "[11/${TOTAL_STEPS}] Resolving unacknowledged alert returns 409" resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_id}/resolve")" assert_status "409" "${resp}" assert_json "${resp}" '.error.code' 'ALERT_NOT_ACKNOWLEDGED' echo "OK: resolve blocked before acknowledge" echo "" echo "[12/${TOTAL_STEPS}] Acknowledging alert" ack_payload='{"clinicianId":"DR-SCRIPT","note":"Reviewing from verification script."}' resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_id}/acknowledge" "${ack_payload}")" assert_status "200" "${resp}" assert_json "${resp}" '.data.status' 'Acknowledged' echo "OK: alert acknowledged" echo "" echo "[13/${TOTAL_STEPS}] Resolving acknowledged alert" resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_id}/resolve")" assert_status "200" "${resp}" assert_json "${resp}" '.data.status' 'Resolved' echo "OK: alert resolved" echo "" echo "[14/${TOTAL_STEPS}] Rejecting implausible observation with 422" implausible_payload="$(jq -nc \ --arg recordedAt "${RECORDED_AT}" \ '{observations:[{observationCode:"HEART_RATE",value:350,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt}]}')" resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${implausible_payload}")" assert_status "422" "${resp}" assert_json "${resp}" '.error.code' 'OBSERVATION_OUT_OF_PLAUSIBLE_RANGE' echo "OK: implausible value rejected" echo "" echo "[15/${TOTAL_STEPS}] Discharging encounter" discharge_payload='{"status":"Discharged"}' resp="$(request PATCH "${BASE_URL}/api/v1/encounters/${encounter_id}/status" "${discharge_payload}")" assert_status "200" "${resp}" echo "OK: encounter discharged" echo "" echo "[16/${TOTAL_STEPS}] Ingest against discharged encounter returns 409" post_discharge_payload="$(jq -nc \ --arg recordedAt "${RECORDED_AT}" \ '{observations:[{observationCode:"HEART_RATE",value:78,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt}]}')" resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${post_discharge_payload}")" assert_status "409" "${resp}" assert_json "${resp}" '.error.code' 'ENCOUNTER_NOT_ACTIVE' echo "OK: ingest blocked for discharged encounter" echo "" echo "All ${TOTAL_STEPS} API + Redis checks passed."