feature: Live Capture (Track B)

This commit is contained in:
voltsrage
2026-06-27 04:23:51 +08:00
parent 01000a2489
commit 88e70b3dbe
30 changed files with 4547 additions and 23 deletions
+943
View File
@@ -0,0 +1,943 @@
#!/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 "$@"