Files
svg-backend/backend/scripts/smoke_common.sh
greebo 54b36ba76c chore(backend): finalize backend baseline and frontend handoff contract
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
2026-03-20 16:46:24 +03:00

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}"
}