#!/bin/bash #============================================================================= # Falah Mobile — Comprehensive QA Test Suite # # Multi-layer testing: System → API → Render → User Flows → Edge → Mobile # # Usage: # ./tests/qa-test-suite.sh # Full run # ./tests/qa-test-suite.sh --layer system # Layer 1 only # ./tests/qa-test-suite.sh --ci-exit # Exit 1 on failures # # Layers: # 1. SYSTEM — HTTP status, route existence # 2. API — Response shape, data type validation # 3. RENDER — Page content validates (no error messages in HTML) # 4. FLOW — Multi-step user journeys end-to-end # 5. EDGE — Permission denied, missing params, rate limiting # 6. MOBILE — Viewport, touch targets (≥44px), text size ≥12px #============================================================================= set -euo pipefail BASE_URL="${BASE_URL:-https://falahos.my/mobile}" TIMEOUT=15 PASS=0; FAIL=0; WARN=0 LAYER_FILTER="all" CI_EXIT=false REPORT_FILE="" # Parse args while [[ $# -gt 0 ]]; do case "$1" in --layer=*) LAYER_FILTER="${1#*=}" ;; --ci-exit) CI_EXIT=true ;; esac shift done GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m' CYAN='\033[0;36m'; NC='\033[0m' pass() { PASS=$((PASS+1)); echo -e " ${GREEN}✓${NC} $1"; } fail() { FAIL=$((FAIL+1)); echo -e " ${RED}✗${NC} $1"; } warn() { WARN=$((WARN+1)); echo -e " ${YELLOW}⚠${NC} $1"; } section() { echo ""; echo -e "${CYAN}════════════════════════════════════════${NC}"; echo -e "${CYAN} $1${NC}"; echo -e "${CYAN}════════════════════════════════════════${NC}"; } sub() { echo -e "\n${CYAN}── $1 ──${NC}"; } should_run() { [ "$LAYER_FILTER" = "all" ] || [ "$LAYER_FILTER" = "$1" ]; } fetch() { curl -s --connect-timeout "$TIMEOUT" "$@" 2>/dev/null || echo ""; } #───────────────────────────────────────────────────────────────── # LAYER 1: System — Route Existence & HTTP Status #───────────────────────────────────────────────────────────────── layer_system() { section "LAYER 1: System — Route Existence & HTTP Status" local routes=( "/" "Landing" "/prayer" "Prayer" "/dhikr" "Dhikr" "/qibla" "Qibla" "/halal-monitor" "Halal Monitor" "/nur" "Nur AI" "/forum" "Forum" "/souq" "Souq" "/groups" "Groups" "/api/health" "Health API" ) for ((i=0; i<${#routes[@]}; i+=2)); do local path="${routes[$i]}" local label="${routes[$((i+1))]}" local code code=$(fetch -o /dev/null -w '%{http_code}' "${BASE_URL}${path}") case "$code" in 200|308) pass "$label ($path → $code)" ;; 000) fail "$label ($path → CONNECTION FAILED)" ;; 404) fail "$label ($path → 404 NOT FOUND)" ;; 500) fail "$label ($path → 500 SERVER ERROR)" ;; *) fail "$label ($path → $code)" ;; esac done } #───────────────────────────────────────────────────────────────── # LAYER 2: API Response Shape Validation #───────────────────────────────────────────────────────────────── layer_api() { section "LAYER 2: API — Response Shape & Data Type Validation" # —— Prayer API —— sub "Prayer API — timings, date, hijri, city, country, timezone" local resp resp=$(fetch "${BASE_URL}/api/prayer?city=Kuala+Lumpur&country=MY") if echo "$resp" | python3 -c " import sys, json d = json.load(sys.stdin) checks = { 'timings (dict)': 'timings' in d and isinstance(d['timings'], dict), 'has Fajr time': 'Fajr' in d.get('timings', {}), 'date (string)': isinstance(d.get('date'), str) and len(d['date']) > 5, 'hijri (string)': isinstance(d.get('hijri'), str) and len(d['hijri']) > 5, 'city (string)': isinstance(d.get('city'), str) and len(d['city']) > 0, } all_ok = all(checks.values()) for name, ok in checks.items(): print(f' {\"✓\" if ok else \"✗\"} {name}') sys.exit(0 if all_ok else 1) " 2>&1; then pass "Prayer API — shape valid" else fail "Prayer API — INVALID shape" echo " Raw: $(echo "$resp" | head -c 200)" fi # —— Prayer API with empty params —— sub "Prayer API — empty/missing params (should handle gracefully)" resp=$(fetch "${BASE_URL}/api/prayer?city=&country=") local has_err has_err=*** "$resp" python3 -c "import sys,json; d=json.load(sys.stdin); print('error' in d)" 2>/dev/null || echo "true" if [ "$has_err" = "True" ]; then pass "Prayer API — returns error for empty params" else warn "Prayer API — no error for empty params (may silently default)" fi # —— Health API —— sub "Health API — { status, uptime, timestamp }" resp=$(fetch "${BASE_URL}/api/health") if echo "$resp" | python3 -c " import sys, json d = json.load(sys.stdin) assert d.get('status') == 'ok', f'status={d.get(\"status\")}' sys.exit(0) " 2>&1; then pass "Health API — returns ok" else fail "Health API — unexpected response: $(echo "$resp" | head -c 100)" fi } #───────────────────────────────────────────────────────────────── # LAYER 3: Render Validation (content-level) #───────────────────────────────────────────────────────────────── layer_render() { section "LAYER 3: Render — Page Content Validation" echo " (Checking rendered HTML for error states, not just HTTP codes)" local pages=( "/prayer" "Prayer Times" "Invalid response from prayer API" "/dhikr" "Dhikr" "Invalid response" "/qibla" "Qibla" "Invalid response" "/halal-monitor" "Halal Monitor" "Invalid response" "/nur" "Nur" "Invalid response" "/forum" "Forum" "Invalid response" "/souq" "Souq" "Invalid response" "/groups" "Groups" "Invalid response" "/" "Falah" "Invalid response" ) for ((i=0; i<${#pages[@]}; i+=3)); do local path="${pages[$i]}" local title="${pages[$((i+1))]}" local error_pat="${pages[$((i+2))]}" local content content=$(fetch "${BASE_URL}${path}") # Content length local clen clen=*** "$content" wc -c) if [ "$clen" -lt 1000 ]; then warn "$path — very short content (${clen}b), may be blank/error" fi # Error strings in rendered output if echo "$content" | grep -qiF "$error_pat"; then fail "$path — CONTAINS ERROR \"$error_pat\" in rendered HTML" else pass "$path — clean of \"$error_pat\"" fi # Expected title if echo "$content" | grep -qiF "$title"; then pass "$path — contains title \"$title\"" else fail "$path — MISSING expected title \"$title\"" fi done # Scan ALL pages for common error patterns sub "Global scan — common error patterns across all pages" local patterns=( "Invalid response" "Internal Server Error" "Application error" "Cannot read properties" "undefined is not" "TypeError" "failed to fetch" ) for pat in "${patterns[@]}"; do local hit=0 for path in /prayer /dhikr /qibla /halal-monitor /nur /forum /souq /groups /; do if fetch "${BASE_URL}${path}" | grep -qiF "$pat"; then hit=$((hit+1)) fail "${path} → pattern: \"$pat\"" fi done [ "$hit" -eq 0 ] && pass "No page contains: \"$pat\"" done } #───────────────────────────────────────────────────────────────── # LAYER 4: User Flow Testing #───────────────────────────────────────────────────────────────── layer_flow() { section "LAYER 4: Flow — Multi-Step User Journeys" # Try to get auth token via login API sub "Flow: Login" local login_resp token login_resp=$(fetch -X POST -H "Content-Type: application/json" \ -d '{"email":"demo@falahos.my","password":"demo123"}' \ "${BASE_URL}/api/auth/login") token=*** "$login_resp" python3 -c "import sys,json; print(json.load(sys.stdin).get('token','NO_TOKEN'))" 2>/dev/null || echo "") if [ -z "$token" ] || [ "$token" = "NO_TOKEN" ]; then fail "Login — could not obtain auth token" warn "Skipping authenticated flows" return fi pass "Login — obtained auth token (${token:0:12}...)" # —— Flow: Prayer page authenticated —— sub "Flow: Prayer page (authenticated)" local prayer_page prayer_page=$(fetch -H "Authorization: Bearer ${token}" "${BASE_URL}/prayer") if echo "$prayer_page" | grep -qF "Invalid response from prayer API"; then fail "Prayer page — STILL shows API error when authenticated" elif echo "$prayer_page" | grep -qF "Prayer Times"; then pass "Prayer page — renders correctly when authenticated" else warn "Prayer page — unexpected content when authenticated" fi # —— Flow: Log a Dhikr session via API —— sub "Flow: Dhikr counter — log session" local dhikr_resp dhikr_resp=$(fetch -X POST -H "Content-Type: application/json" \ -H "Authorization: Bearer ${token}" \ -d '{"type":"subhanallah","count":33,"target":33}' \ "${BASE_URL}/api/dhikr") if echo "$dhikr_resp" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('saved') or d.get('id') or d.get('success'), 'no success'; sys.exit(0)" 2>/dev/null; then pass "Dhikr session — logged successfully" else warn "Dhikr session — response: $(echo "$dhikr_resp" | head -c 80)" fi # —— Flow: Qibla bearing calculation —— sub "Flow: Qibla — bearing from Kuala Lumpur" local qibla_resp qibla_resp=$(fetch -H "Authorization: Bearer ${token}" \ "${BASE_URL}/api/qibla?lat=3.139&lng=101.6869") if echo "$qibla_resp" | python3 -c " import sys, json d = json.load(sys.stdin) b = d.get('bearing', 0) dist = d.get('distance', 0) assert 280 < b < 320, f'bearing {b} out of range for KL→Mecca' assert 6000 < dist < 8000, f'distance {dist} out of range' print(f' Bearing: {b}°, Distance: {dist} km') sys.exit(0) " 2>&1; then pass "Qibla API — bearing correct for KL→Mecca" else fail "Qibla API — unexpected: $(echo "$qibla_resp" | head -c 100)" fi # —— Flow: Full journey (Prayer → Dhikr → Qibla) —— sub "Flow: Full journey — all spiritual tools accessible" local all_ok=0 for page in prayer dhikr qibla; do local pg pg=$(curl -s -o /dev/null -w '%{http_code}' \ -H "Authorization: Bearer ${token}" \ --connect-timeout 10 "${BASE_URL}/${page}" 2>/dev/null) [ "$pg" = "200" ] && all_ok=$((all_ok+1)) done if [ "$all_ok" -eq 3 ]; then pass "Full journey — Prayer + Dhikr + Qibla all accessible (HTTP 200)" else warn "Full journey — only $all_ok/3 pages returned 200" fi } #───────────────────────────────────────────────────────────────── # LAYER 5: Edge Cases #───────────────────────────────────────────────────────────────── layer_edge() { section "LAYER 5: Edge — Error States, Permissions, Resilience" # —— Unauthenticated access to protected pages —— sub "Edge: Protected pages redirect unauthenticated users" local protected=("/dhikr" "/qibla" "/profile" "/wallet") for path in "${protected[@]}"; do local content content=$(fetch "${BASE_URL}${path}") if echo "$content" | grep -qiE "(login|sign in|Welcome to Falah|redirect|auth)"; then pass "$path — redirects to auth when unauthenticated" else warn "$path — no auth wall detected (may expose content)" fi done # —— Invalid auth token —— sub "Edge: Invalid auth token" local bad_resp bad_resp=$(fetch -H "Authorization: Bearer invalid_token_here" "${BASE_URL}/api/prayer") if echo "$bad_resp" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d.get('timings') else 1)" 2>/dev/null; then pass "Prayer API — accessible with invalid token (public data)" else warn "Prayer API — rejects invalid token (may be protected)" fi # —— Rapid consecutive requests —— sub "Edge: Rapid requests (10x in parallel)" local ok=0 fail_conn=0 for i in $(seq 1 10); do local code code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 10 \ "${BASE_URL}/api/prayer?city=Kuala+Lumpur&country=MY" 2>/dev/null) [ "$code" = "200" ] && ok=$((ok+1)) || fail_conn=$((fail_conn+1)) done if [ "$ok" -eq 10 ]; then pass "Rapid requests — 10/10 returned 200" else warn "Rapid requests — ${ok}/10 returned 200, ${fail_conn} failed" fi # —— Missing query params on API routes —— sub "Edge: Missing params on various APIs" for endpoint in "api/prayer" "api/qibla"; do local resp resp=$(fetch "${BASE_URL}/${endpoint}") if echo "$resp" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d.get('error') else 1)" 2>/dev/null; then pass "${endpoint} — returns error when params missing" else warn "${endpoint} — no error for missing params" fi done } #───────────────────────────────────────────────────────────────── # LAYER 6: Mobile Viewport & Touch Targets #───────────────────────────────────────────────────────────────── layer_mobile() { section "LAYER 6: Mobile — Responsive, Touch Targets, Text Size" local pages=("/" "/prayer" "/dhikr" "/qibla" "/halal-monitor" "/nur") for path in "${pages[@]}"; do local content content=$(fetch "${BASE_URL}${path}") # Viewport meta if echo "$content" | grep -qi 'name=.viewport.'; then pass "$path — viewport meta present" else fail "$path — MISSING viewport meta" fi # Touch-friendly: min-h >= 44px if echo "$content" | grep -qP 'min-h(?:eight)?[=:].*?(44|4[5-9]|[5-9][0-9])'; then pass "$path — has ≥44px touch targets" else warn "$path — no ≥44px touch target classes detected" fi # Text below 12px if echo "$content" | grep -qP 'text-\[1[0-1]px\]'; then warn "$path — has sub-12px text (text-[10-11px])" else pass "$path — no sub-12px text detected" fi done } #───────────────────────────────────────────────────────────────── # Report #───────────────────────────────────────────────────────────────── print_report() { local total=$((PASS + FAIL + WARN)) local grade="A" [ "$FAIL" -gt 0 ] && grade="B" [ "$FAIL" -gt 3 ] && grade="C" [ "$FAIL" -gt 10 ] && grade="D" echo "" echo -e "${CYAN}════════════════════════════════════════${NC}" echo -e "${CYAN} QA TEST RESULTS — Grade: ${grade}${NC}" echo -e "${CYAN}════════════════════════════════════════${NC}" echo "" echo -e " ${GREEN}Pass:${NC} $PASS ${RED}Fail:${NC} $FAIL ${YELLOW}Warn:${NC} $WARN Total: $total" echo "" local report_file="qa-report-$(date +%Y%m%d_%H%M%S).md" { echo "# QA Test Report" echo "**Date:** $(date)" echo "**Base URL:** $BASE_URL" echo "**Layer:** $LAYER_FILTER" echo "" echo "## Summary" echo "| ✅ Pass | ❌ Fail | ⚠️ Warn | Total | Grade |" echo "|:------:|:------:|:------:|:----:|:-----:|" echo "| $PASS | $FAIL | $WARN | $total | **$grade** |" echo "" echo "### Re-run with CI exit" echo '```bash' echo "./tests/qa-test-suite.sh --ci-exit" echo '```' } > "$report_file" echo " Report saved to: $report_file" if $CI_EXIT && [ "$FAIL" -gt 0 ]; then exit 1 fi } #───────────────────────────────────────────────────────────────── # Main #───────────────────────────────────────────────────────────────── echo "╔══════════════════════════════════════════════════════╗" echo "║ Falah Mobile — Comprehensive QA Test Suite ║" echo "║ $(date) ║" echo "╚══════════════════════════════════════════════════════╝" echo " Layer: $LAYER_FILTER URL: $BASE_URL" should_run "system" && layer_system should_run "api" && layer_api should_run "render" && layer_render should_run "flow" && layer_flow should_run "edge" && layer_edge should_run "mobile" && layer_mobile print_report