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.
152 lines
5.7 KiB
Python
152 lines
5.7 KiB
Python
"""
|
|
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, "")
|