#!/usr/bin/env bash set -euo pipefail API_URL="${API_URL:-http://127.0.0.1:9020}" API_KEY="${API_KEY:-admin-local-dev-key}" SCHEME_ID="${SCHEME_ID:-82086336d385427f9d56244f9e1dd772}" TMP_DIR="$(mktemp -d)" trap 'rm -rf "${TMP_DIR}"' EXIT HEALTH_MAX_ATTEMPTS="${HEALTH_MAX_ATTEMPTS:-20}" HEALTH_RETRY_DELAY_SECONDS="${HEALTH_RETRY_DELAY_SECONDS:-1}" 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="$(cat "${status_file}")" echo "[${method}] ${url} -> ${actual_status}" cat "${out_file}" echo if [[ "${actual_status}" != "${expected_status}" ]]; then echo "[FAIL] Unexpected HTTP status for ${name}: expected ${expected_status}, got ${actual_status}" >&2 exit 1 fi } json_get() { local file="$1" local expr="$2" python3 - "$file" "$expr" <<'PY' import json import sys path = sys.argv[2].split(".") value = json.load(open(sys.argv[1], "r", encoding="utf-8")) for part in path: if not part: continue if part.isdigit(): value = value[int(part)] else: value = value[part] if isinstance(value, bool): print("true" if value else "false") elif value is None: print("null") else: print(value) PY } assert_json_eq() { local file="$1" local expr="$2" local expected="$3" local actual actual="$(json_get "${file}" "${expr}")" if [[ "${actual}" != "${expected}" ]]; then echo "[FAIL] ${expr}: expected '${expected}', got '${actual}'" >&2 exit 1 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 echo "[FAIL] ${expr}: expected integer, got '${actual}'" >&2 exit 1 fi if (( actual <= threshold )); then echo "[FAIL] ${expr}: expected > ${threshold}, got ${actual}" >&2 exit 1 fi echo "[OK] ${expr}=${actual} (> ${threshold})" } echo "===== health =====" echo "waiting for API to be ready..." health_ready="false" 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 echo "[FAIL] API did not become ready on ${API_URL}/healthz after ${HEALTH_MAX_ATTEMPTS} attempts" >&2 exit 1 fi curl -sS -i "${API_URL}/healthz" request "ping" "GET" "${API_URL}/api/v1/ping" "200" request "db_ping" "GET" "${API_URL}/api/v1/db/ping" "200" request "manifest" "GET" "${API_URL}/api/v1/manifest" "200" request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200" CURRENT_VERSION_ID="$(json_get "${TMP_DIR}/scheme_current.body" "scheme_version_id")" CURRENT_STATUS="$(json_get "${TMP_DIR}/scheme_current.body" "status")" echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}" echo "CURRENT_STATUS=${CURRENT_STATUS}" request "editor_context" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/editor/context" "200" request "ensure_draft" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure" "200" DRAFT_VERSION_ID="$(json_get "${TMP_DIR}/ensure_draft.body" "scheme_version_id")" DRAFT_CREATED="$(json_get "${TMP_DIR}/ensure_draft.body" "created")" echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}" echo "DRAFT_CREATED=${DRAFT_CREATED}" request "draft_summary" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/summary?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" assert_json_eq "${TMP_DIR}/draft_summary.body" "scheme_version_id" "${DRAFT_VERSION_ID}" assert_json_eq "${TMP_DIR}/draft_summary.body" "status" "draft" request "draft_structure" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" assert_json_eq "${TMP_DIR}/draft_structure.body" "scheme_version_id" "${DRAFT_VERSION_ID}" SEAT_RECORD_ID="$(json_get "${TMP_DIR}/draft_structure.body" "seats.0.seat_record_id")" SECTOR_RECORD_ID="$(json_get "${TMP_DIR}/draft_structure.body" "sectors.0.sector_record_id")" GROUP_RECORD_ID="$(json_get "${TMP_DIR}/draft_structure.body" "groups.0.group_record_id")" PRICED_SEAT_ID="$(json_get "${TMP_DIR}/draft_structure.body" "seats.0.seat_id")" UNPRICED_SEAT_ID="$(json_get "${TMP_DIR}/draft_structure.body" "seats.2.seat_id")" echo "SEAT_RECORD_ID=${SEAT_RECORD_ID}" echo "SECTOR_RECORD_ID=${SECTOR_RECORD_ID}" echo "GROUP_RECORD_ID=${GROUP_RECORD_ID}" echo "PRICED_SEAT_ID=${PRICED_SEAT_ID}" echo "UNPRICED_SEAT_ID=${UNPRICED_SEAT_ID}" request "draft_validation" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/validation?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" assert_json_eq "${TMP_DIR}/draft_validation.body" "status" "draft" request "draft_compare_preview" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/compare-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "stale_draft_conflict" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/summary?expected_scheme_version_id=deadbeefdeadbeefdeadbeefdeadbeef" "409" assert_json_eq "${TMP_DIR}/stale_draft_conflict.body" "detail.code" "stale_draft_version" request "draft_seat_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${SEAT_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "draft_sector_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/sectors/records/${SECTOR_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "draft_group_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/groups/records/${GROUP_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "draft_unknown_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/deadbeefdeadbeefdeadbeefdeadbeef" "404" request "current_sectors" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/sectors" "200" request "current_groups" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/groups" "200" request "current_seats" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/seats" "200" request "display_meta" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/svg/display/meta" "200" request "pricing_bundle" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing" "200" request "pricing_coverage" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/coverage" "200" request "pricing_unpriced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/unpriced-seats" "200" request "pricing_explain_priced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/explain/${PRICED_SEAT_ID}" "200" request "pricing_explain_unpriced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/explain/${UNPRICED_SEAT_ID}" "200" request "pricing_rule_diagnostics" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules/diagnostics" "200" request "seat_price" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/seats/${PRICED_SEAT_ID}/price" "200" request "test_mode_priced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/test/seats/${PRICED_SEAT_ID}" "200" request "test_mode_unpriced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/test/seats/${UNPRICED_SEAT_ID}" "200" request "typed_invalid_amount" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules?expected_scheme_version_id=${DRAFT_VERSION_ID}" "422" '{"pricing_category_id":"4ef9e2b78fe0447f9f5db02714c7cad5","target_type":"sector","target_ref":"vip","amount":"bad","currency":"RUB"}' assert_json_eq "${TMP_DIR}/typed_invalid_amount.body" "detail.code" "invalid_amount" request "typed_remap_filter_required" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/remap/preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "422" '{}' assert_json_eq "${TMP_DIR}/typed_remap_filter_required.body" "detail.code" "remap_filter_required" request "draft_pricing_snapshot" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "publish_readiness" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-readiness?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "publish_preview_refresh" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?refresh=true&expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "publish_preview_cached" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "admin_artifacts" "GET" "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/artifacts" "200" request "admin_validation" "GET" "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/validation" "200" request "admin_preview_audit" "GET" "${API_URL}/api/v1/admin/artifacts/publish-preview/audit" "200" request "admin_preview_cleanup_dry_run" "POST" "${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true" "200" request "admin_cleanup_preview" "GET" "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=FAIL_&code_prefix=DIAG_&code_prefix=AUTO_&code_prefix=TYPED_&name_prefix=should-fail-&name_prefix=diag-&name_prefix=auto%20&name_prefix=typed-response-" "200" request "admin_cleanup_dry_run" "POST" "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" "200" '{"code_prefixes":["FAIL_","DIAG_","AUTO_","TYPED_"],"name_prefixes":["should-fail-","diag-","auto ","typed-response-"],"delete_only_without_rules":true,"dry_run":true}' assert_json_eq "${TMP_DIR}/admin_cleanup_dry_run.body" "dry_run" "true" assert_json_eq "${TMP_DIR}/admin_cleanup_dry_run.body" "deleted_count" "0" MATCHED_TOTAL="$(json_get "${TMP_DIR}/admin_cleanup_dry_run.body" "matched_total")" WOULD_DELETE="$(json_get "${TMP_DIR}/admin_cleanup_dry_run.body" "would_delete_count")" if [[ "${MATCHED_TOTAL}" == "0" ]]; then if [[ "${WOULD_DELETE}" != "0" ]]; then echo "[FAIL] would_delete_count expected 0 when matched_total is 0, got ${WOULD_DELETE}" >&2 exit 1 fi echo "[OK] matched_total=0, would_delete_count=0 (clean state)" else assert_json_int_gt "${TMP_DIR}/admin_cleanup_dry_run.body" "would_delete_count" "0" fi request "audit_trail" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/audit" "200" echo echo "===== done =====" echo "[OK] smoke regression completed successfully"