Files
vigilcare-records/scripts/run-vigilcare-records-phase-4-verification.sh
voltsrage c871dc4842
CI / backend (push) Failing after 2m26s
CI / frontend (push) Failing after 53s
Add deployment files
2026-08-11 20:17:53 +08:00

668 lines
24 KiB
Bash
Raw Permalink 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 4 verification checks from docs/plans/phase-4-plan.md.
#
# Covers promotion to clinical tables, idempotency, separation of duties,
# promotion-result endpoint, and the outbox alert behavior matrix.
#
# Prerequisites:
# docker compose up -d (PostgreSQL + Redis; psql via docker compose exec)
# dotnet ef database update --project VigilCareRecordsAPI
# dotnet run --project VigilCareRecordsAPI
# Phase 13 seed data (intake1, entry1, verifier1, approver1 users)
#
# 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 13 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_RECORDED_AT default: 2024-01-15T09:30: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}"
RECORDED_AT="${VIGILCARE_RECORDED_AT:-2024-01-15T09:30:00Z}"
RECORDED_AT_BP="${VIGILCARE_RECORDED_AT_BP:-2024-01-15T09:31:00Z}"
PASS_COUNT=0
FAIL_COUNT=0
FAILED_TESTS=()
log() {
printf '%s\n' "$*"
}
section() {
log ""
log "== $1 =="
}
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
log " PASS: $1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
FAILED_TESTS+=("$1")
log " FAIL: $1"
}
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
log "ERROR: required command not found: $cmd"
exit 1
fi
}
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
}
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:-VITALS_SHEET}"
local track="${3:-BACKFILL}"
local file_path="${4:-$FIXTURE_PDF}"
curl -sS -X POST "$API_URL/api/v1/digitization-batches" \
-H "Authorization: Bearer $token" \
-F "file=@${file_path};type=application/pdf" \
-F "batchType=$batch_type" \
-F "track=$track"
}
assign_batch() {
local token="$1"
local batch_id="$2"
local entry_clerk_id="$3"
curl -sS -X PATCH "$API_URL/api/v1/digitization-batches/$batch_id/assign" \
-H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' \
-d "{\"entryClerkUserId\":\"$entry_clerk_id\"}"
}
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"
if [[ -z "$body" ]]; then
body='{"enableRetroactiveAlerts":false}'
fi
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, assign, enter draft data, submit, and verify.
# Leaves batch in AWAITING_CLINICAL_APPROVAL (VITALS_SHEET). Prints batch id to stdout.
create_batch_ready_for_approval() {
local intake_token="$1"
local track="${2:-BACKFILL}"
local patient_name="${3:-Test Patient}"
local patient_dob="${4:-1990-05-15}"
local entry_token verifier_token batch_id entry_id patient_json
local verify_json verify_status submit_code
patient_json="$(jq -nc \
--arg name "$patient_name" \
--arg dob "$patient_dob" \
'{fullName: $name, dateOfBirth: $dob, sex: "M", bloodType: "A+", noKnownAllergies: true}')"
local upload_json
upload_json="$(upload_batch "$intake_token" "VITALS_SHEET" "$track")"
if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then
log "ERROR: failed to upload VITALS_SHEET batch (track=$track)"
return 1
fi
batch_id="$(extract_data_field "$upload_json" id)"
entry_id="$(extract_data_field "$(login entry1)" userId)"
assign_batch "$intake_token" "$batch_id" "$entry_id" >/dev/null
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-01-15T08:00:00Z","department":"General Medicine","roomBed":"301-A","admissionReason":"Routine checkup","status":"active"}' \
"$entry_token" >/dev/null
json_post \
"$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \
"{\"observationCode\":\"HR\",\"value\":88,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\",\"note\":\"Resting heart rate\"}" \
"$entry_token" >/dev/null
json_post \
"$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \
"{\"observationCode\":\"TEMP\",\"value\":37.2,\"unit\":\"C\",\"recordedAt\":\"$RECORDED_AT\",\"note\":\"Oral temperature\"}" \
"$entry_token" >/dev/null
json_post \
"$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \
"{\"observationCode\":\"BP_SYS\",\"value\":120,\"unit\":\"mmHg\",\"recordedAt\":\"$RECORDED_AT_BP\"}" \
"$entry_token" >/dev/null
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":"patient.fullName","status":"ok","note":null},{"fieldName":"observation.heartRate","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"
}
test_backfill_promotion_creates_live_records() {
section "1. Promotion — BACKFILL batch creates live records, zero outbox events"
local intake_token batch_id approver_token approve_json
local status mrn outbox_written obs_count
intake_token="$(extract_data_field "$(login intake1)" token)"
batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return
approver_token="$(extract_data_field "$(login approver1)" token)"
approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" \
'{"enableRetroactiveAlerts":false}')"
if [[ "$(jq -er '.success' <<<"$approve_json")" != "true" ]]; then
fail "approve BACKFILL batch returns 200"
return
fi
status="$(extract_data_field "$approve_json" status)"
mrn="$(extract_data_field "$approve_json" mrn)"
outbox_written="$(extract_data_field "$approve_json" outboxEventsWritten)"
if [[ "$status" == "PROMOTED" && "$mrn" == VCR-* && "$outbox_written" == "0" ]]; then
pass "BACKFILL promotion returns PROMOTED, VCR-* MRN, outboxEventsWritten=0"
else
fail "BACKFILL promotion returns PROMOTED, VCR-* MRN, outboxEventsWritten=0 (status=$status mrn=$mrn outbox=$outbox_written)"
return
fi
if ! psql_available; then
log " SKIP: PostgreSQL live-record checks (postgres not reachable)"
return
fi
obs_count="$(psql_query "SELECT count(*) FROM clinical.observations WHERE source_batch_id = '$batch_id';")"
if [[ "$obs_count" == "3" ]]; then
pass "clinical.observations has 3 rows for promoted batch"
else
fail "clinical.observations has 3 rows for promoted batch (got: ${obs_count:-<none>})"
fi
local outbox_count
outbox_count="$(psql_query "SELECT count(*) FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%';")"
if [[ "$outbox_count" == "0" ]]; then
pass "clinical.outbox_events has 0 rows for BACKFILL without retroactive alerts"
else
fail "clinical.outbox_events has 0 rows for BACKFILL without retroactive alerts (got: ${outbox_count:-<none>})"
fi
local patient_name batch_status promoted_event
patient_name="$(psql_query "SELECT full_name FROM clinical.patients p JOIN clinical.observations o ON o.patient_id = p.id WHERE o.source_batch_id = '$batch_id' LIMIT 1;")"
if [[ "$patient_name" == "Test Patient" ]]; then
pass "clinical.patients row has correct full_name"
else
fail "clinical.patients row has correct full_name (got: ${patient_name:-<none>})"
fi
batch_status="$(psql_query "SELECT status FROM digitization_batches WHERE id = '$batch_id';")"
if [[ "$batch_status" == "PROMOTED" ]]; then
pass "digitization_batches status is PROMOTED"
else
fail "digitization_batches status is PROMOTED (got: ${batch_status:-<none>})"
fi
promoted_event="$(psql_query "SELECT count(*) FROM digitization_events WHERE batch_id = '$batch_id' AND event_type = 'promoted';")"
if [[ "$promoted_event" == "1" ]]; then
pass "digitization_events includes promoted event"
else
fail "digitization_events includes promoted event (count=${promoted_event:-<none>})"
fi
}
test_idempotency() {
section "2. Idempotency — same Idempotency-Key returns identical result, no duplicate rows"
local intake_token batch_id approver_token idem_key
local first_json second_json first_patient second_patient obs_count
intake_token="$(extract_data_field "$(login intake1)" token)"
batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return
approver_token="$(extract_data_field "$(login approver1)" token)"
idem_key="$(new_idempotency_key)"
first_json="$(approve_batch "$approver_token" "$batch_id" "$idem_key" '{}')"
second_json="$(approve_batch "$approver_token" "$batch_id" "$idem_key" '{}')"
first_patient="$(extract_data_field "$first_json" patientId)"
second_patient="$(extract_data_field "$second_json" patientId)"
if [[ -n "$first_patient" && "$first_patient" == "$second_patient" ]]; then
pass "idempotent replay returns identical patientId"
else
fail "idempotent replay returns identical patientId (first=$first_patient second=$second_patient)"
return
fi
if ! psql_available; then
log " SKIP: idempotency observation count (postgres not reachable)"
return
fi
obs_count="$(psql_query "SELECT count(*) FROM clinical.observations WHERE source_batch_id = '$batch_id';")"
if [[ "$obs_count" == "3" ]]; then
pass "only one set of observations exists after idempotent replay (3, not 6)"
else
fail "only one set of observations exists after idempotent replay (got: ${obs_count:-<none>})"
fi
}
test_separation_of_duties() {
section "3. Separation of duties — entry clerk cannot approve (403)"
local intake_token batch_id entry_token approve_code
intake_token="$(extract_data_field "$(login intake1)" token)"
batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return
entry_token="$(extract_data_field "$(login entry1)" token)"
approve_code="$(http_code -X POST \
"$API_URL/api/v1/digitization-batches/$batch_id/approve" \
-H "Authorization: Bearer $entry_token" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(new_idempotency_key)" \
-d '{}')"
if [[ "$approve_code" == "403" ]]; then
pass "entry clerk approve rejected with 403 (wrong role)"
else
fail "entry clerk approve rejected with 403 (wrong role) (http=$approve_code)"
fi
}
test_missing_idempotency_key() {
section "4. Missing Idempotency-Key — approve returns 400 MISSING_IDEMPOTENCY_KEY"
local intake_token batch_id approver_token approve_json error_code approve_code
intake_token="$(extract_data_field "$(login intake1)" token)"
batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return
approver_token="$(extract_data_field "$(login approver1)" token)"
approve_code="$(http_code -X POST \
"$API_URL/api/v1/digitization-batches/$batch_id/approve" \
-H "Authorization: Bearer $approver_token" \
-H 'Content-Type: application/json' \
-d '{}')"
approve_json="$(json_post \
"$API_URL/api/v1/digitization-batches/$batch_id/approve" \
'{}' "$approver_token")"
error_code="$(extract_error_code "$approve_json")"
if [[ "$approve_code" == "400" && "$error_code" == "MISSING_IDEMPOTENCY_KEY" ]]; then
pass "approve without Idempotency-Key returns 400 MISSING_IDEMPOTENCY_KEY"
else
fail "approve without Idempotency-Key returns 400 MISSING_IDEMPOTENCY_KEY (http=$approve_code code=${error_code:-<none>})"
fi
}
test_approve_wrong_status() {
section "5. Status guard — approve on uploaded batch returns 409"
local intake_token batch_id approver_token approve_json error_code approve_code
intake_token="$(extract_data_field "$(login intake1)" token)"
batch_id="$(extract_data_field "$(upload_batch "$intake_token")" id)"
approver_token="$(extract_data_field "$(login approver1)" token)"
approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" '{}')"
approve_code="$(jq -er '(.statusCode // .status // empty)' <<<"$approve_json")"
error_code="$(extract_error_code "$approve_json")"
if [[ "$approve_code" == "409" && "$error_code" == "ILLEGAL_STATUS_TRANSITION" ]]; then
pass "approve on UPLOADED batch returns 409 ILLEGAL_STATUS_TRANSITION"
else
fail "approve on UPLOADED batch returns 409 ILLEGAL_STATUS_TRANSITION (http=$approve_code code=${error_code:-<none>})"
fi
}
test_promotion_result_after_promotion() {
section "6. Promotion result — GET returns live IDs after promotion"
local intake_token batch_id approver_token approve_json
local result_json result_patient approve_patient obs_len
intake_token="$(extract_data_field "$(login intake1)" token)"
batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return
approver_token="$(extract_data_field "$(login approver1)" token)"
approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" '{}')"
approve_patient="$(extract_data_field "$approve_json" patientId)"
result_json="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id/promotion-result" \
-H "Authorization: Bearer $approver_token")"
result_patient="$(extract_data_field "$result_json" patientId)"
obs_len="$(jq -er '.data.observationIds | length' <<<"$result_json")"
if [[ "$(jq -er '.success' <<<"$result_json")" == "true" &&
"$result_patient" == "$approve_patient" &&
"$obs_len" == "3" ]]; then
pass "GET promotion-result returns patientId and 3 observationIds after promotion"
else
fail "GET promotion-result returns patientId and 3 observationIds after promotion"
fi
}
test_promotion_result_not_promoted() {
section "7. Promotion result — GET on non-promoted batch returns 409"
local intake_token batch_id approver_token result_code
intake_token="$(extract_data_field "$(login intake1)" token)"
batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return
approver_token="$(extract_data_field "$(login approver1)" token)"
result_code="$(http_code "$API_URL/api/v1/digitization-batches/$batch_id/promotion-result" \
-H "Authorization: Bearer $approver_token")"
if [[ "$result_code" == "409" ]]; then
pass "GET promotion-result before approve returns 409"
else
fail "GET promotion-result before approve returns 409 (http=$result_code)"
fi
}
test_backfill_retroactive_alerts() {
section "8. Alert matrix — BACKFILL + enableRetroactiveAlerts=true writes outbox events"
local intake_token batch_id approver_token approve_json outbox_written
intake_token="$(extract_data_field "$(login intake1)" token)"
batch_id="$(create_batch_ready_for_approval "$intake_token" "BACKFILL")" || return
approver_token="$(extract_data_field "$(login approver1)" token)"
approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" \
'{"enableRetroactiveAlerts":true}')"
outbox_written="$(extract_data_field "$approve_json" outboxEventsWritten)"
if [[ "$outbox_written" == "3" ]]; then
pass "BACKFILL with enableRetroactiveAlerts=true writes 3 outbox events"
else
fail "BACKFILL with enableRetroactiveAlerts=true writes 3 outbox events (got: ${outbox_written:-<none>})"
return
fi
if ! psql_available; then
log " SKIP: outbox event payload checks (postgres not reachable)"
return
fi
local outbox_count event_type processed
outbox_count="$(psql_query "SELECT count(*) FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%';")"
event_type="$(psql_query "SELECT DISTINCT event_type FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%';")"
processed="$(psql_query "SELECT count(*) FROM clinical.outbox_events WHERE payload_json::text LIKE '%$batch_id%' AND processed_at IS NOT NULL;")"
if [[ "$outbox_count" == "3" && "$event_type" == "observation.created" && "$processed" == "0" ]]; then
pass "outbox rows are observation.created with processed_at IS NULL"
else
fail "outbox rows are observation.created with processed_at IS NULL (count=$outbox_count type=$event_type processed=$processed)"
fi
}
test_live_capture_always_alerts() {
section "9. Alert matrix — LIVE_CAPTURE always writes outbox events"
local intake_token batch_id approver_token approve_json outbox_written source
intake_token="$(extract_data_field "$(login intake1)" token)"
batch_id="$(create_batch_ready_for_approval "$intake_token" "LIVE_CAPTURE")" || return
approver_token="$(extract_data_field "$(login approver1)" token)"
approve_json="$(approve_batch "$approver_token" "$batch_id" "$(new_idempotency_key)" \
'{"enableRetroactiveAlerts":false}')"
outbox_written="$(extract_data_field "$approve_json" outboxEventsWritten)"
if [[ "$outbox_written" == "3" ]]; then
pass "LIVE_CAPTURE with enableRetroactiveAlerts=false still writes 3 outbox events"
else
fail "LIVE_CAPTURE with enableRetroactiveAlerts=false still writes 3 outbox events (got: ${outbox_written:-<none>})"
return
fi
if ! psql_available; then
log " SKIP: live_capture source check (postgres not reachable)"
return
fi
source="$(psql_query "SELECT DISTINCT source FROM clinical.observations WHERE source_batch_id = '$batch_id';")"
if [[ "$source" == "live_capture" ]]; then
pass "LIVE_CAPTURE observations have source=live_capture"
else
fail "LIVE_CAPTURE observations have source=live_capture (got: ${source:-<none>})"
fi
}
test_sequential_mrns() {
section "10. MRN generation — sequential unique VCR-* values across promotions"
local intake_token batch_id1 batch_id2 approver_token
local approve1 approve2 mrn1 mrn2 num1 num2
intake_token="$(extract_data_field "$(login intake1)" token)"
batch_id1="$(create_batch_ready_for_approval "$intake_token" "BACKFILL" "MRN Test Patient A" "1988-01-10")" || return
batch_id2="$(create_batch_ready_for_approval "$intake_token" "BACKFILL" "MRN Test Patient B" "1992-06-20")" || return
approver_token="$(extract_data_field "$(login approver1)" token)"
approve1="$(approve_batch "$approver_token" "$batch_id1" "$(new_idempotency_key)" '{}')"
approve2="$(approve_batch "$approver_token" "$batch_id2" "$(new_idempotency_key)" '{}')"
mrn1="$(extract_data_field "$approve1" mrn)"
mrn2="$(extract_data_field "$approve2" mrn)"
if [[ "$mrn1" == VCR-* && "$mrn2" == VCR-* && "$mrn1" != "$mrn2" ]]; then
pass "two promotions produce unique VCR-* MRNs"
else
fail "two promotions produce unique VCR-* MRNs (mrn1=$mrn1 mrn2=$mrn2)"
return
fi
num1="${mrn1#VCR-}"
num2="${mrn2#VCR-}"
if [[ "$num2" -gt "$num1" ]]; then
pass "second MRN is numerically greater than first (sequential sequence)"
else
fail "second MRN is numerically greater than first (mrn1=$mrn1 mrn2=$mrn2)"
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 4 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_backfill_promotion_creates_live_records
test_idempotency
test_separation_of_duties
test_missing_idempotency_key
test_approve_wrong_status
test_promotion_result_after_promotion
test_promotion_result_not_promoted
test_backfill_retroactive_alerts
test_live_capture_always_alerts
test_sequential_mrns
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 4 verification checks passed."
}
main "$@"