Skip to content

Commit c6f741a

Browse files
committed
test(scripts): mollifier challenge suite for manual API verification
14 scenario scripts covering every customer-visible behaviour added in Phases A-E. Each script is independently runnable; bash + curl + jq only (script 12 also needs redis-cli; script 14 also needs a session cookie + slugs). Coverage map: - 01 burst baseline + sanity-check - 02 every read endpoint on a buffered run (A1-A6) - 03 each mutation patch type with read-back (B3, C2, C3 replace+ops, C4) - 04 idempotency SETNX collision in burst (B6a) - 05 drainer round-trip — mutations survive materialisation (B2 + drainer) - 06 cancel bifurcation — buffered cancel → CANCELED PG row (C1, Q4) - 07 replay creates fresh distinct runId, original untouched (C5) - 08 listing merges buffered + PG, sorts correctly, pagination dedupes (E) - 09 50-way concurrent metadata increments — CAS retry loop (C3 atomicity) - 10 idempotency-key reset clears both stores (B6b reset) - 11 parent/root metadata operations fan out from buffered child (C3) - 12 state-3 replay (Q2 — direct Redis HSET status=FAILED) - 13 triggerAndWait + idempotencyKey skips buffer lookup (B6b guard) - 14 dashboard cancel/replay/idempotencyKey-reset via session cookies (D1-D3) README documents prerequisites, run order, drainer-on vs drainer-off scripts, and an explicit "not covered" list (busy path, buffer outage, forward-compat skew, CI invocation) with rationale.
1 parent 8639fae commit c6f741a

16 files changed

