Add QA test framework — API tests, infra health, test data seeding

Payfrit:
- Tab API contract tests (bash + python) — all 13 endpoints
- Param validation, response time, cross-env parity checks

Grubflip:
- API endpoint test stubs — menu, restaurant, order, auth
- Ready to activate as Mike deploys endpoints

Shared:
- test_helpers.sh + test_helpers.py — HTTP helpers, pass/fail/skip, JSON output mode
- Master test runner (scripts/run-all.sh)
- Infrastructure health checker (disk, RAM, services, SSL certs)

Test data:
- Grubflip seed SQL (QA test restaurants, menus, orders)
- Payfrit tab seeder script

All Payfrit tab tests confirmed passing against dev + prod.
This commit is contained in:
Luna (QA) 2026-03-26 05:57:37 +00:00
parent 7d6d8c558e
commit e6153ac4b7
16 changed files with 1031 additions and 2 deletions

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# Generated reports
reports/*.txt
reports/*.json
reports/*.md
# Temp files
*.tmp
*.log
.DS_Store
# Secrets (never commit)
.env
*.secret
credentials.*

View file

@ -1,3 +1,46 @@
# payfrit-qa # Payfrit QA Test Framework
QA test framework — API tests, infra health checks, test data seeding Automated test suites for Payfrit and Grubflip APIs, infrastructure health, and deployment validation.
**Maintainer:** Luna (@luna) — QA
**Forgejo:** `payfrit/payfrit-qa`
## Structure
```
payfrit/api/ — Payfrit Tab API contract tests
grubflip/api/ — Grubflip API test suite
shared/lib/ — Shared test utilities (bash + python)
test-data/ — Seed data and fixtures
scripts/ — CI runners, deployment checklists
reports/ — Generated test reports (gitignored)
```
## Quick Start
```bash
# Run all Payfrit API tests against dev
./scripts/run-all.sh payfrit dev
# Run all Grubflip API tests against dev
./scripts/run-all.sh grubflip dev
# Run infrastructure health check
./scripts/infra-health.sh
# Seed test data
./test-data/payfrit/seed-tabs.sh dev
```
## Environments
| Name | Payfrit API | Grubflip API |
|------|-------------|--------------|
| dev | `https://dev.payfrit.com/api` | `https://dev.grubflip.com/api` |
| prod | `https://biz.payfrit.com/api` | `https://api.grubflip.com` |
## Test Conventions
- Every test script exits 0 on all-pass, 1 on any failure.
- JSON output mode available via `--json` flag for CI integration.
- Reports written to `reports/` with timestamp filenames.

View file

@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""
test_endpoints.py Grubflip API test suite
Tests Grubflip API endpoints as they come online.
Structured as stubs that will be filled in as Mike deploys endpoints.
Current endpoint coverage (from Mike's task list):
- Menu endpoints (CRUD)
- Order endpoints
- Restaurant endpoints
- User endpoints
- Auth endpoints
Usage:
python3 grubflip/api/test_endpoints.py [dev|prod] [--json]
Author: Luna (@luna) QA
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
from shared.lib.test_helpers import TestRunner, get_base_url
def main():
env = "dev"
json_mode = False
for arg in sys.argv[1:]:
if arg == "--json":
json_mode = True
elif arg in ("dev", "prod"):
env = arg
base = get_base_url("grubflip", env)
if not base:
print(f"Unknown environment: {env}")
sys.exit(1)
t = TestRunner(f"grubflip-api-{env}", json_mode=json_mode)
# ─── Connectivity check ──────────────────────────────────
t.section("API CONNECTIVITY")
resp = t.http_get(base)
if resp["status"] == 0:
t.fail("API base reachable", resp["error"])
# If we can't even reach it, skip everything else
exit_code = t.summary()
sys.exit(exit_code)
else:
t.pass_(f"API base responds (HTTP {resp['status']}, {resp['time_ms']:.0f}ms)")
# ─── Menu endpoints ──────────────────────────────────────
t.section("MENU ENDPOINTS")
menu_endpoints = [
("GET", "/menus", "List menus"),
("GET", "/menus/1", "Get menu by ID"),
("GET", "/menu-items", "List menu items"),
("GET", "/menu-items/1", "Get menu item by ID"),
]
for method, path, label in menu_endpoints:
url = f"{base}{path}"
if method == "GET":
resp = t.http_get(url)
else:
resp = t.http_post(url)
if resp["status"] == 0:
t.skip(label, f"unreachable: {resp['error']}")
elif resp["status"] == 404:
t.skip(label, "404 — endpoint not deployed yet")
elif resp["status"] in (200, 201, 400, 401, 422):
t.pass_(f"{label} — HTTP {resp['status']} ({resp['time_ms']:.0f}ms)")
else:
t.fail(label, f"HTTP {resp['status']}")
# ─── Restaurant endpoints ────────────────────────────────
t.section("RESTAURANT ENDPOINTS")
restaurant_endpoints = [
("GET", "/restaurants", "List restaurants"),
("GET", "/restaurants/1", "Get restaurant by ID"),
]
for method, path, label in restaurant_endpoints:
url = f"{base}{path}"
resp = t.http_get(url)
if resp["status"] == 0:
t.skip(label, f"unreachable: {resp['error']}")
elif resp["status"] == 404:
t.skip(label, "404 — endpoint not deployed yet")
elif resp["status"] in (200, 400, 401, 403, 422):
t.pass_(f"{label} — HTTP {resp['status']} ({resp['time_ms']:.0f}ms)")
else:
t.fail(label, f"HTTP {resp['status']}")
# ─── Order endpoints ─────────────────────────────────────
t.section("ORDER ENDPOINTS")
order_endpoints = [
("GET", "/orders", "List orders"),
("GET", "/orders/1", "Get order by ID"),
("POST", "/orders", "Create order"),
]
for method, path, label in order_endpoints:
url = f"{base}{path}"
if method == "POST":
resp = t.http_post(url, {"test": True})
else:
resp = t.http_get(url)
if resp["status"] == 0:
t.skip(label, f"unreachable: {resp['error']}")
elif resp["status"] == 404:
t.skip(label, "404 — endpoint not deployed yet")
elif resp["status"] in (200, 201, 400, 401, 422):
t.pass_(f"{label} — HTTP {resp['status']} ({resp['time_ms']:.0f}ms)")
else:
t.fail(label, f"HTTP {resp['status']}")
# ─── Auth endpoints ──────────────────────────────────────
t.section("AUTH ENDPOINTS")
auth_endpoints = [
("POST", "/auth/login", "Login"),
("POST", "/auth/register", "Register"),
]
for method, path, label in auth_endpoints:
url = f"{base}{path}"
resp = t.http_post(url, {})
if resp["status"] == 0:
t.skip(label, f"unreachable: {resp['error']}")
elif resp["status"] == 404:
t.skip(label, "404 — endpoint not deployed yet")
elif resp["status"] in (200, 400, 401, 422):
t.pass_(f"{label} — HTTP {resp['status']} ({resp['time_ms']:.0f}ms)")
else:
t.fail(label, f"HTTP {resp['status']}")
exit_code = t.summary()
sys.exit(exit_code)
if __name__ == "__main__":
main()

64
grubflip/api/test_endpoints.sh Executable file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
# ============================================================
# test_endpoints.sh — Grubflip API smoke tests (bash)
# Quick connectivity and endpoint existence checks.
# Usage: ./grubflip/api/test_endpoints.sh [dev|prod] [--json]
# Author: Luna (@luna) — QA
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
source "$REPO_ROOT/shared/lib/test_helpers.sh"
ENV="${1:-dev}"
case "$ENV" in
dev) BASE="https://dev.grubflip.com/api" ;;
prod) BASE="https://api.grubflip.com" ;;
*) echo "Usage: $0 [dev|prod] [--json]"; exit 1 ;;
esac
$JSON_MODE || {
echo "============================================"
echo " GRUBFLIP API SMOKE TESTS ($ENV)"
echo " $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "============================================"
}
section "CONNECTIVITY"
result=$(http_get "$BASE")
IFS='|' read -r code body ms <<< "$result"
if [ "$code" != "000" ] && [ -n "$code" ]; then
pass "API base responds (HTTP $code, ${ms}ms)"
else
fail "API base unreachable" "HTTP $code"
test_summary
exit $?
fi
section "ENDPOINT DISCOVERY"
ENDPOINTS=(
"GET|/menus|List menus"
"GET|/menus/1|Get menu"
"GET|/menu-items|List menu items"
"GET|/restaurants|List restaurants"
"GET|/restaurants/1|Get restaurant"
"GET|/orders|List orders"
"GET|/orders/1|Get order"
)
for entry in "${ENDPOINTS[@]}"; do
IFS='|' read -r method path label <<< "$entry"
result=$(http_get "$BASE$path")
IFS='|' read -r code body ms <<< "$result"
case "$code" in
000) skip "$label ($path)" "unreachable" ;;
404) skip "$label ($path)" "not deployed yet" ;;
200|201|400|401|422|403)
pass "$label ($path) — HTTP $code (${ms}ms)" ;;
*)
fail "$label ($path)" "HTTP $code" ;;
esac
done
test_summary

0
payfrit/__init__.py Normal file
View file

108
payfrit/api/test_tabs.py Normal file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
test_tabs.py Payfrit Tab API contract tests (Python version)
Tests all 13 tab endpoints for:
- Endpoint existence (not 404)
- Param validation (correct ERROR field when called with no params)
- Response is valid JSON
- Response time < 500ms
Usage:
python3 payfrit/api/test_tabs.py [dev|prod] [--json]
Author: Luna (@luna) QA
"""
import sys
import os
# Add repo root to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
from shared.lib.test_helpers import TestRunner, get_base_url
def main():
env = "dev"
json_mode = False
for arg in sys.argv[1:]:
if arg == "--json":
json_mode = True
elif arg in ("dev", "prod"):
env = arg
base = get_base_url("payfrit", env)
if not base:
print(f"Unknown environment: {env}")
sys.exit(1)
t = TestRunner(f"payfrit-tabs-{env}", json_mode=json_mode)
# All 13 tab endpoints: (filename, expected_error_when_no_params)
TAB_ENDPOINTS = [
("open.php", "missing_UserID"),
("get.php", "missing_TabID"),
("getActive.php", "missing_UserID"),
("getPresence.php", "missing_BusinessID"),
("close.php", "missing_TabID"),
("cancel.php", "missing_TabID"),
("addOrder.php", "missing_TabID"),
("addMember.php", "missing_TabID"),
("removeMember.php", "missing_TabID"),
("approveOrder.php", "missing_TabID"),
("rejectOrder.php", "missing_TabID"),
("pendingOrders.php", "missing_TabID"),
("increaseAuth.php", "missing_TabID"),
]
# --- Endpoint existence & param validation ---
t.section(f"ENDPOINT EXISTENCE & PARAM VALIDATION ({len(TAB_ENDPOINTS)} endpoints)")
for endpoint, expected_error in TAB_ENDPOINTS:
url = f"{base}/tabs/{endpoint}"
resp = t.http_get(url)
if resp["status"] == 404 or resp["status"] == 0:
t.fail(f"tabs/{endpoint}", f"HTTP {resp['status']} — endpoint missing or unreachable")
continue
if resp["json"] is None:
t.fail(f"tabs/{endpoint}", "response is not valid JSON")
continue
actual_error = resp["json"].get("ERROR", "")
if actual_error == expected_error:
t.pass_(f"tabs/{endpoint} — param validation OK ({resp['time_ms']:.0f}ms)")
else:
t.fail(f"tabs/{endpoint}", f"expected ERROR='{expected_error}', got='{actual_error}'")
# --- Response time ---
t.section("RESPONSE TIME (< 500ms threshold)")
MAX_MS = 500
for endpoint, _ in TAB_ENDPOINTS:
url = f"{base}/tabs/{endpoint}"
resp = t.http_get(url)
ms = resp["time_ms"]
if ms < MAX_MS:
t.pass_(f"tabs/{endpoint}: {ms:.0f}ms")
else:
t.fail(f"tabs/{endpoint}: {ms:.0f}ms", f"SLOW (limit {MAX_MS}ms)")
# --- Cross-environment parity ---
if env == "dev":
t.section("CROSS-ENVIRONMENT PARITY (dev vs prod)")
prod_base = get_base_url("payfrit", "prod")
for endpoint, _ in TAB_ENDPOINTS:
resp = t.http_get(f"{prod_base}/tabs/{endpoint}")
if resp["status"] not in (0, 404):
t.pass_(f"tabs/{endpoint} exists on prod (HTTP {resp['status']})")
else:
t.fail(f"tabs/{endpoint} MISSING on prod (HTTP {resp['status']})")
exit_code = t.summary()
sys.exit(exit_code)
if __name__ == "__main__":
main()

105
payfrit/api/test_tabs.sh Executable file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env bash
# ============================================================
# test_tabs.sh — Payfrit Tab API Contract Tests
# Tests all 13 tab endpoints for existence, param validation,
# response format, and response time.
# Usage: ./payfrit/api/test_tabs.sh [dev|prod] [--json]
# Author: Luna (@luna) — QA
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
source "$REPO_ROOT/shared/lib/test_helpers.sh"
ENV="${1:-dev}"
case "$ENV" in
dev) BASE="https://dev.payfrit.com/api" ;;
prod) BASE="https://biz.payfrit.com/api" ;;
*) echo "Usage: $0 [dev|prod] [--json]"; exit 1 ;;
esac
$JSON_MODE || {
echo "============================================"
echo " PAYFRIT TAB API CONTRACT TESTS ($ENV)"
echo " $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "============================================"
}
# All 13 tab endpoints with their expected error when called with no params
TAB_ENDPOINTS=(
"open.php|missing_UserID"
"get.php|missing_TabID"
"getActive.php|missing_UserID"
"getPresence.php|missing_BusinessID"
"close.php|missing_TabID"
"cancel.php|missing_TabID"
"addOrder.php|missing_TabID"
"addMember.php|missing_TabID"
"removeMember.php|missing_TabID"
"approveOrder.php|missing_TabID"
"rejectOrder.php|missing_TabID"
"pendingOrders.php|missing_TabID"
"increaseAuth.php|missing_TabID"
)
# ─── Endpoint existence & param validation ────────────────────
section "ENDPOINT EXISTENCE & PARAM VALIDATION (${#TAB_ENDPOINTS[@]} endpoints)"
for entry in "${TAB_ENDPOINTS[@]}"; do
IFS='|' read -r endpoint expected_error <<< "$entry"
url="$BASE/tabs/$endpoint"
result=$(http_get "$url")
IFS='|' read -r code body ms <<< "$result"
if [ "$code" = "404" ] || [ "$code" = "000" ]; then
fail "tabs/$endpoint" "HTTP $code — endpoint missing"
continue
fi
if ! is_valid_json "$body"; then
fail "tabs/$endpoint" "response is not valid JSON"
continue
fi
actual_error=$(json_field "$body" "ERROR")
if [ "$actual_error" = "$expected_error" ]; then
pass "tabs/$endpoint — correct param validation (${ms}ms)"
else
fail "tabs/$endpoint" "expected error='$expected_error', got='$actual_error'"
fi
done
# ─── Response time checks ─────────────────────────────────────
section "RESPONSE TIME (< 500ms threshold)"
MAX_MS=500
for entry in "${TAB_ENDPOINTS[@]}"; do
IFS='|' read -r endpoint _ <<< "$entry"
url="$BASE/tabs/$endpoint"
result=$(http_get "$url")
IFS='|' read -r code body ms <<< "$result"
if [ "${ms:-0}" -lt "$MAX_MS" ] 2>/dev/null; then
pass "tabs/$endpoint response: ${ms}ms"
else
fail "tabs/$endpoint response: ${ms}ms (SLOW, limit ${MAX_MS}ms)"
fi
done
# ─── Cross-environment parity ────────────────────────────────
if [ "$ENV" = "dev" ]; then
section "CROSS-ENVIRONMENT PARITY (dev vs prod)"
PROD_BASE="https://biz.payfrit.com/api"
for entry in "${TAB_ENDPOINTS[@]}"; do
IFS='|' read -r endpoint _ <<< "$entry"
prod_result=$(http_get "$PROD_BASE/tabs/$endpoint")
IFS='|' read -r prod_code _ _ <<< "$prod_result"
if [ "$prod_code" != "404" ] && [ "$prod_code" != "000" ]; then
pass "tabs/$endpoint exists on prod (HTTP $prod_code)"
else
fail "tabs/$endpoint MISSING on prod (HTTP $prod_code)"
fi
done
fi
test_summary

0
reports/.gitkeep Normal file
View file

110
scripts/infra-health.sh Executable file
View file

@ -0,0 +1,110 @@
#!/usr/bin/env bash
# ============================================================
# infra-health.sh — Infrastructure health checks
# Checks: disk, memory, apache, key services, SSL certs
# Usage: ./scripts/infra-health.sh [--json]
# Author: Luna (@luna) — QA
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$REPO_ROOT/shared/lib/test_helpers.sh"
$JSON_MODE || {
echo "============================================"
echo " INFRASTRUCTURE HEALTH CHECK"
echo " $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo " Host: $(hostname)"
echo "============================================"
}
# ─── Disk ─────────────────────────────────────────────────────
section "DISK USAGE"
while read -r filesystem size used avail pct mount; do
pct_num=${pct%%%}
if [ "$pct_num" -lt 80 ]; then
pass "$mount: ${pct} used ($avail available)"
elif [ "$pct_num" -lt 90 ]; then
fail "$mount: ${pct} used — WARNING" "$avail remaining"
else
fail "$mount: ${pct} used — CRITICAL" "$avail remaining"
fi
done < <(df -h --output=source,size,used,avail,pcent,target -x tmpfs -x devtmpfs 2>/dev/null | tail -n +2)
# ─── Memory ───────────────────────────────────────────────────
section "MEMORY"
read -r total used free shared buff avail < <(free -m | awk '/^Mem:/ {print $2,$3,$4,$5,$6,$7}')
pct_used=$((used * 100 / total))
if [ "$pct_used" -lt 80 ]; then
pass "RAM: ${used}M / ${total}M (${pct_used}%) — ${avail}M available"
else
fail "RAM: ${used}M / ${total}M (${pct_used}%)" "only ${avail}M available"
fi
read -r stotal sused sfree < <(free -m | awk '/^Swap:/ {print $2,$3,$4}')
if [ "${stotal:-0}" -gt 0 ]; then
spct=$((sused * 100 / stotal))
if [ "$spct" -lt 50 ]; then
pass "Swap: ${sused}M / ${stotal}M (${spct}%)"
else
fail "Swap: ${sused}M / ${stotal}M (${spct}%)" "high swap usage"
fi
fi
# ─── Key services ─────────────────────────────────────────────
section "SERVICES"
for svc in apache2 mysql mariadb; do
if systemctl is-active --quiet "$svc" 2>/dev/null; then
pass "$svc is running"
elif systemctl list-unit-files "$svc.service" 2>/dev/null | grep -q "$svc"; then
fail "$svc is NOT running"
fi
done
# ─── SSL cert checks ─────────────────────────────────────────
section "SSL CERTIFICATES"
DOMAINS=("payfrit.com" "dev.payfrit.com" "biz.payfrit.com" "grubflip.com" "dev.grubflip.com")
for domain in "${DOMAINS[@]}"; do
expiry=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
cn=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null | openssl x509 -noout -subject 2>/dev/null | grep -oP 'CN\s*=\s*\K.*')
if [ -z "$expiry" ]; then
fail "$domain SSL" "could not retrieve certificate"
continue
fi
expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null)
now_epoch=$(date +%s)
days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
if echo "$cn" | grep -qi "$domain"; then
cn_ok="CN matches"
else
cn_ok="CN MISMATCH: $cn"
fi
if [ "$days_left" -gt 30 ]; then
pass "$domain${days_left}d until expiry, $cn_ok"
elif [ "$days_left" -gt 0 ]; then
fail "$domain — EXPIRING in ${days_left}d" "$cn_ok"
else
fail "$domain — EXPIRED" "$cn_ok"
fi
done
# ─── Website reachability ────────────────────────────────────
section "WEBSITE REACHABILITY"
SITES=("https://payfrit.com" "https://dev.payfrit.com" "https://biz.payfrit.com" "https://grubflip.com" "https://dev.grubflip.com")
for url in "${SITES[@]}"; do
result=$(http_get "$url")
IFS='|' read -r code body ms <<< "$result"
if [ "$code" = "200" ]; then
pass "$url — HTTP $code (${ms}ms)"
elif [ "$code" = "000" ]; then
fail "$url" "unreachable"
else
fail "$url" "HTTP $code (${ms}ms)"
fi
done
test_summary

64
scripts/run-all.sh Executable file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
# ============================================================
# run-all.sh — Master test runner
# Usage: ./scripts/run-all.sh [payfrit|grubflip|all] [dev|prod] [--json]
# Author: Luna (@luna) — QA
# ============================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REPORTS_DIR="$REPO_ROOT/reports"
PROJECT="${1:-all}"
ENV="${2:-dev}"
JSON_FLAG=""
for arg in "$@"; do [ "$arg" = "--json" ] && JSON_FLAG="--json"; done
TIMESTAMP=$(date -u '+%Y%m%d-%H%M%S')
REPORT_FILE="$REPORTS_DIR/${PROJECT}-${ENV}-${TIMESTAMP}.txt"
mkdir -p "$REPORTS_DIR"
echo "╔══════════════════════════════════════════════╗"
echo "║ PAYFRIT QA — TEST RUNNER ║"
echo "║ Project: $PROJECT | Env: $ENV "
echo "$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "╚══════════════════════════════════════════════╝"
echo ""
EXIT_CODE=0
run_suite() {
local name="$1"
local cmd="$2"
echo "─── Running: $name ───"
if eval "$cmd"; then
echo "$name: ALL PASSED"
else
echo "$name: FAILURES DETECTED"
EXIT_CODE=1
fi
echo ""
}
if [ "$PROJECT" = "payfrit" ] || [ "$PROJECT" = "all" ]; then
run_suite "Payfrit Tab API (bash)" "bash $REPO_ROOT/payfrit/api/test_tabs.sh $ENV $JSON_FLAG"
run_suite "Payfrit Tab API (python)" "python3 $REPO_ROOT/payfrit/api/test_tabs.py $ENV $JSON_FLAG"
fi
if [ "$PROJECT" = "grubflip" ] || [ "$PROJECT" = "all" ]; then
run_suite "Grubflip API (bash)" "bash $REPO_ROOT/grubflip/api/test_endpoints.sh $ENV $JSON_FLAG"
run_suite "Grubflip API (python)" "python3 $REPO_ROOT/grubflip/api/test_endpoints.py $ENV $JSON_FLAG"
fi
echo "════════════════════════════════════════════════"
if [ "$EXIT_CODE" -eq 0 ]; then
echo " ALL SUITES PASSED ✅"
else
echo " SOME SUITES HAD FAILURES ❌"
fi
echo "════════════════════════════════════════════════"
exit $EXIT_CODE

0
shared/__init__.py Normal file
View file

0
shared/lib/__init__.py Normal file
View file

152
shared/lib/test_helpers.py Normal file
View file

@ -0,0 +1,152 @@
"""
test_helpers.py Shared Python test utilities for Payfrit QA
Usage: from shared.lib.test_helpers import TestRunner
"""
import json
import sys
import time
import urllib.request
import urllib.error
import ssl
from dataclasses import dataclass, field
from typing import Optional
# Skip SSL verification for dev/self-signed certs
SSL_CTX = ssl.create_default_context()
SSL_CTX.check_hostname = False
SSL_CTX.verify_mode = ssl.CERT_NONE
@dataclass
class TestResult:
name: str
status: str # pass, fail, skip
detail: str = ""
response_ms: float = 0
class TestRunner:
"""Lightweight test runner with console + JSON output."""
def __init__(self, suite_name: str, json_mode: bool = False):
self.suite_name = suite_name
self.json_mode = json_mode
self.results: list[TestResult] = []
def pass_(self, name: str, detail: str = ""):
r = TestResult(name, "pass", detail)
self.results.append(r)
if not self.json_mode:
print(f"{name}{f'{detail}' if detail else ''}")
def fail(self, name: str, detail: str = ""):
r = TestResult(name, "fail", detail)
self.results.append(r)
if not self.json_mode:
print(f"{name}{f'{detail}' if detail else ''}")
def skip(self, name: str, reason: str = ""):
r = TestResult(name, "skip", reason)
self.results.append(r)
if not self.json_mode:
print(f" ⏭️ {name}{f'{reason}' if reason else ''}")
def section(self, title: str):
if not self.json_mode:
print(f"\n━━━ {title} ━━━")
def http_get(self, url: str, timeout: int = 10) -> dict:
"""Returns dict with keys: status, body, json, time_ms, error"""
start = time.time()
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout, context=SSL_CTX) as resp:
body = resp.read().decode("utf-8", errors="replace")
elapsed = (time.time() - start) * 1000
result = {"status": resp.status, "body": body, "time_ms": elapsed, "error": None}
try:
result["json"] = json.loads(body)
except json.JSONDecodeError:
result["json"] = None
return result
except urllib.error.HTTPError as e:
elapsed = (time.time() - start) * 1000
body = e.read().decode("utf-8", errors="replace") if e.fp else ""
result = {"status": e.code, "body": body, "time_ms": elapsed, "error": str(e)}
try:
result["json"] = json.loads(body)
except (json.JSONDecodeError, Exception):
result["json"] = None
return result
except Exception as e:
elapsed = (time.time() - start) * 1000
return {"status": 0, "body": "", "json": None, "time_ms": elapsed, "error": str(e)}
def http_post(self, url: str, data: dict = None, timeout: int = 10) -> dict:
"""POST JSON, returns same dict as http_get."""
payload = json.dumps(data or {}).encode("utf-8")
start = time.time()
try:
req = urllib.request.Request(url, data=payload, method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout, context=SSL_CTX) as resp:
body = resp.read().decode("utf-8", errors="replace")
elapsed = (time.time() - start) * 1000
result = {"status": resp.status, "body": body, "time_ms": elapsed, "error": None}
try:
result["json"] = json.loads(body)
except json.JSONDecodeError:
result["json"] = None
return result
except urllib.error.HTTPError as e:
elapsed = (time.time() - start) * 1000
body = e.read().decode("utf-8", errors="replace") if e.fp else ""
result = {"status": e.code, "body": body, "time_ms": elapsed, "error": str(e)}
try:
result["json"] = json.loads(body)
except (json.JSONDecodeError, Exception):
result["json"] = None
return result
except Exception as e:
elapsed = (time.time() - start) * 1000
return {"status": 0, "body": "", "json": None, "time_ms": elapsed, "error": str(e)}
def summary(self) -> int:
"""Print summary, return exit code (0=all pass, 1=any fail)."""
passes = sum(1 for r in self.results if r.status == "pass")
fails = sum(1 for r in self.results if r.status == "fail")
skips = sum(1 for r in self.results if r.status == "skip")
total = len(self.results)
if self.json_mode:
out = {
"suite": self.suite_name,
"total": total, "pass": passes, "fail": fails, "skip": skips,
"results": [{"name": r.name, "status": r.status, "detail": r.detail} for r in self.results]
}
print(json.dumps(out, indent=2))
else:
print(f"\n{'' * 42}")
print(f" TOTAL: {total} | PASS: {passes} | FAIL: {fails} | SKIP: {skips}")
print(f"{'' * 42}")
return 0 if fails == 0 else 1
# Environment config
ENVS = {
"payfrit": {
"dev": "https://dev.payfrit.com/api",
"prod": "https://biz.payfrit.com/api",
},
"grubflip": {
"dev": "https://dev.grubflip.com/api",
"prod": "https://api.grubflip.com",
},
}
def get_base_url(project: str, env: str) -> str:
return ENVS.get(project, {}).get(env, "")

101
shared/lib/test_helpers.sh Normal file
View file

@ -0,0 +1,101 @@
#!/usr/bin/env bash
# ============================================================
# test_helpers.sh — Shared test utilities
# Source this in any test script: source shared/lib/test_helpers.sh
# ============================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
PASS_COUNT=0
FAIL_COUNT=0
SKIP_COUNT=0
TEST_RESULTS=()
JSON_MODE=false
# Parse --json flag
for arg in "$@"; do
[ "$arg" = "--json" ] && JSON_MODE=true
done
pass() {
local msg="$1"
((PASS_COUNT++))
TEST_RESULTS+=("{\"status\":\"pass\",\"test\":\"$msg\"}")
$JSON_MODE || echo -e " ${GREEN}${NC} $msg"
}
fail() {
local msg="$1"
local detail="${2:-}"
((FAIL_COUNT++))
TEST_RESULTS+=("{\"status\":\"fail\",\"test\":\"$msg\",\"detail\":\"$detail\"}")
$JSON_MODE || echo -e " ${RED}${NC} $msg${detail:+ — $detail}"
}
skip() {
local msg="$1"
local reason="${2:-}"
((SKIP_COUNT++))
TEST_RESULTS+=("{\"status\":\"skip\",\"test\":\"$msg\",\"reason\":\"$reason\"}")
$JSON_MODE || echo -e " ${YELLOW}⏭️${NC} $msg${reason:+ — $reason}"
}
section() {
$JSON_MODE || echo -e "\n${CYAN}━━━ $1 ━━━${NC}"
}
# HTTP helper: returns "status_code|body|time_ms"
http_get() {
local url="$1"
local tmpfile=$(mktemp)
local http_code time_total body
http_code=$(curl -sk -o "$tmpfile" -w "%{http_code}" "$url" 2>/dev/null)
time_total=$(curl -sk -o /dev/null -w "%{time_total}" "$url" 2>/dev/null)
body=$(cat "$tmpfile")
rm -f "$tmpfile"
local ms=$(echo "$time_total * 1000" | bc 2>/dev/null | cut -d'.' -f1)
echo "${http_code}|${body}|${ms:-0}"
}
http_post() {
local url="$1"
local data="${2:-{}}"
local tmpfile=$(mktemp)
local http_code
http_code=$(curl -sk -X POST -o "$tmpfile" -w "%{http_code}" \
-H "Content-Type: application/json" -d "$data" "$url" 2>/dev/null)
local body=$(cat "$tmpfile")
rm -f "$tmpfile"
echo "${http_code}|${body}"
}
# JSON field extractor (requires python3)
json_field() {
local json="$1"
local field="$2"
echo "$json" | python3 -c "import sys,json; print(json.load(sys.stdin).get('$field',''))" 2>/dev/null
}
is_valid_json() {
echo "$1" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null
}
# Print summary and exit with appropriate code
test_summary() {
local total=$((PASS_COUNT + FAIL_COUNT + SKIP_COUNT))
if $JSON_MODE; then
echo "{\"total\":$total,\"pass\":$PASS_COUNT,\"fail\":$FAIL_COUNT,\"skip\":$SKIP_COUNT,\"results\":[$(IFS=,; echo "${TEST_RESULTS[*]}")]}"
else
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e " TOTAL: $total | ${GREEN}PASS: $PASS_COUNT${NC} | ${RED}FAIL: $FAIL_COUNT${NC} | ${YELLOW}SKIP: $SKIP_COUNT${NC}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
[ "$FAIL_COUNT" -eq 0 ] && return 0 || return 1
}

View file

@ -0,0 +1,45 @@
-- ============================================================
-- Grubflip test data seed
-- Run against grubflip_dev database
-- Author: Luna (@luna) — QA
-- ============================================================
-- Test restaurant
INSERT IGNORE INTO restaurants (id, name, slug, address, phone, status, created_at)
VALUES
(9001, 'QA Test Restaurant', 'qa-test-restaurant', '123 Test St', '555-0100', 'active', NOW()),
(9002, 'QA Inactive Restaurant', 'qa-inactive', '456 Test Ave', '555-0200', 'inactive', NOW());
-- Test menu
INSERT IGNORE INTO menus (id, restaurant_id, name, status, created_at)
VALUES
(9001, 9001, 'QA Test Menu', 'active', NOW()),
(9002, 9001, 'QA Draft Menu', 'draft', NOW());
-- Test menu items
INSERT IGNORE INTO menu_items (id, menu_id, name, description, price, category, status, created_at)
VALUES
(9001, 9001, 'QA Burger', 'Test burger for QA', 9.99, 'Entrees', 'active', NOW()),
(9002, 9001, 'QA Fries', 'Test fries for QA', 4.99, 'Sides', 'active', NOW()),
(9003, 9001, 'QA Shake', 'Test milkshake for QA', 5.99, 'Drinks', 'active', NOW()),
(9004, 9002, 'QA Draft Item', 'Should not appear in active queries', 7.99, 'Entrees', 'draft', NOW());
-- Test users
INSERT IGNORE INTO users (id, email, name, phone, status, created_at)
VALUES
(9001, 'qa-user@test.payfrit.com', 'QA Test User', '555-0001', 'active', NOW()),
(9002, 'qa-admin@test.payfrit.com', 'QA Admin User', '555-0002', 'active', NOW());
-- Test orders
INSERT IGNORE INTO orders (id, user_id, restaurant_id, status, total, created_at)
VALUES
(9001, 9001, 9001, 'completed', 14.98, NOW()),
(9002, 9001, 9001, 'pending', 9.99, NOW()),
(9003, 9001, 9001, 'cancelled', 5.99, NOW());
-- Cleanup query (run manually when done testing):
-- DELETE FROM orders WHERE id BETWEEN 9001 AND 9099;
-- DELETE FROM menu_items WHERE id BETWEEN 9001 AND 9099;
-- DELETE FROM menus WHERE id BETWEEN 9001 AND 9099;
-- DELETE FROM restaurants WHERE id BETWEEN 9001 AND 9099;
-- DELETE FROM users WHERE id BETWEEN 9001 AND 9099;

72
test-data/payfrit/seed-tabs.sh Executable file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
# ============================================================
# seed-tabs.sh — Seed test data for tab API testing
# Creates test users and businesses via the API so tab tests
# can run with real data.
# Usage: ./test-data/payfrit/seed-tabs.sh [dev|prod]
# Author: Luna (@luna) — QA
# ============================================================
ENV="${1:-dev}"
case "$ENV" in
dev) BASE="https://dev.payfrit.com/api" ;;
prod) echo "ERROR: Will not seed prod. Use dev only."; exit 1 ;;
*) echo "Usage: $0 [dev|prod]"; exit 1 ;;
esac
echo "============================================"
echo " TAB TEST DATA SEEDER ($ENV)"
echo " $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "============================================"
echo ""
# Test user/business IDs — these should already exist in dev DB
# If not, they need to be created via the user registration flow
TEST_USER_ID="QA_USER_001"
TEST_BUSINESS_ID="QA_BIZ_001"
echo "Test config:"
echo " User ID: $TEST_USER_ID"
echo " Business ID: $TEST_BUSINESS_ID"
echo " API Base: $BASE"
echo ""
# Try to open a test tab
echo "Attempting to open a test tab..."
RESPONSE=$(curl -sk "$BASE/tabs/open.php?UserID=$TEST_USER_ID&BusinessID=$TEST_BUSINESS_ID" 2>/dev/null)
echo " Response: $RESPONSE"
echo ""
# Check if we got a TabID back
TAB_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('TabID',''))" 2>/dev/null)
if [ -n "$TAB_ID" ] && [ "$TAB_ID" != "None" ]; then
echo "✅ Tab created: $TAB_ID"
echo ""
# Try to get the tab
echo "Fetching tab..."
curl -sk "$BASE/tabs/get.php?TabID=$TAB_ID" 2>/dev/null | python3 -m json.tool 2>/dev/null
echo ""
# Try to get active tabs for user
echo "Fetching active tabs for user..."
curl -sk "$BASE/tabs/getActive.php?UserID=$TEST_USER_ID" 2>/dev/null | python3 -m json.tool 2>/dev/null
echo ""
# Clean up — cancel the test tab
echo "Cancelling test tab..."
curl -sk "$BASE/tabs/cancel.php?TabID=$TAB_ID" 2>/dev/null | python3 -m json.tool 2>/dev/null
echo ""
echo "✅ Seed complete — test tab created and cleaned up"
else
echo "⚠️ Could not create test tab. Response:"
echo "$RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$RESPONSE"
echo ""
echo "This may mean:"
echo " - Test user/business don't exist in the DB yet"
echo " - API requires auth that we don't have"
echo " - Endpoint logic changed"
echo ""
echo "For now, tests will use paramless validation (which works)."
fi