Files
query-orchestration/scripts/manual_tests/test_custom_metadata.1.sh
T

1081 lines
30 KiB
Bash
Raw Normal View History

#!/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("<empty>")
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}" = "<none>" ]; then
echo "${text:-<empty>}"
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 "<none>")
# 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" "<none>" "$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" "<none>" "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}" "<none>" "${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}" "<none>" "${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 "$@"