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

829 lines
27 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 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 14 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 14 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_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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
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:-<none>})"
fi
if [[ "$correction_promoted" == "1" ]]; then
pass "correction batch has correction_promoted event"
else
fail "correction batch has correction_promoted event (count=${correction_promoted:-<none>})"
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:-<none>})"
fi
if [[ "$audit_count" == "2" ]]; then
pass "audit query returns 2 superseded observations"
else
fail "audit query returns 2 superseded observations (got: ${audit_count:-<none>})"
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 "$@"