340 lines
11 KiB
Bash
Executable File
340 lines
11 KiB
Bash
Executable File
#!/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}"
|
|
|
|
PGHOST="${PGHOST:-localhost}"
|
|
PGPORT="${PGPORT:-5436}"
|
|
PGDATABASE="${PGDATABASE:-vigilcare}"
|
|
PGUSER="${PGUSER:-postgres}"
|
|
PGPASSWORD="${PGPASSWORD:-password}"
|
|
|
|
TEST_PROJECT="${TEST_PROJECT:-${ROOT_DIR}/VigilCareClinicalAPI.Tests/VigilCareClinicalAPI.Tests.csproj}"
|
|
TEST_FILTER="${TEST_FILTER:-FullyQualifiedName~News2}"
|
|
FULL_TEST="${FULL_TEST:-0}"
|
|
|
|
NEWS2_CONSUMER_GROUP="${NEWS2_CONSUMER_GROUP:-news2-scoring}"
|
|
ES_CONSUMER_GROUP="${ES_CONSUMER_GROUP:-es-indexer}"
|
|
OUTBOX_RELAY_GROUP="${OUTBOX_RELAY_GROUP:-}"
|
|
NEWS2_WAIT_SECS="${NEWS2_WAIT_SECS:-60}"
|
|
INDEX_WAIT_SECS="${INDEX_WAIT_SECS:-60}"
|
|
|
|
SCRIPT_RUN_ID="$(date -u +"%Y%m%d%H%M%S")"
|
|
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" 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}" "$@"
|
|
}
|
|
|
|
kafka_exec() {
|
|
compose exec -T kafka "$@"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
psql_cmd() {
|
|
local sql="$1"
|
|
if command -v psql >/dev/null 2>&1; then
|
|
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
|
|
else
|
|
compose exec -T postgres psql -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
|
|
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
|
|
}
|
|
|
|
consumer_group_lag() {
|
|
local group="$1"
|
|
kafka_exec /opt/kafka/bin/kafka-consumer-groups.sh \
|
|
--bootstrap-server localhost:9092 \
|
|
--describe \
|
|
--group "${group}" 2>/dev/null | \
|
|
awk 'NR > 1 && $1 != "" { sum += $6 } END { print sum + 0 }'
|
|
}
|
|
|
|
wait_for_consumer_lag_zero() {
|
|
local group="$1"
|
|
local max_secs="$2"
|
|
local elapsed=0
|
|
local lag="unknown"
|
|
while (( elapsed < max_secs )); do
|
|
lag="$(consumer_group_lag "${group}")"
|
|
if [[ "${lag}" == "0" ]]; then
|
|
return 0
|
|
fi
|
|
sleep 2
|
|
elapsed=$((elapsed + 2))
|
|
done
|
|
echo "Consumer group ${group} lag did not reach zero within ${max_secs}s (lag=${lag})"
|
|
exit 1
|
|
}
|
|
|
|
wait_for_news2_score() {
|
|
local encounter_id="$1"
|
|
local max_secs="$2"
|
|
local elapsed=0
|
|
local row=""
|
|
while (( elapsed < max_secs )); do
|
|
row="$(psql_cmd "SELECT total_score, risk_level FROM news2_scores WHERE encounter_id = '${encounter_id}' ORDER BY calculated_at DESC LIMIT 1")"
|
|
if [[ -n "${row}" ]]; then
|
|
echo "${row}"
|
|
return 0
|
|
fi
|
|
sleep 2
|
|
elapsed=$((elapsed + 2))
|
|
done
|
|
echo "No news2_scores row for encounter ${encounter_id} within ${max_secs}s"
|
|
exit 1
|
|
}
|
|
|
|
wait_for_es_news2_fields() {
|
|
local encounter_id="$1"
|
|
local max_secs="$2"
|
|
local elapsed=0
|
|
local score=""
|
|
local risk=""
|
|
while (( elapsed < max_secs )); do
|
|
local body
|
|
body="$(curl -sS "${ES_URL}/patient_encounters/_source/${encounter_id}" 2>/dev/null || true)"
|
|
if [[ -n "${body}" && "${body}" != *"\"found\":false"* ]]; then
|
|
score="$(jq -r '.news2Score // empty' <<< "${body}")"
|
|
risk="$(jq -r '.news2RiskLevel // empty' <<< "${body}")"
|
|
if [[ -n "${score}" && -n "${risk}" ]]; then
|
|
echo "${score}"$'\t'"${risk}"
|
|
return 0
|
|
fi
|
|
fi
|
|
sleep 2
|
|
elapsed=$((elapsed + 2))
|
|
done
|
|
echo "patient_encounters document missing news2Score/news2RiskLevel for ${encounter_id} within ${max_secs}s"
|
|
exit 1
|
|
}
|
|
|
|
TOTAL_STEPS=8
|
|
echo "Phase 12 verification starting..."
|
|
echo "Repo root: ${ROOT_DIR}"
|
|
echo "API: ${BASE_URL}"
|
|
|
|
echo "[1/${TOTAL_STEPS}] Preflight API, Elasticsearch, PostgreSQL, Redis, Kafka"
|
|
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}) — run docker compose up -d and dotnet run"; 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; }
|
|
psql_cmd "SELECT 1" >/dev/null || { echo "PostgreSQL not reachable"; exit 1; }
|
|
kafka_exec /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null || {
|
|
echo "Kafka not ready"
|
|
exit 1
|
|
}
|
|
redis_cmd EXISTS "threshold:HEART_RATE" | grep -q '^1$' || {
|
|
echo "Missing Redis threshold:HEART_RATE — restart API to run ThresholdCacheLoader"
|
|
exit 1
|
|
}
|
|
echo "OK: infrastructure preflight passed"
|
|
|
|
echo "[2/${TOTAL_STEPS}] Create patient and active encounter"
|
|
patient_payload="$(jq -nc \
|
|
--arg fn "Phase12" \
|
|
--arg ln "Verify${SCRIPT_RUN_ID}" \
|
|
'{firstName:$fn,lastName:$ln,dateOfBirth:"1985-06-01",gender:"M"}')"
|
|
patient_resp="$(request POST "${BASE_URL}/api/v1/patients" "${patient_payload}")"
|
|
assert_status "201" "${patient_resp}"
|
|
patient_id="$(jq -r '.data.id' "${patient_resp}")"
|
|
|
|
enc_payload='{"encounterType":"Inpatient","department":"ICU","attendingPhysician":"Dr. Phase12"}'
|
|
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}")"
|
|
echo "OK: patient=${patient_id} encounter=${encounter_id}"
|
|
|
|
echo "[3/${TOTAL_STEPS}] Ingest all 7 NEWS2 parameters (medium risk, total score 6)"
|
|
news2_payload="$(jq -nc \
|
|
--arg recordedAt "${RECORDED_AT}" \
|
|
'{observations:[
|
|
{"observationCode":"RESP_RATE","value":22,"unit":"breaths/min","source":"DEVICE","recordedAt":$recordedAt},
|
|
{"observationCode":"SPO2","value":93,"unit":"%","source":"DEVICE","recordedAt":$recordedAt},
|
|
{"observationCode":"SYSTOLIC_BP","value":105,"unit":"mmHg","source":"DEVICE","recordedAt":$recordedAt},
|
|
{"observationCode":"HEART_RATE","value":95,"unit":"bpm","source":"DEVICE","recordedAt":$recordedAt},
|
|
{"observationCode":"AVPU","value":0,"unit":"score","source":"MANUAL","recordedAt":$recordedAt},
|
|
{"observationCode":"TEMP_C","value":37.0,"unit":"°C","source":"DEVICE","recordedAt":$recordedAt},
|
|
{"observationCode":"SUPPLEMENTAL_O2","value":0,"unit":"flag","source":"MANUAL","recordedAt":$recordedAt}
|
|
]}')"
|
|
obs_resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${news2_payload}")"
|
|
assert_status "201" "${obs_resp}"
|
|
echo "OK: 7 observations ingested"
|
|
|
|
echo "[4/${TOTAL_STEPS}] Wait for news2-scoring and verify PostgreSQL score + NEWS2_WARNING alert"
|
|
wait_for_consumer_lag_zero "${NEWS2_CONSUMER_GROUP}" "${NEWS2_WAIT_SECS}"
|
|
|
|
score_row="$(wait_for_news2_score "${encounter_id}" "${NEWS2_WAIT_SECS}")"
|
|
total_score="${score_row%%|*}"
|
|
risk_level="$(echo "${score_row}" | cut -d'|' -f2)"
|
|
[[ "${total_score}" == "6" ]] || {
|
|
echo "Expected total_score=6 in PostgreSQL, got ${total_score}"
|
|
exit 1
|
|
}
|
|
[[ "${risk_level}" == "MEDIUM" ]] || {
|
|
echo "Expected risk_level=MEDIUM in PostgreSQL, got ${risk_level}"
|
|
exit 1
|
|
}
|
|
|
|
warning_count="$(psql_cmd "SELECT COUNT(*) FROM clinical_alerts WHERE encounter_id = '${encounter_id}' AND alert_type = 'NEWS2_WARNING' AND severity = 'WARNING' AND status = 'OPEN'")"
|
|
[[ "${warning_count}" == "1" ]] || {
|
|
echo "Expected 1 NEWS2_WARNING alert in PostgreSQL, found ${warning_count}"
|
|
psql_cmd "SELECT alert_type, severity, status FROM clinical_alerts WHERE encounter_id = '${encounter_id}'" || true
|
|
exit 1
|
|
}
|
|
echo "OK: PostgreSQL news2_scores total=6 risk=MEDIUM and NEWS2_WARNING alert exists"
|
|
|
|
echo "[5/${TOTAL_STEPS}] Verify NEWS2 API endpoints (current + history)"
|
|
current_resp="$(request GET "${BASE_URL}/api/v1/encounters/${encounter_id}/news2/current")"
|
|
assert_status "200" "${current_resp}"
|
|
[[ "$(jq -r '.data.totalScore' "${current_resp}")" == "6" ]] || {
|
|
echo "Expected current totalScore=6"
|
|
cat "${current_resp}"
|
|
exit 1
|
|
}
|
|
[[ "$(jq -r '.data.riskLevel' "${current_resp}")" == "MEDIUM" ]] || {
|
|
echo "Expected current riskLevel=MEDIUM"
|
|
cat "${current_resp}"
|
|
exit 1
|
|
}
|
|
[[ "$(jq -r '.data.respRateScore' "${current_resp}")" == "2" ]] || {
|
|
echo "Expected respRateScore=2"
|
|
cat "${current_resp}"
|
|
exit 1
|
|
}
|
|
[[ "$(jq -r '.data.spo2Score' "${current_resp}")" == "2" ]] || {
|
|
echo "Expected spo2Score=2"
|
|
cat "${current_resp}"
|
|
exit 1
|
|
}
|
|
|
|
history_resp="$(request GET "${BASE_URL}/api/v1/encounters/${encounter_id}/news2/history")"
|
|
assert_status "200" "${history_resp}"
|
|
history_count="$(jq -r '.data.items | length' "${history_resp}")"
|
|
[[ "${history_count}" -ge 1 ]] || {
|
|
echo "Expected at least one history entry"
|
|
cat "${history_resp}"
|
|
exit 1
|
|
}
|
|
echo "OK: news2/current and news2/history return expected data"
|
|
|
|
echo "[6/${TOTAL_STEPS}] Wait for es-indexer and verify Elasticsearch projection"
|
|
wait_for_consumer_lag_zero "${ES_CONSUMER_GROUP}" "${INDEX_WAIT_SECS}"
|
|
es_row="$(wait_for_es_news2_fields "${encounter_id}" "${INDEX_WAIT_SECS}")"
|
|
es_score="${es_row%%$'\t'*}"
|
|
es_risk="${es_row#*$'\t'}"
|
|
[[ "${es_score}" == "6" ]] || {
|
|
echo "Expected Elasticsearch news2Score=6, got ${es_score}"
|
|
exit 1
|
|
}
|
|
[[ "${es_risk}" == "MEDIUM" ]] || {
|
|
echo "Expected Elasticsearch news2RiskLevel=MEDIUM, got ${es_risk}"
|
|
exit 1
|
|
}
|
|
echo "OK: patient_encounters has news2Score=6 and news2RiskLevel=MEDIUM"
|
|
|
|
echo "[7/${TOTAL_STEPS}] Verify Prometheus NEWS2 metrics"
|
|
metrics_file="$(mktemp)"
|
|
TMP_FILES+=("${metrics_file}")
|
|
curl -sS "${BASE_URL}/metrics" -o "${metrics_file}"
|
|
|
|
grep -q '^news2_scores_total{' "${metrics_file}" || {
|
|
echo "Expected news2_scores_total counter line in /metrics"
|
|
grep 'news2' "${metrics_file}" || true
|
|
exit 1
|
|
}
|
|
grep -q '^news2_scoring_duration_seconds_bucket{' "${metrics_file}" || {
|
|
echo "Expected news2_scoring_duration_seconds_bucket in /metrics"
|
|
exit 1
|
|
}
|
|
grep -q 'news2_scores_total{risk_level="MEDIUM"}' "${metrics_file}" || {
|
|
echo "Expected news2_scores_total{risk_level=\"MEDIUM\"} in /metrics"
|
|
grep 'news2_scores_total' "${metrics_file}" || true
|
|
exit 1
|
|
}
|
|
echo "OK: NEWS2 Prometheus metrics exposed"
|
|
|
|
echo "[8/${TOTAL_STEPS}] Run NEWS2 test suite"
|
|
dotnet test "${TEST_PROJECT}" --filter "${TEST_FILTER}"
|
|
|
|
if [[ "${FULL_TEST}" == "1" ]]; then
|
|
echo "Running full test suite (FULL_TEST=1)"
|
|
dotnet test "${ROOT_DIR}/VigilCareClinicalAPI.sln" 2>/dev/null || dotnet test "${ROOT_DIR}"
|
|
fi
|
|
|
|
echo
|
|
echo "Phase 12 verification checks passed."
|
|
echo "Encounter id: ${encounter_id}"
|