feature: Corrections and Supersession

This commit is contained in:
voltsrage
2026-06-26 16:33:31 +08:00
parent 706318e5d2
commit f232761fd7
35 changed files with 4907 additions and 75 deletions
+828
View File
@@ -0,0 +1,828 @@
#!/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 "$@"