944 lines
29 KiB
Bash
944 lines
29 KiB
Bash
#!/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: <repo>/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:-<none>})"
|
||
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:-<none>})"
|
||
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:-<none>})"
|
||
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:-<none>})"
|
||
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:-<none>})"
|
||
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:-<none>})"
|
||
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:-<none>})"
|
||
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:-<none>})"
|
||
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:-<none>})"
|
||
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 "$@"
|