#!/bin/bash # Manual integration test for the mutable custom metadata flow. # # Prerequisites: # - Local stack running: task compose:refresh # - Local stack using DISABLE_AUTH=true # - Docker available (used to enable clientcansync in Postgres) # # Covered flow: # - Create a fresh client # - Upload a ZIP of valid PDFs under metadata.test/ # - Create schema.test.1 with scalar, object, and typed-array coverage # - Verify the schema via GET /super-admin/custom-schemas # - Assign the schema to one uploaded document # - Reject invalid metadata with the expected validation message # - Accept valid metadata and verify the stored payload by GET /custom-metadata set -euo pipefail BASE_URL="${BASE_URL:-http://localhost:8080}" TEST_USER_SUBJECT="${TEST_USER_SUBJECT:-manual-super-admin-001}" TEST_USER_EMAIL="${TEST_USER_EMAIL:-${TEST_USER_SUBJECT}@test.local}" CLIENT_ID="mt$(date +%s)" CLIENT_NAME="Manual Metadata ${CLIENT_ID}" SCHEMA_NAME="schema.test.1" SCHEMA_DESCRIPTION="Manual supported-type coverage for custom metadata" FOLDER_NAME="metadata.test" TARGET_FILENAME="${FOLDER_NAME}/test_document.pdf" TOTAL_DOCUMENTS=3 MAX_PREVIEW_CHARS=700 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TMP_DIR="" ZIP_FILE="" BATCH_ID="" DOCUMENT_ID="" SCHEMA_ID="" HTTP_CODE="" RESPONSE_BODY="" SECONDS=0 VERBOSE=false TEST_HEADERS=( -H "X-Test-User-Subject: ${TEST_USER_SUBJECT}" -H "X-Test-User-Email: ${TEST_USER_EMAIL}" ) RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' log_info() { echo -e "${GREEN}[INFO]${NC} $1" } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" } log_error() { echo -e "${RED}[ERROR]${NC} $1" } log_step() { echo -e "${BLUE}[STEP]${NC} $1" } # truncate_preview compacts arbitrary text and caps the preview length so # request/response logs stay readable during manual runs. truncate_preview() { local raw_text="${1:-}" PREVIEW_TEXT="${raw_text}" python3 - "$MAX_PREVIEW_CHARS" <<'PY' import os import sys limit = int(sys.argv[1]) text = os.environ.get("PREVIEW_TEXT", "").strip() if not text: print("") raise SystemExit(0) compact = " ".join(text.split()) if len(compact) > limit: print(compact[:limit] + "... [truncated]") else: print(compact) PY } # pretty_print_json attempts to pretty-print a JSON string. Falls back to raw # text when the input is not valid JSON (e.g. multipart previews). pretty_print_json() { local text="${1:-}" if [ -z "${text}" ] || [ "${text}" = "" ]; then echo "${text:-}" return fi TEXT="${text}" python3 <<'PY' import json import os text = os.environ.get("TEXT", "") try: parsed = json.loads(text) print(json.dumps(parsed, indent=2)) except json.JSONDecodeError: print(text) PY } # log_exchange prints one request/response pair. In verbose mode (-v) it shows # the full URL, pretty-printed request body, and pretty-printed response body. # In normal mode it truncates both to MAX_PREVIEW_CHARS. Params: # 1. Human-readable label # 2. HTTP method # 3. Full URL # 4. Request body text (or "") # 5. HTTP status # 6. Raw response body log_exchange() { local label="$1" local method="$2" local url="$3" local request_preview="$4" local status="$5" local response_body="$6" echo "----- ${label} -----" echo "Request: ${method} ${url}" if [ "${VERBOSE}" = "true" ]; then local pretty_request pretty_response pretty_request="$(pretty_print_json "${request_preview}")" pretty_response="$(pretty_print_json "${response_body}")" echo "Body:" echo "${pretty_request}" echo "Response: HTTP ${status}" echo "${pretty_response}" else local truncated_request truncated_response truncated_request="$(truncate_preview "$request_preview")" truncated_response="$(truncate_preview "$response_body")" echo "Input: ${truncated_request}" echo "Response: HTTP ${status}" echo "Output: ${truncated_response}" fi } # require_status checks the actual status against one or more expected # values. Returns 0 on match, 1 otherwise. require_status() { local actual="$1" shift local expected for expected in "$@"; do if [ "$actual" = "$expected" ]; then return 0 fi done return 1 } # run_json_request sends a JSON request, captures the body/status, logs the # exchange, and fails if the status is not expected. run_json_request() { local label="$1" local method="$2" local path="$3" local payload="$4" shift 4 local expected_statuses=("$@") local url="${BASE_URL}${path}" local response response="$(curl -sS -w "\n%{http_code}" -X "$method" "$url" \ "${TEST_HEADERS[@]}" \ -H "Content-Type: application/json" \ --data "$payload")" HTTP_CODE="$(echo "$response" | tail -n1)" RESPONSE_BODY="$(echo "$response" | sed '$d')" log_exchange "$label" "$method" "$url" "$payload" "$HTTP_CODE" "$RESPONSE_BODY" if ! require_status "$HTTP_CODE" "${expected_statuses[@]}"; then log_error "${label} failed: expected HTTP ${expected_statuses[*]}, got HTTP ${HTTP_CODE}" return 1 fi } # run_get_request sends a GET request, captures the body/status, logs the # exchange, and fails if the status is not expected. run_get_request() { local label="$1" local path="$2" shift 2 local expected_statuses=("$@") local url="${BASE_URL}${path}" local response response="$(curl -sS -w "\n%{http_code}" "$url" "${TEST_HEADERS[@]}")" HTTP_CODE="$(echo "$response" | tail -n1)" RESPONSE_BODY="$(echo "$response" | sed '$d')" log_exchange "$label" "GET" "$url" "" "$HTTP_CODE" "$RESPONSE_BODY" if ! require_status "$HTTP_CODE" "${expected_statuses[@]}"; then log_error "${label} failed: expected HTTP ${expected_statuses[*]}, got HTTP ${HTTP_CODE}" return 1 fi } # run_metrics_check verifies the local API is reachable before the longer # batch-processing steps begin. run_metrics_check() { local url="${BASE_URL}/metrics" local body body="$(curl -sS "$url")" log_exchange "Check service health" "GET" "$url" "" "200" "$body" } # file_size_bytes returns the size of a file in bytes on macOS or Linux. file_size_bytes() { local path="$1" stat -f%z "$path" 2>/dev/null || stat -c%s "$path" } # cleanup removes temp artifacts created by this script. cleanup() { if [ -n "${TMP_DIR}" ] && [ -d "${TMP_DIR}" ]; then rm -rf "${TMP_DIR}" fi } # require_commands fails fast when a required local tool is missing. require_commands() { local cmd for cmd in curl python3 zip docker; do if ! command -v "$cmd" >/dev/null 2>&1; then log_error "Required command not found: ${cmd}" exit 1 fi done } # enable_client_sync mirrors the existing ZIP test script. It flips # clientcansync so uploaded documents are allowed to reach clean_passed. enable_client_sync() { log_step "Enabling client sync for ${CLIENT_ID}" local existing existing="$( docker exec deployments-db-1 psql -U postgres -d query_orchestration -t -c \ "SELECT COUNT(*) FROM clientcansync WHERE clientid='${CLIENT_ID}';" 2>/dev/null | tr -d ' ' )" if [ "${existing}" = "0" ]; then docker exec deployments-db-1 psql -U postgres -d query_orchestration -c \ "INSERT INTO clientcansync (clientid, cansync) VALUES ('${CLIENT_ID}', true);" >/dev/null 2>&1 else docker exec deployments-db-1 psql -U postgres -d query_orchestration -c \ "UPDATE clientcansync SET cansync = true WHERE clientid='${CLIENT_ID}';" >/dev/null 2>&1 fi log_info "Client sync enabled" } # create_pdf_zip builds a ZIP rooted at metadata.test/ using repo sample PDFs. create_pdf_zip() { log_step "Creating PDF ZIP payload" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/test_custom_metadata.XXXXXX")" mkdir -p "${TMP_DIR}/${FOLDER_NAME}" cp "${SCRIPT_DIR}/test.pdf.batch/test.pdf" "${TMP_DIR}/${FOLDER_NAME}/test_document.pdf" cp "${SCRIPT_DIR}/test.pdf.batch/DURABOOK.pdf" "${TMP_DIR}/${FOLDER_NAME}/reference_manual.pdf" cp "${SCRIPT_DIR}/test.pdf.batch/georges.greek.pdf" "${TMP_DIR}/${FOLDER_NAME}/greek_sample.pdf" ZIP_FILE="${TMP_DIR}/custom_metadata_upload.zip" ( cd "${TMP_DIR}" zip -q -r "${ZIP_FILE}" "${FOLDER_NAME}" ) local file_size file_size="$(file_size_bytes "${ZIP_FILE}")" local zip_preview zip_preview="$(ZIP_FILE="${ZIP_FILE}" python3 <<'PY' import os import zipfile zip_path = os.environ["ZIP_FILE"] with zipfile.ZipFile(zip_path, "r") as zf: names = zf.namelist() print(f"zip={zip_path}, bytes={os.path.getsize(zip_path)}, entries={names}") PY )" log_info "Created ZIP (${file_size} bytes)" log_info "$(truncate_preview "${zip_preview}")" } # build_schema_definition_json returns the JSON Schema definition used for # schema.test.1. It covers supported scalar, object, and typed-array fields. build_schema_definition_json() { cat <<'JSON' { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, "required": [ "document_title", "status", "review_date", "created_timestamp", "contact_email", "reference_uri", "external_uuid", "page_count", "confidence_score", "is_verified", "contact_info", "tags", "review_scores", "priority_codes", "check_flags", "related_contacts" ], "properties": { "document_title": { "type": "string", "minLength": 1, "maxLength": 200 }, "status": { "type": "string", "enum": ["draft", "review", "approved", "rejected"] }, "review_date": { "type": "string", "format": "date" }, "created_timestamp": { "type": "string", "format": "date-time" }, "contact_email": { "type": "string", "format": "email" }, "reference_uri": { "type": "string", "format": "uri" }, "external_uuid": { "type": "string", "format": "uuid" }, "page_count": { "type": "integer", "minimum": 1, "maximum": 10000 }, "confidence_score": { "type": "number", "minimum": 0, "maximum": 1 }, "is_verified": { "type": "boolean" }, "contact_info": { "type": "object", "additionalProperties": false, "required": ["name", "email", "phone"], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 200 }, "email": { "type": "string", "format": "email" }, "phone": { "type": "string", "pattern": "^\\+?[0-9\\-\\s()]+$" } } }, "tags": { "type": "array", "items": { "type": "string", "maxLength": 50 }, "minItems": 1, "maxItems": 10 }, "review_scores": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 1 }, "minItems": 1, "maxItems": 10 }, "priority_codes": { "type": "array", "items": { "type": "integer", "minimum": 1, "maximum": 10 }, "minItems": 1, "maxItems": 10 }, "check_flags": { "type": "array", "items": { "type": "boolean" }, "minItems": 1, "maxItems": 10 }, "related_contacts": { "type": "array", "minItems": 1, "maxItems": 10, "items": { "type": "object", "additionalProperties": false, "required": ["name", "email", "phone"], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 200 }, "email": { "type": "string", "format": "email" }, "phone": { "type": "string", "pattern": "^\\+?[0-9\\-\\s()]+$" } } } } } } JSON } # build_valid_metadata_json returns a metadata payload that should satisfy # schema.test.1 exactly. build_valid_metadata_json() { cat <<'JSON' { "document_title": "Custom metadata manual test document", "status": "approved", "review_date": "2026-04-15", "created_timestamp": "2026-04-15T12:34:56Z", "contact_email": "qa.metadata@example.com", "reference_uri": "https://example.com/metadata/test", "external_uuid": "123e4567-e89b-42d3-a456-426614174000", "page_count": 12, "confidence_score": 0.98, "is_verified": true, "contact_info": { "name": "Q Metadata Reviewer", "email": "reviewer@example.com", "phone": "+1-555-0100" }, "tags": ["manual", "custom-metadata"], "review_scores": [0.98, 0.93], "priority_codes": [1, 3, 5], "check_flags": [true, false, true], "related_contacts": [ { "name": "Backup Reviewer", "email": "backup@example.com", "phone": "+1-555-0101" } ] } JSON } # build_invalid_metadata_json returns a payload with multiple deliberate # schema violations so the validation error is easy to inspect manually. build_invalid_metadata_json() { cat <<'JSON' { "document_title": "", "status": "not-a-real-status", "review_date": "15-04-2026", "created_timestamp": "not-a-timestamp", "contact_email": "not-an-email", "reference_uri": "not-a-uri", "external_uuid": "not-a-uuid", "page_count": "twelve", "confidence_score": 2.5, "is_verified": "yes", "contact_info": { "name": 123, "email": "still-not-an-email", "phone": false }, "tags": [1, 2], "review_scores": ["high"], "priority_codes": ["urgent"], "check_flags": ["true"], "related_contacts": [ { "name": 42, "email": "bad", "phone": true } ] } JSON } # wrap_schema_payload embeds the schema definition into the create-schema request. wrap_schema_payload() { local schema_definition_json="$1" SCHEMA_DEFINITION_JSON="${schema_definition_json}" \ CLIENT_ID="${CLIENT_ID}" \ SCHEMA_NAME="${SCHEMA_NAME}" \ SCHEMA_DESCRIPTION="${SCHEMA_DESCRIPTION}" \ python3 <<'PY' import json import os payload = { "clientId": os.environ["CLIENT_ID"], "name": os.environ["SCHEMA_NAME"], "description": os.environ["SCHEMA_DESCRIPTION"], "schema": json.loads(os.environ["SCHEMA_DEFINITION_JSON"]), } print(json.dumps(payload, separators=(",", ":"))) PY } # wrap_metadata_payload embeds a metadata object into the POST /custom-metadata request. wrap_metadata_payload() { local metadata_json="$1" DOCUMENT_ID="${DOCUMENT_ID}" \ METADATA_JSON="${metadata_json}" \ python3 <<'PY' import json import os payload = { "documentId": os.environ["DOCUMENT_ID"], "metadata": json.loads(os.environ["METADATA_JSON"]), } print(json.dumps(payload, separators=(",", ":"))) PY } # extract_json_field prints one top-level field from the most recent response body. extract_json_field() { local field_name="$1" RESPONSE_BODY="${RESPONSE_BODY}" FIELD_NAME="${field_name}" python3 <<'PY' import json import os data = json.loads(os.environ["RESPONSE_BODY"]) value = data[os.environ["FIELD_NAME"]] print(value) PY } # upload_zip_batch sends the generated archive to the batch-upload endpoint. upload_zip_batch() { log_step "Uploading ZIP batch" local url="${BASE_URL}/client/${CLIENT_ID}/document/batch" local request_preview local response request_preview="$( ZIP_FILE="${ZIP_FILE}" python3 <<'PY' import os import zipfile zip_path = os.environ["ZIP_FILE"] with zipfile.ZipFile(zip_path, "r") as zf: names = zf.namelist() print(f"archive={zip_path}, bytes={os.path.getsize(zip_path)}, entries={names}") PY )" response="$(curl -sS -w "\n%{http_code}" -X POST "$url" \ "${TEST_HEADERS[@]}" \ -F "archive=@${ZIP_FILE}")" HTTP_CODE="$(echo "$response" | tail -n1)" RESPONSE_BODY="$(echo "$response" | sed '$d')" log_exchange "Upload PDF ZIP batch" "POST" "$url" "$request_preview" "$HTTP_CODE" "$RESPONSE_BODY" if [ "$HTTP_CODE" != "202" ]; then log_error "Upload batch failed: expected HTTP 202, got HTTP ${HTTP_CODE}" return 1 fi BATCH_ID="$(extract_json_field "batch_id")" if [ -z "${BATCH_ID}" ]; then log_error "Batch upload response did not include batch_id" return 1 fi log_info "Batch created: ${BATCH_ID}" } # wait_for_batch_completion polls until the batch reaches completed and all # documents settle into clean_passed. wait_for_batch_completion() { log_step "Waiting for batch completion" local max_wait=240 local interval=5 local elapsed=0 local status="" local last_progress="" while [ "${elapsed}" -lt "${max_wait}" ]; do local response response="$(curl -sS -w "\n%{http_code}" \ "${BASE_URL}/client/${CLIENT_ID}/document/batch/${BATCH_ID}" \ "${TEST_HEADERS[@]}")" local http_code local body http_code="$(echo "$response" | tail -n1)" body="$(echo "$response" | sed '$d')" if [ "${http_code}" != "200" ]; then log_exchange "Poll batch status" "GET" "${BASE_URL}/client/${CLIENT_ID}/document/batch/${BATCH_ID}" "" "${http_code}" "${body}" log_error "Batch status poll failed" return 1 fi local current current="$( BATCH_BODY="${body}" python3 <<'PY' import json import os data = json.loads(os.environ["BATCH_BODY"]) outcomes = data.get("document_outcomes", []) clean_passed = sum(1 for item in outcomes if item.get("outcome") == "clean_passed") print(f"{data.get('status','unknown')}|{clean_passed}/{len(outcomes)}") PY )" status="${current%%|*}" local progress="${current#*|}" if [ "${progress}" != "${last_progress}" ]; then log_info "Batch progress: status=${status}, clean_passed=${progress}, elapsed=${elapsed}s" last_progress="${progress}" fi if [ "${status}" = "completed" ]; then HTTP_CODE="200" RESPONSE_BODY="${body}" log_exchange "Fetch final batch status" "GET" "${BASE_URL}/client/${CLIENT_ID}/document/batch/${BATCH_ID}" "" "${HTTP_CODE}" "${RESPONSE_BODY}" return 0 fi sleep "${interval}" elapsed=$((elapsed + interval)) done log_error "Timed out waiting for batch completion" return 1 } # assert_batch_success_and_capture_document validates the final batch # status and records the document_id for metadata.test/test_document.pdf. assert_batch_success_and_capture_document() { DOCUMENT_ID="$( RESPONSE_BODY="${RESPONSE_BODY}" \ TARGET_FILENAME="${TARGET_FILENAME}" \ TOTAL_DOCUMENTS="${TOTAL_DOCUMENTS}" \ python3 <<'PY' import json import os import sys data = json.loads(os.environ["RESPONSE_BODY"]) target = os.environ["TARGET_FILENAME"] expected_total = int(os.environ["TOTAL_DOCUMENTS"]) errors = [] if data.get("status") != "completed": errors.append(f"status={data.get('status')!r}, expected 'completed'") if data.get("total_documents") != expected_total: errors.append( f"total_documents={data.get('total_documents')!r}, expected {expected_total}" ) outcomes = data.get("document_outcomes", []) if len(outcomes) != expected_total: errors.append(f"document_outcomes length={len(outcomes)}, expected {expected_total}") target_doc_id = "" for outcome in outcomes: if outcome.get("outcome") != "clean_passed": errors.append( f"{outcome.get('filename')}: outcome={outcome.get('outcome')}, " f"detail={outcome.get('error_detail') or outcome.get('clean_fail_reason') or 'none'}" ) if outcome.get("filename") == target: target_doc_id = outcome.get("document_id") or "" if not target_doc_id: errors.append(f"target document_id missing for {target}") if errors: for error in errors: print(error, file=sys.stderr) sys.exit(1) print(target_doc_id) PY )" if [ -z "${DOCUMENT_ID}" ]; then log_error "Target document_id was not captured from batch status" return 1 fi log_info "Selected document for schema assignment: ${DOCUMENT_ID}" } # assert_created_schema_response verifies the create-schema response shape. assert_created_schema_response() { RESPONSE_BODY="${RESPONSE_BODY}" \ CLIENT_ID="${CLIENT_ID}" \ SCHEMA_NAME="${SCHEMA_NAME}" \ TEST_USER_SUBJECT="${TEST_USER_SUBJECT}" \ python3 <<'PY' import json import os import sys resp = json.loads(os.environ["RESPONSE_BODY"]) errors = [] if resp.get("clientId") != os.environ["CLIENT_ID"]: errors.append("clientId mismatch") if resp.get("name") != os.environ["SCHEMA_NAME"]: errors.append("schema name mismatch") if resp.get("status") != "active": errors.append(f"status={resp.get('status')!r}, expected 'active'") if resp.get("version") != 1: errors.append(f"version={resp.get('version')!r}, expected 1") if resp.get("createdBy") != os.environ["TEST_USER_SUBJECT"]: errors.append("createdBy mismatch") if errors: for error in errors: print(error, file=sys.stderr) sys.exit(1) PY } # assert_schema_list verifies that GET /super-admin/custom-schemas returns # schema.test.1 with the created schema id and active version metadata. assert_schema_list() { RESPONSE_BODY="${RESPONSE_BODY}" \ CLIENT_ID="${CLIENT_ID}" \ SCHEMA_NAME="${SCHEMA_NAME}" \ SCHEMA_ID="${SCHEMA_ID}" \ TEST_USER_SUBJECT="${TEST_USER_SUBJECT}" \ python3 <<'PY' import json import os import sys resp = json.loads(os.environ["RESPONSE_BODY"]) schemas = resp.get("schemas", []) errors = [] matches = [ item for item in schemas if item.get("id") == os.environ["SCHEMA_ID"] ] if len(matches) != 1: errors.append(f"expected exactly one schema row for id={os.environ['SCHEMA_ID']}, got {len(matches)}") else: row = matches[0] if row.get("clientId") != os.environ["CLIENT_ID"]: errors.append("list clientId mismatch") if row.get("name") != os.environ["SCHEMA_NAME"]: errors.append("list schema name mismatch") if row.get("status") != "active": errors.append(f"list status={row.get('status')!r}, expected 'active'") if row.get("version") != 1: errors.append(f"list version={row.get('version')!r}, expected 1") if row.get("createdBy") != os.environ["TEST_USER_SUBJECT"]: errors.append("list createdBy mismatch") if errors: for error in errors: print(error, file=sys.stderr) sys.exit(1) PY } # assert_schema_assignment checks the PATCH /schema response. assert_schema_assignment() { RESPONSE_BODY="${RESPONSE_BODY}" \ DOCUMENT_ID="${DOCUMENT_ID}" \ SCHEMA_ID="${SCHEMA_ID}" \ SCHEMA_NAME="${SCHEMA_NAME}" \ python3 <<'PY' import json import os import sys resp = json.loads(os.environ["RESPONSE_BODY"]) errors = [] if resp.get("documentId") != os.environ["DOCUMENT_ID"]: errors.append("documentId mismatch") if resp.get("customSchemaId") != os.environ["SCHEMA_ID"]: errors.append("customSchemaId mismatch") if resp.get("schemaName") != os.environ["SCHEMA_NAME"]: errors.append("schemaName mismatch") if resp.get("schemaVersion") != 1: errors.append(f"schemaVersion={resp.get('schemaVersion')!r}, expected 1") if errors: for error in errors: print(error, file=sys.stderr) sys.exit(1) PY } # assert_invalid_metadata_error checks that the server returned a 400 and # surfaced the metadata validation failure rather than accepting the write. assert_invalid_metadata_error() { RESPONSE_BODY="${RESPONSE_BODY}" python3 <<'PY' import json import os import sys body = os.environ["RESPONSE_BODY"] message = body try: parsed = json.loads(body) if isinstance(parsed, dict) and "message" in parsed: message = str(parsed["message"]) except json.JSONDecodeError: pass lower = message.lower() required = ["validate metadata", "metadata does not conform to schema"] missing = [fragment for fragment in required if fragment not in lower] if missing: print( "invalid metadata response did not include required validation fragments: " + ", ".join(missing), file=sys.stderr, ) sys.exit(1) PY } # assert_valid_metadata_response confirms the accepted metadata response # contains the expected schema/document/version fields. assert_valid_metadata_response() { RESPONSE_BODY="${RESPONSE_BODY}" \ VALID_METADATA_JSON="${VALID_METADATA_JSON}" \ DOCUMENT_ID="${DOCUMENT_ID}" \ SCHEMA_ID="${SCHEMA_ID}" \ SCHEMA_NAME="${SCHEMA_NAME}" \ TEST_USER_SUBJECT="${TEST_USER_SUBJECT}" \ python3 <<'PY' import json import os import sys resp = json.loads(os.environ["RESPONSE_BODY"]) expected = json.loads(os.environ["VALID_METADATA_JSON"]) errors = [] if resp.get("documentId") != os.environ["DOCUMENT_ID"]: errors.append("POST metadata documentId mismatch") if resp.get("schemaId") != os.environ["SCHEMA_ID"]: errors.append("POST metadata schemaId mismatch") if resp.get("schemaName") != os.environ["SCHEMA_NAME"]: errors.append("POST metadata schemaName mismatch") if resp.get("schemaVersion") != 1: errors.append(f"POST metadata schemaVersion={resp.get('schemaVersion')!r}, expected 1") if resp.get("version") != 1: errors.append(f"POST metadata version={resp.get('version')!r}, expected 1") if resp.get("createdBy") != os.environ["TEST_USER_SUBJECT"]: errors.append("POST metadata createdBy mismatch") if resp.get("metadata") != expected: errors.append("POST metadata payload mismatch") if errors: for error in errors: print(error, file=sys.stderr) sys.exit(1) PY } # assert_retrieved_metadata_matches compares GET /custom-metadata to the # original valid payload to prove the stored JSON round-trips unchanged. assert_retrieved_metadata_matches() { RESPONSE_BODY="${RESPONSE_BODY}" \ VALID_METADATA_JSON="${VALID_METADATA_JSON}" \ DOCUMENT_ID="${DOCUMENT_ID}" \ SCHEMA_ID="${SCHEMA_ID}" \ SCHEMA_NAME="${SCHEMA_NAME}" \ TEST_USER_SUBJECT="${TEST_USER_SUBJECT}" \ python3 <<'PY' import json import os import sys resp = json.loads(os.environ["RESPONSE_BODY"]) expected = json.loads(os.environ["VALID_METADATA_JSON"]) errors = [] if resp.get("documentId") != os.environ["DOCUMENT_ID"]: errors.append("GET metadata documentId mismatch") if resp.get("schemaId") != os.environ["SCHEMA_ID"]: errors.append("GET metadata schemaId mismatch") if resp.get("schemaName") != os.environ["SCHEMA_NAME"]: errors.append("GET metadata schemaName mismatch") if resp.get("schemaVersion") != 1: errors.append(f"GET metadata schemaVersion={resp.get('schemaVersion')!r}, expected 1") if resp.get("version") != 1: errors.append(f"GET metadata version={resp.get('version')!r}, expected 1") if resp.get("createdBy") != os.environ["TEST_USER_SUBJECT"]: errors.append("GET metadata createdBy mismatch") if resp.get("metadata") != expected: errors.append("retrieved metadata does not match source metadata") if errors: for error in errors: print(error, file=sys.stderr) sys.exit(1) PY } # parse_args processes command-line flags. Recognized flags: # -v | --verbose enable verbose request/response logging parse_args() { for arg in "$@"; do case "$arg" in -v|--verbose) VERBOSE=true ;; *) log_error "Unknown argument: ${arg}" exit 1 ;; esac done } main() { parse_args "$@" trap cleanup EXIT log_step "Starting custom metadata integration test" log_info "Base URL: ${BASE_URL}" log_info "Client ID: ${CLIENT_ID}" log_info "Schema name: ${SCHEMA_NAME}" log_info "Injected test subject: ${TEST_USER_SUBJECT}" require_commands run_metrics_check create_pdf_zip local create_client_payload create_client_payload="$(CLIENT_ID="${CLIENT_ID}" CLIENT_NAME="${CLIENT_NAME}" python3 <<'PY' import json import os payload = { "id": os.environ["CLIENT_ID"], "name": os.environ["CLIENT_NAME"], } print(json.dumps(payload, separators=(",", ":"))) PY )" run_json_request "Create fresh client" "POST" "/client" "${create_client_payload}" "201" enable_client_sync upload_zip_batch wait_for_batch_completion assert_batch_success_and_capture_document local schema_definition_json schema_definition_json="$(build_schema_definition_json)" local create_schema_payload create_schema_payload="$(wrap_schema_payload "${schema_definition_json}")" run_json_request "Create custom schema" "POST" "/super-admin/custom-schemas" "${create_schema_payload}" "201" assert_created_schema_response SCHEMA_ID="$(extract_json_field "id")" log_info "Created schema: ${SCHEMA_ID}" run_get_request \ "List custom schemas" \ "/super-admin/custom-schemas?clientId=${CLIENT_ID}&name=${SCHEMA_NAME}&includeAllVersions=true&status=any" \ "200" assert_schema_list local assign_payload assign_payload="$(SCHEMA_ID="${SCHEMA_ID}" python3 <<'PY' import json import os payload = {"customSchemaId": os.environ["SCHEMA_ID"]} print(json.dumps(payload, separators=(",", ":"))) PY )" run_json_request \ "Assign schema to uploaded document" \ "PATCH" \ "/super-admin/documents/${DOCUMENT_ID}/schema" \ "${assign_payload}" \ "200" assert_schema_assignment local invalid_metadata_json invalid_metadata_json="$(build_invalid_metadata_json)" local invalid_metadata_payload invalid_metadata_payload="$(wrap_metadata_payload "${invalid_metadata_json}")" run_json_request \ "Reject invalid custom metadata" \ "POST" \ "/custom-metadata" \ "${invalid_metadata_payload}" \ "400" assert_invalid_metadata_error VALID_METADATA_JSON="$(build_valid_metadata_json)" local valid_metadata_payload valid_metadata_payload="$(wrap_metadata_payload "${VALID_METADATA_JSON}")" run_json_request \ "Accept valid custom metadata" \ "POST" \ "/custom-metadata" \ "${valid_metadata_payload}" \ "201" assert_valid_metadata_response run_get_request \ "Retrieve current custom metadata" \ "/custom-metadata?documentId=${DOCUMENT_ID}" \ "200" assert_retrieved_metadata_matches log_info "TEST PASSED: custom metadata integration flow completed successfully in ${SECONDS}s" } main "$@"