Lines changed: 1379 additions & 0 deletions
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/env bash
2+
# Shared helpers for the mollifier challenge suite. Source this from each
3+
# scenario script: `source "$(dirname "$0")/00-lib.sh"`.
4+
5+
set -uo pipefail
6+
7+
: "${API_BASE:=http://localhost:3030}"
8+
: "${TASK_ID:=hello-world}"
9+
: "${BURST_SIZE:=30}"
10+
: "${VERBOSE:=0}"
11+
12+
if [[ -z "${API_KEY:-}" ]]; then
13+
echo "ERROR: API_KEY env var is required" >&2
14+
exit 2
15+
fi
16+
17+
if ! command -v jq >/dev/null 2>&1; then
18+
echo "ERROR: jq is required" >&2
19+
exit 2
20+
fi
21+
22+
if [[ -t 1 ]]; then
23+
C_OK=$'\033[32m'; C_FAIL=$'\033[31m'; C_WARN=$'\033[33m'
24+
C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'
25+
else
26+
C_OK=; C_FAIL=; C_WARN=; C_DIM=; C_BOLD=; C_RESET=
27+
fi
28+
29+
# Per-script work directory, auto-cleaned on exit.
30+
WORK=$(mktemp -d)
31+
trap 'rm -rf "$WORK"' EXIT
32+
33+
# pass_count + fail_count accumulators. Use `pass`, `fail`, and `summary`.
34+
PASS_COUNT=0
35+
FAIL_COUNT=0
36+
declare -a FAILURES=()
37+
38+
pass() {
39+
printf " %s✓%s %s\n" "$C_OK" "$C_RESET" "$1"
40+
PASS_COUNT=$((PASS_COUNT + 1))
41+
}
42+
43+
fail() {
44+
printf " %s✗%s %s\n" "$C_FAIL" "$C_RESET" "$1"
45+
FAILURES+=( "$1" )
46+
FAIL_COUNT=$((FAIL_COUNT + 1))
47+
}
48+
49+
info() {
50+
printf " %s%s%s\n" "$C_DIM" "$1" "$C_RESET"
51+
}
52+
53+
header() {
54+
printf "\n%s==>%s %s%s%s\n" "$C_DIM" "$C_RESET" "$C_BOLD" "$1" "$C_RESET"
55+
}
56+
57+
summary() {
58+
printf "\n%s==>%s Summary\n" "$C_DIM" "$C_RESET"
59+
printf " passed: %d\n" "$PASS_COUNT"
60+
if (( FAIL_COUNT > 0 )); then
61+
printf " %sfailed: %d%s\n" "$C_FAIL" "$FAIL_COUNT" "$C_RESET"
62+
for f in "${FAILURES[@]}"; do
63+
printf " %s- %s%s\n" "$C_FAIL" "$f" "$C_RESET"
64+
done
65+
exit 1
66+
fi
67+
printf " %sall scenarios pass%s\n" "$C_OK" "$C_RESET"
68+
exit 0
69+
}
70+
71+
# api METHOD PATH [DATA] → echoes "STATUS BODY"
72+
# Stores body in $WORK/last.body, status in $WORK/last.status.
73+
api() {
74+
local method=$1 path=$2 data=${3:-}
75+
local body_file=$WORK/last.body
76+
local status_file=$WORK/last.status
77+
local args=( -s -o "$body_file" -w "%{http_code}" -X "$method"
78+
-H "Authorization: Bearer $API_KEY" )
79+
if [[ -n "$data" ]]; then
80+
args+=( -H "Content-Type: application/json" -d "$data" )
81+
fi
82+
args+=( "$API_BASE$path" )
83+
local status
84+
status=$(curl "${args[@]}")
85+
echo "$status" > "$status_file"
86+
if [[ "$VERBOSE" == "1" ]]; then
87+
info "$method $path$status"
88+
info " $(head -c 200 "$body_file")"
89+
fi
90+
printf "%s" "$status"
91+
}
92+
93+
# Returns 0 if last status is 2xx.
94+
last_status_ok() {
95+
[[ "$(cat "$WORK/last.status" 2>/dev/null)" =~ ^2 ]]
96+
}
97+
98+
# Read last body or empty.
99+
last_body() {
100+
cat "$WORK/last.body" 2>/dev/null || echo ""
101+
}
102+
103+
# Returns 0 if the body matches a jq filter.
104+
body_matches() {
105+
local filter=$1
106+
jq -e "$filter" "$WORK/last.body" >/dev/null 2>&1
107+
}
108+
109+
# Trigger a burst, return one buffered runId on stdout (or empty if none).
110+
# Side effect: also writes burst responses to $WORK/burst/.
111+
capture_buffered_run_id() {
112+
local task=${1:-$TASK_ID}
113+
local size=${2:-$BURST_SIZE}
114+
local payload=${3:-'{"message":"burst"}'}
115+
local burst_dir=$WORK/burst
116+
mkdir -p "$burst_dir"
117+
for i in $(seq 1 "$size"); do
118+
curl -s -X POST \
119+
-H "Authorization: Bearer $API_KEY" \
120+
-H "Content-Type: application/json" \
121+
-d "{\"payload\":$payload}" \
122+
"$API_BASE/api/v1/tasks/$task/trigger" \
123+
-o "$burst_dir/$i.json" &
124+
done
125+
wait
126+
for f in "$burst_dir"/*.json; do
127+
if jq -e '.notice.code == "mollifier.queued"' "$f" >/dev/null 2>&1; then
128+
jq -r '.id' "$f"
129+
return 0
130+
fi
131+
done
132+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env bash
2+
# 01 — fire a burst, confirm the gate mollifies at least one trigger,
3+
# capture the buffered runId, sanity-check the response shape.
4+
# Required: drainer OFF.
5+
6+
source "$(dirname "$0")/00-lib.sh"
7+
8+
header "Burst baseline"
9+
10+
info "firing $BURST_SIZE concurrent triggers against $TASK_ID"
11+
BUFFERED_ID=$(capture_buffered_run_id)
12+
13+
if [[ -z "$BUFFERED_ID" ]]; then
14+
fail "no mollifier.queued response across $BURST_SIZE triggers"
15+
info "check: TRIGGER_MOLLIFIER_ENABLED=1, org flag on, threshold low, drainer OFF"
16+
summary
17+
fi
18+
pass "captured buffered runId: $BUFFERED_ID"
19+
20+
# Inspect via /api/v3/runs/{id} — should resolve via the buffer read-fallback
21+
# even though the run isn't in PG.
22+
api GET "/api/v3/runs/$BUFFERED_ID"
23+
if last_status_ok; then
24+
pass "retrieve returns 2xx for the buffered run"
25+
else
26+
fail "retrieve returned $(cat "$WORK/last.status") (expected 2xx)"
27+
fi
28+
29+
if body_matches '.id == "'"$BUFFERED_ID"'"'; then
30+
pass "retrieve body carries the right runId"
31+
else
32+
fail "retrieve body missing runId"
33+
fi
34+
35+
if body_matches '.status == "PENDING" or .status == "QUEUED" or .status == "DELAYED"'; then
36+
pass "retrieve status is QUEUED-equivalent ($(last_body | jq -r .status))"
37+
else
38+
fail "retrieve status unexpected: $(last_body | jq -r .status)"
39+
fi
40+
41+
# Sanity: control trigger with a long delay should be in PG, not mollified.
42+
header "Control sanity"
43+
api POST "/api/v1/tasks/$TASK_ID/trigger" '{"payload":{"control":true},"options":{"delay":"10m"}}'
44+
if last_status_ok; then
45+
CONTROL_ID=$(last_body | jq -r '.id')
46+
if [[ -n "$CONTROL_ID" && "$CONTROL_ID" != "null" ]]; then
47+
if last_body | jq -e '.notice.code == "mollifier.queued"' >/dev/null 2>&1; then
48+
fail "control trigger with delay was mollified — check threshold / hold settings"
49+
else
50+
pass "control trigger landed in PG (delayed), runId: $CONTROL_ID"
51+
fi
52+
else
53+
fail "control trigger response missing id"
54+
fi
55+
else
56+
fail "control trigger returned $(cat "$WORK/last.status")"
57+
fi
58+
59+
summary
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bash
2+
# 02 — read endpoints all behave correctly on a buffered run.
3+
# Required: drainer OFF.
4+
5+
source "$(dirname "$0")/00-lib.sh"
6+
7+
header "Read endpoints on a buffered run"
8+
9+
BUFFERED_ID=$(capture_buffered_run_id)
10+
if [[ -z "$BUFFERED_ID" ]]; then
11+
fail "could not buffer a run (rerun 01 to debug)"
12+
summary
13+
fi
14+
info "using buffered runId: $BUFFERED_ID"
15+
16+
# /api/v3/runs/{id}
17+
api GET "/api/v3/runs/$BUFFERED_ID"
18+
if last_status_ok && body_matches '.id and .taskIdentifier and .status'; then
19+
pass "GET /api/v3/runs/{id} — 2xx with id+taskIdentifier+status"
20+
else
21+
fail "GET /api/v3/runs/{id} — status=$(cat "$WORK/last.status") body=$(last_body | head -c 100)"
22+
fi
23+
24+
# /api/v1/runs/{id}/trace
25+
api GET "/api/v1/runs/$BUFFERED_ID/trace"
26+
if last_status_ok && body_matches '.trace and .trace.traceId'; then
27+
pass "GET /trace — 2xx with trace.traceId"
28+
else
29+
fail "GET /trace — status=$(cat "$WORK/last.status") body=$(last_body | head -c 100)"
30+
fi
31+
32+
# /api/v1/runs/{id}/events
33+
api GET "/api/v1/runs/$BUFFERED_ID/events"
34+
if last_status_ok && body_matches '.events | type == "array"'; then
35+
pass "GET /events — 2xx, events is an array"
36+
else
37+
fail "GET /events — status=$(cat "$WORK/last.status") body=$(last_body | head -c 100)"
38+
fi
39+
40+
# /api/v1/runs/{id}/attempts
41+
api GET "/api/v1/runs/$BUFFERED_ID/attempts"
42+
if last_status_ok && body_matches '.attempts | type == "array" and length == 0'; then
43+
pass "GET /attempts — 2xx, attempts is empty array"
44+
else
45+
fail "GET /attempts — status=$(cat "$WORK/last.status") body=$(last_body | head -c 100)"
46+
fi
47+
48+
# /api/v1/runs/{id}/metadata (loader)
49+
api GET "/api/v1/runs/$BUFFERED_ID/metadata"
50+
if last_status_ok && body_matches 'has("metadata") and has("metadataType")'; then
51+
pass "GET /metadata — 2xx with { metadata, metadataType }"
52+
else
53+
fail "GET /metadata — status=$(cat "$WORK/last.status") body=$(last_body | head -c 100)"
54+
fi
55+
56+
# /api/v1/runs/{id}/result — expected 404 (run not finished)
57+
api GET "/api/v1/runs/$BUFFERED_ID/result"
58+
status=$(cat "$WORK/last.status")
59+
if [[ "$status" == "404" ]]; then
60+
pass "GET /result — 404 (run not finished, expected contract)"
61+
else
62+
fail "GET /result — expected 404, got $status"
63+
fi
64+
65+
# Spans endpoint — buffered run only has the queued span; 404 for any other.
66+
SPAN_ID=$(api GET "/api/v3/runs/$BUFFERED_ID" >/dev/null; last_body | jq -r '.spanId // empty')
67+
if [[ -n "$SPAN_ID" ]]; then
68+
api GET "/api/v1/runs/$BUFFERED_ID/spans/$SPAN_ID"
69+
if last_status_ok; then
70+
pass "GET /spans/{spanId} — 2xx for the queued span"
71+
else
72+
fail "GET /spans/{spanId} — expected 2xx, got $(cat "$WORK/last.status")"
73+
fi
74+
75+
api GET "/api/v1/runs/$BUFFERED_ID/spans/nonexistent_span_xyz"
76+
if [[ "$(cat "$WORK/last.status")" == "404" ]]; then
77+
pass "GET /spans/{unknown} — 404"
78+
else
79+
fail "GET /spans/{unknown} — expected 404, got $(cat "$WORK/last.status")"
80+
fi
81+
else
82+
info "skipping spans probe — no spanId on retrieve response"
83+
fi
84+
85+
summary
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env bash
2+
# 03 — each mutation lands on the snapshot (verified by follow-up read).
3+
# Cancel is left for 06-cancel-bifurcation.sh because it terminates the
4+
# snapshot. Required: drainer OFF.
5+
6+
source "$(dirname "$0")/00-lib.sh"
7+
8+
header "Mutations land on the buffered snapshot"
9+
10+
BUFFERED_ID=$(capture_buffered_run_id)
11+
if [[ -z "$BUFFERED_ID" ]]; then
12+
fail "could not buffer a run"
13+
summary
14+
fi
15+
info "using buffered runId: $BUFFERED_ID"
16+
17+
# --- tags ---
18+
header "tags-add → readback"
19+
api POST "/api/v1/runs/$BUFFERED_ID/tags" '{"tags":["challenge-tag-a","challenge-tag-b"]}'
20+
if last_status_ok; then
21+
pass "POST /tags returned 2xx"
22+
else
23+
fail "POST /tags status=$(cat "$WORK/last.status")"
24+
fi
25+
api GET "/api/v3/runs/$BUFFERED_ID"
26+
if body_matches '.runTags // [] | (any(. == "challenge-tag-a") and any(. == "challenge-tag-b"))'; then
27+
pass "retrieve shows both new tags on the snapshot"
28+
else
29+
fail "retrieve runTags=$(last_body | jq -c '.runTags // []')"
30+
fi
31+
32+
# Idempotent dedup
33+
api POST "/api/v1/runs/$BUFFERED_ID/tags" '{"tags":["challenge-tag-a"]}'
34+
api GET "/api/v3/runs/$BUFFERED_ID"
35+
tag_count=$(last_body | jq '.runTags // [] | map(select(. == "challenge-tag-a")) | length')
36+
if [[ "$tag_count" == "1" ]]; then
37+
pass "duplicate tag deduplicated by mutateSnapshot Lua"
38+
else
39+
fail "duplicate tag landed $tag_count times (expected 1)"
40+
fi
41+
42+
# --- metadata-put replace ---
43+
header "metadata-put (replace) → readback"
44+
api PUT "/api/v1/runs/$BUFFERED_ID/metadata" '{"metadata":{"phase":"challenge","attempt":1}}'
45+
if last_status_ok; then
46+
pass "PUT /metadata returned 2xx"
47+
else
48+
fail "PUT /metadata status=$(cat "$WORK/last.status") body=$(last_body | head -c 200)"
49+
fi
50+
api GET "/api/v1/runs/$BUFFERED_ID/metadata"
51+
if body_matches '(.metadata // "" | tostring) | (contains("\"phase\":\"challenge\"") and contains("\"attempt\":1"))'; then
52+
pass "GET /metadata reflects PUT"
53+
else
54+
fail "metadata readback=$(last_body | head -c 200)"
55+
fi
56+
57+
# --- metadata-put operations (increment) ---
58+
header "metadata operations (increment) → readback"
59+
api PUT "/api/v1/runs/$BUFFERED_ID/metadata" \
60+
'{"operations":[{"type":"increment","key":"counter","value":5}]}'
61+
if last_status_ok; then
62+
pass "PUT /metadata (increment by 5) returned 2xx"
63+
else
64+
fail "PUT /metadata increment status=$(cat "$WORK/last.status") body=$(last_body | head -c 200)"
65+
fi
66+
api PUT "/api/v1/runs/$BUFFERED_ID/metadata" \
67+
'{"operations":[{"type":"increment","key":"counter","value":3}]}'
68+
api GET "/api/v1/runs/$BUFFERED_ID/metadata"
69+
if body_matches '(.metadata // "" | tostring) | contains("\"counter\":8")'; then
70+
pass "two increments produce counter=8 (CAS retry not losing deltas)"
71+
else
72+
fail "counter after 5+3 = $(last_body | head -c 200)"
73+
fi
74+
75+
# --- reschedule ---
76+
header "reschedule → readback"
77+
api POST "/api/v1/runs/$BUFFERED_ID/reschedule" '{"delay":"10m"}'
78+
if last_status_ok; then
79+
pass "POST /reschedule returned 2xx"
80+
else
81+
fail "POST /reschedule status=$(cat "$WORK/last.status") body=$(last_body | head -c 200)"
82+
fi
83+
# Reschedule applies set_delay on the snapshot — no direct read-back via
84+
# the public API (the snapshot delay is internal until materialise).
85+
# This is by design; we accept the 2xx as the contract here.
86+
87+
summary

0 commit comments

Comments
 (0)