#!/usr/bin/env bash # Runs Phase 13 verification checks from docs/plans/phase-13-plan.md. # # Covers OCR schema/config, draft ocrConfidence API field, manual-entry # non-interference, and optional live OCR polling when the API runs with OCR enabled. # # Prerequisites: # docker compose up -d (PostgreSQL, Redis, MinIO) # dotnet ef database update --project VigilCareRecordsAPI # dotnet run --project VigilCareRecordsAPI (Ocr:Enabled=false by default) # Phase 1–12 seed data (intake1, entry1) # # Optional live OCR checks (plan §4–6): # Restart API with OCR enabled, e.g.: # Ocr__Enabled=true Ocr__Provider=tesseract dotnet run --project VigilCareRecordsAPI # Ensure Tesseract data is installed (e.g. /usr/share/tessdata/eng.traineddata) # VIGILCARE_OCR_LIVE=1 ./scripts/run-vigilcare-records-phase-13-verification.sh # # Environment overrides: # VIGILCARE_API_URL default: http://localhost:5217 # VIGILCARE_COMPOSE_FILE default: /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_BUILD_CHECKS set to 1 to skip dotnet build/test # VIGILCARE_SKIP_API_CHECKS set to 1 to skip HTTP API checks # VIGILCARE_OCR_LIVE set to 1 to run live OCR polling tests # VIGILCARE_OCR_POLL_WAIT_SEC default: 20 (should exceed Ocr:PollIntervalSeconds) # # Usage: # chmod +x scripts/run-vigilcare-records-phase-13-verification.sh # ./scripts/run-vigilcare-records-phase-13-verification.sh 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" APPSETTINGS="$REPO_ROOT/VigilCareRecordsAPI/appsettings.json" 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_BUILD_CHECKS="${VIGILCARE_SKIP_BUILD_CHECKS:-0}" SKIP_API_CHECKS="${VIGILCARE_SKIP_API_CHECKS:-0}" OCR_LIVE="${VIGILCARE_OCR_LIVE:-0}" OCR_POLL_WAIT_SEC="${VIGILCARE_OCR_POLL_WAIT_SEC:-20}" INTAKE_TOKEN="" ENTRY_TOKEN="" ENTRY_CLERK1_ID="" 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 } 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<>endobj 2 0 obj<>endobj 3 0 obj<>>>endobj xref 0 4 0000000000 65535 f 0000000009 00000 n 0000000058 00000 n 0000000115 00000 n trailer<> startxref 206 %%EOF EOF } unique_pdf_path() { local suffix="$1" local path="/tmp/vigilcare-p13-${suffix}-${RANDOM}.pdf" cp "$FIXTURE_PDF" "$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_get() { local url="$1" local token="$2" curl -sS "$url" -H "Authorization: Bearer $token" } 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" } 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 } 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 } upload_batch() { local token="$1" local pdf="$2" local batch_type="${3:-VITALS_SHEET}" local track="${4:-BACKFILL}" curl -sS -X POST "$API_URL/api/v1/digitization-batches" \ -H "Authorization: Bearer $token" \ -F "file=@${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 "{\"entryClerkId\":\"$entry_clerk_id\"}" } test_build_and_unit_tests() { section "1. Backend compiles and tests pass (plan §1)" if [[ "$SKIP_BUILD_CHECKS" == "1" ]]; then log " SKIP: dotnet build/test (VIGILCARE_SKIP_BUILD_CHECKS=1)" return fi if ! command -v dotnet >/dev/null 2>&1; then fail "dotnet SDK available for build" return fi if dotnet build "$REPO_ROOT/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj" \ --nologo -v q >/tmp/vigilcare-p13-build.log 2>&1; then pass "dotnet build succeeds" else fail "dotnet build succeeds" log " see /tmp/vigilcare-p13-build.log" return fi if dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj" \ --no-build --nologo >/tmp/vigilcare-p13-tests.log 2>&1; then pass "dotnet test passes" else fail "dotnet test passes" log " see /tmp/vigilcare-p13-tests.log" fi } test_database_schema() { section "2. Migration — ocr_results and OCR event types (plan §2, §4)" if ! psql_available; then log " SKIP: PostgreSQL checks (set VIGILCARE_SKIP_DB_CHECKS=0 and start postgres)" return fi local table_exists unique_batch constraint_ok migration_ok table_exists="$(psql_query " SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ocr_results'; ")" unique_batch="$(psql_query " SELECT count(*) FROM pg_indexes WHERE tablename = 'ocr_results' AND indexdef LIKE '%UNIQUE%' AND indexdef LIKE '%batch_id%'; ")" constraint_ok="$(psql_query " SELECT count(*) FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid WHERE t.relname = 'digitization_events' AND c.conname = 'chk_digitization_events_event_type' AND pg_get_constraintdef(c.oid) LIKE '%ocr_started%' AND pg_get_constraintdef(c.oid) LIKE '%ocr_completed%' AND pg_get_constraintdef(c.oid) LIKE '%ocr_failed%'; ")" migration_ok="$(psql_query " SELECT count(*) FROM public.\"__EFMigrationsHistory\" WHERE \"MigrationId\" LIKE '%AddOcrResult%'; " 2>/dev/null || echo "0")" if [[ "$table_exists" == "1" ]]; then pass "ocr_results table exists" else fail "ocr_results table exists (run: dotnet ef database update --project VigilCareRecordsAPI)" fi if [[ "$unique_batch" == "1" ]]; then pass "unique index on ocr_results.batch_id exists" else fail "unique index on ocr_results.batch_id exists" fi if [[ "$constraint_ok" == "1" ]]; then pass "digitization_events check constraint includes OCR event types" else fail "digitization_events check constraint includes OCR event types" fi if [[ "$migration_ok" == "1" ]]; then pass "AddOcrResult migration applied" else fail "AddOcrResult migration applied" fi } test_ocr_disabled_by_default_config() { section "3. OCR disabled by default — appsettings (plan §3)" if [[ ! -f "$APPSETTINGS" ]]; then fail "appsettings.json exists" return fi local enabled provider enabled="$(jq -er '.Ocr.Enabled' <<<"$(cat "$APPSETTINGS")")" provider="$(jq -er '.Ocr.Provider // empty' <<<"$(cat "$APPSETTINGS")")" if [[ "$enabled" == "false" ]]; then pass "appsettings Ocr.Enabled is false by default" else fail "appsettings Ocr.Enabled is false by default (got: $enabled)" fi if [[ -n "$provider" ]]; then pass "appsettings defines Ocr.Provider" else fail "appsettings defines Ocr.Provider" fi if [[ -f "$REPO_ROOT/VigilCareRecordsAPI/BackgroundServices/OcrProcessingService.cs" ]]; then pass "OcrProcessingService source present" else fail "OcrProcessingService source present" fi if grep -q 'OcrConfidenceMap' "$REPO_ROOT/VigilCareRecordsAPI/Models/Records/Batch/DraftPayloadResponse.cs" 2>/dev/null; then pass "DraftPayloadResponse includes OcrConfidenceMap" else fail "DraftPayloadResponse includes OcrConfidenceMap" fi } test_authentication() { section "4. Authentication" local intake_json entry_json users_json intake_json="$(login intake1)" entry_json="$(login entry1)" INTAKE_TOKEN="$(extract_data_field "$intake_json" token)" ENTRY_TOKEN="$(extract_data_field "$entry_json" token)" if [[ -n "$INTAKE_TOKEN" ]]; then pass "intake1 login returns JWT" else fail "intake1 login returns JWT" fi if [[ -n "$ENTRY_TOKEN" ]]; then pass "entry1 login returns JWT" else fail "entry1 login returns JWT" fi users_json="$(json_get "$API_URL/api/v1/users?role=DATA_ENTRY_CLERK" "$INTAKE_TOKEN")" ENTRY_CLERK1_ID="$(jq -er '.data[0].id // empty' <<<"$users_json" 2>/dev/null || true)" if [[ -n "$ENTRY_CLERK1_ID" ]]; then pass "resolved entry clerk ID for assign test" else fail "resolved entry clerk ID for assign test" fi } test_draft_ocr_confidence_null_when_ocr_off() { section "5. Draft API — ocrConfidence null without OCR run (plan §3, §8)" if [[ -z "$INTAKE_TOKEN" ]]; then fail "draft ocrConfidence test skipped — no intake token" return fi local pdf upload_json batch_id draft_json ocr_conf pdf="$(unique_pdf_path draft-null)" upload_json="$(upload_batch "$INTAKE_TOKEN" "$pdf")" rm -f "$pdf" batch_id="$(extract_data_field "$upload_json" id)" if [[ -z "$batch_id" ]]; then fail "upload batch for draft ocrConfidence test" return fi draft_json="$(json_get "$API_URL/api/v1/digitization-batches/$batch_id/draft" "$ENTRY_TOKEN")" if jq -e '.success == true' <<<"$draft_json" >/dev/null 2>&1; then pass "GET /draft returns success for UPLOADED batch" else fail "GET /draft returns success for UPLOADED batch" return fi if jq -e '.data | has("ocrConfidence")' <<<"$draft_json" >/dev/null 2>&1; then pass "draft payload includes ocrConfidence field" else fail "draft payload includes ocrConfidence field" fi ocr_conf="$(jq -r '.data.ocrConfidence // "missing"' <<<"$draft_json")" if [[ "$ocr_conf" == "null" || "$ocr_conf" == "missing" ]]; then pass "ocrConfidence is null when OCR has not processed batch" else fail "ocrConfidence is null when OCR has not processed batch (got: $ocr_conf)" fi local status status="$(extract_data_field "$upload_json" status)" if [[ "$status" == "UPLOADED" ]]; then pass "uploaded batch remains UPLOADED before entry" else fail "uploaded batch remains UPLOADED before entry (status=$status)" fi } test_manual_entry_skips_ocr() { section "6. No interference with manual entry (plan §7)" if [[ -z "$INTAKE_TOKEN" || -z "$ENTRY_TOKEN" || -z "$ENTRY_CLERK1_ID" ]]; then fail "manual-entry interference test skipped — missing auth IDs" return fi local pdf upload_json batch_id assign_json put_json status ocr_row event_count pdf="$(unique_pdf_path manual-entry)" upload_json="$(upload_batch "$INTAKE_TOKEN" "$pdf")" rm -f "$pdf" batch_id="$(extract_data_field "$upload_json" id)" if [[ -z "$batch_id" ]]; then fail "upload batch for manual-entry test" return fi assign_json="$(assign_batch "$INTAKE_TOKEN" "$batch_id" "$ENTRY_CLERK1_ID")" if [[ "$(jq -er '.success // false' <<<"$assign_json")" == "true" ]]; then pass "batch assigned to entry clerk" else fail "batch assigned to entry clerk" return fi put_json="$(json_put \ "$API_URL/api/v1/digitization-batches/$batch_id/draft/patient" \ '{"fullName":"Manual Entry Patient","dateOfBirth":"1990-01-15","sex":"female"}' \ "$ENTRY_TOKEN")" if [[ "$(jq -er '.success // false' <<<"$put_json")" == "true" ]]; then pass "entry clerk saves draft patient before OCR can run" else fail "entry clerk saves draft patient before OCR can run" return fi status="$(json_get "$API_URL/api/v1/digitization-batches/$batch_id" "$ENTRY_TOKEN" \ | jq -r '.data.status // empty')" if [[ "$status" == "IN_ENTRY" ]]; then pass "batch transitions to IN_ENTRY after first draft save" else fail "batch transitions to IN_ENTRY after first draft save (status=$status)" fi if psql_available; then ocr_row="$(psql_query " SELECT count(*) FROM ocr_results WHERE batch_id = '$batch_id'; ")" event_count="$(psql_query " SELECT count(*) FROM digitization_events WHERE batch_id = '$batch_id' AND event_type IN ('ocr_started', 'ocr_completed', 'ocr_failed'); ")" if [[ "$ocr_row" == "0" ]]; then pass "no ocr_results row for IN_ENTRY batch" else fail "no ocr_results row for IN_ENTRY batch (count=$ocr_row)" fi if [[ "$event_count" == "0" ]]; then pass "no OCR lifecycle events for IN_ENTRY batch" else fail "no OCR lifecycle events for IN_ENTRY batch (count=$event_count)" fi else log " SKIP: DB assertions for manual-entry test (postgres unavailable)" fi } test_live_ocr_polling() { section "7. Live OCR polling (optional — plan §4–6)" if [[ "$OCR_LIVE" != "1" ]]; then log " SKIP: live OCR tests (set VIGILCARE_OCR_LIVE=1 and restart API with Ocr__Enabled=true)" log " Example: Ocr__Enabled=true Ocr__Provider=tesseract dotnet run --project VigilCareRecordsAPI" return fi if [[ -z "$INTAKE_TOKEN" ]]; then fail "live OCR test skipped — no intake token" return fi if ! psql_available; then fail "live OCR test requires PostgreSQL for event/result assertions" return fi local pdf upload_json batch_id draft_json provider ocr_conf event_completed event_failed pdf="$(unique_pdf_path ocr-live)" upload_json="$(upload_batch "$INTAKE_TOKEN" "$pdf")" rm -f "$pdf" batch_id="$(extract_data_field "$upload_json" id)" if [[ -z "$batch_id" ]]; then fail "upload batch for live OCR test" return fi log " Waiting ${OCR_POLL_WAIT_SEC}s for OcrProcessingService poll cycle..." sleep "$OCR_POLL_WAIT_SEC" event_completed="$(psql_query " SELECT count(*) FROM digitization_events WHERE batch_id = '$batch_id' AND event_type = 'ocr_completed'; ")" event_failed="$(psql_query " SELECT count(*) FROM digitization_events WHERE batch_id = '$batch_id' AND event_type = 'ocr_failed'; ")" local ocr_started ocr_started="$(psql_query " SELECT count(*) FROM digitization_events WHERE batch_id = '$batch_id' AND event_type = 'ocr_started'; ")" if [[ "$ocr_started" -ge 1 ]]; then pass "OcrStarted event written for uploaded batch" else fail "OcrStarted event written for uploaded batch (is API running with Ocr__Enabled=true?)" fi if [[ "$event_completed" == "1" ]]; then pass "OcrCompleted event written" elif [[ "$event_failed" == "1" ]]; then pass "OcrFailed event written (OCR failure is non-blocking — plan §6)" local batch_status batch_status="$(psql_query " SELECT status FROM digitization_batches WHERE id = '$batch_id'; ")" if [[ "$batch_status" == "UPLOADED" ]]; then pass "batch remains UPLOADED after OCR failure" else fail "batch remains UPLOADED after OCR failure (status=$batch_status)" fi return else fail "OcrCompleted or OcrFailed event written after poll wait" return fi draft_json="$(json_get "$API_URL/api/v1/digitization-batches/$batch_id/draft" "$ENTRY_TOKEN")" ocr_conf="$(jq -c '.data.ocrConfidence // null' <<<"$draft_json")" provider="$(jq -r '.data.ocrConfidence.provider // empty' <<<"$draft_json")" if [[ "$ocr_conf" != "null" && -n "$provider" ]]; then pass "GET /draft returns ocrConfidence with provider ($provider)" else fail "GET /draft returns ocrConfidence with provider (got: $ocr_conf)" fi if jq -e '.data.ocrConfidence.fieldConfidences | type == "object"' <<<"$draft_json" >/dev/null 2>&1; then pass "ocrConfidence.fieldConfidences is an object" else fail "ocrConfidence.fieldConfidences is an object" fi local ocr_row ocr_row="$(psql_query " SELECT count(*) FROM ocr_results WHERE batch_id = '$batch_id'; ")" if [[ "$ocr_row" == "1" ]]; then pass "ocr_results row persisted for batch" else fail "ocr_results row persisted for batch" fi } print_manual_ui_checklist() { section "8. Manual Vue UI checks (plan §8)" log " Login as entry1 → open an OCR-processed batch in the entry form" log " - Blue OCR banner shows provider name" log " - Pre-filled fields show green/yellow/red left border by confidence" log " - Fields below confidence threshold remain empty" log " Edit a pre-filled field → save → confirm draft_field_updated audit event" log " Login as verifier → open batch in verification form" log " - OCR banner and confidence borders visible in read-only mode" log " OCR disabled (default): confirm no OcrProcessingService started in API logs" log " Azure provider: restart with Ocr__Provider=azure and valid credentials (plan §5)" log " OCR failure: restart with invalid Azure endpoint → OcrFailed logged, manual entry works (plan §6)" } main() { require_cmd curl require_cmd jq ensure_fixture_pdf log "VigilCare Records — Phase 13 verification (Optional OCR-Assisted Draft Pre-Fill)" log "API: $API_URL" log "OCR live tests: $([[ "$OCR_LIVE" == "1" ]] && echo enabled || echo disabled)" test_build_and_unit_tests test_database_schema test_ocr_disabled_by_default_config if [[ "$SKIP_API_CHECKS" == "1" ]]; then log "" log "SKIP: HTTP API checks (VIGILCARE_SKIP_API_CHECKS=1)" else assert_api_reachable test_authentication test_draft_ocr_confidence_null_when_ocr_off test_manual_entry_skips_ocr test_live_ocr_polling fi print_manual_ui_checklist 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 13 automated verification checks passed." log "Complete the manual Vue UI checklist above if not already done." if [[ "$OCR_LIVE" != "1" ]]; then log "For live OCR polling tests, restart the API with OCR enabled and re-run with VIGILCARE_OCR_LIVE=1." fi } main "$@"