#!/usr/bin/env bash # Runs Phase 5 verification checks from docs/plans/phase-5-plan.md. # # Covers correction batch creation, supersession on promotion, live observation # flags, patient digitization history, audit trail integrity, and integration tests. # # Prerequisites: # docker compose up -d (PostgreSQL + Redis + MinIO) # dotnet ef database update --project VigilCareRecordsAPI # dotnet run --project VigilCareRecordsAPI # Phase 1–4 seed data (intake1, entry1/2, verifier1/2, approver1/2, clinician1, admin1) # # 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–4 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_SKIP_TEST_CHECKS set to 1 to skip dotnet integration tests # VIGILCARE_RECORDED_AT default: 2024-06-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}" SKIP_TEST_CHECKS="${VIGILCARE_SKIP_TEST_CHECKS:-0}" RECORDED_AT="${VIGILCARE_RECORDED_AT:-2024-06-01T10:00:00Z}" PASS_COUNT=0 FAIL_COUNT=0 FAILED_TESTS=() # Populated by the full correction flow test for downstream checks. SHARED_ORIGINAL_BATCH_ID="" SHARED_CORRECTION_BATCH_ID="" SHARED_PATIENT_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 } 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 } new_idempotency_key() { if command -v uuidgen >/dev/null 2>&1; then uuidgen else cat /proc/sys/kernel/random/uuid fi } # Unique PDF per upload — duplicate SHA-256 detection rejects same file for one patient. create_temp_pdf() { local suffix="${1:-$(date +%s%N)}" local path path="$(mktemp "/tmp/vigilcare-correction-${suffix}-XXXXXX.pdf")" printf '%%PDF-1.4 correction-%s\n' "$suffix" > "$path" printf '%s' "$path" } 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 // .extensions.code // empty' <<<"$json" 2>/dev/null || jq -er '.title // empty' <<<"$json" 2>/dev/null || true } upload_batch() { local token="$1" local batch_type="${2:-LAB_RESULTS}" local track="${3:-BACKFILL}" local file_path="${4:-$FIXTURE_PDF}" local supersedes_batch_id="${5:-}" local patient_id="${6:-}" local -a form_args=( -H "Authorization: Bearer $token" -F "file=@${file_path};type=application/pdf" -F "batchType=$batch_type" -F "track=$track" ) if [[ -n "$supersedes_batch_id" ]]; then form_args+=(-F "supersedesBatchId=$supersedes_batch_id") fi if [[ -n "$patient_id" ]]; then form_args+=(-F "patientId=$patient_id") fi curl -sS -X POST "$API_URL/api/v1/digitization-batches" "${form_args[@]}" } 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" } approve_batch() { local token="$1" local batch_id="$2" local idempotency_key="$3" local body="${4:-{\"enableRetroactiveAlerts\":false}}" curl -sS -X POST "$API_URL/api/v1/digitization-batches/$batch_id/approve" \ -H "Authorization: Bearer $token" \ -H 'Content-Type: application/json' \ -H "Idempotency-Key: $idempotency_key" \ -d "$body" } 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 } # Upload, enter lab draft data, submit, and verify. # Leaves batch in AWAITING_CLINICAL_APPROVAL (LAB_RESULTS). Prints batch id to stdout. create_lab_batch_ready_for_approval() { local intake_token="$1" local potassium_value="${2:-3.5}" local sodium_value="${3:-140}" local patient_name="${4:-Phase 5 Lab Patient}" local patient_dob="${5:-1980-01-15}" local entry_token verifier_token batch_id patient_json upload_json verify_json verify_status patient_json="$(jq -nc \ --arg name "$patient_name" \ --arg dob "$patient_dob" \ '{fullName: $name, dateOfBirth: $dob, sex: "Female"}')" upload_json="$(upload_batch "$intake_token" "LAB_RESULTS" "BACKFILL")" if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then log "ERROR: failed to upload LAB_RESULTS batch" return 1 fi batch_id="$(extract_data_field "$upload_json" id)" entry_token="$(extract_data_field "$(login entry1)" token)" json_put \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ "$patient_json" \ "$entry_token" >/dev/null json_put \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/encounter" \ '{"admissionDate":"2024-06-01T08:00:00Z","department":"Internal Medicine","roomBed":"4A-12","admissionReason":"Electrolyte panel","status":"active"}' \ "$entry_token" >/dev/null json_post \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ "{\"observationCode\":\"K\",\"value\":$potassium_value,\"unit\":\"mmol/L\",\"recordedAt\":\"$RECORDED_AT\"}" \ "$entry_token" >/dev/null json_post \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \ "{\"observationCode\":\"Na\",\"value\":$sodium_value,\"unit\":\"mmol/L\",\"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 verifier_token="$(extract_data_field "$(login verifier1)" token)" verify_json="$(verify_batch "$verifier_token" "$batch_id" \ '{"fieldChecks":[{"fieldName":"observation.K","status":"ok","note":null}],"passed":true}')" verify_status="$(extract_data_field "$verify_json" status)" if [[ "$(jq -er '.success' <<<"$verify_json")" != "true" || "$verify_status" != "AWAITING_CLINICAL_APPROVAL" ]]; then log "ERROR: verification failed (status=${verify_status:-})" return 1 fi printf '%s' "$batch_id" } # Promote a correction batch (observations only) through entry, verify, and approve. # Prints correction batch id to stdout. promote_correction_batch() { local intake_token="$1" local original_batch_id="$2" local patient_id="$3" local potassium_value="${4:-5.3}" local sodium_value="${5:-140}" local entry_token verifier_token approver_token local upload_json correction_batch_id approve_json correction_pdf correction_pdf="$(create_temp_pdf "$original_batch_id")" upload_json="$(upload_batch "$intake_token" "LAB_RESULTS" "BACKFILL" "$correction_pdf" \ "$original_batch_id" "$patient_id")" rm -f "$correction_pdf" if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then log "ERROR: failed to upload correction batch: $(jq -c '.' <<<"$upload_json" 2>/dev/null || echo "$upload_json")" return 1 fi correction_batch_id="$(extract_data_field "$upload_json" id)" local upload_status is_correction upload_status="$(extract_data_field "$upload_json" status)" is_correction="$(extract_data_field "$upload_json" isCorrection)" if [[ "$upload_status" != "UPLOADED" || "$is_correction" != "true" ]]; then log "ERROR: correction upload status=$upload_status isCorrection=$is_correction" return 1 fi entry_token="$(extract_data_field "$(login entry2)" token)" json_post \ "$API_URL/api/v1/digitization-batches/$correction_batch_id/draft/observations" \ "{\"observationCode\":\"K\",\"value\":$potassium_value,\"unit\":\"mmol/L\",\"recordedAt\":\"$RECORDED_AT\"}" \ "$entry_token" >/dev/null json_post \ "$API_URL/api/v1/digitization-batches/$correction_batch_id/draft/observations" \ "{\"observationCode\":\"Na\",\"value\":$sodium_value,\"unit\":\"mmol/L\",\"recordedAt\":\"$RECORDED_AT\"}" \ "$entry_token" >/dev/null local submit_code submit_code="$(http_code -X POST \ "$API_URL/api/v1/digitization-batches/$correction_batch_id/submit-for-verification" \ -H "Authorization: Bearer $entry_token")" if [[ "$submit_code" != "200" ]]; then log "ERROR: correction submit-for-verification failed (HTTP $submit_code)" return 1 fi verifier_token="$(extract_data_field "$(login verifier2)" token)" local verify_json verify_json="$(verify_batch "$verifier_token" "$correction_batch_id" \ '{"fieldChecks":[{"fieldName":"observation.K","status":"ok","note":null}],"passed":true}')" if [[ "$(jq -er '.success' <<<"$verify_json")" != "true" ]]; then log "ERROR: correction verification failed" return 1 fi approver_token="$(extract_data_field "$(login approver2)" token)" approve_json="$(approve_batch "$approver_token" "$correction_batch_id" "$(new_idempotency_key)")" if [[ "$(jq -er '.success' <<<"$approve_json")" != "true" ]]; then log "ERROR: correction approve failed" return 1 fi if [[ "$(extract_data_field "$approve_json" status)" != "PROMOTED" ]]; then log "ERROR: correction batch not promoted" return 1 fi printf '%s' "$correction_batch_id" } test_schema_supersession_columns() { section "1. Schema — live_observations supersession columns and partial index" if ! psql_available; then log " SKIP: PostgreSQL not reachable" return fi local columns index_count columns="$(psql_query " SELECT column_name FROM information_schema.columns WHERE table_name = 'live_observations' AND column_name IN ('is_superseded', 'superseded_by_batch_id', 'superseded_at') ORDER BY column_name; " | tr '\n' ',' | sed 's/,$//')" if [[ "$columns" == "is_superseded,superseded_at,superseded_by_batch_id" ]]; then pass "live_observations has is_superseded, superseded_by_batch_id, superseded_at" else fail "live_observations has supersession columns (got: ${columns:-})" fi index_count="$(psql_query " SELECT count(*) FROM pg_indexes WHERE tablename = 'live_observations' AND indexdef ILIKE '%is_superseded%'; ")" if [[ "$index_count" -ge 1 ]]; then pass "partial index on is_superseded exists" else fail "partial index on is_superseded exists (count=${index_count:-})" fi } test_no_live_observation_mutation_endpoint() { section "2. Invariant — no API endpoint mutates live observations directly" local swagger_paths swagger_paths="$(curl -sS "$API_URL/swagger/v1/swagger.json")" if jq -e '.paths | keys[] | select(test("live-observation|live_observation"; "i"))' \ <<<"$swagger_paths" >/dev/null 2>&1; then fail "swagger exposes live observation mutation routes" return fi pass "swagger has no live observation mutation routes" local patch_code patch_code="$(http_code -X PATCH \ "$API_URL/api/v1/live-observations/$(new_idempotency_key)" \ -H "Authorization: Bearer $(extract_data_field "$(login admin1)" token)" \ -H 'Content-Type: application/json' \ -d '{"value":99}')" if [[ "$patch_code" == "404" || "$patch_code" == "405" ]]; then pass "PATCH /api/v1/live-observations/:id is not available ($patch_code)" else fail "PATCH /api/v1/live-observations/:id is not available (http=$patch_code)" fi } test_full_correction_supersession_flow() { section "3. Full cycle — wrong K promoted, correction supersedes original" local intake_token approver_token original_batch_id correction_batch_id local approve_json patient_id intake_token="$(extract_data_field "$(login intake1)" token)" original_batch_id="$(create_lab_batch_ready_for_approval "$intake_token" "3.5" "140" \ "Phase5 Correction Patient $(date +%s)" "1980-01-15")" || return approver_token="$(extract_data_field "$(login approver1)" token)" approve_json="$(approve_batch "$approver_token" "$original_batch_id" "$(new_idempotency_key)")" if [[ "$(jq -er '.success' <<<"$approve_json")" != "true" ]]; then fail "original lab batch promotes successfully" return fi patient_id="$(extract_data_field "$approve_json" patientId)" if [[ -z "$patient_id" ]]; then fail "original promotion returns patientId" return fi pass "original lab batch promoted with wrong K=3.5" correction_batch_id="$(promote_correction_batch "$intake_token" "$original_batch_id" \ "$patient_id" "5.3" "140")" || { fail "correction batch promoted with corrected K=5.3" return } pass "correction batch promoted with corrected K=5.3" SHARED_ORIGINAL_BATCH_ID="$original_batch_id" SHARED_CORRECTION_BATCH_ID="$correction_batch_id" SHARED_PATIENT_ID="$patient_id" if ! psql_available; then log " SKIP: live_observations supersession DB checks" return fi local active_k superseded_k total_count correction_event active_k="$(psql_query " SELECT value::text FROM live_observations WHERE patient_id = '$patient_id' AND observation_code = 'K' AND is_superseded = false; ")" superseded_k="$(psql_query " SELECT value::text FROM live_observations WHERE patient_id = '$patient_id' AND observation_code = 'K' AND is_superseded = true AND superseded_by_batch_id = '$correction_batch_id'; ")" total_count="$(psql_query " SELECT count(*) FROM live_observations WHERE patient_id = '$patient_id'; ")" correction_event="$(psql_query " SELECT count(*) FROM digitization_events WHERE batch_id = '$correction_batch_id' AND event_type = 'correction_uploaded'; ")" if [[ "$active_k" == "5.300" || "$active_k" == "5.3" ]]; then pass "active potassium value is 5.3 after correction" else fail "active potassium value is 5.3 after correction (got: ${active_k:-})" fi if [[ "$superseded_k" == "3.500" || "$superseded_k" == "3.5" ]]; then pass "superseded potassium value 3.5 preserved for audit" else fail "superseded potassium value 3.5 preserved for audit (got: ${superseded_k:-})" fi if [[ "$total_count" == "4" ]]; then pass "four live_observations rows retained (2 superseded + 2 active)" else fail "four live_observations rows retained (got: ${total_count:-})" fi if [[ "$correction_event" == "1" ]]; then pass "correction_uploaded event recorded on correction batch" else fail "correction_uploaded event recorded on correction batch (count=${correction_event:-})" fi } test_supersession_validation_non_promoted() { section "4. Validation — correction against non-promoted batch returns 422" local intake_token upload_json error_code upload_code intake_token="$(extract_data_field "$(login intake1)" token)" local pending_batch_id pending_batch_id="$(extract_data_field "$(upload_batch "$intake_token")" id)" upload_json="$(upload_batch "$intake_token" "LAB_RESULTS" "BACKFILL" "$FIXTURE_PDF" \ "$pending_batch_id")" upload_code="$(jq -er '.statusCode // empty' <<<"$upload_json")" error_code="$(extract_error_code "$upload_json")" if [[ "$upload_code" == "422" && "$error_code" == "SUPERSEDED_BATCH_NOT_PROMOTED" ]]; then pass "non-promoted batch supersession returns 422 SUPERSEDED_BATCH_NOT_PROMOTED" else fail "non-promoted batch supersession returns 422 SUPERSEDED_BATCH_NOT_PROMOTED (http=$upload_code code=${error_code:-})" fi } test_supersession_validation_not_found() { section "5. Validation — correction against missing batch returns 404" local intake_token upload_json error_code upload_code fake_id fake_id="$(new_idempotency_key)" intake_token="$(extract_data_field "$(login intake1)" token)" upload_json="$(upload_batch "$intake_token" "LAB_RESULTS" "BACKFILL" "$FIXTURE_PDF" "$fake_id")" upload_code="$(jq -er '.statusCode // empty' <<<"$upload_json")" error_code="$(extract_error_code "$upload_json")" if [[ "$upload_code" == "404" && "$error_code" == "SUPERSEDED_BATCH_NOT_FOUND" ]]; then pass "missing batch supersession returns 404 SUPERSEDED_BATCH_NOT_FOUND" else fail "missing batch supersession returns 404 SUPERSEDED_BATCH_NOT_FOUND (http=$upload_code code=${error_code:-})" fi } test_supersession_validation_already_superseded() { section "6. Validation — second correction against superseded batch returns 409" if [[ -z "$SHARED_ORIGINAL_BATCH_ID" || -z "$SHARED_PATIENT_ID" ]]; then fail "already-superseded validation requires full correction flow (run test 3 first)" return fi local intake_token upload_json error_code upload_code correction_pdf intake_token="$(extract_data_field "$(login intake1)" token)" correction_pdf="$(create_temp_pdf "already-superseded")" upload_json="$(upload_batch "$intake_token" "LAB_RESULTS" "BACKFILL" "$correction_pdf" \ "$SHARED_ORIGINAL_BATCH_ID" "$SHARED_PATIENT_ID")" rm -f "$correction_pdf" upload_code="$(jq -er '.statusCode // empty' <<<"$upload_json")" error_code="$(extract_error_code "$upload_json")" if [[ "$upload_code" == "409" && "$error_code" == "BATCH_ALREADY_SUPERSEDED" ]]; then pass "already-superseded batch returns 409 BATCH_ALREADY_SUPERSEDED" else fail "already-superseded batch returns 409 BATCH_ALREADY_SUPERSEDED (http=$upload_code code=${error_code:-})" fi } test_patient_digitization_history() { section "7. Patient digitization history — correction chain in API response" if [[ -z "$SHARED_PATIENT_ID" || -z "$SHARED_ORIGINAL_BATCH_ID" || -z "$SHARED_CORRECTION_BATCH_ID" ]]; then fail "digitization history requires full correction flow (run test 3 first)" return fi local clinician_token history_json clinician_token="$(extract_data_field "$(login clinician1)" token)" history_json="$(curl -sS \ "$API_URL/api/v1/patients/$SHARED_PATIENT_ID/digitization-history" \ -H "Authorization: Bearer $clinician_token")" local total promoted superseded total="$(extract_data_field "$history_json" totalBatches)" promoted="$(extract_data_field "$history_json" promotedBatches)" superseded="$(extract_data_field "$history_json" supersededBatches)" if [[ "$(jq -er '.success' <<<"$history_json")" == "true" && "$total" == "2" && "$promoted" == "2" && "$superseded" == "1" ]]; then pass "history summary counts: totalBatches=2, promotedBatches=2, supersededBatches=1" else fail "history summary counts (total=$total promoted=$promoted superseded=$superseded)" return fi local original_superseded correction_is_correction original_superseded="$(jq -er \ --arg id "$SHARED_ORIGINAL_BATCH_ID" \ '.data.entries[] | select(.batchId == $id) | .hasBeenSuperseded' <<<"$history_json")" correction_is_correction="$(jq -er \ --arg id "$SHARED_CORRECTION_BATCH_ID" \ '.data.entries[] | select(.batchId == $id) | .isCorrection' <<<"$history_json")" if [[ "$original_superseded" == "true" && "$correction_is_correction" == "true" ]]; then pass "original marked hasBeenSuperseded; correction marked isCorrection" else fail "original hasBeenSuperseded=$original_superseded correction isCorrection=$correction_is_correction" fi local audit_len audit_len="$(jq -er \ --arg id "$SHARED_ORIGINAL_BATCH_ID" \ '.data.entries[] | select(.batchId == $id) | .auditTrail | length' <<<"$history_json")" if [[ "$audit_len" -ge 2 ]]; then pass "original batch entry includes audit trail events" else fail "original batch entry includes audit trail events (len=${audit_len:-0})" fi } test_patient_history_unknown_patient() { section "8. Patient digitization history — unknown patient returns 404" local admin_token unknown_id history_code history_json error_code unknown_id="$(new_idempotency_key)" admin_token="$(extract_data_field "$(login admin1)" token)" history_json="$(curl -sS \ "$API_URL/api/v1/patients/$unknown_id/digitization-history" \ -H "Authorization: Bearer $admin_token")" history_code="$(jq -er '.statusCode // empty' <<<"$history_json")" error_code="$(extract_error_code "$history_json")" if [[ "$history_code" == "404" && "$error_code" == "PATIENT_HISTORY_NOT_FOUND" ]]; then pass "unknown patient returns 404 PATIENT_HISTORY_NOT_FOUND" else fail "unknown patient returns 404 PATIENT_HISTORY_NOT_FOUND (http=$history_code code=${error_code:-})" fi } test_audit_trail_integrity() { section "9. Audit trail — superseded and correction_promoted events" if [[ -z "$SHARED_ORIGINAL_BATCH_ID" || -z "$SHARED_CORRECTION_BATCH_ID" ]]; then fail "audit trail check requires full correction flow (run test 3 first)" return fi if ! psql_available; then log " SKIP: audit trail DB checks" return fi local original_superseded correction_promoted original_promoted original_superseded="$(psql_query " SELECT count(*) FROM digitization_events WHERE batch_id = '$SHARED_ORIGINAL_BATCH_ID' AND event_type = 'superseded'; ")" correction_promoted="$(psql_query " SELECT count(*) FROM digitization_events WHERE batch_id = '$SHARED_CORRECTION_BATCH_ID' AND event_type = 'correction_promoted'; ")" original_promoted="$(psql_query " SELECT count(*) FROM digitization_events WHERE batch_id = '$SHARED_ORIGINAL_BATCH_ID' AND event_type = 'promoted'; ")" if [[ "$original_promoted" == "1" ]]; then pass "original batch has promoted event" else fail "original batch has promoted event (count=${original_promoted:-})" fi if [[ "$original_superseded" == "1" ]]; then pass "original batch has superseded event after correction promotion" else fail "original batch has superseded event (count=${original_superseded:-})" fi if [[ "$correction_promoted" == "1" ]]; then pass "correction batch has correction_promoted event" else fail "correction batch has correction_promoted event (count=${correction_promoted:-})" fi } test_active_observation_filter() { section "10. Clinical query — active observations exclude superseded rows" if [[ -z "$SHARED_PATIENT_ID" ]]; then fail "active observation filter requires full correction flow (run test 3 first)" return fi if ! psql_available; then log " SKIP: active observation filter DB checks" return fi local active_count audit_count active_count="$(psql_query " SELECT count(*) FROM live_observations WHERE patient_id = '$SHARED_PATIENT_ID' AND is_superseded = false; ")" audit_count="$(psql_query " SELECT count(*) FROM live_observations WHERE patient_id = '$SHARED_PATIENT_ID' AND is_superseded = true; ")" if [[ "$active_count" == "2" ]]; then pass "default active query returns 2 non-superseded observations" else fail "default active query returns 2 non-superseded observations (got: ${active_count:-})" fi if [[ "$audit_count" == "2" ]]; then pass "audit query returns 2 superseded observations" else fail "audit query returns 2 superseded observations (got: ${audit_count:-})" fi } test_integration_tests() { section "11. Integration tests — CorrectionSupersessionTests" 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~CorrectionSupersessionTests" \ --verbosity minimal 2>&1)" test_exit=$? if [[ "$test_exit" -eq 0 ]] && grep -q "Passed!" <<<"$test_output"; then pass "CorrectionSupersessionTests pass (dotnet test)" else fail "CorrectionSupersessionTests pass (dotnet test)" log "$test_output" 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 5 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_supersession_columns test_no_live_observation_mutation_endpoint test_full_correction_supersession_flow test_supersession_validation_non_promoted test_supersession_validation_not_found test_supersession_validation_already_superseded test_patient_digitization_history test_patient_history_unknown_patient test_audit_trail_integrity test_active_observation_filter 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 5 verification checks passed." } main "$@"