feature: Observation Ingest, Synchronous Alert Detection, and Alert Lifecycle
This commit is contained in:
Executable
+260
@@ -0,0 +1,260 @@
|
||||
#!/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_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."
|
||||
Reference in New Issue
Block a user