freeze the current backend contract for frontend integration document the stabilized backend surface and handoff expectations mark the current state as the baseline for further frontend work
501 lines
12 KiB
Bash
501 lines
12 KiB
Bash
#!/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
|
|
}
|
|
|
|
request_with_api_key() {
|
|
local api_key="$1"
|
|
local name="$2"
|
|
local method="$3"
|
|
local url="$4"
|
|
local expected_status="$5"
|
|
local body="${6:-}"
|
|
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
|
|
}
|
|
|
|
request_without_api_key() {
|
|
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 "Content-Type: application/json" \
|
|
-o "${out_file}" \
|
|
-w "%{http_code}" \
|
|
"${url}" \
|
|
--data "${body}" > "${status_file}"
|
|
else
|
|
curl -sS \
|
|
-X "${method}" \
|
|
-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
|
|
}
|
|
|
|
upload_file_expect_status() {
|
|
local name="$1"
|
|
local file_path="$2"
|
|
local upload_filename="$3"
|
|
local content_type="$4"
|
|
local expected_status="$5"
|
|
local out_file="${TMP_DIR}/${name}.body"
|
|
local status_file="${TMP_DIR}/${name}.status"
|
|
|
|
if [[ ! -f "${file_path}" ]]; then
|
|
fail "Upload fixture file not found: ${file_path}"
|
|
fi
|
|
|
|
echo
|
|
echo "===== ${name} ====="
|
|
|
|
curl -sS \
|
|
-X POST \
|
|
-H "X-API-Key: ${API_KEY}" \
|
|
-o "${out_file}" \
|
|
-w "%{http_code}" \
|
|
-F "file=@${file_path};filename=${upload_filename};type=${content_type}" \
|
|
"${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}" != "${expected_status}" ]]; then
|
|
fail "Upload failed for ${upload_filename}: expected ${expected_status}, 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}"
|
|
}
|