#!/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: /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:-})" 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:-})" 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:-})" 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:-})" 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:-})" 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:-})" 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:-})" 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:-})" 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:-})" 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 "$@"