#!/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 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"