feature: Prometheus Metrics, Supervisor Dashboard, and Promotion Retry Job
This commit is contained in:
+800
@@ -0,0 +1,800 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs Phase 8 verification checks from docs/plans/phase-8-plan.md.
|
||||
#
|
||||
# Covers Prometheus metrics, MetricsCollectorService gauges, rejection counter,
|
||||
# promotion duration histogram, work queue overview, cursor-paginated audit trail,
|
||||
# promotion retry schema, and the Prometheus/Grafana Docker stack.
|
||||
#
|
||||
# Prerequisites:
|
||||
# docker compose up -d (PostgreSQL, Redis, MinIO, Prometheus, Grafana)
|
||||
# dotnet ef database update --project VigilCareRecordsAPI
|
||||
# dotnet run --project VigilCareRecordsAPI
|
||||
# Phase 1–7 seed data (intake1, entry1, verifier1, approver1, admin1)
|
||||
#
|
||||
# Environment overrides:
|
||||
# VIGILCARE_API_URL default: http://localhost:5217
|
||||
# VIGILCARE_PROMETHEUS_URL default: http://localhost:9095
|
||||
# VIGILCARE_GRAFANA_URL default: http://localhost:3013
|
||||
# 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_MONITORING_CHECKS set to 1 to skip Prometheus/Grafana checks
|
||||
# VIGILCARE_METRICS_COLLECTOR_WAIT default: 35 (seconds; MetricsCollectorService interval is 30s)
|
||||
# VIGILCARE_RECORDED_AT default: 2026-06-27T10: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}"
|
||||
PROMETHEUS_URL="${VIGILCARE_PROMETHEUS_URL:-http://localhost:9095}"
|
||||
GRAFANA_URL="${VIGILCARE_GRAFANA_URL:-http://localhost:3013}"
|
||||
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_MONITORING_CHECKS="${VIGILCARE_SKIP_MONITORING_CHECKS:-0}"
|
||||
METRICS_COLLECTOR_WAIT="${VIGILCARE_METRICS_COLLECTOR_WAIT:-35}"
|
||||
RECORDED_AT="${VIGILCARE_RECORDED_AT:-2026-06-27T10:00:00Z}"
|
||||
RECORDED_AT_BP="${VIGILCARE_RECORDED_AT_BP:-2026-06-27T10:05:00Z}"
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
FAILED_TESTS=()
|
||||
|
||||
ALL_BATCH_STATUSES=(
|
||||
UPLOADED
|
||||
IN_ENTRY
|
||||
PENDING_VERIFICATION
|
||||
REJECTED
|
||||
VERIFIED
|
||||
AWAITING_CLINICAL_APPROVAL
|
||||
APPROVED
|
||||
PROMOTED
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ensure_fixture_pdf() {
|
||||
if [[ -f "$FIXTURE_PDF" ]]; then
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$(dirname "$FIXTURE_PDF")"
|
||||
cat >"$FIXTURE_PDF" <<'EOF'
|
||||
%PDF-1.0
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Resources<<>>>>endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
trailer<</Size 4/Root 1 0 R>>
|
||||
startxref
|
||||
206
|
||||
%%EOF
|
||||
EOF
|
||||
}
|
||||
|
||||
new_uuid() {
|
||||
if command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen
|
||||
else
|
||||
cat /proc/sys/kernel/random/uuid
|
||||
fi
|
||||
}
|
||||
|
||||
new_idempotency_key() {
|
||||
printf 'phase8-%s' "$(new_uuid)"
|
||||
}
|
||||
|
||||
compose_service_running() {
|
||||
local service="$1"
|
||||
"${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service"
|
||||
}
|
||||
|
||||
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 // empty' <<<"$json" 2>/dev/null || true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fetch_metrics() {
|
||||
curl -sS "$API_URL/metrics"
|
||||
}
|
||||
|
||||
metric_gauge_status() {
|
||||
local status="$1"
|
||||
local line value
|
||||
line="$(fetch_metrics | grep -E "digitization_batches_by_status\\{status=\"${status}\"\\}" | head -1 || true)"
|
||||
value="$(awk '{print $2}' <<<"$line")"
|
||||
if [[ -z "$value" ]]; then
|
||||
printf '0'
|
||||
else
|
||||
printf '%s' "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
metric_counter_rejection() {
|
||||
local category="$1"
|
||||
local line value
|
||||
line="$(fetch_metrics | grep -E "digitization_rejection_total\\{reason_category=\"${category}\"\\}" | head -1 || true)"
|
||||
value="$(awk '{print $2}' <<<"$line")"
|
||||
if [[ -z "$value" ]]; then
|
||||
printf '0'
|
||||
else
|
||||
printf '%s' "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
metric_histogram_count() {
|
||||
local line value
|
||||
line="$(fetch_metrics | grep -E '^digitization_promotion_duration_seconds_count ' | head -1 || true)"
|
||||
value="$(awk '{print $2}' <<<"$line")"
|
||||
if [[ -z "$value" ]]; then
|
||||
printf '0'
|
||||
else
|
||||
printf '%s' "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
upload_batch() {
|
||||
local token="$1"
|
||||
local batch_type="${2:-VITALS_SHEET}"
|
||||
local track="${3:-BACKFILL}"
|
||||
|
||||
curl -sS -X POST "$API_URL/api/v1/digitization-batches" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-F "file=@${FIXTURE_PDF};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"
|
||||
}
|
||||
|
||||
reject_batch() {
|
||||
local token="$1"
|
||||
local batch_id="$2"
|
||||
local reason="$3"
|
||||
json_post "$API_URL/api/v1/digitization-batches/$batch_id/reject" \
|
||||
"{\"reason\":\"$reason\"}" "$token"
|
||||
}
|
||||
|
||||
approve_batch() {
|
||||
local token="$1"
|
||||
local batch_id="$2"
|
||||
local idempotency_key="$3"
|
||||
local body='{"enableRetroactiveAlerts":false}'
|
||||
if [[ -n "${4:-}" ]]; then
|
||||
body="$4"
|
||||
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"
|
||||
}
|
||||
|
||||
create_assigned_batch() {
|
||||
local intake_token="$1"
|
||||
local upload_json batch_id entry_id
|
||||
|
||||
upload_json="$(upload_batch "$intake_token")"
|
||||
if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then
|
||||
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
|
||||
printf '%s' "$batch_id"
|
||||
}
|
||||
|
||||
create_pending_vitals_batch() {
|
||||
local intake_token="$1"
|
||||
local entry_token batch_id submit_code
|
||||
|
||||
batch_id="$(create_assigned_batch "$intake_token")" || return 1
|
||||
entry_token="$(extract_data_field "$(login entry1)" token)"
|
||||
|
||||
json_put \
|
||||
"$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \
|
||||
'{"fullName":"Phase8 Verify Patient","dateOfBirth":"1985-03-15","sex":"M","noKnownAllergies":true,"noActiveMedications":true}' \
|
||||
"$entry_token" >/dev/null
|
||||
|
||||
json_put \
|
||||
"$API_URL/api/v1/digitization-batches/$batch_id/draft/encounter" \
|
||||
'{"admissionDate":"2025-01-01T08:00:00Z","department":"ICU","admissionReason":"Phase 8 verification"}' \
|
||||
"$entry_token" >/dev/null
|
||||
|
||||
json_post \
|
||||
"$API_URL/api/v1/digitization-batches/$batch_id/draft/observations" \
|
||||
"{\"observationCode\":\"HEART_RATE\",\"value\":88,\"unit\":\"bpm\",\"recordedAt\":\"$RECORDED_AT\"}" \
|
||||
"$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
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s' "$batch_id"
|
||||
}
|
||||
|
||||
create_batch_ready_for_approval() {
|
||||
local intake_token="$1"
|
||||
local entry_token verifier_token batch_id entry_id verify_json verify_status submit_code
|
||||
|
||||
batch_id="$(create_assigned_batch "$intake_token")" || return 1
|
||||
entry_id="$(extract_data_field "$(login entry1)" userId)"
|
||||
entry_token="$(extract_data_field "$(login entry1)" token)"
|
||||
|
||||
json_put \
|
||||
"$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \
|
||||
'{"fullName":"Phase8 Promote Patient","dateOfBirth":"1990-05-15","sex":"M","bloodType":"A+","noKnownAllergies":true}' \
|
||||
"$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\"}" \
|
||||
"$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\"}" \
|
||||
"$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
|
||||
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
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s' "$batch_id"
|
||||
}
|
||||
|
||||
test_metrics_endpoint() {
|
||||
section "1. Prometheus metrics endpoint"
|
||||
|
||||
local metrics
|
||||
metrics="$(fetch_metrics)"
|
||||
|
||||
if grep -q '^# HELP ' <<<"$metrics" && grep -q '^# TYPE ' <<<"$metrics"; then
|
||||
pass "/metrics serves Prometheus exposition format"
|
||||
else
|
||||
fail "/metrics serves Prometheus exposition format"
|
||||
fi
|
||||
|
||||
local metric
|
||||
for metric in \
|
||||
digitization_batches_by_status \
|
||||
digitization_promotion_duration_seconds \
|
||||
digitization_rejection_total \
|
||||
digitization_queue_age_seconds \
|
||||
http_request_duration_seconds; do
|
||||
if grep -q "$metric" <<<"$metrics"; then
|
||||
pass "metric registered: $metric"
|
||||
else
|
||||
fail "metric registered: $metric"
|
||||
fi
|
||||
done
|
||||
|
||||
local missing=0 status
|
||||
for status in "${ALL_BATCH_STATUSES[@]}"; do
|
||||
if ! grep -q "digitization_batches_by_status{status=\"${status}\"}" <<<"$metrics"; then
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
done
|
||||
if [[ "$missing" -eq 0 ]]; then
|
||||
pass "digitization_batches_by_status exposes all 8 status labels"
|
||||
else
|
||||
fail "digitization_batches_by_status exposes all 8 status labels ($missing missing)"
|
||||
fi
|
||||
|
||||
if grep -q 'digitization_rejection_total{reason_category="verification_failed"}' <<<"$metrics" \
|
||||
&& grep -q 'digitization_rejection_total{reason_category="clinical_rejected"}' <<<"$metrics"; then
|
||||
pass "digitization_rejection_total exposes both reason_category labels"
|
||||
else
|
||||
fail "digitization_rejection_total exposes both reason_category labels"
|
||||
fi
|
||||
}
|
||||
|
||||
test_gauge_updates_after_upload() {
|
||||
section "2. Gauge metrics update after creating a batch"
|
||||
|
||||
local intake_token before after upload_json
|
||||
|
||||
intake_token="$(extract_data_field "$(login intake1)" token)"
|
||||
before="$(metric_gauge_status UPLOADED)"
|
||||
|
||||
upload_json="$(upload_batch "$intake_token")"
|
||||
if [[ "$(jq -er '.success' <<<"$upload_json")" != "true" ]]; then
|
||||
fail "upload batch for gauge test"
|
||||
return
|
||||
fi
|
||||
pass "uploaded batch for gauge test"
|
||||
|
||||
log " waiting ${METRICS_COLLECTOR_WAIT}s for MetricsCollectorService..."
|
||||
sleep "$METRICS_COLLECTOR_WAIT"
|
||||
|
||||
after="$(metric_gauge_status UPLOADED)"
|
||||
if awk -v before="$before" -v after="$after" 'BEGIN { exit (after > before) ? 0 : 1 }'; then
|
||||
pass "digitization_batches_by_status UPLOADED increased ($before -> $after)"
|
||||
else
|
||||
fail "digitization_batches_by_status UPLOADED increased ($before -> $after)"
|
||||
fi
|
||||
}
|
||||
|
||||
test_rejection_counter() {
|
||||
section "3. Rejection counter increments"
|
||||
|
||||
local intake_token verifier_token batch_id before after verify_json
|
||||
|
||||
intake_token="$(extract_data_field "$(login intake1)" token)"
|
||||
batch_id="$(create_pending_vitals_batch "$intake_token")" || {
|
||||
fail "setup pending batch for rejection counter"
|
||||
return
|
||||
}
|
||||
|
||||
before="$(metric_counter_rejection verification_failed)"
|
||||
verifier_token="$(extract_data_field "$(login verifier1)" token)"
|
||||
|
||||
verify_json="$(verify_batch "$verifier_token" "$batch_id" \
|
||||
'{"fieldChecks":[{"fieldName":"patient.fullName","status":"error","note":"Name mismatch"}],"passed":false}')"
|
||||
if [[ "$(jq -er '.success' <<<"$verify_json")" != "true" ]]; then
|
||||
fail "verify with passed=false returns success envelope"
|
||||
return
|
||||
fi
|
||||
pass "verification failed via VerifyAsync (Passed=false)"
|
||||
|
||||
after="$(metric_counter_rejection verification_failed)"
|
||||
if awk -v before="$before" -v after="$after" 'BEGIN { exit (after > before) ? 0 : 1 }'; then
|
||||
pass "digitization_rejection_total verification_failed incremented ($before -> $after)"
|
||||
else
|
||||
fail "digitization_rejection_total verification_failed incremented ($before -> $after)"
|
||||
fi
|
||||
}
|
||||
|
||||
test_promotion_histogram() {
|
||||
section "4. Promotion duration histogram"
|
||||
|
||||
local intake_token approver_token batch_id before after approve_json
|
||||
|
||||
before="$(metric_histogram_count)"
|
||||
intake_token="$(extract_data_field "$(login intake1)" token)"
|
||||
batch_id="$(create_batch_ready_for_approval "$intake_token")" || {
|
||||
fail "setup batch ready for approval"
|
||||
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 batch for promotion histogram"
|
||||
return
|
||||
fi
|
||||
pass "batch promoted via ApproveAndPromoteAsync"
|
||||
|
||||
after="$(metric_histogram_count)"
|
||||
if awk -v before="$before" -v after="$after" 'BEGIN { exit (after > before) ? 0 : 1 }'; then
|
||||
pass "digitization_promotion_duration_seconds_count increased ($before -> $after)"
|
||||
else
|
||||
fail "digitization_promotion_duration_seconds_count increased ($before -> $after)"
|
||||
fi
|
||||
|
||||
if grep -q '^digitization_promotion_duration_seconds_sum ' <<<"$(fetch_metrics)"; then
|
||||
pass "digitization_promotion_duration_seconds_sum present after promotion"
|
||||
else
|
||||
fail "digitization_promotion_duration_seconds_sum present after promotion"
|
||||
fi
|
||||
}
|
||||
|
||||
test_work_queue_overview() {
|
||||
section "5. Work queue overview endpoint"
|
||||
|
||||
local admin_token entry_token overview_json code missing=0 status
|
||||
|
||||
admin_token="$(extract_data_field "$(login admin1)" token)"
|
||||
overview_json="$(curl -sS "$API_URL/api/v1/work-queue/overview" \
|
||||
-H "Authorization: Bearer $admin_token")"
|
||||
|
||||
if [[ "$(jq -er '.success' <<<"$overview_json")" == "true" ]]; then
|
||||
pass "GET /work-queue/overview returns success for administrator"
|
||||
else
|
||||
fail "GET /work-queue/overview returns success for administrator"
|
||||
return
|
||||
fi
|
||||
|
||||
for status in "${ALL_BATCH_STATUSES[@]}"; do
|
||||
if [[ "$(jq -er --arg s "$status" '.data.statusCounts[$s] | type' <<<"$overview_json")" != "number" ]]; then
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
done
|
||||
if [[ "$missing" -eq 0 ]]; then
|
||||
pass "overview statusCounts includes all 8 statuses"
|
||||
else
|
||||
fail "overview statusCounts includes all 8 statuses ($missing missing)"
|
||||
fi
|
||||
|
||||
if jq -er '.data.rejectRate | type' <<<"$overview_json" | grep -qx number; then
|
||||
pass "overview rejectRate is numeric"
|
||||
else
|
||||
fail "overview rejectRate is numeric"
|
||||
fi
|
||||
|
||||
if jq -er '.data.averageTimeInQueueMinutes | type' <<<"$overview_json" | grep -qx number \
|
||||
&& jq -er '.data.oldestPendingVerificationMinutes | type' <<<"$overview_json" | grep -qx number; then
|
||||
pass "overview queue age fields are numeric"
|
||||
else
|
||||
fail "overview queue age fields are numeric"
|
||||
fi
|
||||
|
||||
entry_token="$(extract_data_field "$(login entry1)" token)"
|
||||
code="$(http_code "$API_URL/api/v1/work-queue/overview" \
|
||||
-H "Authorization: Bearer $entry_token")"
|
||||
if [[ "$code" == "403" ]]; then
|
||||
pass "GET /work-queue/overview returns 403 for non-administrator"
|
||||
else
|
||||
fail "GET /work-queue/overview returns 403 for non-administrator (HTTP $code)"
|
||||
fi
|
||||
}
|
||||
|
||||
test_audit_trail_events() {
|
||||
section "6. Cursor-paginated audit trail"
|
||||
|
||||
local admin_token intake_token batch_id page1 page2 code cursor encoded_cursor
|
||||
|
||||
admin_token="$(extract_data_field "$(login admin1)" token)"
|
||||
intake_token="$(extract_data_field "$(login intake1)" token)"
|
||||
batch_id="$(create_batch_ready_for_approval "$intake_token")" || {
|
||||
fail "setup batch with multiple audit events"
|
||||
return
|
||||
}
|
||||
|
||||
page1="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id/events?pageSize=2" \
|
||||
-H "Authorization: Bearer $admin_token")"
|
||||
|
||||
if [[ "$(jq -er '.success' <<<"$page1")" != "true" ]]; then
|
||||
fail "GET /events returns success envelope"
|
||||
return
|
||||
fi
|
||||
pass "GET /events returns success envelope"
|
||||
|
||||
if [[ "$(jq -er '.data.items | length' <<<"$page1")" -ge 1 ]]; then
|
||||
pass "events page contains at least one item"
|
||||
else
|
||||
fail "events page contains at least one item"
|
||||
fi
|
||||
|
||||
if jq -er '.data.items[0] | has("actorUsername") and has("actorFullName") and has("eventType")' \
|
||||
<<<"$page1" | grep -qx true; then
|
||||
pass "event items include actorUsername and actorFullName"
|
||||
else
|
||||
fail "event items include actorUsername and actorFullName"
|
||||
fi
|
||||
|
||||
if [[ "$(jq -er '.data.hasMore' <<<"$page1")" == "true" ]]; then
|
||||
cursor="$(extract_data_field "$page1" nextCursor)"
|
||||
encoded_cursor="$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$cursor")"
|
||||
page2="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id/events?pageSize=2&after=$encoded_cursor" \
|
||||
-H "Authorization: Bearer $admin_token")"
|
||||
if [[ "$(jq -er '.success' <<<"$page2")" == "true" \
|
||||
&& "$(jq -er '.data.items | length' <<<"$page2")" -ge 1 ]]; then
|
||||
pass "cursor pagination returns a second page"
|
||||
else
|
||||
fail "cursor pagination returns a second page"
|
||||
fi
|
||||
else
|
||||
log " SKIP: cursor second-page check (batch has <=2 events)"
|
||||
fi
|
||||
|
||||
code="$(http_code "$API_URL/api/v1/digitization-batches/$batch_id/events?after=not-a-date" \
|
||||
-H "Authorization: Bearer $admin_token")"
|
||||
if [[ "$code" == "400" ]]; then
|
||||
pass "invalid cursor returns 400"
|
||||
else
|
||||
fail "invalid cursor returns 400 (HTTP $code)"
|
||||
fi
|
||||
|
||||
code="$(http_code "$API_URL/api/v1/digitization-batches/00000000-0000-0000-0000-000000000000/events" \
|
||||
-H "Authorization: Bearer $admin_token")"
|
||||
if [[ "$code" == "404" ]]; then
|
||||
pass "missing batch returns 404"
|
||||
else
|
||||
fail "missing batch returns 404 (HTTP $code)"
|
||||
fi
|
||||
}
|
||||
|
||||
test_promotion_retry_schema() {
|
||||
section "7. Promotion retry infrastructure"
|
||||
|
||||
if ! psql_available; then
|
||||
log " SKIP: promotion_attempts table check (postgres not reachable)"
|
||||
return
|
||||
fi
|
||||
|
||||
local table_exists
|
||||
table_exists="$(psql_query "
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'promotion_attempts'
|
||||
);
|
||||
")"
|
||||
|
||||
if [[ "$table_exists" == "t" ]]; then
|
||||
pass "promotion_attempts table exists"
|
||||
else
|
||||
fail "promotion_attempts table exists"
|
||||
fi
|
||||
|
||||
local index_count
|
||||
index_count="$(psql_query "
|
||||
SELECT count(*)
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'promotion_attempts'
|
||||
AND indexname IN (
|
||||
'ix_promotion_attempts_batch_attempt',
|
||||
'ix_promotion_attempts_pending_retry'
|
||||
);
|
||||
")"
|
||||
|
||||
if [[ "$index_count" == "2" ]]; then
|
||||
pass "promotion_attempts has batch/attempt and pending-retry indexes"
|
||||
else
|
||||
fail "promotion_attempts has batch/attempt and pending-retry indexes (got $index_count)"
|
||||
fi
|
||||
|
||||
log " NOTE: full PromotionRetryService retry flow requires simulating a deferred promotion failure (manual test in phase-8 plan section 7)."
|
||||
}
|
||||
|
||||
test_monitoring_stack() {
|
||||
section "8. Prometheus and Grafana stack"
|
||||
|
||||
if [[ "$SKIP_MONITORING_CHECKS" == "1" ]]; then
|
||||
log " SKIP: VIGILCARE_SKIP_MONITORING_CHECKS=1"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! compose_service_running prometheus; then
|
||||
log " SKIP: prometheus container not running (docker compose up -d prometheus grafana)"
|
||||
return
|
||||
fi
|
||||
|
||||
local prom_health grafana_health target_health query_status
|
||||
|
||||
prom_health="$(http_code "$PROMETHEUS_URL/-/healthy" || true)"
|
||||
if [[ "$prom_health" == "200" ]]; then
|
||||
pass "Prometheus healthy at $PROMETHEUS_URL"
|
||||
else
|
||||
fail "Prometheus healthy at $PROMETHEUS_URL (HTTP $prom_health)"
|
||||
fi
|
||||
|
||||
if compose_service_running grafana; then
|
||||
grafana_health="$(http_code "$GRAFANA_URL/api/health" || true)"
|
||||
if [[ "$grafana_health" == "200" ]]; then
|
||||
pass "Grafana healthy at $GRAFANA_URL"
|
||||
else
|
||||
fail "Grafana healthy at $GRAFANA_URL (HTTP $grafana_health)"
|
||||
fi
|
||||
else
|
||||
log " SKIP: grafana container not running"
|
||||
fi
|
||||
|
||||
target_health="$(curl -sS "$PROMETHEUS_URL/api/v1/targets" | jq -er '.data.activeTargets[0].health' 2>/dev/null || true)"
|
||||
if [[ "$target_health" == "up" ]]; then
|
||||
pass "Prometheus scrape target is up"
|
||||
elif [[ "$target_health" == "down" ]]; then
|
||||
fail "Prometheus scrape target is up (currently down — is the API running on port 5217?)"
|
||||
else
|
||||
fail "Prometheus scrape target health could not be determined"
|
||||
fi
|
||||
|
||||
query_status="$(curl -sS "$PROMETHEUS_URL/api/v1/query?query=digitization_batches_by_status" | jq -er '.status' 2>/dev/null || true)"
|
||||
if [[ "$query_status" == "success" ]]; then
|
||||
pass "Prometheus query API returns digitization_batches_by_status"
|
||||
else
|
||||
fail "Prometheus query API returns digitization_batches_by_status"
|
||||
fi
|
||||
|
||||
if [[ -f "$REPO_ROOT/prometheus.yml" ]]; then
|
||||
if grep -q 'host.docker.internal:5217' "$REPO_ROOT/prometheus.yml" \
|
||||
&& grep -q 'vigilcare-records-api' "$REPO_ROOT/prometheus.yml"; then
|
||||
pass "prometheus.yml targets host.docker.internal:5217"
|
||||
else
|
||||
fail "prometheus.yml targets host.docker.internal:5217"
|
||||
fi
|
||||
else
|
||||
fail "prometheus.yml exists in repo root"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
require_cmd docker
|
||||
require_cmd python3
|
||||
ensure_fixture_pdf
|
||||
|
||||
log "VigilCare Records — Phase 8 verification"
|
||||
log "API: $API_URL"
|
||||
log "Prometheus: $PROMETHEUS_URL"
|
||||
log "Grafana: $GRAFANA_URL"
|
||||
|
||||
assert_api_reachable
|
||||
|
||||
test_metrics_endpoint
|
||||
test_gauge_updates_after_upload
|
||||
test_rejection_counter
|
||||
test_promotion_histogram
|
||||
test_work_queue_overview
|
||||
test_audit_trail_events
|
||||
test_promotion_retry_schema
|
||||
test_monitoring_stack
|
||||
|
||||
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 8 verification checks passed."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user