#!/usr/bin/env bash # Runs Phase 6 verification checks from docs/plans/phase-6-plan.md. # # Covers Track B live capture: clinician attestation, synchronous promotion, # critical threshold alerting, outbox events, open-encounter workflow, and # integration tests. # # Prerequisites: # docker compose up -d (PostgreSQL + Redis + MinIO) # dotnet ef database update --project VigilCareRecordsAPI # dotnet run --project VigilCareRecordsAPI # Phase 1 seed data (clinician1, entry1, and other demo 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–5 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_SKIP_TEST_CHECKS set to 1 to skip dotnet integration tests # VIGILCARE_RECORDED_AT default: 2026-06-25T10:00:00Z set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" 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}" SKIP_TEST_CHECKS="${VIGILCARE_SKIP_TEST_CHECKS:-0}" RECORDED_AT="${VIGILCARE_RECORDED_AT:-2026-06-25T10:00:00Z}" RECORDED_AT_CRITICAL="${VIGILCARE_RECORDED_AT_CRITICAL:-2026-06-25T10:05:00Z}" RECORDED_AT_OUTPATIENT="${VIGILCARE_RECORDED_AT_OUTPATIENT:-2026-06-25T11:00:00Z}" PASS_COUNT=0 FAIL_COUNT=0 FAILED_TESTS=() # Populated by promotion / alert tests for downstream checks. SHARED_LIVE_OBS_ID="" SHARED_BATCH_ID="" SHARED_ALERT_ID="" 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 } new_uuid() { if command -v uuidgen >/dev/null 2>&1; then uuidgen else cat /proc/sys/kernel/random/uuid 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 } 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 } 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 } extract_status_code() { local json="$1" jq -er '.statusCode // empty' <<<"$json" } 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 } seed_potassium_threshold() { if ! psql_available; then return 1 fi psql_query " INSERT INTO clinical.alert_thresholds (id, observation_code, display_name, unit, critical_low, warning_low, warning_high, critical_high, created_at) SELECT gen_random_uuid(), 'POTASSIUM_MEQ_L', 'Serum Potassium', 'mEq/L', 2.5, 3.5, 5.0, 6.5, NOW() WHERE NOT EXISTS ( SELECT 1 FROM clinical.alert_thresholds WHERE observation_code = 'POTASSIUM_MEQ_L' ); " >/dev/null } # Creates clinical.patients + clinical.encounters (active). Prints patient_id|encounter_id. create_patient_and_encounter() { local status="${1:-active}" local patient_id encounter_id mrn if ! psql_available; then return 1 fi patient_id="$(new_uuid)" encounter_id="$(new_uuid)" # mrn is varchar(20); use a short unique prefix + uuid fragment mrn="P6-$(echo "$patient_id" | tr -d '-' | cut -c1-13)" psql_query " INSERT INTO clinical.patients (id, mrn, full_name, no_known_allergies, created_at, updated_at) VALUES ('$patient_id', '$mrn', 'Phase 6 Patient', false, NOW(), NOW()); " >/dev/null psql_query " INSERT INTO clinical.encounters (id, patient_id, department, room_bed, admission_reason, status, admission_date, created_at, updated_at) VALUES ( '$encounter_id', '$patient_id', 'Internal Medicine', 'IM-201A', 'Observation', '$status', NOW(), NOW(), NOW() ); " >/dev/null printf '%s|%s' "$patient_id" "$encounter_id" } # Creates clinical.patients only. Prints patient_id. create_patient() { local patient_id mrn if ! psql_available; then return 1 fi patient_id="$(new_uuid)" mrn="P6-$(echo "$patient_id" | tr -d '-' | cut -c1-13)" psql_query " INSERT INTO clinical.patients (id, mrn, full_name, no_known_allergies, created_at, updated_at) VALUES ('$patient_id', '$mrn', 'Phase 6 Patient', false, NOW(), NOW()); " >/dev/null printf '%s' "$patient_id" } live_capture_record() { local token="$1" local encounter_id="$2" local body="$3" json_post "$API_URL/api/v1/live-capture/encounters/$encounter_id/observations" \ "$body" "$token" } live_capture_open_encounter() { local token="$1" local body="$2" json_post "$API_URL/api/v1/live-capture/encounters" "$body" "$token" } normal_observations_body() { local password_confirm="${1:-password}" jq -nc \ --arg recorded_at "$RECORDED_AT" \ --arg password "$password_confirm" \ '{ observations: [ {observationCode: "HEART_RATE", value: 72, unit: "bpm", recordedAt: $recorded_at, note: null}, {observationCode: "TEMP_C", value: 36.8, unit: "C", recordedAt: $recorded_at, note: null}, {observationCode: "SPO2", value: 98, unit: "%", recordedAt: $recorded_at, note: null} ], clinicianAttestation: true, passwordConfirm: $password }' } test_schema_alert_tables() { section "1. Schema — clinical.alert_thresholds and clinical.clinical_alerts" if ! psql_available; then log " SKIP: PostgreSQL not reachable" return fi local threshold_table alert_table threshold_table="$(psql_query " SELECT count(*) FROM information_schema.tables WHERE table_schema = 'clinical' AND table_name = 'alert_thresholds'; ")" alert_table="$(psql_query " SELECT count(*) FROM information_schema.tables WHERE table_schema = 'clinical' AND table_name = 'clinical_alerts'; ")" if [[ "$threshold_table" == "1" ]]; then pass "clinical.alert_thresholds table exists" else fail "clinical.alert_thresholds table exists" fi if [[ "$alert_table" == "1" ]]; then pass "clinical.clinical_alerts table exists" else fail "clinical.clinical_alerts table exists" fi } test_swagger_live_capture_routes() { section "2. API surface — live-capture routes in swagger" local swagger_paths swagger_paths="$(curl -sS "$API_URL/swagger/v1/swagger.json")" if jq -e '.paths["/api/v1/live-capture/encounters/{encounterId}/observations"].post' \ <<<"$swagger_paths" >/dev/null; then pass "POST /api/v1/live-capture/encounters/{encounterId}/observations documented" else fail "POST /api/v1/live-capture/encounters/{encounterId}/observations documented" fi if jq -e '.paths["/api/v1/live-capture/encounters"].post' \ <<<"$swagger_paths" >/dev/null; then pass "POST /api/v1/live-capture/encounters documented" else fail "POST /api/v1/live-capture/encounters documented" fi } test_attestation_wrong_password() { section "3. Attestation — wrong password returns 422 PASSWORD_CONFIRM_INVALID" local clinician_token ids encounter_id body result http_code error_code clinician_token="$(extract_data_field "$(login clinician1)" token)" ids="$(create_patient_and_encounter)" || { fail "setup patient/encounter for wrong-password test" return } encounter_id="${ids#*|}" body="$(normal_observations_body wrong)" result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")" http_code="$(extract_status_code "$result")" error_code="$(extract_error_code "$result")" if [[ "$http_code" == "422" && "$error_code" == "PASSWORD_CONFIRM_INVALID" ]]; then pass "wrong password returns 422 PASSWORD_CONFIRM_INVALID" else fail "wrong password returns 422 PASSWORD_CONFIRM_INVALID (http=$http_code code=${error_code:-})" fi } test_attestation_non_clinician() { section "4. Authorization — entry clerk returns 403 Forbidden" local clerk_token ids encounter_id body http_result clerk_token="$(extract_data_field "$(login entry1)" token)" ids="$(create_patient_and_encounter)" || { fail "setup patient/encounter for non-clinician test" return } encounter_id="${ids#*|}" body="$(normal_observations_body)" http_result="$(http_code -X POST \ "$API_URL/api/v1/live-capture/encounters/$encounter_id/observations" \ -H "Authorization: Bearer $clerk_token" \ -H 'Content-Type: application/json' \ -d "$body")" if [[ "$http_result" == "403" ]]; then pass "entry clerk receives 403 Forbidden" else fail "entry clerk receives 403 Forbidden (http=$http_result)" fi } test_attestation_false() { section "5. Attestation — false attestation returns 422 ATTESTATION_REQUIRED" local clinician_token ids encounter_id body result http_code error_code clinician_token="$(extract_data_field "$(login clinician1)" token)" ids="$(create_patient_and_encounter)" || { fail "setup patient/encounter for false-attestation test" return } encounter_id="${ids#*|}" body="$(jq -nc \ --arg recorded_at "$RECORDED_AT" \ '{ observations: [ {observationCode: "HEART_RATE", value: 72, unit: "bpm", recordedAt: $recorded_at, note: null} ], clinicianAttestation: false, passwordConfirm: "password" }')" result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")" http_code="$(extract_status_code "$result")" error_code="$(extract_error_code "$result")" if [[ "$http_code" == "422" && "$error_code" == "ATTESTATION_REQUIRED" ]]; then pass "false attestation returns 422 ATTESTATION_REQUIRED" else fail "false attestation returns 422 ATTESTATION_REQUIRED (http=$http_code code=${error_code:-})" fi } test_normal_promotion() { section "6. Synchronous promotion — normal vitals promoted with no alert" local clinician_token ids encounter_id body result obs_count alert_count live_obs_id batch_id clinician_token="$(extract_data_field "$(login clinician1)" token)" ids="$(create_patient_and_encounter)" || { fail "setup patient/encounter for normal promotion test" return } encounter_id="${ids#*|}" body="$(normal_observations_body)" result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")" if [[ "$(jq -er '.success' <<<"$result")" != "true" ]]; then fail "normal vitals live capture returns success" return fi obs_count="$(extract_data_field "$result" 'observations | length')" alert_count="$(extract_data_field "$result" criticalAlertCount)" live_obs_id="$(jq -er '.data.observations[0].liveObservationId' <<<"$result")" batch_id="$(extract_data_field "$result" batchId)" if [[ "$obs_count" == "3" && "$alert_count" == "0" ]]; then pass "response has 3 observations and criticalAlertCount=0" else fail "response has 3 observations and criticalAlertCount=0 (obs=$obs_count alerts=$alert_count)" fi SHARED_LIVE_OBS_ID="$live_obs_id" SHARED_BATCH_ID="$batch_id" if ! psql_available; then log " SKIP: DB checks for normal promotion" return fi local obs_source batch_status batch_track batch_attestation obs_source="$(psql_query " SELECT source FROM clinical.observations WHERE id = '$live_obs_id'; ")" batch_status="$(psql_query " SELECT status FROM digitization_batches WHERE id = '$batch_id'; ")" batch_track="$(psql_query " SELECT track FROM digitization_batches WHERE id = '$batch_id'; ")" batch_attestation="$(psql_query " SELECT clinician_attestation FROM digitization_batches WHERE id = '$batch_id'; ")" if [[ "$obs_source" == "live_capture" ]]; then pass "clinical.observations.source is live_capture" else fail "clinical.observations.source is live_capture (got: ${obs_source:-})" fi if [[ "$batch_status" == "PROMOTED" && "$batch_track" == "LIVE_CAPTURE" && "$batch_attestation" == "t" ]]; then pass "digitization_batches: PROMOTED, LIVE_CAPTURE, clinician_attestation=true" else fail "digitization_batches state (status=$batch_status track=$batch_track attestation=$batch_attestation)" fi } test_critical_low_potassium() { section "7. Critical alert — potassium 2.1 mEq/L fires CRITICAL_LOW synchronously" seed_potassium_threshold local clinician_token ids encounter_id body result alert_count severity bound threshold message alert_id clinician_token="$(extract_data_field "$(login clinician1)" token)" ids="$(create_patient_and_encounter)" || { fail "setup patient/encounter for critical-low test" return } encounter_id="${ids#*|}" body="$(jq -nc \ --arg recorded_at "$RECORDED_AT_CRITICAL" \ '{ observations: [ { observationCode: "POTASSIUM_MEQ_L", value: 2.1, unit: "mEq/L", recordedAt: $recorded_at, note: "Bedside iSTAT result" } ], clinicianAttestation: true, passwordConfirm: "password" }')" result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")" if [[ "$(jq -er '.success' <<<"$result")" != "true" ]]; then fail "critical potassium live capture returns success" return fi alert_count="$(extract_data_field "$result" criticalAlertCount)" severity="$(jq -er '.data.observations[0].criticalAlert.severity' <<<"$result")" bound="$(jq -er '.data.observations[0].criticalAlert.thresholdBound' <<<"$result")" threshold="$(jq -er '.data.observations[0].criticalAlert.thresholdValue' <<<"$result")" message="$(jq -er '.data.observations[0].criticalAlert.message' <<<"$result")" alert_id="$(jq -er '.data.observations[0].criticalAlert.alertId' <<<"$result")" SHARED_ALERT_ID="$alert_id" if [[ "$alert_count" == "1" && "$severity" == "CRITICAL" && "$bound" == "CRITICAL_LOW" && "$threshold" == 2.5* && "$message" == *"below critical low"* ]]; then pass "response includes inline CRITICAL_LOW alert for potassium 2.1" else fail "response includes inline CRITICAL_LOW alert (count=$alert_count severity=$severity bound=$bound)" fi if ! psql_available; then log " SKIP: DB checks for critical alert" return fi local db_severity db_status db_details db_severity="$(psql_query " SELECT severity FROM clinical.clinical_alerts WHERE id = '$alert_id'; ")" db_status="$(psql_query " SELECT status FROM clinical.clinical_alerts WHERE id = '$alert_id'; ")" db_details="$(psql_query " SELECT details FROM clinical.clinical_alerts WHERE id = '$alert_id'; ")" if [[ "$db_severity" == "CRITICAL" && "$db_status" == "OPEN" && "$db_details" == *"below critical low"* ]]; then pass "clinical.clinical_alerts row committed (CRITICAL, OPEN)" else fail "clinical.clinical_alerts row (severity=$db_severity status=$db_status)" fi } test_critical_high_potassium() { section "8. Critical alert — potassium 7.2 mEq/L fires CRITICAL_HIGH" seed_potassium_threshold local clinician_token ids encounter_id body result bound threshold message clinician_token="$(extract_data_field "$(login clinician1)" token)" ids="$(create_patient_and_encounter)" || { fail "setup patient/encounter for critical-high test" return } encounter_id="${ids#*|}" body="$(jq -nc \ --arg recorded_at "$RECORDED_AT_CRITICAL" \ '{ observations: [ { observationCode: "POTASSIUM_MEQ_L", value: 7.2, unit: "mEq/L", recordedAt: $recorded_at, note: null } ], clinicianAttestation: true, passwordConfirm: "password" }')" result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")" bound="$(jq -er '.data.observations[0].criticalAlert.thresholdBound // empty' <<<"$result")" threshold="$(jq -er '.data.observations[0].criticalAlert.thresholdValue // empty' <<<"$result")" message="$(jq -er '.data.observations[0].criticalAlert.message // empty' <<<"$result")" if [[ "$bound" == "CRITICAL_HIGH" && "$threshold" == 6.5* && "$message" == *"above critical high"* ]]; then pass "response includes CRITICAL_HIGH alert for potassium 7.2" else fail "response includes CRITICAL_HIGH alert (bound=$bound threshold=$threshold)" fi } test_mixed_batch() { section "9. Mixed batch — only critical observation gets alert" seed_potassium_threshold local clinician_token ids encounter_id body result alert_count hr_alert k_alert temp_alert clinician_token="$(extract_data_field "$(login clinician1)" token)" ids="$(create_patient_and_encounter)" || { fail "setup patient/encounter for mixed-batch test" return } encounter_id="${ids#*|}" body="$(jq -nc \ --arg recorded_at "$RECORDED_AT_CRITICAL" \ '{ observations: [ {observationCode: "HEART_RATE", value: 80, unit: "bpm", recordedAt: $recorded_at, note: null}, {observationCode: "POTASSIUM_MEQ_L", value: 2.1, unit: "mEq/L", recordedAt: $recorded_at, note: null}, {observationCode: "TEMP_C", value: 37.0, unit: "C", recordedAt: $recorded_at, note: null} ], clinicianAttestation: true, passwordConfirm: "password" }')" result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")" alert_count="$(extract_data_field "$result" criticalAlertCount)" hr_alert="$(jq -er '.data.observations[0].criticalAlert // "null"' <<<"$result")" k_alert="$(jq -er '.data.observations[1].criticalAlert.severity // empty' <<<"$result")" temp_alert="$(jq -er '.data.observations[2].criticalAlert // "null"' <<<"$result")" if [[ "$alert_count" == "1" && "$hr_alert" == "null" && "$k_alert" == "CRITICAL" && "$temp_alert" == "null" ]]; then pass "mixed batch: single CRITICAL alert on potassium only" else fail "mixed batch alert shape (count=$alert_count hr=$hr_alert k=$k_alert temp=$temp_alert)" fi } test_outbox_events() { section "10. Outbox — observation.recorded and alert.generated events" if [[ -z "$SHARED_LIVE_OBS_ID" || -z "$SHARED_ALERT_ID" ]]; then fail "outbox checks require normal promotion and critical-low tests first" return fi if ! psql_available; then log " SKIP: outbox DB checks" return fi local obs_event_type alert_event_type obs_event_type="$(psql_query " SELECT event_type FROM clinical.outbox_events WHERE aggregate_id = '$SHARED_LIVE_OBS_ID' LIMIT 1; ")" alert_event_type="$(psql_query " SELECT event_type FROM clinical.outbox_events WHERE aggregate_id = '$SHARED_ALERT_ID' LIMIT 1; ")" if [[ "$obs_event_type" == "observation.recorded" ]]; then pass "outbox event observation.recorded written for live observation" else fail "outbox event observation.recorded (got: ${obs_event_type:-})" fi if [[ "$alert_event_type" == "alert.generated" ]]; then pass "outbox event alert.generated written for clinical alert" else fail "outbox event alert.generated (got: ${alert_event_type:-})" fi local obs_payload_source obs_payload_source="$(psql_query " SELECT payload_json::text FROM clinical.outbox_events WHERE aggregate_id = '$SHARED_LIVE_OBS_ID' LIMIT 1; ")" if [[ "$obs_payload_source" == *"live_capture"* ]]; then pass "observation outbox payload includes source live_capture" else fail "observation outbox payload includes source live_capture" fi } test_open_encounter_with_vitals() { section "11. Open encounter + vitals — outpatient workflow" local clinician_token patient_id body result encounter_id obs_count department if ! psql_available; then log " SKIP: open-encounter test requires DB for patient setup" return fi clinician_token="$(extract_data_field "$(login clinician1)" token)" patient_id="$(create_patient)" || { fail "setup patient for open-encounter test" return } body="$(jq -nc \ --arg patient_id "$patient_id" \ --arg recorded_at "$RECORDED_AT_OUTPATIENT" \ '{ patientId: $patient_id, department: "Outpatient Clinic", roomBed: "OPD-3", admissionReason: "Follow-up", observations: [ {observationCode: "HEART_RATE", value: 68, unit: "bpm", recordedAt: $recorded_at, note: null}, {observationCode: "BP_SYSTOLIC", value: 120, unit: "mmHg", recordedAt: $recorded_at, note: null} ], clinicianAttestation: true, passwordConfirm: "password" }')" result="$(live_capture_open_encounter "$clinician_token" "$body")" if [[ "$(jq -er '.success' <<<"$result")" != "true" ]]; then fail "open encounter with vitals returns success" return fi encounter_id="$(extract_data_field "$result" encounterId)" obs_count="$(extract_data_field "$result" 'observations | length')" if [[ "$obs_count" == "2" && -n "$encounter_id" ]]; then pass "open encounter response has encounterId and 2 observations" else fail "open encounter response (encounterId=$encounter_id obs=$obs_count)" return fi department="$(psql_query " SELECT department FROM clinical.encounters WHERE id = '$encounter_id'; ")" if [[ "$department" == "Outpatient Clinic" ]]; then pass "new encounter department is Outpatient Clinic" else fail "new encounter department is Outpatient Clinic (got: ${department:-})" fi } test_duplicate_active_encounter() { section "12. Validation — duplicate active encounter returns 409 ACTIVE_ENCOUNTER_EXISTS" local clinician_token ids patient_id body result http_code error_code clinician_token="$(extract_data_field "$(login clinician1)" token)" ids="$(create_patient_and_encounter)" || { fail "setup patient with active encounter for duplicate test" return } patient_id="${ids%%|*}" body="$(jq -nc \ --arg patient_id "$patient_id" \ --arg recorded_at "$RECORDED_AT" \ '{ patientId: $patient_id, department: "Emergency Department", roomBed: "ER-1", admissionReason: "Chest pain", observations: [ {observationCode: "HEART_RATE", value: 90, unit: "bpm", recordedAt: $recorded_at, note: null} ], clinicianAttestation: true, passwordConfirm: "password" }')" result="$(live_capture_open_encounter "$clinician_token" "$body")" http_code="$(extract_status_code "$result")" error_code="$(extract_error_code "$result")" if [[ "$http_code" == "409" && "$error_code" == "ACTIVE_ENCOUNTER_EXISTS" ]]; then pass "second active encounter returns 409 ACTIVE_ENCOUNTER_EXISTS" else fail "second active encounter returns 409 ACTIVE_ENCOUNTER_EXISTS (http=$http_code code=${error_code:-})" fi } test_discharged_encounter() { section "13. Validation — discharged encounter returns 409 ENCOUNTER_NOT_ACTIVE" local clinician_token ids encounter_id body result http_code error_code clinician_token="$(extract_data_field "$(login clinician1)" token)" ids="$(create_patient_and_encounter discharged)" || { fail "setup discharged encounter" return } encounter_id="${ids#*|}" body="$(jq -nc \ --arg recorded_at "$RECORDED_AT" \ '{ observations: [ {observationCode: "HEART_RATE", value: 72, unit: "bpm", recordedAt: $recorded_at, note: null} ], clinicianAttestation: true, passwordConfirm: "password" }')" result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")" http_code="$(extract_status_code "$result")" error_code="$(extract_error_code "$result")" if [[ "$http_code" == "409" && "$error_code" == "ENCOUNTER_NOT_ACTIVE" ]]; then pass "discharged encounter returns 409 ENCOUNTER_NOT_ACTIVE" else fail "discharged encounter returns 409 ENCOUNTER_NOT_ACTIVE (http=$http_code code=${error_code:-})" fi } test_empty_observations() { section "14. Validation — empty observations returns 422 EMPTY_OBSERVATIONS" local clinician_token ids encounter_id body result http_code error_code clinician_token="$(extract_data_field "$(login clinician1)" token)" ids="$(create_patient_and_encounter)" || { fail "setup patient/encounter for empty-observations test" return } encounter_id="${ids#*|}" body='{"observations":[],"clinicianAttestation":true,"passwordConfirm":"password"}' result="$(live_capture_record "$clinician_token" "$encounter_id" "$body")" http_code="$(extract_status_code "$result")" error_code="$(extract_error_code "$result")" if [[ "$http_code" == "422" && "$error_code" == "EMPTY_OBSERVATIONS" ]]; then pass "empty observations returns 422 EMPTY_OBSERVATIONS" else fail "empty observations returns 422 EMPTY_OBSERVATIONS (http=$http_code code=${error_code:-})" fi } test_digitization_events() { section "15. Audit trail — live_capture_attested and promoted digitization events" if [[ -z "$SHARED_BATCH_ID" ]]; then fail "digitization event check requires normal promotion test first" return fi if ! psql_available; then log " SKIP: digitization event DB checks" return fi local attested_count promoted_count attested_count="$(psql_query " SELECT count(*) FROM digitization_events WHERE batch_id = '$SHARED_BATCH_ID' AND event_type = 'live_capture_attested'; ")" promoted_count="$(psql_query " SELECT count(*) FROM digitization_events WHERE batch_id = '$SHARED_BATCH_ID' AND event_type = 'promoted'; ")" if [[ "$attested_count" == "1" && "$promoted_count" == "1" ]]; then pass "batch has live_capture_attested and promoted digitization events" else fail "digitization events (attested=$attested_count promoted=$promoted_count)" fi } test_integration_tests() { section "16. Integration tests — LiveCaptureIntegrationTests" if [[ "$SKIP_TEST_CHECKS" == "1" ]]; then log " SKIP: VIGILCARE_SKIP_TEST_CHECKS=1" return fi if ! command -v dotnet >/dev/null 2>&1; then log " SKIP: dotnet not found" return fi local test_output test_exit test_output="$(dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests" \ --filter "FullyQualifiedName~LiveCaptureIntegrationTests" \ --verbosity minimal 2>&1)" test_exit=$? if [[ "$test_exit" -eq 0 ]] && grep -q "Passed!" <<<"$test_output"; then pass "LiveCaptureIntegrationTests pass (dotnet test)" else fail "LiveCaptureIntegrationTests pass (dotnet test)" log "$test_output" fi } main() { require_cmd curl require_cmd jq require_cmd docker log "VigilCare Records — Phase 6 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_schema_alert_tables test_swagger_live_capture_routes test_attestation_wrong_password test_attestation_non_clinician test_attestation_false test_normal_promotion test_critical_low_potassium test_critical_high_potassium test_mixed_batch test_outbox_events test_open_encounter_with_vitals test_duplicate_active_encounter test_discharged_encounter test_empty_observations test_digitization_events test_integration_tests 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 6 verification checks passed." } main "$@"