Files
ERP-core/Backend/smoke/smoke_common.py
ImanThiyanga 15ddac178c Refactor production and stock tests to remove UOM dependency
- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases.
- Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation.
- Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only.
- Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities.
- Implemented ItemContent service to validate and normalize content sizes.
- Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations.
- Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
2026-08-11 11:32:40 +05:30

261 lines
9.6 KiB
Python

"""Shared harness for the manufacturing smoke tests (docs/30-BACKEND-PHASE2.md).
The repo has no test project; verification is a live smoke test against local Postgres
with a real AuthHex session, with the assertion count recorded in Backend/PROGRESS.md.
These scripts make that repeatable instead of ad-hoc curl.
Auth note: ERPCore validates AuthHex's RS256 tokens offline against a statically
configured public key, so we log in to AuthHex *directly* and send the access token as a
Bearer header. That deliberately bypasses ERPCore's own /auth/login proxy, which would
otherwise need AuthHex:BaseUrl to match the port AuthHex actually listens on.
Usage:
python m2_templates.py [--api URL] [--auth URL] [--user EMAIL] [--password PW]
Environment variables (ERP_SMOKE_API, ERP_SMOKE_AUTH, ERP_SMOKE_USER,
ERP_SMOKE_PASSWORD) override the defaults; command-line flags override those.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
# Server messages and box-drawing output contain non-cp1252 characters, and the default
# Windows console codepage would raise UnicodeEncodeError mid-report — losing exactly the
# diagnostic text a failing assertion needs to show.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
DEFAULTS = {
"api": "http://localhost:5224/api/v1",
"auth": "http://localhost:5602",
"user": "admin@gmail.com",
"password": "Naveen@99",
}
def parse_args(description: str) -> argparse.Namespace:
p = argparse.ArgumentParser(description=description)
for key, default in DEFAULTS.items():
p.add_argument(f"--{key}", default=os.environ.get(f"ERP_SMOKE_{key.upper()}", default))
return p.parse_args()
class Response:
__slots__ = ("status", "body", "headers")
def __init__(self, status: int, body, headers: dict):
self.status = status
self.body = body
self.headers = headers
@property
def code(self):
"""The RFC 7807 domain error code, when the body carries one (docs/11 §1.8)."""
return self.body.get("code") if isinstance(self.body, dict) else None
@property
def etag(self):
return self.headers.get("ETag")
def __repr__(self):
return f"<{self.status} code={self.code}>"
class Client:
def __init__(self, api: str, token: str):
self.api = api.rstrip("/")
self.token = token
def request(self, method: str, path: str, body=None, if_match: str | None = None,
idempotency_key: str | None = None) -> Response:
url = f"{self.api}{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Accept", "application/json")
req.add_header("Authorization", f"Bearer {self.token}")
if data is not None:
req.add_header("Content-Type", "application/json")
if if_match:
req.add_header("If-Match", if_match)
if idempotency_key:
req.add_header("Idempotency-Key", idempotency_key)
try:
with urllib.request.urlopen(req) as r:
raw = r.read()
return Response(r.status, _decode(raw), dict(r.headers))
except urllib.error.HTTPError as e:
raw = e.read()
return Response(e.code, _decode(raw), dict(e.headers))
def get(self, path):
return self.request("GET", path)
def post(self, path, body=None, **kw):
return self.request("POST", path, body, **kw)
def put(self, path, body=None, **kw):
return self.request("PUT", path, body, **kw)
def patch(self, path, body=None, **kw):
return self.request("PATCH", path, body, **kw)
def _decode(raw: bytes):
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return raw.decode(errors="replace")
def login(auth_url: str, identifier: str, password: str) -> str:
"""Obtain an AuthHex access token via its `{functionName, payload}` envelope."""
payload = {
"functionName": "loginUser",
"payload": {"identifier": identifier, "password": password, "deviceName": "erp-smoke"},
"reference": "",
}
req = urllib.request.Request(
f"{auth_url.rstrip('/')}/api/user",
data=json.dumps(payload).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req) as r:
envelope = json.loads(r.read())
except urllib.error.URLError as e:
sys.exit(f"FATAL: cannot reach AuthHex at {auth_url} ({e}). Start ERP_Auth_Service first.")
if not envelope.get("success") or not envelope.get("data"):
sys.exit(f"FATAL: AuthHex login failed: {envelope.get('message')!r}")
return envelope["data"]["accessToken"]
class Checker:
"""Counts assertions so the pass total can be recorded in PROGRESS.md."""
def __init__(self):
self.passed = 0
self.failed = 0
def check(self, label: str, actual, expected) -> bool:
ok = actual == expected
if ok:
self.passed += 1
print(f" PASS {label}")
else:
self.failed += 1
print(f" FAIL {label}\n expected: {expected!r}\n actual: {actual!r}")
return ok
def status(self, label: str, response: Response, expected_status: int, expected_code: str | None = None):
ok = self.check(f"{label} -> {expected_status}", response.status, expected_status)
if expected_code is not None:
ok = self.check(f"{label} -> code {expected_code}", response.code, expected_code) and ok
if not ok and response.status >= 400:
detail = response.body.get("detail") if isinstance(response.body, dict) else response.body
print(f" server said: {detail}")
return ok
def section(self, title: str):
print(f"\n--- {title} ---")
def finish(self, name: str) -> int:
total = self.passed + self.failed
print(f"\n{'=' * 60}\n{name}: {self.passed}/{total} assertions passed"
+ (f" ({self.failed} FAILED)" if self.failed else " — ALL GREEN")
+ f"\n{'=' * 60}")
return 1 if self.failed else 0
def bootstrap(description: str):
"""Standard entry point: parse args, log in, return (client, checker, args)."""
args = parse_args(description)
token = login(args.auth, args.user, args.password)
return Client(args.api, token), Checker(), args
# --- stock fixtures ----------------------------------------------------------
#
# Seeding matters more than it looks. A positive stock ADJUSTMENT is the obvious way to
# create on-hand, but StockMutator's inbound path costs it at *last cost*, which is 0.00
# when the item has no prior layers. Stock seeded that way makes every cost-pool assertion
# pass trivially against zeros and proves nothing. A direct (no-PO) GRN lets us state the
# unit cost explicitly, so consumption produces a real, checkable value.
def drain_stock(c, warehouse_id: int) -> list:
"""
Zero out every item's on-hand in a warehouse via one negative adjustment.
Needed because these scripts are re-runnable and FIFO is oldest-first: stock left behind
by a previous execution is consumed *before* anything seeded now. If an earlier run left
zero-cost layers (as an adjustment-based seed does), a later run's cost assertions would
silently read 0.00 and pass against nothing. Draining first makes each execution start
from a known-empty warehouse.
"""
rows = c.get(f"/stock/on-hand/list?warehouseId={warehouse_id}&pageSize=200").body["items"]
lines = [{"itemId": r["itemId"], "qtyDelta": -float(r["onHand"])}
for r in rows if float(r["onHand"]) > 0]
if not lines:
return []
reasons = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"]
if not reasons:
sys.exit("FATAL: no Adjustment reason codes seeded.")
res = c.post("/stock-adjustments", {
"warehouseId": warehouse_id,
"reasonCodeId": reasons[0]["reasonCodeId"],
"lines": lines,
})
if res.status != 201:
sys.exit(f"FATAL: could not drain the smoke warehouse: {res.status} {res.body}")
return lines
def ensure_vendor(c) -> int:
existing = c.get("/vendors?pageSize=1").body["items"]
if existing:
return existing[0]["vendorId"]
created = c.post("/vendors", {"code": "SMOKE-V", "name": "Smoke vendor"})
if created.status != 201:
sys.exit(f"FATAL: could not create a vendor: {created.status} {created.body}")
return created.body["vendorId"]
def seed_costed_stock(c, warehouse_id: int, lines, vendor_id: int | None = None) -> None:
"""
Create on-hand at explicit unit costs via a direct GRN + confirm.
`lines` is an iterable of (item_id, qty, unit_cost). Quantities are counts of the item's
base UOM — document lines carry no unit of their own.
"""
vendor_id = vendor_id or ensure_vendor(c)
grn = c.post("/grns", {
"vendorId": vendor_id,
"warehouseId": warehouse_id,
"lines": [
{"itemId": i, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0}
for (i, q, cost) in lines
],
})
if grn.status != 201:
sys.exit(f"FATAL: could not create the seeding GRN: {grn.status} {grn.body}")
confirmed = c.post(f"/grns/{grn.body['grnId']}/confirm")
if confirmed.status != 200:
sys.exit(f"FATAL: could not confirm the seeding GRN: {confirmed.status} {confirmed.body}")