fix(auth): auth deps use their own DB session; smoke suites made dataset-independent
RUNTIME BUG (caught by live smoke tests): the global auth dependency queried the request-scoped session, auto-beginning its transaction — any endpoint opening 'async with db.begin()' afterwards crashed with 'transaction already begun' (POST /materials 500). get_current_user / get_actor now resolve in a short-lived session (expire_on_commit=False keeps the context usable detached); inventory/pm scopes keep passing the request session explicitly. Smoke suites rewritten against CURRENT contracts: - assertions are behavior-based (shapes, envelopes, filters), no magic seed counts; dynamic lookups instead of hard-coded names/ids - rerun-tolerant: tolerate 409-on-create by reusing prior artifacts - aligned with current rules: revert blocked after inventory receipts (v2.6), attachments allowed in every status, termsheets optional at quote level - SMOKE_USER/SMOKE_PASSWORD env for login (default tester/123456) Verified on a fresh volume: migration + audit_log bootstrap, Casdoor seed (bcrypt), demo data, and 29+76+61 smoke checks all green.
This commit is contained in:
+136
-63
@@ -1,9 +1,14 @@
|
||||
"""End-to-end smoke tests against a running server (http://127.0.0.1:8000).
|
||||
|
||||
Run: .venv/Scripts/python.exe tools/smoke_test.py
|
||||
Dataset-independent: assertions verify BEHAVIOR (status codes, envelopes,
|
||||
shapes, filtering) rather than exact seed counts, so the suite works with
|
||||
any seed version. Run from the api container or a host with access:
|
||||
|
||||
Run: python tools/smoke_test.py
|
||||
Env: SMOKE_USER / SMOKE_PASSWORD (default tester / 123456 — seeded users)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pprint import pprint
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -24,8 +29,6 @@ def check(name, ok, extra=""):
|
||||
c = httpx.Client(base_url=BASE, timeout=30)
|
||||
|
||||
# ---------- auth: every endpoint now requires a Casdoor JWT ----------
|
||||
import os
|
||||
|
||||
r = c.post("/auth/login", json={
|
||||
"username": os.environ.get("SMOKE_USER", "tester"),
|
||||
"password": os.environ.get("SMOKE_PASSWORD", "123456"),
|
||||
@@ -36,82 +39,139 @@ if r.status_code == 200:
|
||||
|
||||
# ---------- catalog ----------
|
||||
r = c.get("/units", params={"page_size": 5})
|
||||
check("GET /units", r.status_code == 200 and len(r.json()["data"]) == 5 and "si_factor" in r.json()["data"][0])
|
||||
units = r.json()["data"] if r.status_code == 200 else []
|
||||
check("GET /units", r.status_code == 200 and len(units) == 5 and "si_factor" in units[0])
|
||||
|
||||
r = c.get("/units", params={"dimension": "mass"})
|
||||
check("GET /units?dimension=mass", r.status_code == 200 and r.json()["meta"]["total"] == 3)
|
||||
if units:
|
||||
dim = units[0]["dimension"]
|
||||
r = c.get("/units", params={"dimension": dim, "page_size": 100})
|
||||
body = r.json()
|
||||
check(
|
||||
"GET /units?dimension filter",
|
||||
r.status_code == 200 and body["meta"]["total"] >= 1
|
||||
and all(u["dimension"] == dim for u in body["data"]),
|
||||
r.text[:200],
|
||||
)
|
||||
|
||||
r = c.get("/brands")
|
||||
check("GET /brands", r.status_code == 200 and r.json()["meta"]["total"] == 10)
|
||||
brands = r.json()["data"] if r.status_code == 200 else []
|
||||
check("GET /brands", r.status_code == 200 and brands and r.json()["meta"]["total"] >= 1)
|
||||
|
||||
r = c.get("/material-categories", params={"q": "سیمان"})
|
||||
check("GET /material-categories?q", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
|
||||
|
||||
r = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"})
|
||||
check("GET /materials?q (unicode)", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
|
||||
|
||||
r = c.get("/materials", params={"expand": "category,brands,canonical_unit,attributes", "q": "میلگرد A3 سایز ۱۴"})
|
||||
if r.status_code == 200 and r.json()["data"]:
|
||||
m = r.json()["data"][0]
|
||||
# category q-search (only when categories exist)
|
||||
r = c.get("/material-categories", params={"page_size": 100})
|
||||
cats = r.json()["data"] if r.status_code == 200 else []
|
||||
check("GET /material-categories", r.status_code == 200 and isinstance(cats, list))
|
||||
if cats:
|
||||
needle = cats[0]["name"][:3]
|
||||
r = c.get("/material-categories", params={"q": needle})
|
||||
check(
|
||||
"GET /materials expand",
|
||||
"category" in m and "brands" in m and "canonical_unit" in m and "attributes" in m,
|
||||
"GET /material-categories?q (unicode search)",
|
||||
r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200],
|
||||
)
|
||||
check("expand category shape", m["category"].keys() == {"id", "slug", "name"})
|
||||
check("expand brands has catalog_code+is_active", all(set(b) >= {"id", "name", "catalog_code", "is_active"} for b in m["brands"]))
|
||||
check("expand canonical_unit shape", m["canonical_unit"].keys() == {"id", "code", "name_fa"})
|
||||
check("expand attributes EAV flattened", all(set(a) >= {"attribute_key", "label", "data_type", "value", "unit"} for a in m["attributes"]))
|
||||
|
||||
# materials: q-search against a REAL seeded name (fetched, not hard-coded)
|
||||
r = c.get("/materials", params={"page_size": 100})
|
||||
materials = r.json()["data"] if r.status_code == 200 else []
|
||||
check("GET /materials", r.status_code == 200 and materials and r.json()["meta"]["total"] >= 1)
|
||||
|
||||
if materials:
|
||||
target = materials[0]
|
||||
needle = target["name"].split()[0]
|
||||
r = c.get("/materials", params={"q": needle})
|
||||
check(
|
||||
"GET /materials?q (unicode search)",
|
||||
r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200],
|
||||
)
|
||||
|
||||
r = c.get("/materials", params={"expand": "category,brands,canonical_unit,attributes", "q": needle})
|
||||
expanded = [m for m in r.json().get("data", []) if m["id"] == target["id"]]
|
||||
if r.status_code == 200 and expanded:
|
||||
m = expanded[0]
|
||||
check(
|
||||
"GET /materials expand",
|
||||
"category" in m and "brands" in m and "canonical_unit" in m and "attributes" in m,
|
||||
)
|
||||
check("expand category shape", m["category"].keys() == {"id", "slug", "name"})
|
||||
check("expand brands shape", all(set(b) >= {"id", "name", "catalog_code", "is_active"} for b in m["brands"]))
|
||||
check("expand canonical_unit shape", m["canonical_unit"].keys() == {"id", "code", "name_fa"})
|
||||
check("expand attributes EAV flattened", all(set(a) >= {"attribute_key", "label", "data_type", "value", "unit"} for a in m["attributes"]))
|
||||
else:
|
||||
check("GET /materials expand", False, r.text[:300])
|
||||
|
||||
# ---------- EAV filters (positive filter only when the seed has values) ----------
|
||||
r = c.get("/attribute-definitions", params={"page_size": 100})
|
||||
attr_defs = r.json()["data"] if r.status_code == 200 else []
|
||||
|
||||
if attr_defs and materials:
|
||||
r = c.get("/materials", params={"expand": "attributes", "page_size": 100})
|
||||
with_attrs = [m for m in r.json().get("data", []) if m.get("attributes")]
|
||||
if with_attrs:
|
||||
m0 = with_attrs[0]
|
||||
key = m0["attributes"][0]["attribute_key"]
|
||||
value = str(m0["attributes"][0]["value"])
|
||||
r = c.get("/materials", params={f"attr_{key}": value, "expand": "attributes"})
|
||||
check(
|
||||
f"GET /materials?attr_{key} filter",
|
||||
r.status_code == 200 and any(x["id"] == m0["id"] for x in r.json().get("data", [])),
|
||||
r.text[:200],
|
||||
)
|
||||
else:
|
||||
print(" SKIP EAV positive filter — seed defines no material attribute values")
|
||||
else:
|
||||
check("GET /materials expand", False, r.text[:300])
|
||||
print(" SKIP EAV positive filter — seed defines no attribute definitions")
|
||||
|
||||
# dynamic EAV filter: attr_rebar_diameter=14
|
||||
r = c.get("/materials", params={"attr_rebar_diameter": "14", "expand": "attributes"})
|
||||
if r.status_code == 200:
|
||||
data = r.json()["data"]
|
||||
ok = len(data) == 1 and data[0]["name"] == "میلگرد A3 سایز ۱۴"
|
||||
check("GET /materials?attr_rebar_diameter=14", ok, r.text[:200])
|
||||
else:
|
||||
check("GET /materials?attr_rebar_diameter=14", False, r.text[:200])
|
||||
|
||||
# EAV boolean filter: attr_rebar_ribbed=true
|
||||
r = c.get("/materials", params={"attr_rebar_ribbed": "true"})
|
||||
check("GET /materials?attr_rebar_ribbed=true", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
|
||||
|
||||
# brand filter
|
||||
r = c.get("/brands", params={"q": "فولاد مبارکه"})
|
||||
brand_id = r.json()["data"][0]["id"]
|
||||
r = c.get("/materials", params={"brand_id": brand_id})
|
||||
check("GET /materials?brand_id=", r.status_code == 200 and r.json()["meta"]["total"] == 3, r.text[:200])
|
||||
|
||||
# unknown attr filter -> 400
|
||||
# unknown attr filter -> 400 (dataset-independent)
|
||||
r = c.get("/materials", params={"attr_nonexistent": "1"})
|
||||
check("unknown attr filter -> 400", r.status_code == 400 and r.json()["error"]["code"] == "VALIDATION_ERROR")
|
||||
|
||||
r = c.get("/materials?page_size=999")
|
||||
check("page_size>100 -> 400", r.status_code == 400)
|
||||
|
||||
# brand filter via a brand that actually has material links
|
||||
r = c.get("/brand-materials", params={"page_size": 100})
|
||||
brand_links = r.json()["data"] if r.status_code == 200 else []
|
||||
if brand_links:
|
||||
brand_id = brand_links[0]["brand_id"]
|
||||
r = c.get("/materials", params={"brand_id": brand_id})
|
||||
check("GET /materials?brand_id=", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200])
|
||||
r = c.get("/brand-materials", params={"brand_id": brand_id})
|
||||
check("GET /brand-materials?brand_id", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200])
|
||||
else:
|
||||
check("GET /materials?brand_id=", False, "seed has no brand-material links")
|
||||
|
||||
# ---------- suppliers / projects ----------
|
||||
r = c.get("/suppliers")
|
||||
check("GET /suppliers", r.status_code == 200 and r.json()["meta"]["total"] == 6)
|
||||
suppliers = r.json()["data"] if r.status_code == 200 else []
|
||||
check("GET /suppliers", r.status_code == 200 and suppliers)
|
||||
|
||||
r = c.get("/projects")
|
||||
check("GET /projects", r.status_code == 200 and r.json()["meta"]["total"] == 5)
|
||||
projects = r.json()["data"] if r.status_code == 200 else []
|
||||
check("GET /projects", r.status_code == 200 and projects)
|
||||
|
||||
r = c.get("/attribute-definitions", params={"expand": "unit"})
|
||||
check("GET /attribute-definitions", r.status_code == 200 and r.json()["meta"]["total"] == 28, r.text[:200])
|
||||
r = c.get("/attribute-definitions")
|
||||
attr_shape_ok = r.status_code == 200 and all(
|
||||
set(a) >= {"attribute_key", "label", "data_type"} for a in r.json().get("data", [])
|
||||
)
|
||||
check("GET /attribute-definitions", attr_shape_ok, r.text[:200])
|
||||
|
||||
r = c.get("/material-categories/{cid}/attributes".format(cid=c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]["id"]))
|
||||
check("GET category attributes (nested attribute)", r.status_code == 200 and len(r.json()["data"]) == 3 and "attribute" in r.json()["data"][0])
|
||||
|
||||
r = c.get("/brand-materials", params={"brand_id": brand_id})
|
||||
check("GET /brand-materials?brand_id", r.status_code == 200 and r.json()["meta"]["total"] == 3)
|
||||
if cats:
|
||||
r = c.get(f"/material-categories/{cats[0]['id']}/attributes")
|
||||
check(
|
||||
"GET category attributes (nested)",
|
||||
r.status_code == 200 and isinstance(r.json()["data"], list)
|
||||
and all("attribute" in a for a in r.json()["data"]),
|
||||
r.text[:200],
|
||||
)
|
||||
|
||||
# ---------- quotes ----------
|
||||
r = c.get("/quotes", params={"page_size": 5})
|
||||
check("GET /quotes", r.status_code == 200 and r.json()["meta"]["total"] == 7, r.text[:300])
|
||||
quotes = r.json()["data"] if r.status_code == 200 else []
|
||||
check("GET /quotes", r.status_code == 200 and quotes and r.json()["meta"]["total"] >= 1)
|
||||
|
||||
r = c.get("/quotes", params={"expand": "line_items,termsheets,events,supplier,project", "status": "accepted"})
|
||||
if r.status_code == 200 and r.json()["data"]:
|
||||
q = r.json()["data"][0]
|
||||
r = c.get("/quotes", params={"expand": "line_items,termsheets,events,supplier,project"})
|
||||
accepted = [q for q in r.json().get("data", []) if q.get("line_items")] if r.status_code == 200 else []
|
||||
if accepted:
|
||||
q = accepted[0]
|
||||
li = q["line_items"][0]
|
||||
check(
|
||||
"GET /quotes expand full shape",
|
||||
@@ -122,21 +182,34 @@ if r.status_code == 200 and r.json()["data"]:
|
||||
r.text[:400],
|
||||
)
|
||||
check("events sorted ASC", [e["event_type"] for e in q["events"]] == sorted([e["event_type"] for e in q["events"]]))
|
||||
check("termsheet has schedule_steps", "schedule_steps" in q["termsheets"][0])
|
||||
if q["termsheets"]:
|
||||
check("termsheet has schedule_steps", "schedule_steps" in q["termsheets"][0])
|
||||
else:
|
||||
check("GET /quotes expand full shape", False, r.text[:400])
|
||||
|
||||
r = c.get("/quotes/stats")
|
||||
check("GET /quotes/stats", r.status_code == 200 and r.json()["data"]["total"] == 7 and "average_lead_time_days" in r.json()["data"], r.text[:300])
|
||||
stats_ok = r.status_code == 200 and "average_lead_time_days" in r.json()["data"]
|
||||
if quotes:
|
||||
stats_ok = stats_ok and r.json()["data"]["total"] == r.json()["data"]["offered"] + \
|
||||
r.json()["data"]["accepted"] + r.json()["data"]["arrived"] + \
|
||||
r.json()["data"]["rejected"] + r.json()["data"]["expired"]
|
||||
check("GET /quotes/stats", stats_ok, r.text[:300])
|
||||
|
||||
r = c.get("/quotes/stats", params={"status": "offered"}) # status filter not part of stats contract; ensure no crash
|
||||
check("GET /quotes/stats (extra param ignored)", r.status_code == 200)
|
||||
|
||||
r = c.get("/quotes", params={"q": "میلگرد"})
|
||||
check("GET /quotes?q (searches material names)", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:300])
|
||||
if materials:
|
||||
needle = materials[0]["name"].split()[0]
|
||||
r = c.get("/quotes", params={"q": needle})
|
||||
check("GET /quotes?q (searches material names)", r.status_code == 200, r.text[:300])
|
||||
|
||||
r = c.get("/projects/{pid}/stats".format(pid=c.get("/projects", params={"q": "ونوس"}).json()["data"][0]["id"]))
|
||||
check("GET /projects/{id}/stats", r.status_code == 200 and set(r.json()["data"]) >= {"total_quotes", "offered", "accepted", "arrived", "rejected", "expired"}, r.text[:300])
|
||||
if projects:
|
||||
r = c.get(f"/projects/{projects[0]['id']}/stats")
|
||||
check(
|
||||
"GET /projects/{id}/stats",
|
||||
r.status_code == 200 and set(r.json()["data"]) >= {"total_quotes", "offered", "accepted", "arrived", "rejected", "expired"},
|
||||
r.text[:300],
|
||||
)
|
||||
|
||||
print(f"\n===== {PASS} passed, {FAIL} failed =====")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
|
||||
Reference in New Issue
Block a user