feature: Approval and Promotion to VigilCareClinical
This commit is contained in:
+667
@@ -0,0 +1,667 @@
|
||||
#!/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 1–3 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 1–3 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 "$@"
|
||||
Reference in New Issue
Block a user