Files
vigilcare-clinical/scripts/run-reconciliation-tests.sh
T

497 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# Phase 7 verification — reconciliation jobs (see docs/plans/phase-7-plan.md).
#
# Prerequisites:
# docker compose up -d
# dotnet run --project VigilCareClinicalAPI
#
# For a practical runtime, set ReconciliationJobs.IntervalMinutes to 1 in appsettings.json
# and restart the API before running this script (default 30 min is too slow).
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}"
RABBITMQ_MGMT_URL="${RABBITMQ_MGMT_URL:-http://localhost:15674}"
RABBITMQ_USER="${RABBITMQ_USER:-guest}"
RABBITMQ_PASS="${RABBITMQ_PASS:-guest}"
PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5436}"
PGDATABASE="${PGDATABASE:-vigilcare}"
PGUSER="${PGUSER:-postgres}"
PGPASSWORD="${PGPASSWORD:-password}"
# Scheduler waits: first tick ~15s after API start, then every IntervalMinutes.
RECONCILIATION_WAIT_SECS="${RECONCILIATION_WAIT_SECS:-120}"
RECONCILIATION_CYCLE_WAIT_SECS="${RECONCILIATION_CYCLE_WAIT_SECS:-70}"
SCRIPT_RUN_ID="$(date -u +"%Y%m%d%H%M%S")"
RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
CHECK1="UNACKNOWLEDGED_CRITICAL_ALERT"
CHECK2="PENDING_ORDER_NO_RESULT"
CHECK3="ACTIVE_INPATIENT_NO_OBSERVATION"
EXPECTED_QUEUES=(
"alerts.paging.queue"
"alerts.paging.dlq"
"alerts.escalation.queue"
"notifications.discharge.queue"
"notifications.appointment.queue"
"notifications.reconciliation.queue"
)
TMP_FILES=()
cleanup() {
local f
for f in "${TMP_FILES[@]}"; do
rm -f "${f}" "${f}.status" 2>/dev/null || true
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
compose() {
docker compose -f "${COMPOSE_FILE}" "$@"
}
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_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}" >&2
echo "Response body:" >&2
cat "${body_file}" >&2
echo >&2
exit 1
fi
}
rabbit_api() {
curl -sS -u "${RABBITMQ_USER}:${RABBITMQ_PASS}" "${RABBITMQ_MGMT_URL}/api/${1}"
}
queue_field() {
local queue="$1"
local field="$2"
rabbit_api "queues/%2F/${queue}" | jq -r ".${field} // 0"
}
wait_for_queue_increase() {
local queue="$1"
local baseline="$2"
local timeout_secs="${3:-15}"
local elapsed=0
while (( elapsed < timeout_secs )); do
local depth
depth="$(queue_field "${queue}" "messages")"
if [[ "${depth}" -gt "${baseline}" ]]; then
echo "${depth}"
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "${depth:-${baseline}}"
return 1
}
reconciliation_count() {
local encounter_id="$1"
local check_type="$2"
psql_cmd "SELECT COUNT(*) FROM reconciliation_alerts
WHERE encounter_id = '${encounter_id}'
AND check_type = '${check_type}'
AND resolved_at IS NULL"
}
wait_for_reconciliation_row() {
local encounter_id="$1"
local check_type="$2"
local timeout_secs="$3"
local elapsed=0
while (( elapsed < timeout_secs )); do
local count
count="$(reconciliation_count "${encounter_id}" "${check_type}")"
if [[ "${count}" -ge 1 ]]; then
return 0
fi
sleep 5
elapsed=$((elapsed + 5))
done
echo "No open reconciliation_alert (${check_type}) for encounter ${encounter_id} within ${timeout_secs}s"
return 1
}
wait_for_scheduler_cycle() {
echo " (waiting ${RECONCILIATION_CYCLE_WAIT_SECS}s for reconciliation scheduler cycle...)"
sleep "${RECONCILIATION_CYCLE_WAIT_SECS}"
}
create_patient() {
local suffix="$1"
local payload
payload="$(jq -nc \
--arg first "Recon${suffix}" \
--arg last "Verify${SCRIPT_RUN_ID}" \
'{firstName:$first,lastName:$last,dateOfBirth:"1970-06-01",gender:"Other"}')"
local resp
resp="$(request POST "${BASE_URL}/api/v1/patients" "${payload}")"
assert_status "201" "${resp}"
jq -r '.data.id' "${resp}"
}
create_encounter() {
local patient_id="$1"
local encounter_type="$2"
local department="${3:-ICU}"
local payload
payload="$(jq -nc \
--arg et "${encounter_type}" \
--arg dept "${department}" \
--arg physician "Dr. Recon ${SCRIPT_RUN_ID}" \
'{encounterType:$et,department:$dept,attendingPhysician:$physician}')"
local resp
resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${payload}")"
assert_status "201" "${resp}"
jq -r '.data.id' "${resp}"
}
ingest_critical_potassium() {
local encounter_id="$1"
local idempotency_key="$2"
local payload
payload="$(jq -nc \
--arg recordedAt "${RECORDED_AT}" \
--arg key "${idempotency_key}" \
'{observations:[{observationCode:"POTASSIUM_MEQ_L",value:2.1,unit:"mEq/L",source:"LAB",recordedAt:$recordedAt,idempotencyKey:$key}]}')"
local resp
resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${payload}")"
assert_status "201" "${resp}"
if [[ "$(jq -r '.data.alertGenerated' "${resp}")" != "true" ]]; then
echo "Expected critical potassium ingest to generate an alert"
exit 1
fi
jq -r '.data.alertId' "${resp}"
}
ingest_heart_rate() {
local encounter_id="$1"
local recorded_at="$2"
local idempotency_key="$3"
local payload
payload="$(jq -nc \
--arg recordedAt "${recorded_at}" \
--arg key "${idempotency_key}" \
'{observations:[{observationCode:"HEART_RATE",value:72,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt,idempotencyKey:$key}]}')"
local resp
resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${payload}")"
assert_status "201" "${resp}"
}
insert_stale_pending_order() {
local encounter_id="$1"
local description="$2"
psql_cmd "INSERT INTO orders (id, encounter_id, order_type, description, ordered_by, status, ordered_at, resulted_at)
VALUES (gen_random_uuid(), '${encounter_id}', 'LAB', '${description}', 'Dr. Osei', 'PENDING',
NOW() - INTERVAL '5 hours', NULL)"
}
insert_resulted_order() {
local encounter_id="$1"
psql_cmd "INSERT INTO orders (id, encounter_id, order_type, description, ordered_by, status, ordered_at, resulted_at)
VALUES (gen_random_uuid(), '${encounter_id}', 'LAB', 'CBC', 'Dr. Osei', 'RESULTED',
NOW() - INTERVAL '5 hours', NOW() - INTERVAL '4 hours')"
}
backdate_alert_triggered_at() {
local alert_id="$1"
local minutes_ago="$2"
psql_cmd "UPDATE clinical_alerts
SET triggered_at = NOW() - INTERVAL '${minutes_ago} minutes'
WHERE id = '${alert_id}'"
}
TOTAL_STEPS=11
echo "Running Phase 7 reconciliation verification against ${BASE_URL}"
echo "Script run id: ${SCRIPT_RUN_ID}"
echo "Hint: set ReconciliationJobs.IntervalMinutes=1 and restart the API for ~${RECONCILIATION_CYCLE_WAIT_SECS}s waits per cycle."
echo ""
echo "[0/${TOTAL_STEPS}] Preflight — API, Postgres, and RabbitMQ 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
if ! psql_cmd "SELECT 1" >/dev/null 2>&1; then
echo "Postgres not reachable on ${PGHOST}:${PGPORT}."
echo "Start the stack with: docker compose up -d"
exit 1
fi
rabbit_health="$(rabbit_api "health/checks/alarms" 2>/dev/null | jq -r '.status // empty' || true)"
if [[ -z "${rabbit_health}" ]]; then
echo "RabbitMQ management API not reachable at ${RABBITMQ_MGMT_URL}."
exit 1
fi
potassium_threshold="$(psql_cmd "SELECT COUNT(*) FROM alert_thresholds WHERE observation_code = 'POTASSIUM_MEQ_L'")"
heart_rate_threshold="$(psql_cmd "SELECT COUNT(*) FROM alert_thresholds WHERE observation_code = 'HEART_RATE'")"
if [[ "${potassium_threshold}" == "0" || "${heart_rate_threshold}" == "0" ]]; then
echo "Missing alert thresholds (POTASSIUM_MEQ_L and/or HEART_RATE)."
echo "Start the API once against an empty database so DataSeeder runs, or register thresholds via the API."
exit 1
fi
echo "OK: API, Postgres, RabbitMQ, and alert thresholds are ready"
echo ""
echo "[1/${TOTAL_STEPS}] Verifying reconciliation_alerts migration"
table_exists="$(psql_cmd "SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'reconciliation_alerts'
)")"
if [[ "${table_exists}" != "t" ]]; then
echo "Table reconciliation_alerts not found. Run: dotnet ef database update --project VigilCareClinicalAPI"
exit 1
fi
columns="$(psql_cmd "SELECT string_agg(column_name, ',' ORDER BY ordinal_position)
FROM information_schema.columns
WHERE table_name = 'reconciliation_alerts'")"
for col in check_type encounter_id patient_id details resolved_at created_at; do
if [[ "${columns}" != *"${col}"* ]]; then
echo "reconciliation_alerts missing column: ${col}"
exit 1
fi
done
echo "OK: reconciliation_alerts table and columns present"
echo ""
echo "[2/${TOTAL_STEPS}] Verifying RabbitMQ topology includes notifications.reconciliation.queue"
exchange_name="$(rabbit_api "exchanges/%2F/clinical.notifications.exchange" | jq -r '.name // empty')"
if [[ "${exchange_name}" != "clinical.notifications.exchange" ]]; then
echo "Exchange clinical.notifications.exchange not found. Start the API so RabbitMqTopologyProvisioner runs."
exit 1
fi
for queue in "${EXPECTED_QUEUES[@]}"; do
queue_name="$(rabbit_api "queues/%2F/${queue}" | jq -r '.name // empty')"
if [[ "${queue_name}" != "${queue}" ]]; then
echo "Queue ${queue} not found (got '${queue_name}')."
exit 1
fi
done
recon_queue_before="$(queue_field "notifications.reconciliation.queue" "messages")"
echo "OK: 6 queues provisioned (including notifications.reconciliation.queue); messages=${recon_queue_before}"
echo ""
echo "[3/${TOTAL_STEPS}] Check 1 — stale critical alert creates reconciliation_alert"
patient_c1="$(create_patient "C1")"
encounter_c1="$(create_encounter "${patient_c1}" "Inpatient")"
alert_c1="$(ingest_critical_potassium "${encounter_c1}" "recon-c1-${SCRIPT_RUN_ID}")"
backdate_alert_triggered_at "${alert_c1}" 31
if ! wait_for_reconciliation_row "${encounter_c1}" "${CHECK1}" "${RECONCILIATION_WAIT_SECS}"; then
echo "Hint: set ReconciliationJobs.IntervalMinutes=1 in appsettings.json and restart the API."
exit 1
fi
details_c1="$(psql_cmd "SELECT details FROM reconciliation_alerts
WHERE encounter_id = '${encounter_c1}' AND check_type = '${CHECK1}' AND resolved_at IS NULL
ORDER BY created_at DESC LIMIT 1")"
if [[ "${details_c1}" != *"${alert_c1}"* ]]; then
echo "Check 1 details should reference alert id ${alert_c1}"
echo "Got: ${details_c1}"
exit 1
fi
if ! recon_queue_after_c1="$(wait_for_queue_increase "notifications.reconciliation.queue" "${recon_queue_before}" 15)"; then
echo "Expected messages on notifications.reconciliation.queue to increase after Check 1"
echo "Before=${recon_queue_before}, after=${recon_queue_after_c1}"
exit 1
fi
echo "OK: Check 1 reconciliation_alert created; queue depth ${recon_queue_before} -> ${recon_queue_after_c1}"
echo ""
echo "[4/${TOTAL_STEPS}] Check 1 — second scheduler cycle does not duplicate"
count_before_dup="$(reconciliation_count "${encounter_c1}" "${CHECK1}")"
wait_for_scheduler_cycle
count_after_dup="$(reconciliation_count "${encounter_c1}" "${CHECK1}")"
if [[ "${count_after_dup}" != "${count_before_dup}" ]]; then
echo "Expected deduplication to keep count at ${count_before_dup}, got ${count_after_dup}"
exit 1
fi
echo "OK: open reconciliation_alert count unchanged (${count_after_dup})"
echo ""
echo "[5/${TOTAL_STEPS}] Check 1 — acknowledged critical alert is not flagged"
patient_c1a="$(create_patient "C1A")"
encounter_c1a="$(create_encounter "${patient_c1a}" "Inpatient")"
alert_c1a="$(ingest_critical_potassium "${encounter_c1a}" "recon-c1a-${SCRIPT_RUN_ID}")"
ack_payload='{"clinicianId":"DR-RECON-SCRIPT","note":"Acknowledged during reconciliation verification."}'
resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_c1a}/acknowledge" "${ack_payload}")"
assert_status "200" "${resp}"
backdate_alert_triggered_at "${alert_c1a}" 31
wait_for_scheduler_cycle
if [[ "$(reconciliation_count "${encounter_c1a}" "${CHECK1}")" != "0" ]]; then
echo "Acknowledged alert should not produce a Check 1 reconciliation_alert"
exit 1
fi
echo "OK: acknowledged alert produced no Check 1 row"
echo ""
echo "[6/${TOTAL_STEPS}] Check 2 — stale pending order creates reconciliation_alert"
patient_c2="$(create_patient "C2")"
encounter_c2="$(create_encounter "${patient_c2}" "Inpatient")"
insert_stale_pending_order "${encounter_c2}" "Comprehensive metabolic panel"
if ! wait_for_reconciliation_row "${encounter_c2}" "${CHECK2}" "${RECONCILIATION_WAIT_SECS}"; then
exit 1
fi
details_c2="$(psql_cmd "SELECT details FROM reconciliation_alerts
WHERE encounter_id = '${encounter_c2}' AND check_type = '${CHECK2}' AND resolved_at IS NULL
ORDER BY created_at DESC LIMIT 1")"
if [[ "${details_c2}" != *"Comprehensive metabolic panel"* ]]; then
echo "Check 2 details should mention the stale order"
echo "Got: ${details_c2}"
exit 1
fi
echo "OK: Check 2 reconciliation_alert created for stale pending order"
echo ""
echo "[7/${TOTAL_STEPS}] Check 2 — resulted order is not flagged"
patient_c2r="$(create_patient "C2R")"
encounter_c2r="$(create_encounter "${patient_c2r}" "Inpatient")"
insert_resulted_order "${encounter_c2r}"
wait_for_scheduler_cycle
if [[ "$(reconciliation_count "${encounter_c2r}" "${CHECK2}")" != "0" ]]; then
echo "Resulted order should not produce a Check 2 reconciliation_alert"
exit 1
fi
echo "OK: resulted order produced no Check 2 row"
echo ""
echo "[8/${TOTAL_STEPS}] Check 3 — active inpatient with no observations is flagged"
patient_c3="$(create_patient "C3")"
encounter_c3="$(create_encounter "${patient_c3}" "Inpatient")"
if ! wait_for_reconciliation_row "${encounter_c3}" "${CHECK3}" "${RECONCILIATION_WAIT_SECS}"; then
exit 1
fi
details_c3="$(psql_cmd "SELECT details FROM reconciliation_alerts
WHERE encounter_id = '${encounter_c3}' AND check_type = '${CHECK3}' AND resolved_at IS NULL
ORDER BY created_at DESC LIMIT 1")"
if [[ "${details_c3}" != *"no observations ever recorded"* ]]; then
echo "Check 3 details should mention no observations ever recorded"
echo "Got: ${details_c3}"
exit 1
fi
echo "OK: Check 3 reconciliation_alert created for inpatient without observations"
echo ""
echo "[9/${TOTAL_STEPS}] Check 3 — stale observation (3 hours ago) is flagged"
patient_c3s="$(create_patient "C3S")"
encounter_c3s="$(create_encounter "${patient_c3s}" "Inpatient")"
stale_recorded_at="$(date -u -d '3 hours ago' +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-3H +"%Y-%m-%dT%H:%M:%SZ")"
ingest_heart_rate "${encounter_c3s}" "${stale_recorded_at}" "recon-c3s-${SCRIPT_RUN_ID}"
if ! wait_for_reconciliation_row "${encounter_c3s}" "${CHECK3}" "${RECONCILIATION_WAIT_SECS}"; then
exit 1
fi
echo "OK: Check 3 reconciliation_alert created for stale observation"
echo ""
echo "[10/${TOTAL_STEPS}] Check 3 — recent observation (30 minutes ago) is not flagged"
patient_c3r="$(create_patient "C3R")"
encounter_c3r="$(create_encounter "${patient_c3r}" "Inpatient")"
recent_recorded_at="$(date -u -d '30 minutes ago' +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-30M +"%Y-%m-%dT%H:%M:%SZ")"
ingest_heart_rate "${encounter_c3r}" "${recent_recorded_at}" "recon-c3r-${SCRIPT_RUN_ID}"
wait_for_scheduler_cycle
if [[ "$(reconciliation_count "${encounter_c3r}" "${CHECK3}")" != "0" ]]; then
echo "Recent observation should not produce a Check 3 reconciliation_alert"
exit 1
fi
echo "OK: recent observation produced no Check 3 row"
echo ""
echo "[11/${TOTAL_STEPS}] Check 3 — outpatient encounter without observations is ignored"
patient_c3o="$(create_patient "C3O")"
encounter_c3o="$(create_encounter "${patient_c3o}" "Outpatient" "GeneralMedicine")"
wait_for_scheduler_cycle
if [[ "$(reconciliation_count "${encounter_c3o}" "${CHECK3}")" != "0" ]]; then
echo "Outpatient encounter should not produce a Check 3 reconciliation_alert"
exit 1
fi
# Plan verification query — candidate inpatients with no recent observations
candidate_count="$(psql_cmd "
SELECT COUNT(*) FROM (
SELECT e.id
FROM encounters e
LEFT JOIN observations o ON o.encounter_id = e.id
WHERE e.status = 'ACTIVE' AND e.encounter_type = 'INPATIENT'
GROUP BY e.id
HAVING MAX(o.recorded_at) < NOW() - INTERVAL '2 hours'
OR MAX(o.recorded_at) IS NULL
) t")"
echo "OK: outpatient not flagged; ${candidate_count} active inpatient(s) match Check 3 candidate query"
echo ""
echo "All ${TOTAL_STEPS} Phase 7 reconciliation checks passed."
echo ""
echo "Prerequisites: docker compose up -d && dotnet run --project VigilCareClinicalAPI"
echo "Recommended: ReconciliationJobs.IntervalMinutes=1 in appsettings.json (restart API before running)."
echo "Optional: RECONCILIATION_WAIT_SECS=180 RECONCILIATION_CYCLE_WAIT_SECS=75"