829 lines
27 KiB
Bash
829 lines
27 KiB
Bash
#!/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: <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 "$@"
|