feat: add CI/CD testing infrastructure with test.sh orchestrator
Complete testing system: pyproject.toml (pytest markers), test.sh orchestrator with auto app start/stop and colorful summary, pre-push hook, Gitea Actions workflow. New QA tests: API health (7 endpoints), responsive (3 viewports), log monitoring (ERROR/ORA-/Traceback detection), real GoMag sync, PL/SQL package validation, smoke prod (read-only). Converted test_app_basic.py and test_integration.py to pytest. Added pytestmark to all existing tests (unit/e2e/oracle). E2E conftest upgraded: console error collector, screenshot on failure, auto-detect live app on :5003. Usage: ./test.sh ci (30s) | ./test.sh full (2-3min) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
142
api/tests/qa/test_qa_smoke_prod.py
Normal file
142
api/tests/qa/test_qa_smoke_prod.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Smoke tests for production — read-only, no clicks.
|
||||
Run against a live app: pytest api/tests/qa/test_qa_smoke_prod.py --base-url http://localhost:5003
|
||||
"""
|
||||
import time
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
pytestmark = pytest.mark.smoke
|
||||
|
||||
PAGES = ["/", "/logs", "/mappings", "/missing-skus", "/settings"]
|
||||
|
||||
|
||||
def _app_is_reachable(base_url: str) -> bool:
|
||||
"""Quick check if the app is reachable."""
|
||||
try:
|
||||
urllib.request.urlopen(f"{base_url}/health", timeout=3)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _require_app(base_url):
|
||||
"""Skip all smoke tests if the app is not running."""
|
||||
if not _app_is_reachable(base_url):
|
||||
pytest.skip(f"App not reachable at {base_url} — start the app first")
|
||||
|
||||
PAGE_TITLES = {
|
||||
"/": "Panou de Comanda",
|
||||
"/logs": "Jurnale Import",
|
||||
"/mappings": "Mapari SKU",
|
||||
"/missing-skus": "SKU-uri Lipsa",
|
||||
"/settings": "Setari",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def browser():
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=True)
|
||||
yield b
|
||||
b.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_page_loads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("path", PAGES)
|
||||
def test_page_loads(browser, base_url, screenshots_dir, path):
|
||||
"""Each page returns HTTP 200 and loads without crashing."""
|
||||
page = browser.new_page()
|
||||
try:
|
||||
response = page.goto(f"{base_url}{path}", wait_until="domcontentloaded", timeout=15_000)
|
||||
assert response is not None, f"No response for {path}"
|
||||
assert response.status == 200, f"Expected 200, got {response.status} for {path}"
|
||||
|
||||
safe_name = path.strip("/").replace("/", "_") or "dashboard"
|
||||
screenshot_path = screenshots_dir / f"smoke_{safe_name}.png"
|
||||
page.screenshot(path=str(screenshot_path))
|
||||
finally:
|
||||
page.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_page_titles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("path", PAGES)
|
||||
def test_page_titles(browser, base_url, path):
|
||||
"""Each page has the correct h4 heading text."""
|
||||
expected = PAGE_TITLES[path]
|
||||
page = browser.new_page()
|
||||
try:
|
||||
page.goto(f"{base_url}{path}", wait_until="domcontentloaded", timeout=15_000)
|
||||
h4 = page.locator("h4").first
|
||||
actual = h4.inner_text().strip()
|
||||
assert actual == expected, f"{path}: expected h4='{expected}', got '{actual}'"
|
||||
finally:
|
||||
page.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_no_console_errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("path", PAGES)
|
||||
def test_no_console_errors(browser, base_url, path):
|
||||
"""No console.error events on any page."""
|
||||
errors = []
|
||||
page = browser.new_page()
|
||||
try:
|
||||
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.goto(f"{base_url}{path}", wait_until="networkidle", timeout=15_000)
|
||||
finally:
|
||||
page.close()
|
||||
|
||||
assert errors == [], f"Console errors on {path}: {errors}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_api_health_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_api_health_json(base_url):
|
||||
"""GET /health returns valid JSON with 'oracle' key."""
|
||||
with urllib.request.urlopen(f"{base_url}/health", timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
assert "oracle" in data, f"/health JSON missing 'oracle' key: {data}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_api_dashboard_orders_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_api_dashboard_orders_json(base_url):
|
||||
"""GET /api/dashboard/orders returns valid JSON with 'orders' key."""
|
||||
with urllib.request.urlopen(f"{base_url}/api/dashboard/orders", timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
assert "orders" in data, f"/api/dashboard/orders JSON missing 'orders' key: {data}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_response_time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("path", PAGES)
|
||||
def test_response_time(browser, base_url, path):
|
||||
"""Each page loads in under 10 seconds."""
|
||||
page = browser.new_page()
|
||||
try:
|
||||
start = time.monotonic()
|
||||
page.goto(f"{base_url}{path}", wait_until="domcontentloaded", timeout=15_000)
|
||||
elapsed = time.monotonic() - start
|
||||
finally:
|
||||
page.close()
|
||||
|
||||
assert elapsed < 10, f"{path} took {elapsed:.2f}s (limit: 10s)"
|
||||
Reference in New Issue
Block a user