Files
vigilcare-records/scripts/run-vigilcare-records-phase-11-verification.sh
T

586 lines
19 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Runs Phase 11 verification checks from docs/plans/phase-11-plan.md.
#
# Covers FHIR metadata, Patient/Encounter/Observation read & search,
# LOINC mapping, $everything, content-type negotiation, 404 OperationOutcome,
# and FhirIntegrationTests.
#
# Prerequisites:
# docker compose up -d (PostgreSQL, Redis, MinIO)
# dotnet ef database update --project VigilCareRecordsAPI
# dotnet run --project VigilCareRecordsAPI
# Phase 110 seed data (admin1)
#
# Environment overrides:
# 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 seed/assertions
# VIGILCARE_SKIP_TEST_CHECKS set to 1 to skip dotnet integration tests
#
# Usage:
# chmod +x scripts/run-vigilcare-records-phase-11-verification.sh
# ./scripts/run-vigilcare-records-phase-11-verification.sh
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}"
FHIR_URL="${API_URL}/fhir"
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}"
# Deterministic clinical IDs (match FhirClinicalSeedHelper)
PATIENT1_ID="b1000000-0000-0000-0000-000000000001"
PATIENT2_ID="b1000000-0000-0000-0000-000000000002"
ENCOUNTER1_ID="d1000000-0000-0000-0000-000000000001"
HEART_RATE_OBS_ID="e1000000-0000-0000-0000-000000000001"
WBC_OBS_ID="e1000000-0000-0000-0000-000000000002"
BATCH1_ID="c1000000-0000-0000-0000-000000000001"
ADMIN_TOKEN=""
RESOLVED_PATIENT_ID=""
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
}
http_code() {
curl -sS -o /dev/null -w '%{http_code}' "$@"
}
json_post() {
local url="$1"
local body="$2"
curl -sS -X POST "$url" \
-H 'Content-Type: application/json' \
-d "$body"
}
fhir_get() {
local path_query="$1"
local token="${2:-}"
if [[ -n "$token" ]]; then
curl -sS "${FHIR_URL}${path_query}" \
-H "Authorization: Bearer $token" \
-H 'Accept: application/fhir+json'
else
curl -sS "${FHIR_URL}${path_query}" \
-H 'Accept: application/fhir+json'
fi
}
fhir_get_status() {
local path_query="$1"
local token="$2"
local body_file http_status
body_file="$(mktemp)"
http_status="$(curl -sS -o "$body_file" -w '%{http_code}' \
"${FHIR_URL}${path_query}" \
-H "Authorization: Bearer $token" \
-H 'Accept: application/fhir+json')"
cat "$body_file"
rm -f "$body_file"
printf '\n__HTTP_STATUS__:%s' "$http_status"
}
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
}
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
}
psql_exec() {
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" -v ON_ERROR_STOP=1 -q -c "$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" \
-v ON_ERROR_STOP=1 -q -c "$1"
else
return 1
fi
}
test_authentication() {
section "0. Authentication"
local admin_json
admin_json="$(json_post "$API_URL/api/v1/auth/login" \
'{"username":"admin1","password":"password"}')"
ADMIN_TOKEN="$(jq -er '.data.token // empty' <<<"$admin_json" 2>/dev/null || true)"
if [[ -n "$ADMIN_TOKEN" ]]; then
pass "admin1 login returns JWT"
else
log "ERROR: admin1 login failed."
log "Response: ${admin_json:-<empty>}"
exit 1
fi
}
ensure_fhir_clinical_seed() {
section "1. Clinical seed data for FHIR endpoints"
if ! psql_available; then
log " SKIP: PostgreSQL checks (set VIGILCARE_SKIP_DB_CHECKS=0 and start postgres)"
log " NOTE: FHIR curl tests require clinical.patients with MRN VCR-000001"
log " Promote a batch or run with postgres available to auto-seed."
return
fi
local patient_count
patient_count="$(psql_query "SELECT count(*) FROM clinical.patients WHERE mrn = 'VCR-000001';" || echo "0")"
if [[ "${patient_count:-0}" -ge 1 ]]; then
pass "clinical patient VCR-000001 already present"
return
fi
log " Seeding demo clinical records for FHIR verification..."
psql_exec "
INSERT INTO clinical.patients (
id, mrn, full_name, date_of_birth, sex, blood_type,
emergency_contact, allergies_json, no_known_allergies, created_at, updated_at
) VALUES
('${PATIENT1_ID}', 'VCR-000001', 'MARIA SANTOS', '1978-03-15', 'female', 'A+',
'Juan Santos - 555-0101', '[\"Penicillin\", \"Sulfa drugs\"]', false,
NOW() - interval '3 days', NOW() - interval '12 hours'),
('${PATIENT2_ID}', 'VCR-000002', 'KENJI NAKAMURA', '1952-11-08', 'male', 'O-',
'Yuki Nakamura - 555-0202', NULL, true,
NOW() - interval '1 day', NOW() - interval '1 day')
ON CONFLICT (id) DO NOTHING;
" || {
fail "seed clinical.patients for FHIR verification"
return
}
psql_exec "
INSERT INTO clinical.encounters (
id, patient_id, admission_date, department, room_bed, admission_reason,
status, source_batch_id, created_at, updated_at
) VALUES (
'${ENCOUNTER1_ID}', '${PATIENT1_ID}', NOW() - interval '5 days',
'Internal Medicine', '2A-04', 'Pneumonia with elevated WBC',
'active', '${BATCH1_ID}', NOW() - interval '3 days', NOW() - interval '12 hours'
)
ON CONFLICT (id) DO NOTHING;
" || {
fail "seed clinical.encounters for FHIR verification"
return
}
psql_exec "
INSERT INTO clinical.observations (
id, encounter_id, patient_id, observation_code, value, unit,
recorded_at, source, source_batch_id, created_at
) VALUES
('${HEART_RATE_OBS_ID}', '${ENCOUNTER1_ID}', '${PATIENT1_ID}',
'HEART_RATE', 88.000, 'bpm', NOW() - interval '5 days',
'digitization_backfill', '${BATCH1_ID}', NOW() - interval '12 hours'),
('${WBC_OBS_ID}', '${ENCOUNTER1_ID}', '${PATIENT1_ID}',
'WBC_K_UL', 14.200, 'K/uL', NOW() - interval '5 days',
'digitization_backfill', '${BATCH1_ID}', NOW() - interval '12 hours')
ON CONFLICT (id) DO NOTHING;
" || {
fail "seed clinical.observations for FHIR verification"
return
}
patient_count="$(psql_query "SELECT count(*) FROM clinical.patients WHERE mrn = 'VCR-000001';" || echo "0")"
if [[ "${patient_count:-0}" -ge 1 ]]; then
pass "seeded clinical patient VCR-000001 for FHIR verification"
else
fail "seeded clinical patient VCR-000001 for FHIR verification"
fi
}
test_fhir_metadata() {
section "2. FHIR metadata — GET /fhir/metadata"
local metadata types
metadata="$(fhir_get '/metadata')"
types="$(jq -r '.rest[0].resource[].type' <<<"$metadata" 2>/dev/null | sort | tr '\n' ' ')"
if jq -e '.resourceType == "CapabilityStatement"' <<<"$metadata" >/dev/null 2>&1; then
pass "metadata returns CapabilityStatement"
else
fail "metadata returns CapabilityStatement"
fi
if grep -q 'Patient' <<<"$types" && grep -q 'Encounter' <<<"$types" && grep -q 'Observation' <<<"$types"; then
pass "metadata lists Patient, Encounter, Observation resources"
else
fail "metadata lists Patient, Encounter, Observation resources (got: $types)"
fi
}
resolve_patient_id() {
section "3. Resolve FHIR Patient ID"
local search_json
search_json="$(fhir_get "/Patient?name=Santos" "$ADMIN_TOKEN")"
RESOLVED_PATIENT_ID="$(jq -er '.entry[0].resource.id // empty' <<<"$search_json" 2>/dev/null || true)"
if [[ -z "$RESOLVED_PATIENT_ID" ]]; then
RESOLVED_PATIENT_ID="$PATIENT1_ID"
log " NOTE: Patient search returned no entries; using seeded ID $PATIENT1_ID"
fi
if [[ -n "$RESOLVED_PATIENT_ID" ]]; then
pass "resolved FHIR patient ID ($RESOLVED_PATIENT_ID)"
else
fail "resolved FHIR patient ID"
fi
}
test_fhir_patient_read() {
section "4. FHIR Patient read & search"
local patient_json mrn gender birth_date identifier_json
patient_json="$(fhir_get "/Patient/${RESOLVED_PATIENT_ID}" "$ADMIN_TOKEN")"
if jq -e '.resourceType == "Patient"' <<<"$patient_json" >/dev/null 2>&1; then
pass "GET /fhir/Patient/{id} returns Patient resource"
else
fail "GET /fhir/Patient/{id} returns Patient resource"
fi
mrn="$(jq -er '.identifier[0].value // empty' <<<"$patient_json" 2>/dev/null || true)"
if [[ "$mrn" == "VCR-000001" ]]; then
pass "Patient read includes MRN identifier VCR-000001"
else
fail "Patient read includes MRN identifier VCR-000001 (got: ${mrn:-<empty>})"
fi
gender="$(jq -er '.gender // empty' <<<"$patient_json" 2>/dev/null || true)"
birth_date="$(jq -er '.birthDate // empty' <<<"$patient_json" 2>/dev/null || true)"
if [[ "$gender" == "female" && "$birth_date" == "1978-03-15" ]]; then
pass "Patient read includes gender and birthDate"
else
fail "Patient read includes gender and birthDate (gender=$gender birthDate=$birth_date)"
fi
identifier_json="$(fhir_get "/Patient?identifier=VCR-000001" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$identifier_json")" -ge 1 ]]; then
pass "GET /fhir/Patient?identifier=VCR-000001 returns matches"
else
fail "GET /fhir/Patient?identifier=VCR-000001 returns matches"
fi
}
test_fhir_encounter() {
section "5. FHIR Encounter read & search"
local encounter_json status subject patient_search
encounter_json="$(fhir_get "/Encounter/${ENCOUNTER1_ID}" "$ADMIN_TOKEN")"
if jq -e '.resourceType == "Encounter"' <<<"$encounter_json" >/dev/null 2>&1; then
pass "GET /fhir/Encounter/{id} returns Encounter resource"
else
fail "GET /fhir/Encounter/{id} returns Encounter resource"
fi
status="$(jq -er '.status // empty' <<<"$encounter_json" 2>/dev/null || true)"
subject="$(jq -er '.subject.reference // empty' <<<"$encounter_json" 2>/dev/null || true)"
if [[ "$status" == "in-progress" && "$subject" == "Patient/${RESOLVED_PATIENT_ID}" ]]; then
pass "Encounter read has in-progress status and patient reference"
else
fail "Encounter read has in-progress status and patient reference"
fi
patient_search="$(fhir_get "/Encounter?patient=${RESOLVED_PATIENT_ID}" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$patient_search")" -ge 1 ]]; then
pass "GET /fhir/Encounter?patient={id} returns matches"
else
fail "GET /fhir/Encounter?patient={id} returns matches"
fi
}
test_fhir_observation() {
section "6. FHIR Observation read, LOINC search, category & date"
local obs_json loinc_code loinc_unit loinc_value loinc_search category_search date_search
obs_json="$(fhir_get "/Observation/${HEART_RATE_OBS_ID}" "$ADMIN_TOKEN")"
if jq -e '.resourceType == "Observation"' <<<"$obs_json" >/dev/null 2>&1; then
pass "GET /fhir/Observation/{id} returns Observation resource"
else
fail "GET /fhir/Observation/{id} returns Observation resource"
fi
loinc_code="$(jq -er '.code.coding[0].code // empty' <<<"$obs_json" 2>/dev/null || true)"
loinc_unit="$(jq -er '.valueQuantity.unit // empty' <<<"$obs_json" 2>/dev/null || true)"
loinc_value="$(jq -er '.valueQuantity.value // empty' <<<"$obs_json" 2>/dev/null || true)"
if [[ "$loinc_code" == "8867-4" && "$loinc_unit" == "bpm" && "$loinc_value" == "88" ]]; then
pass "Observation read maps HEART_RATE to LOINC 8867-4 with value 88 bpm"
else
fail "Observation read maps HEART_RATE to LOINC 8867-4 with value 88 bpm"
fi
loinc_search="$(fhir_get "/Observation?patient=${RESOLVED_PATIENT_ID}&code=8867-4" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.entry | length' <<<"$loinc_search" 2>/dev/null || echo 0)" -ge 1 ]]; then
pass "GET /fhir/Observation?code=8867-4 resolves LOINC to HEART_RATE"
else
fail "GET /fhir/Observation?code=8867-4 resolves LOINC to HEART_RATE"
fi
category_search="$(fhir_get "/Observation?patient=${RESOLVED_PATIENT_ID}&category=vital-signs" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.entry | length' <<<"$category_search" 2>/dev/null || echo 0)" -ge 1 ]]; then
pass "GET /fhir/Observation?category=vital-signs returns vital sign observations"
else
fail "GET /fhir/Observation?category=vital-signs returns vital sign observations"
fi
date_search="$(fhir_get "/Observation?patient=${RESOLVED_PATIENT_ID}&date=ge2026-06-20" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$date_search" 2>/dev/null || echo 0)" -ge 1 ]]; then
pass "GET /fhir/Observation?date=ge2026-06-20 filters by recordedAt"
else
fail "GET /fhir/Observation?date=ge2026-06-20 filters by recordedAt"
fi
}
test_fhir_patient_everything() {
section "7. FHIR Patient \$everything"
local bundle_json total resource_types has_patient has_encounter has_observation
bundle_json="$(fhir_get "/Patient/${RESOLVED_PATIENT_ID}/\$everything" "$ADMIN_TOKEN")"
total="$(jq -er '.total // 0' <<<"$bundle_json" 2>/dev/null || echo 0)"
resource_types="$(jq -r '[.entry[]?.resource.resourceType] | join(",")' <<<"$bundle_json" 2>/dev/null || true)"
if [[ "$total" -gt 0 ]]; then
pass "GET /fhir/Patient/{id}/\$everything returns non-empty Bundle"
else
fail "GET /fhir/Patient/{id}/\$everything returns non-empty Bundle"
fi
has_patient="$(grep -c 'Patient' <<<"$resource_types" || true)"
has_encounter="$(grep -c 'Encounter' <<<"$resource_types" || true)"
has_observation="$(grep -c 'Observation' <<<"$resource_types" || true)"
if [[ "$has_patient" -ge 1 && "$has_encounter" -ge 1 && "$has_observation" -ge 1 ]]; then
pass "\$everything Bundle contains Patient, Encounter, and Observation"
else
fail "\$everything Bundle contains Patient, Encounter, and Observation (types: $resource_types)"
fi
}
test_fhir_content_type() {
section "8. Content-Type negotiation"
local content_type
content_type="$(curl -sS -o /dev/null -w '%{content_type}' \
"${FHIR_URL}/Patient/${RESOLVED_PATIENT_ID}" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Accept: application/fhir+json')"
if [[ "$content_type" == application/fhir+json* ]]; then
pass "Patient read Content-Type is application/fhir+json"
else
fail "Patient read Content-Type is application/fhir+json (got: $content_type)"
fi
}
test_fhir_error_handling() {
section "9. FHIR error handling — 404 OperationOutcome"
local response http_status issue_code issue_severity
response="$(fhir_get_status "/Patient/00000000-0000-0000-0000-000000000000" "$ADMIN_TOKEN")"
http_status="${response##*__HTTP_STATUS__:}"
response="${response%__HTTP_STATUS__:*}"
issue_code="$(jq -er '.issue[0].code // empty' <<<"$response" 2>/dev/null || true)"
issue_severity="$(jq -er '.issue[0].severity // empty' <<<"$response" 2>/dev/null || true)"
if [[ "$http_status" == "404" ]]; then
pass "unknown Patient returns HTTP 404"
else
fail "unknown Patient returns HTTP 404 (got HTTP $http_status)"
fi
if [[ "$issue_code" == "not-found" && "$issue_severity" == "error" ]]; then
pass "404 response is OperationOutcome with not-found issue"
else
fail "404 response is OperationOutcome with not-found issue"
fi
}
test_bundle_pagination() {
section "10. Bundle pagination links"
local bundle_json
bundle_json="$(fhir_get "/Patient?_count=1&_offset=0" "$ADMIN_TOKEN")"
if [[ "$(jq -er '.total // 0' <<<"$bundle_json")" -ge 2 ]]; then
pass "Patient search total >= 2 for pagination test"
else
fail "Patient search total >= 2 for pagination test"
return
fi
if jq -e '.link[] | select(.relation == "self")' <<<"$bundle_json" >/dev/null 2>&1; then
pass "search Bundle includes self link"
else
fail "search Bundle includes self link"
fi
if jq -e '.link[] | select(.relation == "next")' <<<"$bundle_json" >/dev/null 2>&1; then
pass "search Bundle includes next link"
else
fail "search Bundle includes next link"
fi
}
test_integration_tests() {
section "11. dotnet integration tests — FhirIntegrationTests"
if [[ "$SKIP_TEST_CHECKS" == "1" ]]; then
log " SKIP: dotnet integration tests (VIGILCARE_SKIP_TEST_CHECKS=1)"
return
fi
if ! command -v dotnet >/dev/null 2>&1; then
log " SKIP: dotnet not installed"
return
fi
if dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj" \
--filter "FullyQualifiedName~FhirIntegrationTests" \
--no-restore >/tmp/vigilcare-p11-tests.log 2>&1; then
pass "FhirIntegrationTests passed"
else
fail "FhirIntegrationTests passed"
log " see /tmp/vigilcare-p11-tests.log"
fi
}
print_manual_ui_checklist() {
section "12. Manual Vue UI checks (plan §7)"
log " Login as admin1 → http://localhost:3028/fhir-explorer"
log " - Select Patient resource type; search by name Santos"
log " - Results table shows matching patients"
log " - Click a row to see full FHIR JSON"
log " - Patient \$everything: select patient, Load All Data"
log " - Grouped Patient summary, Encounters list, Observations timeline"
log " - Open CapabilityStatement link opens metadata JSON in new tab"
}
main() {
require_cmd curl
require_cmd jq
log "VigilCare Records — Phase 11 verification"
log "API: $API_URL"
log "FHIR: $FHIR_URL"
assert_api_reachable
test_authentication
ensure_fhir_clinical_seed
test_fhir_metadata
resolve_patient_id
test_fhir_patient_read
test_fhir_encounter
test_fhir_observation
test_fhir_patient_everything
test_fhir_content_type
test_fhir_error_handling
test_bundle_pagination
test_integration_tests
print_manual_ui_checklist
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 11 API verification checks passed."
log "Complete the manual Vue FHIR Explorer checklist above if not already done."
}
main "$@"