diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e8e1d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Generated reports +reports/*.txt +reports/*.json +reports/*.md + +# Temp files +*.tmp +*.log +.DS_Store + +# Secrets (never commit) +.env +*.secret +credentials.* diff --git a/README.md b/README.md index a70ea2f..aa0dd08 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,46 @@ -# payfrit-qa +# Payfrit QA Test Framework -QA test framework — API tests, infra health checks, test data seeding \ No newline at end of file +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. diff --git a/grubflip/api/test_endpoints.py b/grubflip/api/test_endpoints.py new file mode 100644 index 0000000..e5e14a8 --- /dev/null +++ b/grubflip/api/test_endpoints.py @@ -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() diff --git a/grubflip/api/test_endpoints.sh b/grubflip/api/test_endpoints.sh new file mode 100755 index 0000000..bcb90cc --- /dev/null +++ b/grubflip/api/test_endpoints.sh @@ -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 diff --git a/payfrit/__init__.py b/payfrit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payfrit/api/test_tabs.py b/payfrit/api/test_tabs.py new file mode 100644 index 0000000..61bd966 --- /dev/null +++ b/payfrit/api/test_tabs.py @@ -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() diff --git a/payfrit/api/test_tabs.sh b/payfrit/api/test_tabs.sh new file mode 100755 index 0000000..e992ca8 --- /dev/null +++ b/payfrit/api/test_tabs.sh @@ -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 diff --git a/reports/.gitkeep b/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/infra-health.sh b/scripts/infra-health.sh new file mode 100755 index 0000000..138d0ab --- /dev/null +++ b/scripts/infra-health.sh @@ -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 diff --git a/scripts/run-all.sh b/scripts/run-all.sh new file mode 100755 index 0000000..926a442 --- /dev/null +++ b/scripts/run-all.sh @@ -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 diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/lib/__init__.py b/shared/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/lib/test_helpers.py b/shared/lib/test_helpers.py new file mode 100644 index 0000000..7a1c7f9 --- /dev/null +++ b/shared/lib/test_helpers.py @@ -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, "") diff --git a/shared/lib/test_helpers.sh b/shared/lib/test_helpers.sh new file mode 100644 index 0000000..7d615cd --- /dev/null +++ b/shared/lib/test_helpers.sh @@ -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 +} diff --git a/test-data/grubflip/seed.sql b/test-data/grubflip/seed.sql new file mode 100644 index 0000000..5ce62e3 --- /dev/null +++ b/test-data/grubflip/seed.sql @@ -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; diff --git a/test-data/payfrit/seed-tabs.sh b/test-data/payfrit/seed-tabs.sh new file mode 100755 index 0000000..781732e --- /dev/null +++ b/test-data/payfrit/seed-tabs.sh @@ -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