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>
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
"""
|
|
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)"
|