#!/usr/bin/env bash SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" API_URL="${API_URL:-http://127.0.0.1:9020}" API_KEY="${API_KEY:-admin-local-dev-key}" FIXTURE_SVG_PATH="${FIXTURE_SVG_PATH:-${REPO_ROOT}/sample-contract.svg}" HEALTH_MAX_ATTEMPTS="${HEALTH_MAX_ATTEMPTS:-20}" HEALTH_RETRY_DELAY_SECONDS="${HEALTH_RETRY_DELAY_SECONDS:-1}" log() { echo echo "===== $* =====" } fail() { echo echo "[FAIL] $*" >&2 exit 1 } require_fixture_svg() { if [[ ! -f "${FIXTURE_SVG_PATH}" ]]; then fail "Fixture SVG not found: ${FIXTURE_SVG_PATH}" fi } wait_for_health() { log "health" echo "waiting for API to be ready..." local health_ready="false" local health_status="" for ((i = 1; i <= HEALTH_MAX_ATTEMPTS; i++)); do health_status="$(curl -sS -o /dev/null -w "%{http_code}" "${API_URL}/healthz" || true)" if [[ "${health_status}" == "200" ]]; then health_ready="true" echo "API is ready" break fi echo "waiting... (${i}/${HEALTH_MAX_ATTEMPTS}) healthz=${health_status}" sleep "${HEALTH_RETRY_DELAY_SECONDS}" done if [[ "${health_ready}" != "true" ]]; then fail "API did not become ready on ${API_URL}/healthz after ${HEALTH_MAX_ATTEMPTS} attempts" fi curl -sS -i "${API_URL}/healthz" } request() { local name="$1" local method="$2" local url="$3" local expected_status="$4" local body="${5:-}" local out_file="${TMP_DIR}/${name}.body" local status_file="${TMP_DIR}/${name}.status" echo echo "===== ${name} =====" if [[ -n "${body}" ]]; then curl -sS \ -X "${method}" \ -H "X-API-Key: ${API_KEY}" \ -H "Content-Type: application/json" \ -o "${out_file}" \ -w "%{http_code}" \ "${url}" \ --data "${body}" > "${status_file}" else curl -sS \ -X "${method}" \ -H "X-API-Key: ${API_KEY}" \ -o "${out_file}" \ -w "%{http_code}" \ "${url}" > "${status_file}" fi local actual_status actual_status="$(python3 - "$status_file" <<'PY' from pathlib import Path import sys print(Path(sys.argv[1]).read_text(encoding="utf-8").strip()) PY )" echo "[${method}] ${url} -> ${actual_status}" python3 - "$out_file" <<'PY' from pathlib import Path import sys print(Path(sys.argv[1]).read_text(encoding="utf-8")) PY echo if [[ "${actual_status}" != "${expected_status}" ]]; then fail "Unexpected HTTP status for ${name}: expected ${expected_status}, got ${actual_status}" fi } upload_svg() { local name="$1" local upload_filename="$2" local out_file="${TMP_DIR}/${name}.body" local status_file="${TMP_DIR}/${name}.status" require_fixture_svg echo echo "===== ${name} =====" curl -sS \ -X POST \ -H "X-API-Key: ${API_KEY}" \ -o "${out_file}" \ -w "%{http_code}" \ -F "file=@${FIXTURE_SVG_PATH};filename=${upload_filename};type=image/svg+xml" \ "${API_URL}/api/v1/schemes/upload" > "${status_file}" local actual_status actual_status="$(python3 - "$status_file" <<'PY' from pathlib import Path import sys print(Path(sys.argv[1]).read_text(encoding="utf-8").strip()) PY )" echo "[POST] ${API_URL}/api/v1/schemes/upload -> ${actual_status}" python3 - "$out_file" <<'PY' from pathlib import Path import sys print(Path(sys.argv[1]).read_text(encoding="utf-8")) PY echo if [[ "${actual_status}" != "200" ]]; then fail "Upload failed for ${upload_filename}: expected 200, got ${actual_status}" fi } json_get() { local file="$1" local expr="$2" python3 - "$file" "$expr" <<'PY' import json import re import sys from pathlib import Path data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) expr = sys.argv[2] def apply_selector(value, selector): if value is None: return None if selector == "LAST": return value[-1] if value else None if selector.isdigit(): idx = int(selector) return value[idx] if len(value) > idx else None match = re.fullmatch(r"([^!=]+?)(==|!=)(.+)", selector) if not match: return value[0] if value else None key, op, raw_expected = match.groups() key = key.strip() raw_expected = raw_expected.strip() if raw_expected == "null": expected = None else: expected = raw_expected for item in value: if not isinstance(item, dict): continue item_value = item.get(key) matched = item_value == expected if op == "==" else item_value != expected if matched: return item return None value = data for part in expr.split("."): if not part: continue if part.startswith("[") and part.endswith("]"): value = apply_selector(value, part[1:-1]) elif part.isdigit(): idx = int(part) value = value[idx] if value is not None and len(value) > idx else None elif isinstance(value, dict): value = value.get(part) else: value = None if isinstance(value, bool): print("true" if value else "false") elif value is None: print("") elif isinstance(value, (dict, list)): print(json.dumps(value, ensure_ascii=False)) else: print(value) PY } json_len() { local file="$1" local expr="$2" python3 - "$file" "$expr" <<'PY' import json import sys from pathlib import Path data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) expr = sys.argv[2] value = data for part in expr.split("."): if not part: continue if part.isdigit(): value = value[int(part)] else: value = value.get(part) if isinstance(value, dict) else None if value is None: print(0) elif isinstance(value, (list, dict, str)): print(len(value)) else: print(0) PY } assert_json_eq() { local file="$1" local expr="$2" local expected="$3" local actual actual="$(json_get "${file}" "${expr}")" if [[ "${actual}" != "${expected}" ]]; then fail "${expr}: expected '${expected}', got '${actual}'" fi echo "[OK] ${expr}=${actual}" } assert_json_int_eq() { local file="$1" local expr="$2" local expected="$3" local actual actual="$(json_get "${file}" "${expr}")" if ! [[ "${actual}" =~ ^[0-9]+$ ]]; then fail "${expr}: expected integer, got '${actual}'" fi if (( actual != expected )); then fail "${expr}: expected ${expected}, got ${actual}" fi echo "[OK] ${expr}=${actual}" } assert_json_int_gt() { local file="$1" local expr="$2" local threshold="$3" local actual actual="$(json_get "${file}" "${expr}")" if ! [[ "${actual}" =~ ^[0-9]+$ ]]; then fail "${expr}: expected integer, got '${actual}'" fi if (( actual <= threshold )); then fail "${expr}: expected > ${threshold}, got ${actual}" fi echo "[OK] ${expr}=${actual} (> ${threshold})" } assert_json_int_ge() { local file="$1" local expr="$2" local threshold="$3" local actual actual="$(json_get "${file}" "${expr}")" if ! [[ "${actual}" =~ ^[0-9]+$ ]]; then fail "${expr}: expected integer, got '${actual}'" fi if (( actual < threshold )); then fail "${expr}: expected >= ${threshold}, got ${actual}" fi echo "[OK] ${expr}=${actual} (>= ${threshold})" } assert_json_len_eq() { local file="$1" local expr="$2" local expected="$3" local actual actual="$(json_len "${file}" "${expr}")" if (( actual != expected )); then fail "len(${expr}): expected ${expected}, got ${actual}" fi echo "[OK] len(${expr})=${actual}" } assert_file_contains() { local file="$1" local needle="$2" if ! python3 - "$file" "$needle" <<'PY' from pathlib import Path import sys haystack = Path(sys.argv[1]).read_text(encoding="utf-8") needle = sys.argv[2] if needle not in haystack: raise SystemExit(1) PY then fail "Expected '${needle}' in ${file}" fi echo "[OK] found '${needle}'" } create_fresh_scheme_from_upload() { local scenario_prefix="$1" local stamp stamp="$(date +%s)-$$" FRESH_SCHEME_NAME="${scenario_prefix}-${stamp}" local upload_filename="${FRESH_SCHEME_NAME}.svg" upload_svg "upload_${scenario_prefix}" "${upload_filename}" request "schemes_after_upload_${scenario_prefix}" "GET" "${API_URL}/api/v1/schemes?limit=200&offset=0" "200" if ! SCHEME_ID="$(python3 - "${TMP_DIR}/schemes_after_upload_${scenario_prefix}.body" "${FRESH_SCHEME_NAME}" <<'PY' import json import sys from pathlib import Path payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) target_name = sys.argv[2] for item in payload.get("items", []): if item.get("name") == target_name: print(item["scheme_id"]) raise SystemExit(0) raise SystemExit(1) PY )"; then fail "Unable to resolve uploaded scheme_id for ${FRESH_SCHEME_NAME}" fi echo "FRESH_SCHEME_NAME=${FRESH_SCHEME_NAME}" echo "FRESH_SCHEME_ID=${SCHEME_ID}" }