#!/usr/bin/env bash # Runs Phase 2 verification checks from docs/plans/phase-2-plan.md. # # Prerequisites: # docker compose up -d (PostgreSQL + Redis) # dotnet run --project VigilCareRecordsAPI # Phase 1 seed data (entry1, entry2, intake1 users) # # Environment overrides (same defaults as Phase 1 script): # 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 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 command -v psql >/dev/null 2>&1 && return 0 compose_service_running postgres } psql_query() { if 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" elif compose_service_running postgres; then "${COMPOSE[@]}" exec -T postgres \ psql -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" } extract_error_message() { local json="$1" jq -er '.error.message // empty' <<<"$json" } upload_batch() { local token="$1" local file_path="${2:-$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=VITALS_SHEET" } 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\"}" } 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 } # Upload a VITALS_SHEET batch and assign it to entry1. # Prints the batch id to stdout. create_assigned_vitals_batch() { local intake_token="$1" local upload_json batch_id entry_json entry_id upload_json="$(upload_batch "$intake_token")" if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then log "ERROR: failed to upload 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" } test_draft_patient_upsert() { section "1. Draft CRUD — patient upsert creates and updates" local intake_json intake_token entry_json entry_token batch_id local first_json second_json blood_type intake_json="$(login intake1)" intake_token="$(extract_data_field "$intake_json" token)" batch_id="$(create_assigned_vitals_batch "$intake_token")" || return entry_json="$(login entry1)" entry_token="$(extract_data_field "$entry_json" token)" first_json="$(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")" if [[ "$(jq -er '.success' <<<"$first_json")" == "true" ]]; then pass "first patient PUT creates draft patient (200)" else fail "first patient PUT creates draft patient (200)" return fi second_json="$(json_put \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ '{"fullName":"Chen Wei-Lin","dateOfBirth":"1985-03-15","sex":"M","bloodType":"O+","noKnownAllergies":true,"noActiveMedications":true}' \ "$entry_token")" blood_type="$(extract_data_field "$second_json" bloodType)" if [[ "$(jq -er '.success' <<<"$second_json")" == "true" && "$blood_type" == "O+" ]]; then pass "second patient PUT updates draft patient (bloodType added)" else fail "second patient PUT updates draft patient (bloodType added) (got: ${blood_type:-})" fi } test_status_transition_to_in_entry() { section "2. Status transitions — first draft save moves to IN_ENTRY" local intake_json intake_token entry_json entry_token batch_id status_before status_after intake_json="$(login intake1)" intake_token="$(extract_data_field "$intake_json" token)" batch_id="$(create_assigned_vitals_batch "$intake_token")" || return entry_json="$(login entry1)" entry_token="$(extract_data_field "$entry_json" token)" status_before="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id" \ -H "Authorization: Bearer $entry_token" | jq -er '.data.status')" if [[ "$status_before" == "UPLOADED" ]]; then pass "batch status is UPLOADED before first draft save" else fail "batch status is UPLOADED before first draft save (got: $status_before)" fi json_put \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ '{"fullName":"Test Patient","noKnownAllergies":true,"noActiveMedications":true}' \ "$entry_token" >/dev/null status_after="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id" \ -H "Authorization: Bearer $entry_token" | jq -er '.data.status')" if [[ "$status_after" == "IN_ENTRY" ]]; then pass "batch status transitions to IN_ENTRY after first draft save" else fail "batch status transitions to IN_ENTRY after first draft save (got: $status_after)" fi } test_plausibility_validation() { section "3. Plausibility validation — implausible values rejected" local intake_json intake_token entry_json entry_token batch_id local bad_json bad_code good_code intake_json="$(login intake1)" intake_token="$(extract_data_field "$intake_json" token)" batch_id="$(create_assigned_vitals_batch "$intake_token")" || return entry_json="$(login entry1)" entry_token="$(extract_data_field "$entry_json" token)" bad_json="$(json_post \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ "{\"observationCode\":\"HEART_RATE\",\"value\":350,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}" \ "$entry_token")" bad_code="$(extract_error_code "$bad_json")" if [[ "$bad_code" == "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE" ]]; then pass "heart rate 350 bpm returns OBSERVATION_OUT_OF_PLAUSIBLE_RANGE" else fail "heart rate 350 bpm returns OBSERVATION_OUT_OF_PLAUSIBLE_RANGE (got: ${bad_code:-})" fi good_code="$(http_code -X POST \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ -H "Authorization: Bearer $entry_token" \ -H 'Content-Type: application/json' \ -d "{\"observationCode\":\"HEART_RATE\",\"value\":78,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}")" if [[ "$good_code" == "201" ]]; then pass "heart rate 78 bpm saves successfully (201)" else fail "heart rate 78 bpm saves successfully (201) (got: $good_code)" fi } test_incomplete_submit_rejected() { section "4. Submit-for-verification — incomplete batch rejected" local intake_json intake_token entry_json entry_token batch_id local submit_body submit_code error_code error_msg local submit_tmp intake_json="$(login intake1)" intake_token="$(extract_data_field "$intake_json" token)" batch_id="$(create_assigned_vitals_batch "$intake_token")" || return entry_json="$(login entry1)" entry_token="$(extract_data_field "$entry_json" token)" json_put \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ '{"fullName":"Test Patient","noKnownAllergies":true,"noActiveMedications":true}' \ "$entry_token" >/dev/null submit_tmp="$(mktemp)" submit_code="$(curl -sS -o "$submit_tmp" -w '%{http_code}' -X POST \ "$API_URL/api/v1/digitization-batches/$batch_id/submit-for-verification" \ -H "Authorization: Bearer $entry_token")" submit_body="$(cat "$submit_tmp")" rm -f "$submit_tmp" error_code="$(extract_error_code "$submit_body")" error_msg="$(extract_error_message "$submit_body")" if [[ "$submit_code" == "422" && "$error_code" == "BATCH_INCOMPLETE" ]]; then pass "incomplete vitals batch submit returns 422 BATCH_INCOMPLETE" else fail "incomplete vitals batch submit returns 422 BATCH_INCOMPLETE (http=$submit_code code=${error_code:-})" return fi if [[ "$error_msg" == *"Encounter context is required"* && "$error_msg" == *"At least one observation"* ]]; then pass "BATCH_INCOMPLETE message lists missing encounter and observations" else fail "BATCH_INCOMPLETE message lists missing encounter and observations" fi } test_get_draft_payload() { section "5. Full draft payload — GET returns patient, encounter, observations" local intake_json intake_token entry_json entry_token batch_id draft_json local patient_name department obs_count intake_json="$(login intake1)" intake_token="$(extract_data_field "$intake_json" token)" batch_id="$(create_assigned_vitals_batch "$intake_token")" || return entry_json="$(login entry1)" entry_token="$(extract_data_field "$entry_json" 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","roomBed":"ICU-3B","admissionReason":"Chest pain"}' \ "$entry_token" >/dev/null json_post \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ "{\"observationCode\":\"HEART_RATE\",\"value\":92,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}" \ "$entry_token" >/dev/null draft_json="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id/draft" \ -H "Authorization: Bearer $entry_token")" patient_name="$(jq -er '.data.patient.fullName // empty' <<<"$draft_json")" department="$(jq -er '.data.encounter.department // empty' <<<"$draft_json")" obs_count="$(jq -er '.data.observations | length' <<<"$draft_json")" if [[ "$patient_name" == "Chen Wei-Lin" && "$department" == "ICU" && "$obs_count" -ge 1 ]]; then pass "GET /draft returns patient, encounter, and observations" else fail "GET /draft returns patient, encounter, and observations (patient=$patient_name dept=$department obs=$obs_count)" fi } test_complete_submit_succeeds() { section "6. Full lifecycle — complete vitals batch submits to PENDING_VERIFICATION" local intake_json intake_token entry_json entry_token batch_id submit_json status intake_json="$(login intake1)" intake_token="$(extract_data_field "$intake_json" token)" batch_id="$(create_assigned_vitals_batch "$intake_token")" || return entry_json="$(login entry1)" entry_token="$(extract_data_field "$entry_json" 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\":92,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}" \ "$entry_token" >/dev/null submit_json="$(curl -sS -X POST \ "$API_URL/api/v1/digitization-batches/$batch_id/submit-for-verification" \ -H "Authorization: Bearer $entry_token")" status="$(extract_data_field "$submit_json" status)" if [[ "$(jq -er '.success' <<<"$submit_json")" == "true" && "$status" == "PENDING_VERIFICATION" ]]; then pass "complete vitals batch submits successfully (PENDING_VERIFICATION)" else fail "complete vitals batch submits successfully (PENDING_VERIFICATION) (status=${status:-})" fi } test_observation_crud() { section "7. Observation CRUD — add, update, delete" local intake_json intake_token entry_json entry_token batch_id local add_json obs_id update_json update_value delete_code intake_json="$(login intake1)" intake_token="$(extract_data_field "$intake_json" token)" batch_id="$(create_assigned_vitals_batch "$intake_token")" || return entry_json="$(login entry1)" entry_token="$(extract_data_field "$entry_json" token)" add_json="$(json_post \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ "{\"observationCode\":\"TEMP_C\",\"value\":37.2,\"unit\":\"C\",\"recordedAt\":\"$RECORDED_AT\",\"note\":\"Oral temperature\"}" \ "$entry_token")" obs_id="$(extract_data_field "$add_json" id)" if [[ "$(jq -er '.success' <<<"$add_json")" == "true" && -n "$obs_id" ]]; then pass "POST observation returns 201 with id" else fail "POST observation returns 201 with id" return fi update_json="$(json_put \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations/$obs_id" \ "{\"observationCode\":\"TEMP_C\",\"value\":38.1,\"unit\":\"C\",\"recordedAt\":\"$RECORDED_AT\",\"note\":\"Corrected — misread decimal\"}" \ "$entry_token")" update_value="$(extract_data_field "$update_json" value)" if [[ "$(jq -er '.success' <<<"$update_json")" == "true" && "$update_value" == "38.1" ]]; then pass "PUT observation updates value (200)" else fail "PUT observation updates value (200) (got: ${update_value:-})" fi delete_code="$(http_code -X DELETE \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations/$obs_id" \ -H "Authorization: Bearer $entry_token")" if [[ "$delete_code" == "204" ]]; then pass "DELETE observation returns 204" else fail "DELETE observation returns 204 (got: $delete_code)" fi } test_rejected_batch_reentry() { section "8. Rejected batch — draft save transitions back to IN_ENTRY" if ! psql_available; then if [[ "$SKIP_DB_CHECKS" == "1" ]]; then log " SKIP: rejected batch re-entry (VIGILCARE_SKIP_DB_CHECKS=1)" else log " SKIP: rejected batch re-entry (postgres not reachable)" fi return fi local intake_json intake_token entry_json entry_token batch_id status event_meta intake_json="$(login intake1)" intake_token="$(extract_data_field "$intake_json" token)" batch_id="$(create_assigned_vitals_batch "$intake_token")" || return entry_json="$(login entry1)" entry_token="$(extract_data_field "$entry_json" token)" psql_query "UPDATE digitization_batches SET status = 'REJECTED', rejection_reason = 'Missing encounter details' WHERE id = '$batch_id';" >/dev/null json_put \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/encounter" \ '{"admissionDate":"2025-01-01T08:00:00Z","department":"Emergency Department","roomBed":"ER-7"}' \ "$entry_token" >/dev/null status="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id" \ -H "Authorization: Bearer $entry_token" | jq -er '.data.status')" if [[ "$status" == "IN_ENTRY" ]]; then pass "REJECTED batch transitions to IN_ENTRY on draft save" else fail "REJECTED batch transitions to IN_ENTRY on draft save (got: $status)" fi event_meta="$(psql_query "SELECT metadata_json FROM digitization_events WHERE batch_id = '$batch_id' AND event_type = 'entry_started' ORDER BY occurred_at DESC LIMIT 1;")" if [[ "$event_meta" == *"REJECTED"* ]]; then pass "entry_started event metadata records previous REJECTED status" else fail "entry_started event metadata records previous REJECTED status" fi } test_verified_batch_blocks_entry() { section "9. Verified batch — data entry blocked with ENTRY_NOT_ALLOWED" if ! psql_available; then if [[ "$SKIP_DB_CHECKS" == "1" ]]; then log " SKIP: verified batch entry guard (VIGILCARE_SKIP_DB_CHECKS=1)" else log " SKIP: verified batch entry guard (postgres not reachable)" fi return fi local intake_json intake_token entry_json entry_token batch_id resp_json error_code intake_json="$(login intake1)" intake_token="$(extract_data_field "$intake_json" token)" batch_id="$(create_assigned_vitals_batch "$intake_token")" || return entry_json="$(login entry1)" entry_token="$(extract_data_field "$entry_json" token)" psql_query "UPDATE digitization_batches SET status = 'VERIFIED' WHERE id = '$batch_id';" >/dev/null resp_json="$(json_post \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ "{\"observationCode\":\"HEART_RATE\",\"value\":80,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}" \ "$entry_token")" error_code="$(extract_error_code "$resp_json")" if [[ "$error_code" == "ENTRY_NOT_ALLOWED" ]]; then pass "verified batch blocks observation add (ENTRY_NOT_ALLOWED)" else fail "verified batch blocks observation add (ENTRY_NOT_ALLOWED) (got: ${error_code:-})" fi } test_batch_not_assigned() { section "10. Assignment guard — non-assigned clerk receives BATCH_NOT_ASSIGNED" local intake_json intake_token entry1_json entry2_json entry2_token batch_id entry1_id resp_json error_code intake_json="$(login intake1)" intake_token="$(extract_data_field "$intake_json" token)" batch_id="$(create_assigned_vitals_batch "$intake_token")" || return entry1_json="$(login entry1)" entry1_id="$(extract_data_field "$entry1_json" userId)" entry2_json="$(login entry2)" entry2_token="$(extract_data_field "$entry2_json" token)" assign_batch "$intake_token" "$batch_id" "$entry1_id" >/dev/null resp_json="$(json_put \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ '{"fullName":"Blocked Clerk","noKnownAllergies":true,"noActiveMedications":true}' \ "$entry2_token")" error_code="$(extract_error_code "$resp_json")" if [[ "$error_code" == "BATCH_NOT_ASSIGNED" ]]; then pass "entry2 cannot save draft on batch assigned to entry1 (BATCH_NOT_ASSIGNED)" else fail "entry2 cannot save draft on batch assigned to entry1 (BATCH_NOT_ASSIGNED) (got: ${error_code:-})" fi } main() { require_cmd curl require_cmd jq if [[ ! -f "$FIXTURE_PDF" ]]; then log "ERROR: missing fixture PDF at $FIXTURE_PDF" exit 1 fi log "VigilCare Records — Phase 2 verification" log "API: $API_URL" assert_api_reachable test_draft_patient_upsert test_status_transition_to_in_entry test_plausibility_validation test_incomplete_submit_rejected test_get_draft_payload test_complete_submit_succeeds test_observation_crud test_rejected_batch_reentry test_verified_batch_blocks_entry test_batch_not_assigned 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 2 verification checks passed." } main "$@"