#!/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}" PGHOST="${PGHOST:-localhost}" PGPORT="${PGPORT:-5436}" PGDATABASE="${PGDATABASE:-vigilcare}" PGUSER="${PGUSER:-postgres}" PGPASSWORD="${PGPASSWORD:-password}" TOPIC_OBSERVATION="${TOPIC_OBSERVATION:-observation.recorded}" TOPIC_ENCOUNTER="${TOPIC_ENCOUNTER:-encounter.status.changed}" RELAY_WAIT_SECS="${RELAY_WAIT_SECS:-45}" KAFKA_READY_WAIT_SECS="${KAFKA_READY_WAIT_SECS:-30}" RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" SCRIPT_RUN_ID="$(date -u +"%Y%m%d%H%M%S")" 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 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 "$@" } 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}" echo "Response body:" cat "${body_file}" echo return 1 fi } wait_for_kafka() { local elapsed=0 while (( elapsed < KAFKA_READY_WAIT_SECS )); do if kafka_exec /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1; then return 0 fi sleep 2 elapsed=$((elapsed + 2)) done echo "Kafka did not become ready within ${KAFKA_READY_WAIT_SECS}s" return 1 } wait_for_outbox_processed() { local outbox_id="$1" local elapsed=0 while (( elapsed < RELAY_WAIT_SECS )); do local processed processed="$(psql_cmd "SELECT processed_at IS NOT NULL FROM outbox_events WHERE id = '${outbox_id}'")" if [[ "${processed}" == "t" ]]; then return 0 fi sleep 1 elapsed=$((elapsed + 1)) done echo "Outbox row ${outbox_id} was not processed within ${RELAY_WAIT_SECS}s" return 1 } wait_for_pending_outbox_count() { local expected="$1" local elapsed=0 while (( elapsed < RELAY_WAIT_SECS )); do local pending pending="$(psql_cmd "SELECT COUNT(*) FROM outbox_events WHERE processed_at IS NULL")" if [[ "${pending}" == "${expected}" ]]; then return 0 fi sleep 1 elapsed=$((elapsed + 1)) done echo "Expected ${expected} pending outbox row(s), timed out after ${RELAY_WAIT_SECS}s" return 1 } consume_topic() { local topic="$1" local group="$2" local max_messages="$3" local tmp_out tmp_out="$(mktemp)" TMP_FILES+=("${tmp_out}") kafka_exec /opt/kafka/bin/kafka-console-consumer.sh \ --bootstrap-server localhost:9092 \ --topic "${topic}" \ --group "${group}" \ --from-beginning \ --property print.key=true \ --property print.partition=true \ --timeout-ms 15000 \ --max-messages "${max_messages}" > "${tmp_out}" 2>/dev/null || true echo "${tmp_out}" } TOTAL_STEPS=11 echo "Running Kafka + outbox verification against ${BASE_URL}" echo "Script run id: ${SCRIPT_RUN_ID}" echo "" echo "[0/${TOTAL_STEPS}] Preflight — API, Postgres, and Kafka 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 if ! wait_for_kafka; then exit 1 fi echo "OK: API, Postgres, and Kafka are up" echo "" echo "[1/${TOTAL_STEPS}] Verifying Kafka topics were provisioned" topic_list="$(kafka_exec /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list)" for topic in alert.generated alert.acknowledged encounter.status.changed observation.recorded; do if ! grep -qx "${topic}" <<< "${topic_list}"; then echo "Missing Kafka topic: ${topic}" echo "Topics found:" echo "${topic_list}" exit 1 fi done echo "OK: observation.recorded, alert.generated, alert.acknowledged, encounter.status.changed exist" echo "" echo "[2/${TOTAL_STEPS}] Creating patient and two active encounters" patient_payload='{"firstName":"Kafka","lastName":"Verifier","dateOfBirth":"1990-06-01","gender":"M"}' resp="$(request POST "${BASE_URL}/api/v1/patients" "${patient_payload}")" assert_status "201" "${resp}" patient_id="$(jq -r '.data.id' "${resp}")" enc_payload_a='{"encounterType":"Inpatient","department":"ICU","attendingPhysician":"Dr. Kafka"}' resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload_a}")" assert_status "201" "${resp}" encounter_a="$(jq -r '.data.id' "${resp}")" enc_payload_b='{"encounterType":"Outpatient","department":"Clinic","attendingPhysician":"Dr. Kafka"}' resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload_b}")" assert_status "201" "${resp}" encounter_b="$(jq -r '.data.id' "${resp}")" enc_payload_discharge='{"encounterType":"Emergency","department":"ED","attendingPhysician":"Dr. Kafka"}' resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${enc_payload_discharge}")" assert_status "201" "${resp}" encounter_discharge="$(jq -r '.data.id' "${resp}")" echo "OK: encounters ${encounter_a}, ${encounter_b}, ${encounter_discharge}" echo "" echo "[3/${TOTAL_STEPS}] Ingesting observations for partition-key verification" for encounter_id in "${encounter_a}" "${encounter_b}"; do for i in 1 2; do obs_payload="$(jq -nc \ --arg recordedAt "${RECORDED_AT}" \ --arg key "kafka-script-${SCRIPT_RUN_ID}-${encounter_id}-${i}" \ '{observations:[{observationCode:"HEART_RATE",value:80,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt,idempotencyKey:$key}]}')" resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${obs_payload}")" assert_status "201" "${resp}" done done echo "OK: four observation.recorded outbox rows written" echo "" echo "[4/${TOTAL_STEPS}] Waiting for relay to publish observation events" latest_outbox="$(psql_cmd "SELECT id FROM outbox_events WHERE topic = '${TOPIC_OBSERVATION}' AND partition_key = '${encounter_a}' ORDER BY created_at DESC LIMIT 1")" if [[ -z "${latest_outbox}" ]]; then echo "Could not find outbox row for encounter ${encounter_a}" exit 1 fi wait_for_outbox_processed "${latest_outbox}" echo "OK: relay marked observation outbox rows processed" echo "" echo "[5/${TOTAL_STEPS}] Verifying partition ordering (same encounter key → same partition)" consumer_out="$(consume_topic "${TOPIC_OBSERVATION}" "partition-check-${SCRIPT_RUN_ID}" 50)" for encounter_id in "${encounter_a}" "${encounter_b}"; do partitions="$(grep -F "${encounter_id}" "${consumer_out}" | sed -n 's/^Partition:\([0-9]*\).*/\1/p' | sort -u)" partition_count="$(echo "${partitions}" | grep -c . || true)" if [[ "${partition_count}" -lt 1 ]]; then echo "No Kafka messages found for encounter ${encounter_id}" exit 1 fi if [[ "${partition_count}" -gt 1 ]]; then echo "Encounter ${encounter_id} landed on multiple partitions: ${partitions}" exit 1 fi done echo "OK: each encounter's messages share a single partition" echo "" echo "[6/${TOTAL_STEPS}] Verifying consumer group independence" group_a="es-indexer-${SCRIPT_RUN_ID}" group_b="sepsis-engine-${SCRIPT_RUN_ID}" out_a="$(consume_topic "${TOPIC_OBSERVATION}" "${group_a}" 20)" out_b="$(consume_topic "${TOPIC_OBSERVATION}" "${group_b}" 20)" count_a="$(grep -c '^Partition:' "${out_a}" || true)" count_b="$(grep -c '^Partition:' "${out_b}" || true)" if [[ "${count_a}" -lt 4 || "${count_b}" -lt 4 ]]; then echo "Expected each consumer group to read at least 4 messages, got ${count_a} and ${count_b}" exit 1 fi offset_a="$(kafka_exec /opt/kafka/bin/kafka-consumer-groups.sh \ --bootstrap-server localhost:9092 \ --describe --group "${group_a}" 2>/dev/null | awk '/observation.recorded/ {sum += $4} END {print sum+0}')" offset_b="$(kafka_exec /opt/kafka/bin/kafka-consumer-groups.sh \ --bootstrap-server localhost:9092 \ --describe --group "${group_b}" 2>/dev/null | awk '/observation.recorded/ {sum += $4} END {print sum+0}')" if [[ "${offset_a}" -lt 4 || "${offset_b}" -lt 4 ]]; then echo "Consumer groups did not commit expected offsets (es-indexer=${offset_a}, sepsis-engine=${offset_b})" exit 1 fi echo "OK: two consumer groups read independently (offsets es-indexer=${offset_a}, sepsis-engine=${offset_b})" echo "" echo "[7/${TOTAL_STEPS}] Verifying encounter.status.changed outbox write and Kafka publish" discharge_payload='{"status":"Discharged"}' resp="$(request PATCH "${BASE_URL}/api/v1/encounters/${encounter_discharge}/status" "${discharge_payload}")" assert_status "200" "${resp}" outbox_status="$(psql_cmd "SELECT payload->>'newStatus' FROM outbox_events WHERE topic = '${TOPIC_ENCOUNTER}' AND partition_key = '${encounter_discharge}' ORDER BY created_at DESC LIMIT 1")" if [[ "${outbox_status}" != "DISCHARGED" ]]; then echo "Expected encounter.status.changed outbox row with newStatus=DISCHARGED, got '${outbox_status}'" exit 1 fi discharge_outbox_id="$(psql_cmd "SELECT id FROM outbox_events WHERE topic = '${TOPIC_ENCOUNTER}' AND partition_key = '${encounter_discharge}' ORDER BY created_at DESC LIMIT 1")" wait_for_outbox_processed "${discharge_outbox_id}" encounter_consumer_out="$(consume_topic "${TOPIC_ENCOUNTER}" "encounter-check-${SCRIPT_RUN_ID}" 5)" if ! grep -Fq "${encounter_discharge}" "${encounter_consumer_out}"; then echo "encounter.status.changed message not found on Kafka for ${encounter_discharge}" exit 1 fi echo "OK: discharge produced outbox row and Kafka message" echo "" echo "[8/${TOTAL_STEPS}] Verifying relay survives Kafka restart (no rows lost)" compose stop kafka >/dev/null restart_obs_payload="$(jq -nc \ --arg recordedAt "${RECORDED_AT}" \ --arg key "kafka-restart-${SCRIPT_RUN_ID}" \ '{observations:[{observationCode:"HEART_RATE",value:83,unit:"bpm",source:"DEVICE",recordedAt:$recordedAt,idempotencyKey:$key}]}')" resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_a}/observations" "${restart_obs_payload}")" assert_status "201" "${resp}" restart_outbox_id="$(psql_cmd "SELECT id FROM outbox_events WHERE topic = '${TOPIC_OBSERVATION}' AND partition_key = '${encounter_a}' ORDER BY created_at DESC LIMIT 1")" restart_pending="$(psql_cmd "SELECT processed_at IS NULL FROM outbox_events WHERE id = '${restart_outbox_id}'")" if [[ "${restart_pending}" != "t" ]]; then echo "Expected outbox row ${restart_outbox_id} to remain pending while Kafka is down" exit 1 fi echo "OK: outbox row pending while Kafka is stopped" compose start kafka >/dev/null wait_for_kafka wait_for_outbox_processed "${restart_outbox_id}" echo "OK: relay caught up after Kafka restart" echo "" echo "[9/${TOTAL_STEPS}] Verifying partial index and relay poll plan" index_count="$(psql_cmd "SELECT COUNT(*) FROM pg_indexes WHERE tablename = 'outbox_events' AND indexdef LIKE '%processed_at IS NULL%'")" if [[ "${index_count}" -lt 1 ]]; then echo "Expected partial index on outbox_events (processed_at IS NULL), found ${index_count}" exit 1 fi psql_cmd "ANALYZE outbox_events" >/dev/null explain_out="$(psql_cmd "BEGIN; SET LOCAL enable_seqscan = off; EXPLAIN SELECT id, topic, payload, partition_key FROM outbox_events WHERE processed_at IS NULL ORDER BY created_at ASC LIMIT 100 FOR UPDATE SKIP LOCKED; ROLLBACK;")" if ! grep -qi 'Index Scan' <<< "${explain_out}"; then echo "Expected Index Scan when seqscan is disabled, got:" echo "${explain_out}" exit 1 fi echo "OK: partial index exists and backs the relay poll query" echo "" echo "[10/${TOTAL_STEPS}] Verifying partition_key is set on clinical outbox rows" missing_keys="$(psql_cmd "SELECT COUNT(*) FROM outbox_events WHERE created_at >= NOW() - INTERVAL '10 minutes' AND topic IN ('${TOPIC_OBSERVATION}', '${TOPIC_ENCOUNTER}', 'alert.generated', 'alert.acknowledged') AND (partition_key IS NULL OR partition_key = '')")" if [[ "${missing_keys}" != "0" ]]; then echo "Found ${missing_keys} clinical outbox row(s) without partition_key" exit 1 fi echo "OK: all clinical outbox rows have partition_key" echo "" echo "All ${TOTAL_STEPS} Kafka + outbox checks passed."