diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css
index 9a3a2fc..a457beb 100644
--- a/html/wp-creation-styles.css
+++ b/html/wp-creation-styles.css
@@ -568,6 +568,10 @@
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid var(--wp-status-success-border-b); }
.rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid var(--wp-status-warning-border-b); }
.rb-hold { background:var(--red-dim); color:var(--red); border:1px solid var(--wp-status-error-border-b); }
+ /* CR-014: the QA-queue state. Informational, not alarming - accent, not amber. */
+ .rb-qa { background:var(--accent-dim); color:var(--accent); border:1px solid var(--accent); }
+ .rb-act-ghost { background:transparent; color:var(--accent); border:1px solid var(--accent); margin-left:8px; }
+ .rb-act-ghost:hover { background:var(--accent-dim); }
/* D4: the audited-override action on the release banner. A real button in the
primary position for an Urgent package; it simply never renders otherwise. */
.rb-act { margin-left:auto; border:none; border-radius:var(--radius); cursor:pointer;
diff --git a/server/app.py b/server/app.py
index 5e6aa4d..6cd3463 100644
--- a/server/app.py
+++ b/server/app.py
@@ -448,8 +448,12 @@ def require_assignable(db: Session, user_id: str, project_id: Optional[str]) ->
def wp_link(db: Session, wp: "models.WorkPackage") -> str:
+ """A link that opens THIS work package. X1: never the app root - a recipient
+ who has to hunt for the package after signing in stops opening the emails.
+ The creator has been its own document since T7.1 and boots ?wp= deep links;
+ a signed-out recipient rides login.html?next= straight back to it."""
base = (notify.get_settings(db).get("app_base_url") or "").rstrip("/")
- path = f"/work-package-suite.html?tab=wp&project={wp.project_id or ''}"
+ path = f"/wp-creation-index.html?project={wp.project_id or ''}&wp={wp.id}"
return (base + path) if base else path
@@ -524,6 +528,10 @@ class TestEmailIn(BaseModel):
class StatusIn(BaseModel):
status: str
+ # CR-014: a rejection (Ready for QA -> In Progress) must say why. The upsert
+ # route carries the comment inside data.qaRejections; this route has no data,
+ # so it carries the comment here and the server appends the record itself.
+ comment: Optional[str] = None
class ArchiveIn(BaseModel):
@@ -1411,7 +1419,8 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
# _released() must never count it: counting it is what let every transition out of
# hold skip the release gates as "already released", which made /status a side
# door past an open constraint (CR-015 / T7.3).
-STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Issue", "QC", "Closed"]
+STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Ready for QA", "Issue", "QC", "Closed"]
+QA_READY_STATUS = "Ready for QA"
ISSUED_IDX = STATUS_ORDER.index("Issued")
DONE_STATUS = "Closed"
HOLD_STATUS = "Issue"
@@ -1563,6 +1572,108 @@ def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]:
return [i for i in (proj.get("pmId"), proj.get("cmId")) if i]
+def project_qa_group(db: Session, project_id: Optional[str]) -> list["models.User"]:
+ """D2: the QA group named on the project's latest complete SOP. pushSOP writes
+ the row as data={sop, state}, so the project block is data['sop']['project'] -
+ note that project_sop_team above reads data['project'], which that shape never
+ has (BL-021, logged, not fixed here)."""
+ if not project_id:
+ return []
+ sop = db.scalars(
+ select(models.Sop)
+ .where((models.Sop.project_id == project_id) & (models.Sop.complete.is_(True)))
+ .order_by(models.Sop.updated_at.desc())
+ .limit(1)
+ ).first()
+ if not sop:
+ return []
+ data = sop.data or {}
+ proj = ((data.get("sop") or {}).get("project")
+ or data.get("project") or {})
+ ids = [i for i in (proj.get("qaGroupIds") or []) if isinstance(i, str) and i]
+ if not ids:
+ return []
+ return list(db.scalars(select(models.User).where(models.User.id.in_(ids))))
+
+
+def qa_rejection_comment(data: Optional[dict]) -> str:
+ rejs = [r for r in ((data or {}).get("qaRejections") or []) if isinstance(r, dict)]
+ if not rejs:
+ return ""
+ return str(rejs[-1].get("comment") or "").strip()
+
+
+def enforce_qa_rejection_comment(data: Optional[dict], new_status: str,
+ old_status: Optional[str],
+ old_data: Optional[dict] = None) -> None:
+ """CR-014: a rejection with no reason is the after-the-fact surprise this gate
+ exists to end. Runs before anything is written, like the release gates.
+ The comment must be FRESH: a rejection entry already on the record satisfied
+ the first version of this check, which made every rejection after the first
+ one free. New entry, non-empty comment, or the transition is refused."""
+ if old_status == QA_READY_STATUS and new_status == "In Progress":
+ new_rejs = [r for r in ((data or {}).get("qaRejections") or []) if isinstance(r, dict)]
+ old_rejs = [r for r in ((old_data or {}).get("qaRejections") or []) if isinstance(r, dict)]
+ fresh = len(new_rejs) > len(old_rejs) and str(new_rejs[-1].get("comment") or "").strip()
+ if not fresh:
+ raise HTTPException(status_code=409, detail={
+ "message": "Returning a package from Ready for QA requires a comment",
+ })
+
+
+def qa_ready_body(user: "models.User", wp: "models.WorkPackage",
+ actor: "models.User", link: str) -> str:
+ # A WP number and a deep link - NOT the package contents. The task text asked
+ # for location and a scope summary, but the done-when list (and the standing
+ # rule) says no customer IP in a message body; the link is the summary.
+ who = actor.full_name or actor.username
+ name = user.full_name or user.username
+ return (
+ f"Hi {name},\n\n"
+ f"{who} moved {wp.number or 'a work package'} to Ready for QA.\n"
+ f"It is in the QA queue waiting to be accepted or returned.\n\n"
+ f"Open it here:\n{link}\n\n"
+ f"— This is an automated message from the Work Package Suite."
+ )
+
+
+def qa_reject_body(user: "models.User", wp: "models.WorkPackage",
+ actor: "models.User", link: str) -> str:
+ who = actor.full_name or actor.username
+ name = user.full_name or user.username
+ return (
+ f"Hi {name},\n\n"
+ f"{who} returned {wp.number or 'a work package'} from Ready for QA to In Progress.\n"
+ f"The reason is recorded on the package.\n\n"
+ f"Open it here:\n{link}\n\n"
+ f"— This is an automated message from the Work Package Suite."
+ )
+
+
+def notify_qa_transition(db: Session, wp: "models.WorkPackage",
+ actor: "models.User", rejected: bool) -> list:
+ """Entering Ready for QA emails the QA group and nobody else (D2). A rejection
+ emails the owner AND the same group (D9's sibling decision). Deduplicated;
+ every send is an outbox row, so a failure is recorded, never silent."""
+ recipients = {u.id: u for u in project_qa_group(db, wp.project_id)}
+ if rejected and wp.assignee_id:
+ owner = db.get(models.User, wp.assignee_id)
+ if owner:
+ recipients[owner.id] = owner
+ out = []
+ link = wp_link(db, wp)
+ for u in recipients.values():
+ out.append(notify.enqueue(
+ db, user=u, kind="qa_rejected" if rejected else "qa_ready",
+ subject=(f"Returned from QA: {wp.number or 'work package'}" if rejected
+ else f"Ready for QA: {wp.number or 'work package'}"),
+ body=(qa_reject_body(u, wp, actor, link) if rejected
+ else qa_ready_body(u, wp, actor, link)),
+ link=link, wp_id=wp.id, project_id=wp.project_id,
+ ))
+ return out
+
+
def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str],
actor: "models.User", link: str) -> str:
# Constraint names and a WP number only — no package contents, same rule as the
@@ -1632,6 +1743,7 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
wp_id_for_checks = body.id or wp.id
check_predecessor_cycle(db, wp_id_for_checks, body.data)
enforce_release_gates(db, wp_id_for_checks, body.data, body.status, old_status)
+ enforce_qa_rejection_comment(body.data, body.status, old_status, old_data)
wp.project_id = body.project_id
wp.sop_id = body.sop_id
wp.parent_id = body.parent_id
@@ -1694,6 +1806,19 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
summary=(wp.number or wp.subject or wp.id),
detail={"critical": reopened, "status": wp.status})
notifs.extend(notify_critical_reopen(db, wp, reopened, user))
+ # CR-014: the QA gate. Entering Ready for QA tells the QA group their queue
+ # grew; a rejection tells the owner and the same group. Both write their own
+ # audit line - status_changed says from/to, these say what it MEANS.
+ if not is_new and old_status != wp.status:
+ if wp.status == QA_READY_STATUS:
+ log_event(db, user, "qa_ready", "wp", wp.id, project_id=wp.project_id,
+ summary=(wp.number or wp.subject or wp.id), detail={"from": old_status})
+ notifs.extend(notify_qa_transition(db, wp, user, rejected=False))
+ elif old_status == QA_READY_STATUS and wp.status == "In Progress":
+ log_event(db, user, "qa_rejected", "wp", wp.id, project_id=wp.project_id,
+ summary=(wp.number or wp.subject or wp.id),
+ detail={"comment": qa_rejection_comment(wp.data)[:300]})
+ notifs.extend(notify_qa_transition(db, wp, user, rejected=True))
# Notify a newly-assigned owner (skip self-assignment).
notif = None
if new_assignee and new_assignee != old_assignee and new_assignee != user.id:
@@ -2419,15 +2544,28 @@ def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db:
@app.post("/api/wps/{wp_id}/status")
-def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
+def set_wp_status(wp_id: str, body: StatusIn, background_tasks: BackgroundTasks, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id)
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
require_project_writable(db, user, wp.project_id, "Changing a work package's status")
old_status = wp.status
+ _qa_notifs = []
# Same gates as /issue — this route must not be a way around them.
enforce_release_gates(db, wp.id, wp.data, body.status, old_status)
+ if old_status == QA_READY_STATUS and body.status == "In Progress":
+ comment = str(body.comment or "").strip()
+ if not comment:
+ raise HTTPException(status_code=409, detail={
+ "message": "Returning a package from Ready for QA requires a comment",
+ })
+ data = dict(wp.data or {})
+ rejs = [r for r in (data.get("qaRejections") or []) if isinstance(r, dict)]
+ rejs.append({"ts": models.utcnow().isoformat(), "comment": comment,
+ "by": user.full_name or user.username, "from": QA_READY_STATUS})
+ data["qaRejections"] = rejs
+ wp.data = data
wp.status = body.status
if body.status == "Issued" and wp.issued_at is None:
wp.issued_at = models.utcnow()
@@ -2450,8 +2588,19 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g
detail={"to": body.status,
"constraint": str((_h or {}).get("constraint") or "")[:200],
"reason": str((_h or {}).get("details") or "")[:300]})
+ if body.status == QA_READY_STATUS:
+ log_event(db, user, "qa_ready", "wp", wp.id, project_id=wp.project_id,
+ summary=(wp.number or wp.subject or wp.id), detail={"from": old_status})
+ _qa_notifs = notify_qa_transition(db, wp, user, rejected=False)
+ elif old_status == QA_READY_STATUS and body.status == "In Progress":
+ log_event(db, user, "qa_rejected", "wp", wp.id, project_id=wp.project_id,
+ summary=(wp.number or wp.subject or wp.id),
+ detail={"comment": qa_rejection_comment(wp.data)[:300]})
+ _qa_notifs = notify_qa_transition(db, wp, user, rejected=True)
db.commit()
db.refresh(wp)
+ for n in _qa_notifs:
+ background_tasks.add_task(notify.deliver, n.id)
return wp.to_dict()
diff --git a/tests/qa_gate_check.py b/tests/qa_gate_check.py
new file mode 100644
index 0000000..7218118
--- /dev/null
+++ b/tests/qa_gate_check.py
@@ -0,0 +1,438 @@
+#!/usr/bin/env python3
+"""Does the QA gate notify the right people, and only when told to? — CR-014, T7.6.
+
+Marlena's ask: QA needs to see inbound work ahead of time, not be told after the
+fact. `Ready for QA` is a rung between In Progress and QC; entering it emails the
+QA group configured on the SOP (D2); a rejection returns the package to In
+Progress with a mandatory comment and emails the owner plus the same group.
+
+Email discipline, per the standing rules and D10:
+ - ships OFF: with the toggle off, transitions write outbox rows marked
+ 'skipped' and the capture sink receives NOTHING
+ - only an administrator can turn it on, from the stored setting (not an env
+ var); the change lands in the audit log
+ - the capture sink is a real SMTP conversation on localhost - the count and
+ the recipients of captured messages are asserted, and no real mail exists
+ - the SMTP password lives in the environment alone; message bodies carry a WP
+ number and a deep link, never package contents (the location canary proves
+ it)
+
+Self-contained: throwaway SQLite, its own uvicorn, its own SMTP sink, headless
+browser for the client half. Exit 0 all passed, 1 a failure, 2 could not run.
+"""
+import json
+import os
+import re
+import socket
+import sys
+import tempfile
+import threading
+import time
+import urllib.error
+import urllib.request
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import cdp # noqa: E402
+from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
+from sections_check import set_sop # noqa: E402
+from stepper_check import dismiss_dialogs # noqa: E402
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+CANARY = "FAB-9 SECRET SECTOR" # rides on the package; must never reach a body
+
+
+def ascii_(v, n=300):
+ return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
+
+
+def settle(seconds=0.6):
+ time.sleep(seconds)
+
+
+def api(base, path, token, method="GET", body=None):
+ req = urllib.request.Request(base + path, method=method)
+ req.add_header("Cookie", "wp_session=" + token)
+ req.add_header("Accept", "application/json")
+ data = None
+ if body is not None:
+ data = json.dumps(body).encode()
+ req.add_header("Content-Type", "application/json")
+ try:
+ with urllib.request.urlopen(req, data, timeout=15) as r:
+ return r.status, json.loads(r.read().decode() or "null")
+ except urllib.error.HTTPError as e:
+ try:
+ return e.code, json.loads(e.read().decode() or "null")
+ except Exception:
+ return e.code, None
+
+
+def audit(base, tok, wp_id, action):
+ _, rows = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=%s" % (wp_id, action), tok)
+ return rows or []
+
+
+class SmtpSink(threading.Thread):
+ """The smallest SMTP server that satisfies smtplib: enough protocol to accept
+ a message and record it. TLS is refused by omission (the settings turn it
+ off), auth is never requested. Everything captured stays in memory."""
+ daemon = True
+
+ def __init__(self):
+ super().__init__()
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ self.sock.bind(("127.0.0.1", 0))
+ self.sock.listen(8)
+ self.port = self.sock.getsockname()[1]
+ self.messages = [] # {"to": [...], "data": str}
+ self._stop = False
+
+ def run(self):
+ self.sock.settimeout(0.5)
+ while not self._stop:
+ try:
+ conn, _ = self.sock.accept()
+ except socket.timeout:
+ continue
+ except OSError:
+ break
+ try:
+ self._serve(conn)
+ except Exception:
+ pass
+ finally:
+ try:
+ conn.close()
+ except Exception:
+ pass
+
+ def stop(self):
+ self._stop = True
+ try:
+ self.sock.close()
+ except Exception:
+ pass
+
+ def _serve(self, conn):
+ conn.settimeout(10)
+ f = conn.makefile("rb")
+ conn.sendall(b"220 sink ready\r\n")
+ rcpt, in_data, buf = [], False, []
+ while True:
+ line = f.readline()
+ if not line:
+ return
+ if in_data:
+ if line.rstrip(b"\r\n") == b".":
+ self.messages.append({"to": list(rcpt),
+ "data": b"".join(buf).decode("utf-8", "replace")})
+ rcpt, in_data, buf = [], False, []
+ conn.sendall(b"250 OK\r\n")
+ else:
+ buf.append(line)
+ continue
+ cmd = line.decode("utf-8", "replace").strip()
+ up = cmd.upper()
+ if up.startswith(("EHLO", "HELO")):
+ conn.sendall(b"250-sink\r\n250 OK\r\n")
+ elif up.startswith("MAIL FROM"):
+ conn.sendall(b"250 OK\r\n")
+ elif up.startswith("RCPT TO"):
+ m = re.search(r"<([^>]+)>", cmd)
+ rcpt.append(m.group(1) if m else cmd)
+ conn.sendall(b"250 OK\r\n")
+ elif up.startswith("DATA"):
+ in_data = True
+ conn.sendall(b"354 go\r\n")
+ elif up.startswith("QUIT"):
+ conn.sendall(b"221 bye\r\n")
+ return
+ else:
+ conn.sendall(b"250 OK\r\n")
+
+
+def wait_for(fn, timeout=12.0):
+ end = time.time() + timeout
+ while time.time() < end:
+ if fn():
+ return True
+ time.sleep(0.3)
+ return False
+
+
+def set_qa_group(ids):
+ """D2's field, as buildSOP() writes it: data['sop']['project']['qaGroupIds']."""
+ from server.db import SessionLocal
+ from server import models
+ with SessionLocal() as db:
+ sop = db.get(models.Sop, "sopA")
+ data = json.loads(json.dumps(sop.data or {}))
+ data.setdefault("sop", {}).setdefault("project", {})["qaGroupIds"] = ids
+ sop.data = data
+ db.commit()
+
+
+def mkwp(base, tok, wp_id, status="In Progress", extra_data=None, assignee=None):
+ data = {"constraints": [{"name": "Boom lift", "status": "cleared", "comment": ""}],
+ "location": CANARY, "desc": "600ft of 3/4 EMT through " + CANARY}
+ data.update(extra_data or {})
+ return api(base, "/api/wps", tok, "POST", {
+ "id": wp_id, "project_id": "projA", "number": "QA-" + wp_id[-2:],
+ "subject": "conduit " + CANARY, "status": status,
+ "assignee_id": assignee, "data": data})
+
+
+def main():
+ exe = cdp.find_browser()
+ if not exe:
+ print("no headless-capable browser found; set WP_BROWSER.")
+ return 2
+
+ tmpdir = tempfile.mkdtemp(prefix="wpsuite-qa-")
+ db_path = os.path.join(tmpdir, "check.db")
+ server = None
+ browser = None
+ sink = SmtpSink()
+ sink.start()
+ try:
+ tok = seed(db_path)
+ set_sop(db_path, {})
+ set_qa_group(["user_sue", "user_pat"])
+ port = cdp.free_port()
+ base = "http://127.0.0.1:%d" % port
+ server = start_server(port, db_path)
+ root, pat = tok["root"], tok["pat"]
+
+ # ── 1. the rung, and the ship-off default ────────────────────────────
+ print("\n1. the new state, with email OFF (as shipped)")
+ code, _ = mkwp(base, root, "wpQA1", assignee="user_mix")
+ chk("a package saves at In Progress", code == 200, code)
+ code, _ = api(base, "/api/wps/wpQA1/status", root, "POST", {"status": "Ready for QA"})
+ chk("Ready for QA is a legal transition from In Progress", code == 200, code)
+ chk("...and it is written to history",
+ bool(audit(base, root, "wpQA1", "qa_ready")), )
+ _, rows = api(base, "/api/notifications?all=true", root)
+ qa_rows = [r for r in (rows or []) if r.get("kind") == "qa_ready"]
+ chk("with email OFF the outbox records the sends as skipped, not silent",
+ qa_rows and all(r.get("status") == "skipped" for r in qa_rows),
+ ascii_([(r.get("email"), r.get("status")) for r in qa_rows]))
+ chk("...and the capture sink received NOTHING", len(sink.messages) == 0,
+ len(sink.messages))
+
+ code, _ = api(base, "/api/wps/wpQA1/status", root, "POST",
+ {"status": "Draft"})
+ chk("the rung is inside the transition model: leaving it re-crosses the "
+ "release gate on the way back up",
+ api(base, "/api/wps/wpQA1/status", root, "POST",
+ {"status": "Ready for QA"})[0] == 200)
+
+ # ── 2. D10: who may turn it on ────────────────────────────────────────
+ print("\n2. D10: the switch")
+ code, _ = api(base, "/api/settings", pat, "PUT", {"email_enabled": True})
+ chk("a non-administrator cannot turn email on", code == 403, code)
+ code, _ = api(base, "/api/settings", root, "PUT", {
+ "email_enabled": True, "smtp_host": "127.0.0.1", "smtp_port": sink.port,
+ "smtp_use_tls": False, "from_addr": "suite@sink.local",
+ "app_base_url": base})
+ chk("an administrator can", code == 200, code)
+ _, ev = api(base, "/api/audit?entity_type=settings&action=settings_updated", root)
+ chk("...and the change is in the audit log",
+ ev and ev[0].get("detail", {}).get("email_enabled") is True, ascii_(ev[:1]))
+ _, pub = api(base, "/api/settings", root)
+ chk("the settings API never returns an SMTP password, only whether one is set",
+ "smtp_password" not in (pub or {}) and "smtp_password_set" in (pub or {}),
+ ascii_(sorted((pub or {}).keys())))
+ src = open(os.path.join(ROOT, "server", "notify.py"), encoding="utf-8").read()
+ chk("the password is read from the environment and stored nowhere",
+ "SMTP_PASSWORD" in src and "smtp_password" not in json.dumps(
+ __import__("server.notify", fromlist=["DEFAULTS"]).DEFAULTS))
+
+ # ── 3. entering Ready for QA emails the group, and nobody else ───────
+ print("\n3. the send, against the sink")
+ code, _ = mkwp(base, root, "wpQA2", assignee="user_mix")
+ code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "Ready for QA"})
+ chk("the transition succeeds with email on", code == 200, code)
+ # The wp was created WITH an assignee, so one wp_assigned mail rides along
+ # - correct and its own feature. This section asserts the QA mails alone.
+ qa_msgs = lambda: [m for m in sink.messages if "Ready for QA:" in m["data"]]
+ chk("the sink captures exactly the QA group - two Ready-for-QA messages",
+ wait_for(lambda: len(qa_msgs()) == 2, 12), len(qa_msgs()))
+ rcpts = sorted(m["to"][0] for m in qa_msgs())
+ chk("...addressed to the group members and NOBODY else",
+ rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts))
+ body = qa_msgs()[0]["data"] if qa_msgs() else ""
+ chk("the message carries the WP number", "QA-A2" in body, ascii_(body, 200))
+ chk("...and a link that opens THAT work package, not the app root",
+ "/wp-creation-index.html?project=projA&wp=wpQA2" in body, ascii_(body, 400))
+ chk("...and no customer IP: the location canary does not appear",
+ CANARY not in body and all(CANARY not in m["data"] for m in sink.messages))
+ chk("...and no SMTP password either", "SMTP_PASSWORD" not in body
+ and os.getenv("SMTP_PASSWORD", "hunter2-not-set") not in body)
+
+ # ── 4. rejection ──────────────────────────────────────────────────────
+ print("\n4. rejection: comment required, owner + group notified")
+ sink.messages.clear()
+ code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "In Progress"})
+ chk("a rejection with no comment is refused", code == 409, code)
+ code, _ = api(base, "/api/wps/wpQA2/status", root, "POST",
+ {"status": "In Progress", "comment": "Torque strap missing on run 4"})
+ chk("with a comment it returns to In Progress", code == 200, code)
+ rows = audit(base, root, "wpQA2", "qa_rejected")
+ chk("...the rejection is in history WITH the comment",
+ rows and "Torque strap" in (rows[0].get("detail", {}).get("comment") or ""),
+ ascii_(rows[:1]))
+ chk("...the owner and the same group are emailed - three messages",
+ wait_for(lambda: len(sink.messages) == 3, 12), len(sink.messages))
+ rcpts = sorted(m["to"][0] for m in sink.messages)
+ chk("...exactly them", rcpts == ["mix@example.test", "pat@example.test",
+ "sue@example.test"], ascii_(rcpts))
+ chk("...and the comment itself stays on the package, out of the mail",
+ all("Torque strap" not in m["data"] for m in sink.messages))
+
+ # The upsert path enforces the same comment rule (it is how the browser saves).
+ code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "Ready for QA"})
+ _, wp = api(base, "/api/wps/wpQA2", root)
+ code, _ = api(base, "/api/wps", root, "POST", {
+ "id": "wpQA2", "project_id": "projA", "number": wp["number"],
+ "subject": wp["subject"], "status": "In Progress", "data": wp["data"]})
+ chk("the upsert refuses the same no-comment rejection - it is not a side door",
+ code == 409, code)
+
+ # ── 5. failure is recorded, not silent ────────────────────────────────
+ print("\n5. a dead SMTP host is a recorded failure")
+ sink.messages.clear()
+ dead = cdp.free_port()
+ api(base, "/api/settings", root, "PUT", {"smtp_host": "127.0.0.1",
+ "smtp_port": dead})
+ code, _ = mkwp(base, root, "wpQA3")
+ api(base, "/api/wps/wpQA3/status", root, "POST", {"status": "Ready for QA"})
+
+ def failed_rows():
+ _, rows = api(base, "/api/notifications?all=true", root)
+ return [r for r in (rows or [])
+ if r.get("kind") == "qa_ready" and r.get("status") == "failed"]
+ chk("the outbox row is marked failed with the error recorded",
+ wait_for(lambda: len(failed_rows()) >= 1, 15)
+ and bool(failed_rows()[0].get("error")), ascii_(failed_rows()[:1]))
+ api(base, "/api/settings", root, "PUT", {"smtp_host": "127.0.0.1",
+ "smtp_port": sink.port})
+
+ # ── 6. the client: pill, banner, accept/reject, deep link, field view ─
+ print("\n6. the browser half")
+ browser = cdp.Browser(exe)
+ page = browser.page()
+ page.clear_cookies()
+ page.set_cookie("wp_session", root)
+ page.viewport(1440, 900)
+ page.goto(base + "/wp-creation-index.html?project=projA&wp=wpQA2")
+ dismiss_dialogs(page)
+ ok = wait_for(lambda: page.eval("!!window.wpCreatorReady"), 15)
+ settle(1.5)
+ chk("the creator boots on an emailed deep link and opens THAT package",
+ ok and page.eval("editingId") == "wpQA2", page.eval("editingId"))
+ page.eval("window.prompt=()=>null; window.alert=()=>{}; window.confirm=()=>false;")
+ chk("the status control has a real Ready for QA pill",
+ page.eval("!!document.querySelector('.radio-pill[data-val=\"Ready for QA\"] input')"))
+ chk("the dashboard status filter learned the new state on its own",
+ page.eval("STATUS_ORDER.includes('Ready for QA')"))
+
+ # The creator expands the record to the SOP's constraint set, whose
+ # defaults are OPEN - so Ready for QA is (correctly) refused until they
+ # are dealt with. Clear them the way a user would before the QA flow.
+ n = page.eval("pkgConstraints.length")
+ for i in range(n):
+ page.eval("""(() => {
+ const tr = document.querySelectorAll('#constraint-body tr')[%d];
+ [...tr.querySelectorAll('.cstatus button')]
+ .find(b => b.textContent.trim() === 'N/A').click();
+ })()""" % i)
+ settle(0.15)
+ page.eval("document.querySelector('.radio-pill[data-val=\"Ready for QA\"]').click()")
+ settle(0.5)
+ chk("selecting it shows the QA banner with accept and reject as real buttons",
+ page.eval("document.querySelectorAll('#release-banner .rb-act').length") == 2)
+ page.eval("qaAccept()")
+ settle(0.3)
+ chk("accept moves to QC", page.eval("getRadio('status')") == "QC")
+
+ page.eval("document.querySelector('.radio-pill[data-val=\"Ready for QA\"]').click()")
+ settle(0.4)
+ page.eval("qaRejectOpen()")
+ chk("reject opens the comment modal",
+ page.eval("document.getElementById('qa-reject-modal').classList.contains('open')"))
+ page.eval("qaRejectSubmit()")
+ settle(0.2)
+ chk("an empty comment does not pass",
+ page.eval("getRadio('status')") == "Ready for QA")
+ page.eval("document.getElementById('qa-reject-comment').value='Redo the strapping'")
+ page.eval("qaRejectSubmit()")
+ settle(0.3)
+ chk("with a comment the package returns to In Progress",
+ page.eval("getRadio('status')") == "In Progress")
+ chk("...and the comment is on the package",
+ "Redo the strapping" in (page.eval(
+ "JSON.stringify(pkgQaRejections[pkgQaRejections.length-1]||{})") or ""))
+
+ # Field View (D9): the state is text on the card, legible at 390px.
+ page.viewport(390, 900, mobile=True)
+ page.goto(base + "/field.html?project=projA")
+ dismiss_dialogs(page)
+ settle(2.5)
+ pill = json.loads(page.eval("""JSON.stringify((() => {
+ const el = [...document.querySelectorAll('.pill')]
+ .find(x => x.textContent.trim() === 'Ready for QA');
+ if (!el) return null;
+ const cs = getComputedStyle(el);
+ return {size: parseFloat(cs.fontSize), text: el.textContent.trim()};
+ })())"""))
+ chk("390px Field View: 'Ready for QA' is carried by TEXT on the card (D9)",
+ pill is not None, ascii_(pill))
+ chk("...at a legible size", bool(pill) and pill["size"] >= 11, ascii_(pill))
+
+ # The wizard's D2 field: rendered from project members, feeds state.
+ page.viewport(1440, 900)
+ page.goto(base + "/work-package-suite.html?tab=sop")
+ dismiss_dialogs(page)
+ settle(2.5)
+ chk("the SOP wizard has the QA group field, populated from project members",
+ page.eval("(document.getElementById('proj_qagroup')||{options:[]}).options.length") > 0,
+ page.eval("(document.getElementById('proj_qagroup')||{options:[]}).options.length"))
+ page.eval("""(() => {
+ const sel = document.getElementById('proj_qagroup');
+ [...sel.options].forEach(o => o.selected = ['user_sue','user_pat'].includes(o.value));
+ sel.dispatchEvent(new Event('change'));
+ })()""")
+ chk("picking people lands in the SOP state that completeSOP() persists",
+ sorted(json.loads(page.eval("JSON.stringify(state.qaGroupIds||[])")))
+ == ["user_pat", "user_sue"],
+ page.eval("JSON.stringify(state.qaGroupIds||[])"))
+
+ js_errors = [e for e in page.js_errors()]
+ chk("no JavaScript errors anywhere in the browser half", not js_errors,
+ ascii_(js_errors[:2]))
+
+ finally:
+ sink.stop()
+ if browser is not None:
+ try:
+ browser.close()
+ except Exception:
+ pass
+ if server is not None:
+ try:
+ server.terminate()
+ except Exception:
+ pass
+
+ print("\n" + "-" * 54)
+ print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
+ for f in _FAIL:
+ print(" - " + f)
+ return 1 if _FAIL else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())