Files
vigilcare-records/scripts/run-vigilcare-records-phase-6-verification.sh
voltsrage c871dc4842
CI / backend (push) Failing after 2m26s
CI / frontend (push) Failing after 53s
Add deployment files
2026-08-11 20:17:53 +08:00

944 lines
29 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 15 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 "$@"