#!/usr/bin/env bash # Runs Phase 4 verification checks from docs/plans/phase-4-plan.md. # # Covers promotion to clinical tables, idempotency, separation of duties, # promotion-result endpoint, and the outbox alert behavior matrix. # # Prerequisites: # docker compose up -d (PostgreSQL + Redis; psql via docker compose exec) # dotnet ef database update --project VigilCareRecordsAPI # dotnet run --project VigilCareRecordsAPI # Phase 1–3 seed data (intake1, entry1, verifier1, approver1 users) # # PostgreSQL checks use docker compose exec when the postgres service is running, # otherwise host psql against VIGILCARE_PG_HOST:VIGILCARE_PG_PORT. # Environment overrides (same defaults as Phase 1–3 scripts): # VIGILCARE_API_URL default: http://localhost:5217 # VIGILCARE_COMPOSE_FILE default: /docker-compose.yml # VIGILCARE_PG_HOST default: localhost # VIGILCARE_PG_PORT default: 5437 # VIGILCARE_PG_DB default: vigilcare_records # VIGILCARE_PG_USER default: postgres # VIGILCARE_PG_PASSWORD default: password # VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions # VIGILCARE_RECORDED_AT default: 2024-01-15T09:30:00Z set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" FIXTURE_PDF="$SCRIPT_DIR/fixtures/test-scan.pdf" API_URL="${VIGILCARE_API_URL:-http://localhost:5217}" COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}" COMPOSE=(docker compose -f "$COMPOSE_FILE") PG_HOST="${VIGILCARE_PG_HOST:-localhost}" PG_PORT="${VIGILCARE_PG_PORT:-5437}" PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}" PG_USER="${VIGILCARE_PG_USER:-postgres}" PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}" SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}" RECORDED_AT="${VIGILCARE_RECORDED_AT:-2024-01-15T09:30:00Z}" RECORDED_AT_BP="${VIGILCARE_RECORDED_AT_BP:-2024-01-15T09:31:00Z}" PASS_COUNT=0 FAIL_COUNT=0 FAILED_TESTS=() log() { printf '%s\n' "$*" } section() { log "" log "== $1 ==" } pass() { PASS_COUNT=$((PASS_COUNT + 1)) log " PASS: $1" } fail() { FAIL_COUNT=$((FAIL_COUNT + 1)) FAILED_TESTS+=("$1") log " FAIL: $1" } require_cmd() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then log "ERROR: required command not found: $cmd" exit 1 fi } compose_service_running() { local service="$1" "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service" } psql_available() { [[ "$SKIP_DB_CHECKS" == "1" ]] && return 1 compose_service_running postgres && return 0 command -v psql >/dev/null 2>&1 && return 0 return 1 } psql_query() { if [[ "$SKIP_DB_CHECKS" == "1" ]]; then return 1 fi if compose_service_running postgres; then "${COMPOSE[@]}" exec -T postgres \ psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1" elif command -v psql >/dev/null 2>&1; then PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1" else return 1 fi } new_idempotency_key() { if command -v uuidgen >/dev/null 2>&1; then uuidgen else cat /proc/sys/kernel/random/uuid fi } http_code() { curl -sS -o /dev/null -w '%{http_code}' "$@" } json_post() { local url="$1" local body="$2" local token="${3:-}" if [[ -n "$token" ]]; then curl -sS -X POST "$url" \ -H "Authorization: Bearer $token" \ -H 'Content-Type: application/json' \ -d "$body" else curl -sS -X POST "$url" \ -H 'Content-Type: application/json' \ -d "$body" fi } json_put() { local url="$1" local body="$2" local token="$3" curl -sS -X PUT "$url" \ -H "Authorization: Bearer $token" \ -H 'Content-Type: application/json' \ -d "$body" } login() { local username="$1" local password="${2:-password}" json_post "$API_URL/api/v1/auth/login" \ "{\"username\":\"$username\",\"password\":\"$password\"}" } extract_data_field() { local json="$1" local field="$2" jq -er ".data.$field // empty" <<<"$json" } extract_error_code() { local json="$1" jq -er '.error.code // .extensions.code // empty' <<<"$json" 2>/dev/null || jq -er '.title // empty' <<<"$json" 2>/dev/null || true } upload_batch() { local token="$1" local batch_type="${2:-VITALS_SHEET}" local track="${3:-BACKFILL}" local file_path="${4:-$FIXTURE_PDF}" curl -sS -X POST "$API_URL/api/v1/digitization-batches" \ -H "Authorization: Bearer $token" \ -F "file=@${file_path};type=application/pdf" \ -F "batchType=$batch_type" \ -F "track=$track" } assign_batch() { local token="$1" local batch_id="$2" local entry_clerk_id="$3" curl -sS -X PATCH "$API_URL/api/v1/digitization-batches/$batch_id/assign" \ -H "Authorization: Bearer $token" \ -H 'Content-Type: application/json' \ -d "{\"entryClerkUserId\":\"$entry_clerk_id\"}" } verify_batch() { local token="$1" local batch_id="$2" local body="$3" json_post "$API_URL/api/v1/digitization-batches/$batch_id/verify" "$body" "$token" } approve_batch() { local token="$1" local batch_id="$2" local idempotency_key="$3" local body="$4" if [[ -z "$body" ]]; then body='{"enableRetroactiveAlerts":false}' fi curl -sS -X POST "$API_URL/api/v1/digitization-batches/$batch_id/approve" \ -H "Authorization: Bearer $token" \ -H 'Content-Type: application/json' \ -H "Idempotency-Key: $idempotency_key" \ -d "$body" } assert_api_reachable() { local code code="$(http_code "$API_URL/swagger/index.html" || true)" if [[ "$code" != "200" ]]; then log "ERROR: API not reachable at $API_URL (HTTP $code)." log "Start infrastructure with: docker compose up -d" log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI" log "Start API with: dotnet run --project VigilCareRecordsAPI" exit 1 fi } # Upload, assign, enter draft data, submit, and verify. # Leaves batch in AWAITING_CLINICAL_APPROVAL (VITALS_SHEET). Prints batch id to stdout. create_batch_ready_for_approval() { local intake_token="$1" local track="${2:-BACKFILL}" local patient_name="${3:-Test Patient}" local patient_dob="${4:-1990-05-15}" local entry_token verifier_token batch_id entry_id patient_json local verify_json verify_status submit_code patient_json="$(jq -nc \ --arg name "$patient_name" \ --arg dob "$patient_dob" \ '{fullName: $name, dateOfBirth: $dob, sex: "M", bloodType: "A+", noKnownAllergies: true}')" local upload_json upload_json="$(upload_batch "$intake_token" "VITALS_SHEET" "$track")" if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then log "ERROR: failed to upload VITALS_SHEET batch (track=$track)" return 1 fi batch_id="$(extract_data_field "$upload_json" id)" entry_id="$(extract_data_field "$(login entry1)" userId)" assign_batch "$intake_token" "$batch_id" "$entry_id" >/dev/null entry_token="$(extract_data_field "$(login entry1)" token)" json_put \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ "$patient_json" \ "$entry_token" >/dev/null json_put \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/encounter" \ '{"admissionDate":"2024-01-15T08:00:00Z","department":"General Medicine","roomBed":"301-A","admissionReason":"Routine checkup","status":"active"}' \ "$entry_token" >/dev/null json_post \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ "{\"observationCode\":\"HR\",\"value\":88,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\",\"note\":\"Resting heart rate\"}" \ "$entry_token" >/dev/null json_post \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ "{\"observationCode\":\"TEMP\",\"value\":37.2,\"unit\":\"C\",\"recordedAt\":\"$RECORDED_AT\",\"note\":\"Oral temperature\"}" \ "$entry_token" >/dev/null json_post \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ "{\"observationCode\":\"BP_SYS\",\"value\":120,\"unit\":\"mmHg\",\"recordedAt\":\"$RECORDED_AT_BP\"}" \ "$entry_token" >/dev/null submit_code="$(http_code -X POST \ "$API_URL/api/v1/digitization-batches/$batch_id/submit-for-verification" \ -H "Authorization: Bearer $entry_token")" if [[ "$submit_code" != "200" ]]; then log "ERROR: submit-for-verification failed (HTTP $submit_code)" return 1 fi verifier_token="$(extract_data_field "$(login verifier1)" token)" verify_json="$(verify_batch "$verifier_token" "$batch_id" \ '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null},{"fieldName":"observation.heartRate","status":"ok","note":null}],"passed":true}')" verify_status="$(extract_data_field "$verify_json" status)" if [[ "$(jq -er '.success' <<<"$verify_json")" != "true" || "$verify_status" != "AWAITING_CLINICAL_APPROVAL" ]]; then log "ERROR: verification failed (status=${verify_status:-})" return 1 fi printf '%s' "$batch_id" } test_backfill_promotion_creates_live_records() { section "1. Promotion — BACKFILL batch creates live records, zero outbox events" local intake_token batch_id approver_token approve_json local status mrn outbox_written obs_count intake_token="$(extract_data_field "$(login intake1)" token)" batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return approver_token="$(extract_data_field "$(login approver1)" token)" approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" \ '{"enableRetroactiveAlerts":false}')" if [[ "$(jq -er '.success' <<<"$approve_json")" != "true" ]]; then fail "approve BACKFILL batch returns 200" return fi status="$(extract_data_field "$approve_json" status)" mrn="$(extract_data_field "$approve_json" mrn)" outbox_written="$(extract_data_field "$approve_json" outboxEventsWritten)" if [[ "$status" == "PROMOTED" && "$mrn" == VCR-* && "$outbox_written" == "0" ]]; then pass "BACKFILL promotion returns PROMOTED, VCR-* MRN, outboxEventsWritten=0" else fail "BACKFILL promotion returns PROMOTED, VCR-* MRN, outboxEventsWritten=0 (status=$status mrn=$mrn outbox=$outbox_written)" return fi if ! psql_available; then log " SKIP: PostgreSQL live-record checks (postgres not reachable)" return fi obs_count="$(psql_query "SELECT count(*) FROM clinical.observations WHERE source_batch_id = '$batch_id';")" if [[ "$obs_count" == "3" ]]; then pass "clinical.observations has 3 rows for promoted batch" else fail "clinical.observations has 3 rows for promoted batch (got: ${obs_count:-})" fi local outbox_count outbox_count="$(psql_query "SELECT count(*) FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%';")" if [[ "$outbox_count" == "0" ]]; then pass "clinical.outbox_events has 0 rows for BACKFILL without retroactive alerts" else fail "clinical.outbox_events has 0 rows for BACKFILL without retroactive alerts (got: ${outbox_count:-})" fi local patient_name batch_status promoted_event patient_name="$(psql_query "SELECT full_name FROM clinical.patients p JOIN clinical.observations o ON o.patient_id = p.id WHERE o.source_batch_id = '$batch_id' LIMIT 1;")" if [[ "$patient_name" == "Test Patient" ]]; then pass "clinical.patients row has correct full_name" else fail "clinical.patients row has correct full_name (got: ${patient_name:-})" fi batch_status="$(psql_query "SELECT status FROM digitization_batches WHERE id = '$batch_id';")" if [[ "$batch_status" == "PROMOTED" ]]; then pass "digitization_batches status is PROMOTED" else fail "digitization_batches status is PROMOTED (got: ${batch_status:-})" fi promoted_event="$(psql_query "SELECT count(*) FROM digitization_events WHERE batch_id = '$batch_id' AND event_type = 'promoted';")" if [[ "$promoted_event" == "1" ]]; then pass "digitization_events includes promoted event" else fail "digitization_events includes promoted event (count=${promoted_event:-})" fi } test_idempotency() { section "2. Idempotency — same Idempotency-Key returns identical result, no duplicate rows" local intake_token batch_id approver_token idem_key local first_json second_json first_patient second_patient obs_count intake_token="$(extract_data_field "$(login intake1)" token)" batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return approver_token="$(extract_data_field "$(login approver1)" token)" idem_key="$(new_idempotency_key)" first_json="$(approve_batch "$approver_token" "$batch_id" "$idem_key" '{}')" second_json="$(approve_batch "$approver_token" "$batch_id" "$idem_key" '{}')" first_patient="$(extract_data_field "$first_json" patientId)" second_patient="$(extract_data_field "$second_json" patientId)" if [[ -n "$first_patient" && "$first_patient" == "$second_patient" ]]; then pass "idempotent replay returns identical patientId" else fail "idempotent replay returns identical patientId (first=$first_patient second=$second_patient)" return fi if ! psql_available; then log " SKIP: idempotency observation count (postgres not reachable)" return fi obs_count="$(psql_query "SELECT count(*) FROM clinical.observations WHERE source_batch_id = '$batch_id';")" if [[ "$obs_count" == "3" ]]; then pass "only one set of observations exists after idempotent replay (3, not 6)" else fail "only one set of observations exists after idempotent replay (got: ${obs_count:-})" fi } test_separation_of_duties() { section "3. Separation of duties — entry clerk cannot approve (403)" local intake_token batch_id entry_token approve_code intake_token="$(extract_data_field "$(login intake1)" token)" batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return entry_token="$(extract_data_field "$(login entry1)" token)" approve_code="$(http_code -X POST \ "$API_URL/api/v1/digitization-batches/$batch_id/approve" \ -H "Authorization: Bearer $entry_token" \ -H 'Content-Type: application/json' \ -H "Idempotency-Key: $(new_idempotency_key)" \ -d '{}')" if [[ "$approve_code" == "403" ]]; then pass "entry clerk approve rejected with 403 (wrong role)" else fail "entry clerk approve rejected with 403 (wrong role) (http=$approve_code)" fi } test_missing_idempotency_key() { section "4. Missing Idempotency-Key — approve returns 400 MISSING_IDEMPOTENCY_KEY" local intake_token batch_id approver_token approve_json error_code approve_code intake_token="$(extract_data_field "$(login intake1)" token)" batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return approver_token="$(extract_data_field "$(login approver1)" token)" approve_code="$(http_code -X POST \ "$API_URL/api/v1/digitization-batches/$batch_id/approve" \ -H "Authorization: Bearer $approver_token" \ -H 'Content-Type: application/json' \ -d '{}')" approve_json="$(json_post \ "$API_URL/api/v1/digitization-batches/$batch_id/approve" \ '{}' "$approver_token")" error_code="$(extract_error_code "$approve_json")" if [[ "$approve_code" == "400" && "$error_code" == "MISSING_IDEMPOTENCY_KEY" ]]; then pass "approve without Idempotency-Key returns 400 MISSING_IDEMPOTENCY_KEY" else fail "approve without Idempotency-Key returns 400 MISSING_IDEMPOTENCY_KEY (http=$approve_code code=${error_code:-})" fi } test_approve_wrong_status() { section "5. Status guard — approve on uploaded batch returns 409" local intake_token batch_id approver_token approve_json error_code approve_code intake_token="$(extract_data_field "$(login intake1)" token)" batch_id="$(extract_data_field "$(upload_batch "$intake_token")" id)" approver_token="$(extract_data_field "$(login approver1)" token)" approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" '{}')" approve_code="$(jq -er '(.statusCode // .status // empty)' <<<"$approve_json")" error_code="$(extract_error_code "$approve_json")" if [[ "$approve_code" == "409" && "$error_code" == "ILLEGAL_STATUS_TRANSITION" ]]; then pass "approve on UPLOADED batch returns 409 ILLEGAL_STATUS_TRANSITION" else fail "approve on UPLOADED batch returns 409 ILLEGAL_STATUS_TRANSITION (http=$approve_code code=${error_code:-})" fi } test_promotion_result_after_promotion() { section "6. Promotion result — GET returns live IDs after promotion" local intake_token batch_id approver_token approve_json local result_json result_patient approve_patient obs_len intake_token="$(extract_data_field "$(login intake1)" token)" batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return approver_token="$(extract_data_field "$(login approver1)" token)" approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" '{}')" approve_patient="$(extract_data_field "$approve_json" patientId)" result_json="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id/promotion-result" \ -H "Authorization: Bearer $approver_token")" result_patient="$(extract_data_field "$result_json" patientId)" obs_len="$(jq -er '.data.observationIds | length' <<<"$result_json")" if [[ "$(jq -er '.success' <<<"$result_json")" == "true" && "$result_patient" == "$approve_patient" && "$obs_len" == "3" ]]; then pass "GET promotion-result returns patientId and 3 observationIds after promotion" else fail "GET promotion-result returns patientId and 3 observationIds after promotion" fi } test_promotion_result_not_promoted() { section "7. Promotion result — GET on non-promoted batch returns 409" local intake_token batch_id approver_token result_code intake_token="$(extract_data_field "$(login intake1)" token)" batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return approver_token="$(extract_data_field "$(login approver1)" token)" result_code="$(http_code "$API_URL/api/v1/digitization-batches/$batch_id/promotion-result" \ -H "Authorization: Bearer $approver_token")" if [[ "$result_code" == "409" ]]; then pass "GET promotion-result before approve returns 409" else fail "GET promotion-result before approve returns 409 (http=$result_code)" fi } test_backfill_retroactive_alerts() { section "8. Alert matrix — BACKFILL + enableRetroactiveAlerts=true writes outbox events" local intake_token batch_id approver_token approve_json outbox_written intake_token="$(extract_data_field "$(login intake1)" token)" batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return approver_token="$(extract_data_field "$(login approver1)" token)" approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" \ '{"enableRetroactiveAlerts":true}')" outbox_written="$(extract_data_field "$approve_json" outboxEventsWritten)" if [[ "$outbox_written" == "3" ]]; then pass "BACKFILL with enableRetroactiveAlerts=true writes 3 outbox events" else fail "BACKFILL with enableRetroactiveAlerts=true writes 3 outbox events (got: ${outbox_written:-})" return fi if ! psql_available; then log " SKIP: outbox event payload checks (postgres not reachable)" return fi local outbox_count event_type processed outbox_count="$(psql_query "SELECT count(*) FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%';")" event_type="$(psql_query "SELECT DISTINCT event_type FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%';")" processed="$(psql_query "SELECT count(*) FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%' AND processed_at IS NOT NULL;")" if [[ "$outbox_count" == "3" && "$event_type" == "observation.created" && "$processed" == "0" ]]; then pass "outbox rows are observation.created with processed_at IS NULL" else fail "outbox rows are observation.created with processed_at IS NULL (count=$outbox_count type=$event_type processed=$processed)" fi } test_live_capture_always_alerts() { section "9. Alert matrix — LIVE_CAPTURE always writes outbox events" local intake_token batch_id approver_token approve_json outbox_written source intake_token="$(extract_data_field "$(login intake1)" token)" batch_id="$(create_batch_ready_for_approval "$intake_token" "LIVE_CAPTURE")" || return approver_token="$(extract_data_field "$(login approver1)" token)" approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" \ '{"enableRetroactiveAlerts":false}')" outbox_written="$(extract_data_field "$approve_json" outboxEventsWritten)" if [[ "$outbox_written" == "3" ]]; then pass "LIVE_CAPTURE with enableRetroactiveAlerts=false still writes 3 outbox events" else fail "LIVE_CAPTURE with enableRetroactiveAlerts=false still writes 3 outbox events (got: ${outbox_written:-})" return fi if ! psql_available; then log " SKIP: live_capture source check (postgres not reachable)" return fi source="$(psql_query "SELECT DISTINCT source FROM clinical.observations WHERE source_batch_id = '$batch_id';")" if [[ "$source" == "live_capture" ]]; then pass "LIVE_CAPTURE observations have source=live_capture" else fail "LIVE_CAPTURE observations have source=live_capture (got: ${source:-})" fi } test_sequential_mrns() { section "10. MRN generation — sequential unique VCR-* values across promotions" local intake_token batch_id1 batch_id2 approver_token local approve1 approve2 mrn1 mrn2 num1 num2 intake_token="$(extract_data_field "$(login intake1)" token)" batch_id1="$(create_batch_ready_for_approval "$intake_token" "BACKFILL" "MRN Test Patient A" "1988-01-10")" || return batch_id2="$(create_batch_ready_for_approval "$intake_token" "BACKFILL" "MRN Test Patient B" "1992-06-20")" || return approver_token="$(extract_data_field "$(login approver1)" token)" approve1="$(approve_batch "$approver_token" "$batch_id1" "$(new_idempotency_key)" '{}')" approve2="$(approve_batch "$approver_token" "$batch_id2" "$(new_idempotency_key)" '{}')" mrn1="$(extract_data_field "$approve1" mrn)" mrn2="$(extract_data_field "$approve2" mrn)" if [[ "$mrn1" == VCR-* && "$mrn2" == VCR-* && "$mrn1" != "$mrn2" ]]; then pass "two promotions produce unique VCR-* MRNs" else fail "two promotions produce unique VCR-* MRNs (mrn1=$mrn1 mrn2=$mrn2)" return fi num1="${mrn1#VCR-}" num2="${mrn2#VCR-}" if [[ "$num2" -gt "$num1" ]]; then pass "second MRN is numerically greater than first (sequential sequence)" else fail "second MRN is numerically greater than first (mrn1=$mrn1 mrn2=$mrn2)" fi } main() { require_cmd curl require_cmd jq require_cmd docker if [[ ! -f "$FIXTURE_PDF" ]]; then log "ERROR: missing fixture PDF at $FIXTURE_PDF" exit 1 fi log "VigilCare Records — Phase 4 verification" log "API: $API_URL" if compose_service_running postgres; then log "PostgreSQL: docker compose exec (service: postgres)" elif command -v psql >/dev/null 2>&1; then log "PostgreSQL: host psql ($PG_HOST:$PG_PORT)" fi assert_api_reachable test_backfill_promotion_creates_live_records test_idempotency test_separation_of_duties test_missing_idempotency_key test_approve_wrong_status test_promotion_result_after_promotion test_promotion_result_not_promoted test_backfill_retroactive_alerts test_live_capture_always_alerts test_sequential_mrns log "" log "Results: $PASS_COUNT passed, $FAIL_COUNT failed" if (( FAIL_COUNT > 0 )); then log "Failed checks:" for item in "${FAILED_TESTS[@]}"; do log " - $item" done exit 1 fi log "All Phase 4 verification checks passed." } main "$@"