#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TMP_DIR="$(mktemp -d)" trap 'rm -rf "${TMP_DIR}"' EXIT # shellcheck source=backend/scripts/smoke_common.sh source "${SCRIPT_DIR}/smoke_common.sh" wait_for_health create_fresh_scheme_from_upload "smoke-admin-ops" 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")" echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}" request "ensure_draft" "POST" \ "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure?expected_current_scheme_version_id=${CURRENT_VERSION_ID}" \ "200" DRAFT_VERSION_ID="$(json_get "${TMP_DIR}/ensure_draft.body" "scheme_version_id")" echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}" request "draft_structure" "GET" \ "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${DRAFT_VERSION_ID}" \ "200" TARGET_SEAT_ID="$(python3 - "${TMP_DIR}/draft_structure.body" <<'PY' import json import sys from pathlib import Path payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) seat = next((item for item in payload.get("seats", []) if item.get("seat_id")), None) if seat is None: raise SystemExit("No seat with seat_id found for admin ops smoke") print(seat["seat_id"]) PY )" echo "TARGET_SEAT_ID=${TARGET_SEAT_ID}" STAMP="$(date +%s)-$$" CLEANUP_PREFIX="ADMINOPS_CLEAN_${STAMP}_" DELETE_CATEGORY_NAME="adminops-clean-delete-${STAMP}" DELETE_CATEGORY_CODE="${CLEANUP_PREFIX}DELETE" KEEP_CATEGORY_NAME="adminops-clean-keep-${STAMP}" KEEP_CATEGORY_CODE="${CLEANUP_PREFIX}KEEP" request "create_delete_category" "POST" \ "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/categories?expected_scheme_version_id=${DRAFT_VERSION_ID}" \ "200" \ "{\"name\":\"${DELETE_CATEGORY_NAME}\",\"code\":\"${DELETE_CATEGORY_CODE}\"}" DELETE_CATEGORY_ID="$(json_get "${TMP_DIR}/create_delete_category.body" "pricing_category_id")" echo "DELETE_CATEGORY_ID=${DELETE_CATEGORY_ID}" request "create_keep_category" "POST" \ "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/categories?expected_scheme_version_id=${DRAFT_VERSION_ID}" \ "200" \ "{\"name\":\"${KEEP_CATEGORY_NAME}\",\"code\":\"${KEEP_CATEGORY_CODE}\"}" KEEP_CATEGORY_ID="$(json_get "${TMP_DIR}/create_keep_category.body" "pricing_category_id")" echo "KEEP_CATEGORY_ID=${KEEP_CATEGORY_ID}" request "create_keep_category_rule" "POST" \ "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules?expected_scheme_version_id=${DRAFT_VERSION_ID}" \ "200" \ "{\"pricing_category_id\":\"${KEEP_CATEGORY_ID}\",\"target_type\":\"seat\",\"target_ref\":\"${TARGET_SEAT_ID}\",\"amount\":\"555.00\",\"currency\":\"RUB\"}" KEEP_RULE_ID="$(json_get "${TMP_DIR}/create_keep_category_rule.body" "price_rule_id")" echo "KEEP_RULE_ID=${KEEP_RULE_ID}" 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_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_current_artifacts" "GET" \ "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/artifacts" \ "200" assert_json_eq "${TMP_DIR}/admin_current_artifacts.body" "scheme_version_id" "${DRAFT_VERSION_ID}" assert_json_int_ge "${TMP_DIR}/admin_current_artifacts.body" "total" "4" assert_file_contains "${TMP_DIR}/admin_current_artifacts.body" "\"artifact_type\":\"publish_preview\"" request "admin_current_validation" "GET" \ "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/validation" \ "200" assert_json_eq "${TMP_DIR}/admin_current_validation.body" "scheme_version_id" "${DRAFT_VERSION_ID}" assert_file_contains "${TMP_DIR}/admin_current_validation.body" "\"report\":" request "publish_preview_audit" "GET" \ "${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \ "200" assert_json_eq "${TMP_DIR}/publish_preview_audit.body" "artifact_type" "publish_preview" assert_json_int_ge "${TMP_DIR}/publish_preview_audit.body" "db_rows_count" "1" assert_json_int_ge "${TMP_DIR}/publish_preview_audit.body" "disk_files_count" "1" PRE_CLEANUP_DB_ROWS_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "db_rows_count")" PRE_CLEANUP_DISK_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "disk_files_count")" PRE_CLEANUP_ORPHAN_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "orphan_files_count")" PRE_CLEANUP_MISSING_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "missing_files_for_db_rows_count")" echo "PRE_CLEANUP_DB_ROWS_COUNT=${PRE_CLEANUP_DB_ROWS_COUNT}" echo "PRE_CLEANUP_DISK_FILES_COUNT=${PRE_CLEANUP_DISK_FILES_COUNT}" echo "PRE_CLEANUP_ORPHAN_FILES_COUNT=${PRE_CLEANUP_ORPHAN_FILES_COUNT}" echo "PRE_CLEANUP_MISSING_FILES_COUNT=${PRE_CLEANUP_MISSING_FILES_COUNT}" request "publish_preview_cleanup_dry_run" "POST" \ "${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true" \ "200" assert_json_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "artifact_type" "publish_preview" assert_json_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "dry_run" "true" assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "deleted_files_count" "0" assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "deleted_db_rows_count" "0" assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "orphan_files_count" "${PRE_CLEANUP_ORPHAN_FILES_COUNT}" assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "missing_files_for_db_rows_count" "${PRE_CLEANUP_MISSING_FILES_COUNT}" request "publish_preview_cleanup_execute" "POST" \ "${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=false" \ "200" assert_json_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "artifact_type" "publish_preview" assert_json_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "dry_run" "false" assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "orphan_files_count" "${PRE_CLEANUP_ORPHAN_FILES_COUNT}" assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "missing_files_for_db_rows_count" "${PRE_CLEANUP_MISSING_FILES_COUNT}" assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "deleted_files_count" "${PRE_CLEANUP_ORPHAN_FILES_COUNT}" assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "deleted_db_rows_count" "${PRE_CLEANUP_MISSING_FILES_COUNT}" request "publish_preview_audit_after_cleanup" "GET" \ "${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \ "200" assert_json_eq "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "artifact_type" "publish_preview" assert_json_int_eq "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "orphan_files_count" "0" assert_json_int_eq "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "missing_files_for_db_rows_count" "0" POST_CLEANUP_DB_ROWS_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "db_rows_count")" POST_CLEANUP_DISK_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "disk_files_count")" POST_CLEANUP_ORPHAN_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "orphan_files_count")" POST_CLEANUP_MISSING_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "missing_files_for_db_rows_count")" echo "POST_CLEANUP_DB_ROWS_COUNT=${POST_CLEANUP_DB_ROWS_COUNT}" echo "POST_CLEANUP_DISK_FILES_COUNT=${POST_CLEANUP_DISK_FILES_COUNT}" echo "POST_CLEANUP_ORPHAN_FILES_COUNT=${POST_CLEANUP_ORPHAN_FILES_COUNT}" echo "POST_CLEANUP_MISSING_FILES_COUNT=${POST_CLEANUP_MISSING_FILES_COUNT}" if [[ "${POST_CLEANUP_DB_ROWS_COUNT}" != "${POST_CLEANUP_DISK_FILES_COUNT}" ]]; then fail "publish-preview audit mismatch after cleanup: db_rows_count=${POST_CLEANUP_DB_ROWS_COUNT}, disk_files_count=${POST_CLEANUP_DISK_FILES_COUNT}" fi echo "[OK] publish-preview audit is fully consistent after cleanup" request "pricing_cleanup_preview" "GET" \ "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=${CLEANUP_PREFIX}" \ "200" assert_json_eq "${TMP_DIR}/pricing_cleanup_preview.body" "scheme_id" "${SCHEME_ID}" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview.body" "total_candidates" "2" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview.body" "safe_to_delete_count" "1" python3 - "${TMP_DIR}/pricing_cleanup_preview.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" <<'PY' import json import sys from pathlib import Path payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) delete_category_id = sys.argv[2] keep_category_id = sys.argv[3] items = {item["pricing_category_id"]: item for item in payload.get("items", [])} if delete_category_id not in items: raise SystemExit(f"Delete candidate {delete_category_id} missing from cleanup preview") if keep_category_id not in items: raise SystemExit(f"Protected category {keep_category_id} missing from cleanup preview") if not items[delete_category_id]["deletable"]: raise SystemExit("Delete candidate is expected to be deletable") if items[keep_category_id]["deletable"]: raise SystemExit("Protected category is expected to be skipped because it has rules") PY echo "[OK] pricing cleanup preview matched expected deletable/skipped categories" request "pricing_cleanup_dry_run" "POST" \ "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" \ "200" \ "{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":true}" assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "scheme_id" "${SCHEME_ID}" assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "dry_run" "true" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "matched_total" "2" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "would_delete_count" "1" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "deleted_count" "0" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "skipped_count" "1" python3 - "${TMP_DIR}/pricing_cleanup_dry_run.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" <<'PY' import json import sys from pathlib import Path payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) delete_category_id = sys.argv[2] keep_category_id = sys.argv[3] would_delete_ids = set(payload.get("would_delete_category_ids", [])) if would_delete_ids != {delete_category_id}: raise SystemExit( f"Dry run expected would_delete_category_ids={[delete_category_id]}, got={sorted(would_delete_ids)}" ) skipped_ids = {item["pricing_category_id"] for item in payload.get("skipped", [])} if skipped_ids != {keep_category_id}: raise SystemExit( f"Dry run expected skipped={[keep_category_id]}, got={sorted(skipped_ids)}" ) PY echo "[OK] pricing cleanup dry-run kept protected category and selected only empty fixture category" request "pricing_cleanup_execute" "POST" \ "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" \ "200" \ "{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":false}" assert_json_eq "${TMP_DIR}/pricing_cleanup_execute.body" "scheme_id" "${SCHEME_ID}" assert_json_eq "${TMP_DIR}/pricing_cleanup_execute.body" "dry_run" "false" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "matched_total" "2" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "would_delete_count" "1" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "deleted_count" "1" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "skipped_count" "1" python3 - "${TMP_DIR}/pricing_cleanup_execute.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" <<'PY' import json import sys from pathlib import Path payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) delete_category_id = sys.argv[2] keep_category_id = sys.argv[3] deleted_ids = set(payload.get("deleted_category_ids", [])) if deleted_ids != {delete_category_id}: raise SystemExit( f"Cleanup execute expected deleted_category_ids={[delete_category_id]}, got={sorted(deleted_ids)}" ) skipped_ids = {item["pricing_category_id"] for item in payload.get("skipped", [])} if skipped_ids != {keep_category_id}: raise SystemExit( f"Cleanup execute expected skipped={[keep_category_id]}, got={sorted(skipped_ids)}" ) PY echo "[OK] pricing cleanup execute deleted only safe fixture category" request "pricing_bundle_after_cleanup" "GET" \ "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing" \ "200" assert_json_len_eq "${TMP_DIR}/pricing_bundle_after_cleanup.body" "categories" "1" assert_json_len_eq "${TMP_DIR}/pricing_bundle_after_cleanup.body" "rules" "1" python3 - "${TMP_DIR}/pricing_bundle_after_cleanup.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" "${KEEP_RULE_ID}" <<'PY' import json import sys from pathlib import Path payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) delete_category_id = sys.argv[2] keep_category_id = sys.argv[3] keep_rule_id = sys.argv[4] category_ids = {item["pricing_category_id"] for item in payload.get("categories", [])} rule_ids = {item["price_rule_id"] for item in payload.get("rules", [])} if delete_category_id in category_ids: raise SystemExit("Deleted cleanup category still present in pricing bundle") if keep_category_id not in category_ids: raise SystemExit("Protected cleanup category missing after execute cleanup") if keep_rule_id not in rule_ids: raise SystemExit("Protected pricing rule missing after execute cleanup") PY echo "[OK] pricing bundle reflects destructive cleanup result" request "pricing_cleanup_preview_after_cleanup" "GET" \ "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=${CLEANUP_PREFIX}" \ "200" assert_json_eq "${TMP_DIR}/pricing_cleanup_preview_after_cleanup.body" "scheme_id" "${SCHEME_ID}" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview_after_cleanup.body" "total_candidates" "1" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview_after_cleanup.body" "safe_to_delete_count" "0" request "pricing_cleanup_dry_run_after_cleanup" "POST" \ "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" \ "200" \ "{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":true}" assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "scheme_id" "${SCHEME_ID}" assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "dry_run" "true" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "matched_total" "1" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "would_delete_count" "0" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "deleted_count" "0" assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "skipped_count" "1" python3 - "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "${KEEP_CATEGORY_ID}" <<'PY' import json import sys from pathlib import Path payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) keep_category_id = sys.argv[2] would_delete_ids = payload.get("would_delete_category_ids", []) if would_delete_ids: raise SystemExit(f"Expected no deletable categories after cleanup, got={would_delete_ids}") skipped_ids = {item["pricing_category_id"] for item in payload.get("skipped", [])} if skipped_ids != {keep_category_id}: raise SystemExit( f"Post-cleanup dry run expected skipped={[keep_category_id]}, got={sorted(skipped_ids)}" ) PY echo "[OK] repeated cleanup state is stable after destructive cleanup" echo echo "===== done =====" echo "[OK] smoke admin ops completed successfully" echo "FRESH_SCHEME_ID=${SCHEME_ID}" echo "DELETE_CATEGORY_ID=${DELETE_CATEGORY_ID}" echo "KEEP_CATEGORY_ID=${KEEP_CATEGORY_ID}" echo "KEEP_RULE_ID=${KEEP_RULE_ID}"