17fc813823
M1, M2 and M3 complete * M1, M2 and M3 complete * review changes * docs * docs
1081 lines
30 KiB
Bash
Executable File
1081 lines
30 KiB
Bash
Executable File
#!/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 "$@"
|