fix(core): stabilize editor lifecycle, transactional versions, and runtime config
This commit is contained in:
274
backend/scripts/editor_mutation_regression.sh
Executable file
274
backend/scripts/editor_mutation_regression.sh
Executable file
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo 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
|
||||
|
||||
log() {
|
||||
echo
|
||||
echo "===== $* ====="
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo
|
||||
echo "[FAIL] $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
request() {
|
||||
local name="$1"
|
||||
local method="$2"
|
||||
local url="$3"
|
||||
local body="${4:-}"
|
||||
local expected="${5:-200}"
|
||||
|
||||
local body_file="${TMP_DIR}/${name}.body"
|
||||
local code_file="${TMP_DIR}/${name}.code"
|
||||
|
||||
if [[ -n "${body}" ]]; then
|
||||
curl -sS \
|
||||
-X "${method}" \
|
||||
-H "X-API-Key: ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-o "${body_file}" \
|
||||
-w "%{http_code}" \
|
||||
--data "${body}" \
|
||||
"${url}" > "${code_file}"
|
||||
else
|
||||
curl -sS \
|
||||
-X "${method}" \
|
||||
-H "X-API-Key: ${API_KEY}" \
|
||||
-o "${body_file}" \
|
||||
-w "%{http_code}" \
|
||||
"${url}" > "${code_file}"
|
||||
fi
|
||||
|
||||
local code
|
||||
code="$(cat "${code_file}")"
|
||||
|
||||
echo "[${method}] ${url} -> ${code}"
|
||||
cat "${body_file}"
|
||||
echo
|
||||
|
||||
if [[ "${code}" != "${expected}" ]]; then
|
||||
fail "Unexpected HTTP status for ${name}: expected ${expected}, got ${code}"
|
||||
fi
|
||||
}
|
||||
|
||||
json_get() {
|
||||
local file="$1"
|
||||
local expr="$2"
|
||||
python3 - <<PY
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
data = json.loads(Path("${file}").read_text())
|
||||
expr = "${expr}"
|
||||
|
||||
value = data
|
||||
for part in expr.split("."):
|
||||
if not part:
|
||||
continue
|
||||
if part.startswith("[") and part.endswith("]"):
|
||||
cond = part[1:-1]
|
||||
try:
|
||||
if cond.endswith("!=null"):
|
||||
k = cond[:-6]
|
||||
value = next(item for item in value if item.get(k) is not None)
|
||||
elif cond.endswith("==null"):
|
||||
k = cond[:-6]
|
||||
value = next(item for item in value if item.get(k) is None)
|
||||
elif cond == "LAST":
|
||||
value = value[-1]
|
||||
else:
|
||||
value = value[0]
|
||||
except StopIteration:
|
||||
value = None
|
||||
elif part.isdigit():
|
||||
value = value[int(part)]
|
||||
else:
|
||||
value = value[part] if value else None
|
||||
|
||||
if value is None:
|
||||
print("")
|
||||
elif isinstance(value, bool):
|
||||
print("true" if value else "false")
|
||||
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
|
||||
fail "Assertion failed: ${expr} expected '${expected}', got '${actual}'"
|
||||
fi
|
||||
echo "[OK] ${expr}=${actual}"
|
||||
}
|
||||
|
||||
extract_current() {
|
||||
request "current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "" "200"
|
||||
CURRENT_VERSION_ID="$(json_get "${TMP_DIR}/current.body" "scheme_version_id")"
|
||||
CURRENT_STATUS="$(json_get "${TMP_DIR}/current.body" "status")"
|
||||
echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}"
|
||||
echo "CURRENT_STATUS=${CURRENT_STATUS}"
|
||||
}
|
||||
|
||||
ensure_draft() {
|
||||
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}"
|
||||
}
|
||||
|
||||
read_structure() {
|
||||
request "draft_structure" "GET" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"" "200"
|
||||
|
||||
SEAT_RECORD_ID="$(json_get "${TMP_DIR}/draft_structure.body" "seats.[seat_id!=null].seat_record_id")"
|
||||
SEAT_ID="$(json_get "${TMP_DIR}/draft_structure.body" "seats.[seat_id!=null].seat_id")"
|
||||
ORIG_SEAT_NUMBER="$(json_get "${TMP_DIR}/draft_structure.body" "seats.[seat_id!=null].seat_number")"
|
||||
|
||||
echo "SEAT_RECORD_ID=${SEAT_RECORD_ID}"
|
||||
echo "SEAT_ID=${SEAT_ID}"
|
||||
echo "ORIG_SEAT_NUMBER=${ORIG_SEAT_NUMBER}"
|
||||
}
|
||||
|
||||
check_read_models() {
|
||||
request "draft_summary" "GET" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/summary?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"" "200"
|
||||
request "draft_validation" "GET" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/validation?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"" "200"
|
||||
request "draft_compare_preview" "GET" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/compare-preview?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_validation.body" "scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||
assert_json_eq "${TMP_DIR}/draft_compare_preview.body" "draft_scheme_version_id" "${DRAFT_VERSION_ID}"
|
||||
}
|
||||
|
||||
log "health"
|
||||
curl -fsS "${API_URL}/healthz" >/dev/null || fail "healthz failed"
|
||||
echo "[OK] healthz"
|
||||
|
||||
log "current + ensure draft"
|
||||
extract_current
|
||||
ensure_draft
|
||||
read_structure
|
||||
check_read_models
|
||||
|
||||
STAMP="$(date +%s)"
|
||||
TEST_SECTOR_ID="reg-sector-${STAMP}"
|
||||
TEST_GROUP_ID="reg-group-${STAMP}"
|
||||
TEST_SECTOR_ELEMENT_ID="reg-sector-element-${STAMP}"
|
||||
TEST_GROUP_ELEMENT_ID="reg-group-element-${STAMP}"
|
||||
|
||||
log "create sector"
|
||||
request "create_sector" "POST" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/sectors?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"{\"element_id\":\"${TEST_SECTOR_ELEMENT_ID}\",\"sector_id\":\"${TEST_SECTOR_ID}\",\"name\":\"${TEST_SECTOR_ID}\"}" \
|
||||
"200"
|
||||
CREATE_SECTOR_RECORD_ID="$(json_get "${TMP_DIR}/create_sector.body" "sector_record_id")"
|
||||
echo "CREATE_SECTOR_RECORD_ID=${CREATE_SECTOR_RECORD_ID}"
|
||||
|
||||
log "create group"
|
||||
request "create_group" "POST" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/groups?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"{\"element_id\":\"${TEST_GROUP_ELEMENT_ID}\",\"group_id\":\"${TEST_GROUP_ID}\",\"name\":\"${TEST_GROUP_ID}\"}" \
|
||||
"200"
|
||||
CREATE_GROUP_RECORD_ID="$(json_get "${TMP_DIR}/create_group.body" "group_record_id")"
|
||||
echo "CREATE_GROUP_RECORD_ID=${CREATE_GROUP_RECORD_ID}"
|
||||
|
||||
log "patch seat -> bind to new group"
|
||||
request "patch_seat_group" "PATCH" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${SEAT_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"{\"group_id\":\"${TEST_GROUP_ID}\"}" \
|
||||
"200"
|
||||
|
||||
log "verify seat after patch"
|
||||
request "seat_after_patch" "GET" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${SEAT_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"" "200"
|
||||
assert_json_eq "${TMP_DIR}/seat_after_patch.body" "group_id" "${TEST_GROUP_ID}"
|
||||
assert_json_eq "${TMP_DIR}/seat_after_patch.body" "seat_number" "${ORIG_SEAT_NUMBER}"
|
||||
|
||||
log "patch group name"
|
||||
request "patch_group" "PATCH" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/groups/records/${CREATE_GROUP_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"{\"name\":\"${TEST_GROUP_ID}-updated\"}" \
|
||||
"200"
|
||||
|
||||
log "patch sector name"
|
||||
request "patch_sector" "PATCH" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/sectors/records/${CREATE_SECTOR_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"{\"name\":\"${TEST_SECTOR_ID}-updated\"}" \
|
||||
"200"
|
||||
|
||||
log "verify sector after patch"
|
||||
request "sector_after_patch" "GET" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/sectors/records/${CREATE_SECTOR_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"" "200"
|
||||
assert_json_eq "${TMP_DIR}/sector_after_patch.body" "name" "${TEST_SECTOR_ID}-updated"
|
||||
assert_json_eq "${TMP_DIR}/sector_after_patch.body" "sector_id" "${TEST_SECTOR_ID}"
|
||||
|
||||
log "bulk seat update validation path"
|
||||
request "bulk_seats" "POST" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/bulk?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"{\"items\":[{\"seat_record_id\":\"${SEAT_RECORD_ID}\",\"row_label\":\"ZZ\",\"seat_number\":\"999\"}]}" \
|
||||
"200"
|
||||
|
||||
log "verify seat after bulk patch"
|
||||
request "seat_after_bulk" "GET" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${SEAT_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"" "200"
|
||||
assert_json_eq "${TMP_DIR}/seat_after_bulk.body" "row_label" "ZZ"
|
||||
assert_json_eq "${TMP_DIR}/seat_after_bulk.body" "seat_number" "999"
|
||||
|
||||
log "typed error: duplicate sector id"
|
||||
request "duplicate_sector" "POST" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/sectors?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"{\"element_id\":\"dup-${TEST_SECTOR_ELEMENT_ID}\",\"sector_id\":\"${TEST_SECTOR_ID}\",\"name\":\"dup\"}" \
|
||||
"422"
|
||||
|
||||
log "typed error: duplicate group id"
|
||||
request "duplicate_group" "POST" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/groups?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"{\"element_id\":\"dup-${TEST_GROUP_ELEMENT_ID}\",\"group_id\":\"${TEST_GROUP_ID}\",\"name\":\"dup\"}" \
|
||||
"422"
|
||||
|
||||
log "typed error: stale draft version"
|
||||
request "stale_patch" "PATCH" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${SEAT_RECORD_ID}?expected_scheme_version_id=deadbeefdeadbeefdeadbeefdeadbeef" \
|
||||
"{\"row_label\":\"STALE\"}" \
|
||||
"409"
|
||||
|
||||
log "typed error: remap preview without filters"
|
||||
request "remap_preview_invalid" "POST" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/remap/preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"{}" \
|
||||
"422"
|
||||
|
||||
log "repair references"
|
||||
request "repair_refs" "POST" \
|
||||
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/repair-references?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
|
||||
"{}" \
|
||||
"200"
|
||||
|
||||
log "post-mutation read models"
|
||||
check_read_models
|
||||
|
||||
log "done"
|
||||
echo "[OK] editor mutation regression completed successfully"
|
||||
@@ -7,6 +7,8 @@ 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"
|
||||
@@ -107,7 +109,25 @@ assert_json_int_gt() {
|
||||
}
|
||||
|
||||
echo "===== health ====="
|
||||
curl -i "${API_URL}/healthz"
|
||||
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"
|
||||
@@ -194,7 +214,17 @@ request "admin_cleanup_dry_run" "POST" "${API_URL}/api/v1/admin/schemes/${SCHEME
|
||||
|
||||
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"
|
||||
assert_json_int_gt "${TMP_DIR}/admin_cleanup_dry_run.body" "would_delete_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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user