initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

556
scripts/perf-test.sh Executable file
View File

@@ -0,0 +1,556 @@
#!/usr/bin/env bash
# =============================================================================
# BukidBounty Performance & Load Test Script
#
# Usage:
# ./scripts/perf-test.sh [options] [test...]
#
# Options:
# -u, --url URL Base URL (default: http://localhost:9522)
# -t, --token TOKEN X-Perf-Token value (default: $PERF_API_TOKEN env)
# -s, --store HASH Store hashkey for POS simulation (required for pos/*)
# -c, --concurrency N Max concurrent workers for ramp test (default: 50)
# -o, --output FILE Write JSON summary to FILE
# --no-color Disable colored output
#
# Tests (pass one or more; default: all):
# ping Verify connectivity and token
# seed Create 50 users + 20 stores + 100 products, report timing
# pos POS simulation: 1 → 5 → 20 items × 20 cycles
# concurrent Hit /api/perf/pos/simulate with N parallel workers
# ramp Ramp concurrency from 1 → MAX workers, report throughput
# web Curl-based throughput on public endpoints (no token needed)
#
# Environment:
# PERF_API_TOKEN Token set in .env on the server (required)
# PERF_BASE_URL Overrides --url
# PERF_STORE_HASH Overrides --store
#
# Examples:
# # Quick health check (local dev):
# PERF_API_TOKEN=secret ./scripts/perf-test.sh ping
#
# # Full suite against prod:
# ./scripts/perf-test.sh -u https://yourapp.com -t SECRET -s STORE_HASH
#
# # Concurrent POS with 30 workers:
# ./scripts/perf-test.sh -u https://yourapp.com -t SECRET -s STORE_HASH \
# -c 30 concurrent
#
# # Ramp test only, save JSON report:
# ./scripts/perf-test.sh ... ramp -o results.json
# =============================================================================
set -euo pipefail
# ---------------------------------------------------------------------------
# Defaults / env
# ---------------------------------------------------------------------------
BASE_URL="${PERF_BASE_URL:-http://localhost:9522}"
TOKEN="${PERF_API_TOKEN:-}"
STORE_HASH="${PERF_STORE_HASH:-}"
MAX_CONCURRENCY=50
OUTPUT_FILE=""
USE_COLOR=true
RUN_TESTS=()
# ---------------------------------------------------------------------------
# Arg parsing
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
-u|--url) BASE_URL="$2"; shift 2 ;;
-t|--token) TOKEN="$2"; shift 2 ;;
-s|--store) STORE_HASH="$2"; shift 2 ;;
-c|--concurrency) MAX_CONCURRENCY="$2"; shift 2 ;;
-o|--output) OUTPUT_FILE="$2"; shift 2 ;;
--no-color) USE_COLOR=false; shift ;;
ping|seed|pos|concurrent|ramp|web)
RUN_TESTS+=("$1"); shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
[[ ${#RUN_TESTS[@]} -eq 0 ]] && RUN_TESTS=(ping seed pos concurrent ramp web)
BASE_URL="${BASE_URL%/}" # strip trailing slash
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
if $USE_COLOR && [[ -t 1 ]]; then
C_RESET="\033[0m"; C_BOLD="\033[1m"
C_GREEN="\033[32m"; C_YELLOW="\033[33m"; C_RED="\033[31m"
C_CYAN="\033[36m"; C_BLUE="\033[34m"; C_DIM="\033[2m"
else
C_RESET=""; C_BOLD=""; C_GREEN=""; C_YELLOW=""; C_RED=""
C_CYAN=""; C_BLUE=""; C_DIM=""
fi
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
NOW() { date +"%Y-%m-%d %H:%M:%S"; }
log_section() { echo -e "\n${C_BOLD}${C_BLUE}══ $1 ══${C_RESET}"; }
log_ok() { echo -e " ${C_GREEN}${C_RESET} $*"; }
log_warn() { echo -e " ${C_YELLOW}${C_RESET} $*"; }
log_err() { echo -e " ${C_RED}${C_RESET} $*"; }
log_dim() { echo -e " ${C_DIM}$*${C_RESET}"; }
# Check required tools
need() { command -v "$1" &>/dev/null || { log_err "Required tool not found: $1 — install it first"; exit 1; }; }
need curl
need jq
need bc
need awk
# curl wrapper: returns HTTP body; exits 1 on HTTP error or curl failure
# Usage: perf_get PATH
perf_get() {
local path="$1"
curl -sf --max-time 30 \
-H "X-Perf-Token: ${TOKEN}" \
-H "Accept: application/json" \
"${BASE_URL}${path}"
}
# POST JSON body
perf_post() {
local path="$1"
local body="$2"
curl -sf --max-time 60 \
-H "X-Perf-Token: ${TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "$body" \
"${BASE_URL}${path}"
}
# POST silently into a tmp file; echo that file path
perf_post_async() {
local path="$1" body="$2" out="$3"
curl -s --max-time 60 \
-H "X-Perf-Token: ${TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "$body" \
"${BASE_URL}${path}" -o "$out" 2>/dev/null
}
# Extract a numeric field from JSON
jnum() { echo "$1" | jq -r ".$2 // 0"; }
# Pretty-print a ms value
ms_label() {
local v
v=$(printf "%.1f" "${1:-0}")
if (( $(echo "$v < 100" | bc -l) )); then
echo -e "${C_GREEN}${v}ms${C_RESET}"
elif (( $(echo "$v < 500" | bc -l) )); then
echo -e "${C_YELLOW}${v}ms${C_RESET}"
else
echo -e "${C_RED}${v}ms${C_RESET}"
fi
}
# ---------------------------------------------------------------------------
# JSON results accumulator
# ---------------------------------------------------------------------------
RESULTS='{"run_at":"","tests":{}}'
result_set() {
# result_set <test> <key> <value(json)>
RESULTS=$(echo "$RESULTS" | jq --arg t "$1" --arg k "$2" --argjson v "$3" \
'.tests[$t][$k] = $v')
}
result_section() {
RESULTS=$(echo "$RESULTS" | jq --arg t "$1" '.tests[$t] //= {}')
}
# ---------------------------------------------------------------------------
# Guard: token must be set for API tests
# ---------------------------------------------------------------------------
require_token() {
if [[ -z "$TOKEN" ]]; then
log_err "PERF_API_TOKEN / --token is required for this test"
exit 1
fi
}
require_store() {
if [[ -z "$STORE_HASH" ]]; then
log_err "--store / PERF_STORE_HASH is required for this test"
exit 1
fi
}
# =============================================================================
# TEST: ping
# =============================================================================
test_ping() {
log_section "PING — Connectivity & token check"
require_token
result_section "ping"
local t0 body
t0=$(date +%s%N)
if ! body=$(perf_get "/api/perf/ping" 2>&1); then
log_err "Ping failed: $body"
result_set ping ok false
return 1
fi
local elapsed_ms
elapsed_ms=$(( ( $(date +%s%N) - t0 ) / 1000000 ))
local ts php
ts=$(echo "$body" | jq -r '.ts // "?"')
php=$(echo "$body" | jq -r '.php // "?"')
log_ok "Server responded in $(ms_label $elapsed_ms)"
log_ok "Server time: ${ts} PHP: ${php}"
log_dim "Target: ${BASE_URL}"
result_set ping ok true
result_set ping latency_ms "$elapsed_ms"
result_set ping server_time "\"$ts\""
}
# =============================================================================
# TEST: seed
# =============================================================================
test_seed() {
log_section "SEED — Create synthetic users / stores / products"
require_token
result_section "seed"
# 1. Seed users
local r
echo -e " Seeding 50 users..."
r=$(perf_post "/api/perf/seed/users" '{"count":50,"prefix":"loadtest"}')
local u_ms u_avg
u_ms=$(jnum "$r" total_ms)
u_avg=$(jnum "$r" avg_ms)
log_ok "Users — 50 created total: $(ms_label $u_ms) avg/user: $(ms_label $u_avg)"
result_set seed users_total_ms "$u_ms"
result_set seed users_avg_ms "$u_avg"
# 2. Seed stores
echo -e " Seeding 20 stores..."
r=$(perf_post "/api/perf/seed/stores" '{"count":20,"prefix":"LoadTestStore"}')
local s_ms s_avg
s_ms=$(jnum "$r" total_ms)
s_avg=$(jnum "$r" avg_ms)
log_ok "Stores — 20 created total: $(ms_label $s_ms) avg/store: $(ms_label $s_avg)"
result_set seed stores_total_ms "$s_ms"
result_set seed stores_avg_ms "$s_avg"
# 3. Seed products (global, no store attach)
echo -e " Seeding 100 products..."
r=$(perf_post "/api/perf/seed/products" '{"count":100,"prefix":"LoadTestPrd","attach_to_store":false}')
local p_ms p_avg
p_ms=$(jnum "$r" total_ms)
p_avg=$(jnum "$r" avg_ms)
log_ok "Products— 100 created total: $(ms_label $p_ms) avg/prod: $(ms_label $p_avg)"
result_set seed products_total_ms "$p_ms"
result_set seed products_avg_ms "$p_avg"
}
# =============================================================================
# TEST: pos — sequential POS simulation at various item counts
# =============================================================================
test_pos() {
log_section "POS — Sequential simulation (1 / 5 / 20 items × 20 cycles)"
require_token
require_store
result_section "pos"
local scenario items cycles
for scenario in "1items:1:20" "5items:5:20" "20items:20:20"; do
label="${scenario%%:*}"
rest="${scenario#*:}"
items="${rest%%:*}"
cycles="${rest#*:}"
echo -e " Running ${cycles} cycles × ${items} items..."
local r avg_ms min_ms max_ms
r=$(perf_post "/api/perf/pos/simulate" \
"{\"store_hash\":\"${STORE_HASH}\",\"items\":${items},\"cycles\":${cycles},\"complete\":true}")
avg_ms=$(jnum "$r" avg_cycle_ms)
min_ms=$(jnum "$r" min_cycle_ms)
max_ms=$(jnum "$r" max_cycle_ms)
log_ok "${label} avg: $(ms_label $avg_ms) min: $(ms_label $min_ms) max: $(ms_label $max_ms)"
result_set pos "${label}_avg_ms" "$avg_ms"
result_set pos "${label}_min_ms" "$min_ms"
result_set pos "${label}_max_ms" "$max_ms"
done
}
# =============================================================================
# TEST: concurrent — N parallel POS simulations at once
# =============================================================================
test_concurrent() {
log_section "CONCURRENT — Parallel POS workers (5 items / session)"
require_token
require_store
result_section "concurrent"
local workers_list=(1 5 10 25 $MAX_CONCURRENCY)
# deduplicate and sort
workers_list=($(printf '%s\n' "${workers_list[@]}" | sort -nu))
local TMPDIR_PERF
TMPDIR_PERF=$(mktemp -d)
trap "rm -rf '$TMPDIR_PERF'" EXIT
for workers in "${workers_list[@]}"; do
[[ $workers -gt $MAX_CONCURRENCY ]] && continue
local pids=() out_files=()
local t0
t0=$(date +%s%N)
for ((w=1; w<=workers; w++)); do
local out="${TMPDIR_PERF}/w${workers}_${w}.json"
out_files+=("$out")
perf_post_async "/api/perf/pos/simulate" \
"{\"store_hash\":\"${STORE_HASH}\",\"items\":5,\"cycles\":3,\"complete\":true}" \
"$out" &
pids+=($!)
done
# Wait for all workers
local failed=0
for pid in "${pids[@]}"; do
wait "$pid" 2>/dev/null || (( failed++ )) || true
done
local wall_ms errors=0 total_avg=0 count=0
wall_ms=$(( ( $(date +%s%N) - t0 ) / 1000000 ))
for f in "${out_files[@]}"; do
if [[ ! -f "$f" ]]; then (( errors++ )); continue; fi
local ok avg
ok=$(jq -r '.success // false' "$f" 2>/dev/null)
if [[ "$ok" != "true" ]]; then (( errors++ )); continue; fi
avg=$(jq -r '.avg_cycle_ms // 0' "$f" 2>/dev/null)
total_avg=$(echo "$total_avg + $avg" | bc)
(( count++ ))
done
local mean_avg=0
[[ $count -gt 0 ]] && mean_avg=$(echo "scale=1; $total_avg / $count" | bc)
local status_icon="${C_GREEN}${C_RESET}"
[[ $errors -gt 0 ]] && status_icon="${C_RED}${C_RESET}"
printf " ${status_icon} %3d workers wall: $(ms_label $wall_ms) mean cycle: $(ms_label $mean_avg) errors: %d\n" \
"$workers" "$errors"
result_set concurrent "w${workers}_wall_ms" "$wall_ms"
result_set concurrent "w${workers}_mean_cycle_ms" "$mean_avg"
result_set concurrent "w${workers}_errors" "$errors"
done
rm -rf "$TMPDIR_PERF"
trap - EXIT
}
# =============================================================================
# TEST: ramp — gradually increase concurrency to find the saturation point
# =============================================================================
test_ramp() {
log_section "RAMP — Concurrency ramp 1 → ${MAX_CONCURRENCY} workers"
require_token
require_store
result_section "ramp"
# Steps: 1, 5, 10, 20, 30, MAX (if not already listed)
local steps=(1 5 10 20 30)
steps+=($MAX_CONCURRENCY)
steps=($(printf '%s\n' "${steps[@]}" | sort -nu | awk -v m=$MAX_CONCURRENCY '$1<=m'))
local TMPDIR_RAMP
TMPDIR_RAMP=$(mktemp -d)
trap "rm -rf '$TMPDIR_RAMP'" EXIT
echo -e " ${C_DIM}workers | wall_ms | mean_cycle_ms | throughput(cyc/s) | errors${C_RESET}"
echo -e " ${C_DIM}--------|---------|---------------|-----------------|-------${C_RESET}"
local prev_throughput=0
local saturation_workers=""
for workers in "${steps[@]}"; do
local pids=() out_files=()
local t0
t0=$(date +%s%N)
for ((w=1; w<=workers; w++)); do
local out="${TMPDIR_RAMP}/ramp_${workers}_${w}.json"
out_files+=("$out")
perf_post_async "/api/perf/pos/simulate" \
"{\"store_hash\":\"${STORE_HASH}\",\"items\":5,\"cycles\":5,\"complete\":true}" \
"$out" &
pids+=($!)
done
for pid in "${pids[@]}"; do wait "$pid" 2>/dev/null || true; done
local wall_ms errors=0 total_cycles=0 total_avg=0 count=0
wall_ms=$(( ( $(date +%s%N) - t0 ) / 1000000 ))
for f in "${out_files[@]}"; do
[[ ! -f "$f" ]] && (( errors++ )) && continue
local ok
ok=$(jq -r '.success // false' "$f" 2>/dev/null)
[[ "$ok" != "true" ]] && (( errors++ )) && continue
local cyc avg
cyc=$(jq -r '.cycles // 0' "$f" 2>/dev/null)
avg=$(jq -r '.avg_cycle_ms // 0' "$f" 2>/dev/null)
total_cycles=$(( total_cycles + cyc ))
total_avg=$(echo "$total_avg + $avg" | bc)
(( count++ ))
done
local mean_avg=0 throughput=0
[[ $count -gt 0 ]] && mean_avg=$(echo "scale=1; $total_avg / $count" | bc)
[[ $wall_ms -gt 0 ]] && throughput=$(echo "scale=1; $total_cycles * 1000 / $wall_ms" | bc)
# Saturation detection: throughput stopped growing by >5%
if [[ -n "$prev_throughput" ]] && (( $(echo "$prev_throughput > 0" | bc -l) )); then
local growth
growth=$(echo "scale=2; ($throughput - $prev_throughput) / $prev_throughput * 100" | bc 2>/dev/null || echo 0)
if (( $(echo "$growth < 5 && $workers > 1" | bc -l) )) && [[ -z "$saturation_workers" ]]; then
saturation_workers=$workers
fi
fi
prev_throughput=$throughput
local err_col="${C_GREEN}${errors}${C_RESET}"
[[ $errors -gt 0 ]] && err_col="${C_RED}${errors}${C_RESET}"
printf " %7d | %7d | %13.1f | %17.1f | ${err_col}\n" \
"$workers" "$wall_ms" "$mean_avg" "$throughput"
result_set ramp "w${workers}" "{\"wall_ms\":$wall_ms,\"mean_cycle_ms\":$mean_avg,\"throughput\":$throughput,\"errors\":$errors}"
done
if [[ -n "$saturation_workers" ]]; then
log_warn "Throughput plateaued around ${saturation_workers} workers — saturation point"
result_set ramp saturation_workers "$saturation_workers"
else
log_ok "No saturation detected at ${MAX_CONCURRENCY} workers — system still scaling"
fi
rm -rf "$TMPDIR_RAMP"
trap - EXIT
}
# =============================================================================
# TEST: web — curl burst on public/session endpoints (no token needed)
# =============================================================================
test_web() {
log_section "WEB — Public endpoint throughput (curl burst, no token)"
result_section "web"
local endpoints=(
"GET|/|Public home"
"GET|/login|Login page"
"GET|/api/public/landing-page|Public landing page API"
)
local TMPDIR_WEB
TMPDIR_WEB=$(mktemp -d)
trap "rm -rf '$TMPDIR_WEB'" EXIT
for entry in "${endpoints[@]}"; do
local method path label
method="${entry%%|*}"; rest="${entry#*|}"; path="${rest%%|*}"; label="${rest#*|}"
# Burst: 20 sequential requests, measure p50/p95/p99
local times=()
local errors=0
for i in $(seq 1 20); do
local t0 t1 ms
t0=$(date +%s%N)
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
-X "$method" "${BASE_URL}${path}" 2>/dev/null || echo "000")
t1=$(date +%s%N)
ms=$(( ( t1 - t0 ) / 1000000 ))
if [[ "$http_code" == "000" || "$http_code" == "5"* ]]; then
(( errors++ ))
else
times+=($ms)
fi
done
if [[ ${#times[@]} -eq 0 ]]; then
log_err "${label} — all 20 requests failed"
result_set web "$path" '{"errors":20}'
continue
fi
# Sort times
local sorted
sorted=($(printf '%s\n' "${times[@]}" | sort -n))
local n=${#sorted[@]}
local p50_idx p95_idx p99_idx
p50_idx=$(( (n * 50) / 100 ))
p95_idx=$(( (n * 95) / 100 ))
p99_idx=$(( (n * 99) / 100 ))
[[ $p50_idx -ge $n ]] && p50_idx=$(( n - 1 ))
[[ $p95_idx -ge $n ]] && p95_idx=$(( n - 1 ))
[[ $p99_idx -ge $n ]] && p99_idx=$(( n - 1 ))
local p50=${sorted[$p50_idx]}
local p95=${sorted[$p95_idx]}
local p99=${sorted[$p99_idx]}
local avg
avg=$(printf '%s\n' "${times[@]}" | awk '{sum+=$1} END {printf "%.0f", sum/NR}')
log_ok "${label}"
log_dim " avg: $(ms_label $avg) p50: $(ms_label $p50) p95: $(ms_label $p95) p99: $(ms_label $p99) errors: $errors / 20"
result_set web "$path" "{\"avg_ms\":$avg,\"p50_ms\":$p50,\"p95_ms\":$p95,\"p99_ms\":$p99,\"errors\":$errors}"
done
rm -rf "$TMPDIR_WEB"
trap - EXIT
}
# =============================================================================
# MAIN
# =============================================================================
echo -e "\n${C_BOLD}${C_CYAN}BukidBounty Performance Test${C_RESET} $(NOW)"
echo -e " Target : ${C_BOLD}${BASE_URL}${C_RESET}"
echo -e " Tests : ${RUN_TESTS[*]}"
[[ -n "$STORE_HASH" ]] && echo -e " Store : ${STORE_HASH}"
RESULTS=$(echo "$RESULTS" | jq --arg d "$(NOW)" '.run_at = $d')
for t in "${RUN_TESTS[@]}"; do
case "$t" in
ping) test_ping || true ;;
seed) test_seed || true ;;
pos) test_pos || true ;;
concurrent) test_concurrent || true ;;
ramp) test_ramp || true ;;
web) test_web || true ;;
*) log_warn "Unknown test: $t (skipped)" ;;
esac
done
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
log_section "SUMMARY"
RESULTS=$(echo "$RESULTS" | jq --arg url "$BASE_URL" '.target = $url')
if [[ -n "$OUTPUT_FILE" ]]; then
echo "$RESULTS" | jq . > "$OUTPUT_FILE"
log_ok "Results written to: ${OUTPUT_FILE}"
fi
echo -e "\n${C_DIM}Tip: run with -o results.json to capture structured results${C_RESET}"
echo -e "${C_DIM}Tip: set PERF_API_TOKEN in .env on the server to enable the perf API${C_RESET}\n"