655 lines
22 KiB
Bash
655 lines
22 KiB
Bash
#!/usr/bin/env bash
|
|
# Runs Phase 3 verification checks from docs/plans/phase-3-plan.md.
|
|
#
|
|
# Covers verification, rejection, separation of duties, clinical approval routing,
|
|
# work queues, and digitization event trails.
|
|
#
|
|
# Prerequisites:
|
|
# docker compose up -d (PostgreSQL + Redis; psql via docker compose exec)
|
|
# dotnet run --project VigilCareRecordsAPI
|
|
# Phase 1 seed data (entry1, entry2, verifier1, verifier2, intake1 users)
|
|
# Phase 2 draft entry API (submit-for-verification, draft CRUD)
|
|
#
|
|
# 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/2 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_RECORDED_AT default: 2025-01-01T10:00:00Z
|
|
|
|
set -uo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
FIXTURE_PDF="$SCRIPT_DIR/fixtures/test-scan.pdf"
|
|
|
|
API_URL="${VIGILCARE_API_URL:-http://localhost:5217}"
|
|
COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}"
|
|
COMPOSE=(docker compose -f "$COMPOSE_FILE")
|
|
PG_HOST="${VIGILCARE_PG_HOST:-localhost}"
|
|
PG_PORT="${VIGILCARE_PG_PORT:-5437}"
|
|
PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}"
|
|
PG_USER="${VIGILCARE_PG_USER:-postgres}"
|
|
PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}"
|
|
SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}"
|
|
|
|
RECORDED_AT="${VIGILCARE_RECORDED_AT:-2025-01-01T10:00:00Z}"
|
|
|
|
PASS_COUNT=0
|
|
FAIL_COUNT=0
|
|
FAILED_TESTS=()
|
|
|
|
log() {
|
|
printf '%s\n' "$*"
|
|
}
|
|
|
|
section() {
|
|
log ""
|
|
log "== $1 =="
|
|
}
|
|
|
|
pass() {
|
|
PASS_COUNT=$((PASS_COUNT + 1))
|
|
log " PASS: $1"
|
|
}
|
|
|
|
fail() {
|
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
|
FAILED_TESTS+=("$1")
|
|
log " FAIL: $1"
|
|
}
|
|
|
|
require_cmd() {
|
|
local cmd="$1"
|
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
|
log "ERROR: required command not found: $cmd"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
compose_service_running() {
|
|
local service="$1"
|
|
"${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service"
|
|
}
|
|
|
|
psql_available() {
|
|
[[ "$SKIP_DB_CHECKS" == "1" ]] && return 1
|
|
compose_service_running postgres && return 0
|
|
command -v psql >/dev/null 2>&1 && return 0
|
|
return 1
|
|
}
|
|
|
|
psql_query() {
|
|
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
|
|
return 1
|
|
fi
|
|
# Prefer docker compose postgres — matches local dev setup and avoids host psql gaps.
|
|
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
|
|
}
|
|
|
|
json_put() {
|
|
local url="$1"
|
|
local body="$2"
|
|
local token="$3"
|
|
curl -sS -X PUT "$url" \
|
|
-H "Authorization: Bearer $token" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "$body"
|
|
}
|
|
|
|
login() {
|
|
local username="$1"
|
|
local password="${2:-password}"
|
|
json_post "$API_URL/api/v1/auth/login" \
|
|
"{\"username\":\"$username\",\"password\":\"$password\"}"
|
|
}
|
|
|
|
extract_data_field() {
|
|
local json="$1"
|
|
local field="$2"
|
|
jq -er ".data.$field // empty" <<<"$json"
|
|
}
|
|
|
|
extract_error_code() {
|
|
local json="$1"
|
|
jq -er '.error.code // empty' <<<"$json"
|
|
}
|
|
|
|
upload_batch() {
|
|
local token="$1"
|
|
local batch_type="${2:-VITALS_SHEET}"
|
|
local file_path="${3:-$FIXTURE_PDF}"
|
|
|
|
curl -sS -X POST "$API_URL/api/v1/digitization-batches" \
|
|
-H "Authorization: Bearer $token" \
|
|
-F "file=@${file_path};type=application/pdf" \
|
|
-F "batchType=$batch_type"
|
|
}
|
|
|
|
assign_batch() {
|
|
local token="$1"
|
|
local batch_id="$2"
|
|
local entry_clerk_id="$3"
|
|
|
|
curl -sS -X PATCH "$API_URL/api/v1/digitization-batches/$batch_id/assign" \
|
|
-H "Authorization: Bearer $token" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"entryClerkUserId\":\"$entry_clerk_id\"}"
|
|
}
|
|
|
|
verify_batch() {
|
|
local token="$1"
|
|
local batch_id="$2"
|
|
local body="$3"
|
|
|
|
json_post "$API_URL/api/v1/digitization-batches/$batch_id/verify" "$body" "$token"
|
|
}
|
|
|
|
reject_batch() {
|
|
local token="$1"
|
|
local batch_id="$2"
|
|
local reason="$3"
|
|
|
|
json_post "$API_URL/api/v1/digitization-batches/$batch_id/reject" \
|
|
"{\"reason\":\"$reason\"}" "$token"
|
|
}
|
|
|
|
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 "Start API with: dotnet run --project VigilCareRecordsAPI"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Returns 0 if batch_id appears anywhere in the paginated work queue.
|
|
queue_contains_batch() {
|
|
local token="$1"
|
|
local queue_name="$2"
|
|
local batch_id="$3"
|
|
local page=1 page_size=100 total_pages=1
|
|
|
|
while (( page <= total_pages )); do
|
|
local json found
|
|
json="$(curl -sS "$API_URL/api/v1/work-queue/$queue_name?page=$page&pageSize=$page_size" \
|
|
-H "Authorization: Bearer $token")"
|
|
found="$(jq -er --arg id "$batch_id" \
|
|
'if (.data.items | map(.batchId) | index($id)) != null then "true" else "false" end' \
|
|
<<<"$json")"
|
|
if [[ "$found" == "true" ]]; then
|
|
return 0
|
|
fi
|
|
total_pages="$(jq -er '.data.totalPages // 1' <<<"$json")"
|
|
page=$((page + 1))
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
# Upload a batch, assign to entry1. Prints batch id to stdout.
|
|
create_assigned_batch() {
|
|
local intake_token="$1"
|
|
local batch_type="${2:-VITALS_SHEET}"
|
|
local upload_json batch_id entry_json entry_id
|
|
|
|
upload_json="$(upload_batch "$intake_token" "$batch_type")"
|
|
if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then
|
|
log "ERROR: failed to upload $batch_type batch for test setup"
|
|
return 1
|
|
fi
|
|
|
|
batch_id="$(extract_data_field "$upload_json" id)"
|
|
entry_json="$(login entry1)"
|
|
entry_id="$(extract_data_field "$entry_json" userId)"
|
|
assign_batch "$intake_token" "$batch_id" "$entry_id" >/dev/null
|
|
|
|
printf '%s' "$batch_id"
|
|
}
|
|
|
|
# Drive a VITALS_SHEET batch through data entry and submit to PENDING_VERIFICATION.
|
|
# Prints batch id to stdout.
|
|
create_pending_vitals_batch() {
|
|
local intake_token="$1"
|
|
local entry_token batch_id
|
|
|
|
batch_id="$(create_assigned_batch "$intake_token" "VITALS_SHEET")" || return 1
|
|
entry_token="$(extract_data_field "$(login entry1)" token)"
|
|
|
|
json_put \
|
|
"$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \
|
|
'{"fullName":"Chen Wei-Lin","dateOfBirth":"1985-03-15","sex":"M","noKnownAllergies":true,"noActiveMedications":true}' \
|
|
"$entry_token" >/dev/null
|
|
|
|
json_put \
|
|
"$API_URL/api/v1/digitization-batches/$batch_id/draft/encounter" \
|
|
'{"admissionDate":"2025-01-01T08:00:00Z","department":"ICU","admissionReason":"Chest pain"}' \
|
|
"$entry_token" >/dev/null
|
|
|
|
json_post \
|
|
"$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \
|
|
"{\"observationCode\":\"HEART_RATE\",\"value\":88,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}" \
|
|
"$entry_token" >/dev/null
|
|
|
|
local submit_code
|
|
submit_code="$(http_code -X POST \
|
|
"$API_URL/api/v1/digitization-batches/$batch_id/submit-for-verification" \
|
|
-H "Authorization: Bearer $entry_token")"
|
|
if [[ "$submit_code" != "200" ]]; then
|
|
log "ERROR: submit-for-verification failed (HTTP $submit_code)"
|
|
return 1
|
|
fi
|
|
|
|
printf '%s' "$batch_id"
|
|
}
|
|
|
|
# Drive a PATIENT_REGISTRATION batch to PENDING_VERIFICATION.
|
|
create_pending_patient_registration_batch() {
|
|
local intake_token="$1"
|
|
local entry_token batch_id
|
|
|
|
batch_id="$(create_assigned_batch "$intake_token" "PATIENT_REGISTRATION")" || return 1
|
|
entry_token="$(extract_data_field "$(login entry1)" token)"
|
|
|
|
json_put \
|
|
"$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \
|
|
'{"fullName":"Maria Santos","dateOfBirth":"1992-07-20","sex":"F","noKnownAllergies":true,"noActiveMedications":true}' \
|
|
"$entry_token" >/dev/null
|
|
|
|
local submit_code
|
|
submit_code="$(http_code -X POST \
|
|
"$API_URL/api/v1/digitization-batches/$batch_id/submit-for-verification" \
|
|
-H "Authorization: Bearer $entry_token")"
|
|
if [[ "$submit_code" != "200" ]]; then
|
|
log "ERROR: patient registration submit failed (HTTP $submit_code)"
|
|
return 1
|
|
fi
|
|
|
|
printf '%s' "$batch_id"
|
|
}
|
|
|
|
test_separation_of_duties() {
|
|
section "1. Separation of duties — same user cannot verify own batch (409)"
|
|
|
|
if ! psql_available; then
|
|
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
|
|
log " SKIP: separation of duties (VIGILCARE_SKIP_DB_CHECKS=1)"
|
|
else
|
|
log " SKIP: separation of duties (postgres not reachable)"
|
|
fi
|
|
return
|
|
fi
|
|
|
|
local intake_json intake_token batch_id verifier_json verifier_id
|
|
local verify_json verify_code error_code
|
|
|
|
intake_json="$(login intake1)"
|
|
intake_token="$(extract_data_field "$intake_json" token)"
|
|
batch_id="$(create_pending_vitals_batch "$intake_token")" || return
|
|
|
|
verifier_json="$(login verifier1)"
|
|
verifier_id="$(extract_data_field "$verifier_json" userId)"
|
|
|
|
# Simulate a batch entered by verifier1 — they must not verify their own work.
|
|
psql_query "UPDATE digitization_batches SET entered_by_user_id = '$verifier_id' WHERE id = '$batch_id';" >/dev/null
|
|
|
|
verify_json="$(verify_batch "$(extract_data_field "$verifier_json" token)" "$batch_id" \
|
|
'{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null}],"passed":true}')"
|
|
verify_code="$(http_code -X POST \
|
|
"$API_URL/api/v1/digitization-batches/$batch_id/verify" \
|
|
-H "Authorization: Bearer $(extract_data_field "$verifier_json" token)" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null}],"passed":true}')"
|
|
error_code="$(extract_error_code "$verify_json")"
|
|
|
|
if [[ "$verify_code" == "409" && "$error_code" == "SEPARATION_OF_DUTIES_VIOLATION" ]]; then
|
|
pass "verifier cannot verify batch they entered (409 SEPARATION_OF_DUTIES_VIOLATION)"
|
|
else
|
|
fail "verifier cannot verify batch they entered (409 SEPARATION_OF_DUTIES_VIOLATION) (http=$verify_code code=${error_code:-<none>})"
|
|
fi
|
|
}
|
|
|
|
test_different_verifier_succeeds() {
|
|
section "2. Different verifier succeeds — vitals batch transitions to AWAITING_CLINICAL_APPROVAL"
|
|
|
|
local intake_json intake_token batch_id verifier_json verifier_token
|
|
local verify_json status
|
|
|
|
intake_json="$(login intake1)"
|
|
intake_token="$(extract_data_field "$intake_json" token)"
|
|
batch_id="$(create_pending_vitals_batch "$intake_token")" || return
|
|
|
|
verifier_json="$(login verifier1)"
|
|
verifier_token="$(extract_data_field "$verifier_json" token)"
|
|
|
|
verify_json="$(verify_batch "$verifier_token" "$batch_id" \
|
|
'{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null},{"fieldName":"observation.heartRate","status":"ok","note":"Within range"}],"passed":true}')"
|
|
status="$(extract_data_field "$verify_json" status)"
|
|
|
|
if [[ "$(jq -er '.success' <<<"$verify_json")" == "true" && "$status" == "AWAITING_CLINICAL_APPROVAL" ]]; then
|
|
pass "verifier1 verifies entry1 batch (AWAITING_CLINICAL_APPROVAL for VITALS_SHEET)"
|
|
else
|
|
fail "verifier1 verifies entry1 batch (AWAITING_CLINICAL_APPROVAL for VITALS_SHEET) (status=${status:-<none>})"
|
|
fi
|
|
}
|
|
|
|
test_clinical_approval_routing_patient_registration() {
|
|
section "3. Clinical approval routing — PATIENT_REGISTRATION goes directly to VERIFIED"
|
|
|
|
local intake_json intake_token batch_id verifier_token verify_json status
|
|
|
|
intake_json="$(login intake1)"
|
|
intake_token="$(extract_data_field "$intake_json" token)"
|
|
batch_id="$(create_pending_patient_registration_batch "$intake_token")" || return
|
|
|
|
verifier_token="$(extract_data_field "$(login verifier1)" token)"
|
|
|
|
verify_json="$(verify_batch "$verifier_token" "$batch_id" \
|
|
'{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null},{"fieldName":"patient.dateOfBirth","status":"ok","note":null}],"passed":true}')"
|
|
status="$(extract_data_field "$verify_json" status)"
|
|
|
|
if [[ "$(jq -er '.success' <<<"$verify_json")" == "true" && "$status" == "VERIFIED" ]]; then
|
|
pass "PATIENT_REGISTRATION verification skips clinical approval (VERIFIED)"
|
|
else
|
|
fail "PATIENT_REGISTRATION verification skips clinical approval (VERIFIED) (status=${status:-<none>})"
|
|
fi
|
|
}
|
|
|
|
test_reject_with_valid_reason() {
|
|
section "4. Rejection with reason — batch becomes REJECTED and appears in entry queue"
|
|
|
|
local intake_json intake_token batch_id verifier_token entry_token
|
|
local reject_json status queue_json queue_contains
|
|
|
|
intake_json="$(login intake1)"
|
|
intake_token="$(extract_data_field "$intake_json" token)"
|
|
batch_id="$(create_pending_vitals_batch "$intake_token")" || return
|
|
|
|
verifier_token="$(extract_data_field "$(login verifier1)" token)"
|
|
entry_token="$(extract_data_field "$(login entry1)" token)"
|
|
|
|
reject_json="$(reject_batch "$verifier_token" "$batch_id" \
|
|
"Patient name does not match the scanned registration form. Please re-enter from chart.")"
|
|
status="$(extract_data_field "$reject_json" status)"
|
|
|
|
if [[ "$(jq -er '.success' <<<"$reject_json")" == "true" && "$status" == "REJECTED" ]]; then
|
|
pass "reject with valid reason transitions to REJECTED"
|
|
else
|
|
fail "reject with valid reason transitions to REJECTED (status=${status:-<none>})"
|
|
return
|
|
fi
|
|
|
|
if psql_available; then
|
|
local db_status
|
|
db_status="$(psql_query "SELECT status FROM digitization_batches WHERE id = '$batch_id';")"
|
|
if [[ "$db_status" == "REJECTED" ]]; then
|
|
pass "rejected batch status confirmed in PostgreSQL"
|
|
else
|
|
fail "rejected batch status confirmed in PostgreSQL (got: ${db_status:-<none>})"
|
|
fi
|
|
fi
|
|
|
|
if queue_contains_batch "$entry_token" "entry" "$batch_id"; then
|
|
pass "rejected batch appears in entry work queue"
|
|
else
|
|
fail "rejected batch appears in entry work queue"
|
|
fi
|
|
}
|
|
|
|
test_reject_validation() {
|
|
section "5. Rejection validation — missing or short reason returns 422"
|
|
|
|
local intake_json intake_token batch_id verifier_token
|
|
local empty_json empty_code empty_error short_json short_error
|
|
|
|
intake_json="$(login intake1)"
|
|
intake_token="$(extract_data_field "$intake_json" token)"
|
|
batch_id="$(create_pending_vitals_batch "$intake_token")" || return
|
|
|
|
verifier_token="$(extract_data_field "$(login verifier1)" token)"
|
|
|
|
empty_json="$(reject_batch "$verifier_token" "$batch_id" "")"
|
|
empty_code="$(http_code -X POST \
|
|
"$API_URL/api/v1/digitization-batches/$batch_id/reject" \
|
|
-H "Authorization: Bearer $verifier_token" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"reason":""}')"
|
|
empty_error="$(extract_error_code "$empty_json")"
|
|
|
|
if [[ "$empty_code" == "422" && "$empty_error" == "REJECTION_REASON_REQUIRED" ]]; then
|
|
pass "reject without reason returns 422 REJECTION_REASON_REQUIRED"
|
|
else
|
|
fail "reject without reason returns 422 REJECTION_REASON_REQUIRED (http=$empty_code code=${empty_error:-<none>})"
|
|
fi
|
|
|
|
short_json="$(reject_batch "$verifier_token" "$batch_id" "Bad data")"
|
|
short_error="$(extract_error_code "$short_json")"
|
|
if [[ "$(http_code -X POST \
|
|
"$API_URL/api/v1/digitization-batches/$batch_id/reject" \
|
|
-H "Authorization: Bearer $verifier_token" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"reason":"Bad data"}')" == "422" &&
|
|
"$short_error" == "REJECTION_REASON_TOO_SHORT" ]]; then
|
|
pass "reject with short reason returns 422 REJECTION_REASON_TOO_SHORT"
|
|
else
|
|
fail "reject with short reason returns 422 REJECTION_REASON_TOO_SHORT (code=${short_error:-<none>})"
|
|
fi
|
|
}
|
|
|
|
test_verify_wrong_status() {
|
|
section "6. Status guard — verify on non-pending batch returns 409"
|
|
|
|
if ! psql_available; then
|
|
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
|
|
log " SKIP: verify wrong status (VIGILCARE_SKIP_DB_CHECKS=1)"
|
|
else
|
|
log " SKIP: verify wrong status (postgres not reachable)"
|
|
fi
|
|
return
|
|
fi
|
|
|
|
local intake_json intake_token batch_id verifier_token verify_json error_code
|
|
|
|
intake_json="$(login intake1)"
|
|
intake_token="$(extract_data_field "$intake_json" token)"
|
|
batch_id="$(create_pending_vitals_batch "$intake_token")" || return
|
|
|
|
psql_query "UPDATE digitization_batches SET status = 'REJECTED' WHERE id = '$batch_id';" >/dev/null
|
|
|
|
verifier_token="$(extract_data_field "$(login verifier1)" token)"
|
|
verify_json="$(verify_batch "$verifier_token" "$batch_id" \
|
|
'{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null}],"passed":true}')"
|
|
error_code="$(extract_error_code "$verify_json")"
|
|
|
|
if [[ "$(http_code -X POST \
|
|
"$API_URL/api/v1/digitization-batches/$batch_id/verify" \
|
|
-H "Authorization: Bearer $verifier_token" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null}],"passed":true}')" == "409" &&
|
|
"$error_code" == "ILLEGAL_STATUS_TRANSITION" ]]; then
|
|
pass "verify on REJECTED batch returns 409 ILLEGAL_STATUS_TRANSITION"
|
|
else
|
|
fail "verify on REJECTED batch returns 409 ILLEGAL_STATUS_TRANSITION (code=${error_code:-<none>})"
|
|
fi
|
|
}
|
|
|
|
test_verify_passed_false() {
|
|
section "7. Verification failed — passed=false transitions to REJECTED with field errors"
|
|
|
|
local intake_json intake_token batch_id verifier_token verify_json status reason
|
|
|
|
intake_json="$(login intake1)"
|
|
intake_token="$(extract_data_field "$intake_json" token)"
|
|
batch_id="$(create_pending_vitals_batch "$intake_token")" || return
|
|
|
|
verifier_token="$(extract_data_field "$(login verifier1)" token)"
|
|
|
|
verify_json="$(verify_batch "$verifier_token" "$batch_id" \
|
|
'{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null},{"fieldName":"observation.heartRate","status":"error","note":"Value 350 is not physiologically possible"}],"passed":false}')"
|
|
status="$(extract_data_field "$verify_json" status)"
|
|
reason="$(jq -er '.data.rejectionReason // empty' <<<"$verify_json")"
|
|
|
|
if [[ "$(jq -er '.success' <<<"$verify_json")" == "true" && "$status" == "REJECTED" &&
|
|
"$reason" == *"350 is not physiologically possible"* ]]; then
|
|
pass "passed=false verification rejects batch with field error details"
|
|
else
|
|
fail "passed=false verification rejects batch with field error details (status=${status:-<none>})"
|
|
fi
|
|
}
|
|
|
|
test_verification_queue() {
|
|
section "8. Work queues — verification queue lists PENDING_VERIFICATION batches only"
|
|
|
|
local intake_json intake_token batch_id verifier_token queue_json
|
|
local item_count all_pending
|
|
|
|
intake_json="$(login intake1)"
|
|
intake_token="$(extract_data_field "$intake_json" token)"
|
|
batch_id="$(create_pending_vitals_batch "$intake_token")" || return
|
|
|
|
verifier_token="$(extract_data_field "$(login verifier1)" token)"
|
|
|
|
if queue_contains_batch "$verifier_token" "verification" "$batch_id"; then
|
|
pass "verification queue includes test batch"
|
|
else
|
|
fail "verification queue includes test batch"
|
|
return
|
|
fi
|
|
|
|
queue_json="$(curl -sS "$API_URL/api/v1/work-queue/verification?page=1&pageSize=100" \
|
|
-H "Authorization: Bearer $verifier_token")"
|
|
item_count="$(jq -er '.data.items | length' <<<"$queue_json")"
|
|
all_pending="$(jq -er 'if (.data.items | length) == 0 then true else ([.data.items[].status] | all(. == "PENDING_VERIFICATION")) end' <<<"$queue_json")"
|
|
|
|
if [[ "$item_count" -ge 1 && "$all_pending" == "true" ]]; then
|
|
pass "verification queue returns only PENDING_VERIFICATION batches"
|
|
else
|
|
fail "verification queue returns only PENDING_VERIFICATION batches (count=$item_count)"
|
|
fi
|
|
}
|
|
|
|
test_event_trail() {
|
|
section "9. Event trail — digitization_events recorded for verify/reject"
|
|
|
|
if ! psql_available; then
|
|
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
|
|
log " SKIP: event trail (VIGILCARE_SKIP_DB_CHECKS=1)"
|
|
else
|
|
log " SKIP: event trail (postgres not reachable)"
|
|
fi
|
|
return
|
|
fi
|
|
|
|
local intake_json intake_token batch_id verifier_token verify_json event_types
|
|
|
|
intake_json="$(login intake1)"
|
|
intake_token="$(extract_data_field "$intake_json" token)"
|
|
batch_id="$(create_pending_vitals_batch "$intake_token")" || return
|
|
|
|
verifier_token="$(extract_data_field "$(login verifier1)" token)"
|
|
|
|
verify_json="$(verify_batch "$verifier_token" "$batch_id" \
|
|
'{"fieldChecks":[{"fieldName":"patient.fullName","status":"ok","note":null}],"passed":true}')"
|
|
if [[ "$(jq -er '.success' <<<"$verify_json")" != "true" ]]; then
|
|
fail "event trail setup — verify succeeded"
|
|
return
|
|
fi
|
|
|
|
event_types="$(psql_query "SELECT event_type FROM digitization_events WHERE batch_id = '$batch_id' ORDER BY occurred_at;")"
|
|
if grep -q 'submitted_for_verification' <<<"$event_types" &&
|
|
grep -q 'verified_pending_clinical' <<<"$event_types"; then
|
|
pass "digitization_events include submitted_for_verification and verified_pending_clinical"
|
|
else
|
|
fail "digitization_events include submitted_for_verification and verified_pending_clinical"
|
|
log " events: $(tr '\n' ' ' <<<"$event_types")"
|
|
fi
|
|
|
|
local metadata
|
|
metadata="$(psql_query "SELECT metadata_json FROM digitization_events WHERE batch_id = '$batch_id' AND event_type = 'verified_pending_clinical' LIMIT 1;")"
|
|
if [[ "$metadata" == *"fieldChecks"* ]]; then
|
|
pass "verify event metadata_json contains fieldChecks"
|
|
else
|
|
fail "verify event metadata_json contains fieldChecks"
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
require_cmd curl
|
|
require_cmd jq
|
|
require_cmd docker
|
|
|
|
if [[ ! -f "$FIXTURE_PDF" ]]; then
|
|
log "ERROR: missing fixture PDF at $FIXTURE_PDF"
|
|
exit 1
|
|
fi
|
|
|
|
log "VigilCare Records — Phase 3 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_separation_of_duties
|
|
test_different_verifier_succeeds
|
|
test_clinical_approval_routing_patient_registration
|
|
test_reject_with_valid_reason
|
|
test_reject_validation
|
|
test_verify_wrong_status
|
|
test_verify_passed_false
|
|
test_verification_queue
|
|
test_event_trail
|
|
|
|
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 3 verification checks passed."
|
|
}
|
|
|
|
main "$@"
|