Nick's decision, 2026-08-20: 'email bodies provide links back to the system. we can talk about customers we just cant exposed their confidential documents.' The T7.6-era rule (no customer IP at all, so number + link only) is refined: context IN, content OUT. - wp_titled() and wp_where() compose 'number - title' and the CR-004 location (structured paths first, legacy free text second); the where-line is dropped entirely when unset rather than mailing 'Where: '. - assign, qa-ready, qa-reject and hold bodies gain title + location. The scope summary the original CR asked for stays OUT - scope text is document content; the link is its summary. Rejection comments stay on the package. - hold_body gains the house footer it alone lacked. - kitting and material-request bodies adopt wp_titled for the same identity line (their delivery-location rule is unchanged). - notify.py's docstring states the new rule where the transport documents it. Pins flipped WITH the rule, reasons in code: qa_gate_check's location canary is now asserted PRESENT in QA bodies; a new DESC_CANARY (document content) is asserted absent from every message (40 -> 41 checks). The sink also gains a decoded-body view: the em-dash switches smtplib to quoted-printable, whose column-76 soft breaks made raw-payload substring pins pass or fail on luck of line position - content pins now read the decoded body, header pins still read the wire payload. Battery: qa_gate_check 41/41, kitting_notify_check 17/17, mreq_check 19/19. Items: CR-014 (rule per decisions-2026-08-20.md), CR-011 pins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
6.5 KiB
Python
137 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Does a kitting change tell the right people, once? — CR-011 / D10, T8.3.
|
|
|
|
The field learns when material moves without chasing it in Teams. A kitting
|
|
status change emails the package's distribution list plus its warehouse owner
|
|
(CR-010's default recipient), through the SAME gate T7.6 built - off by
|
|
default, admin-only, every send an outbox row, no real mail anywhere.
|
|
|
|
Coalescing: rapid consecutive changes rewrite the held (unsent) notification
|
|
to the newest transition instead of stacking near-identical siblings.
|
|
|
|
Reuses qa_gate_check's SMTP sink - one sink implementation, not two.
|
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
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 qa_gate_check import SmtpSink, api, wait_for # noqa: E402
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
def ascii_(v, n=300):
|
|
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
|
|
|
|
|
def save_wp(base, tok, kit_status, extra=None):
|
|
data = {"constraints": [{"name": "Materials", "status": "cleared", "comment": ""}],
|
|
"kitStatus": kit_status, "kitOwner": "Sue", "kitOwnerId": "user_sue",
|
|
"distributionIds": ["user_pat"], "mimoLoc": "Staging 04, dock B",
|
|
# CR-012 (T8.4): the composed delivery location wins over mimoLoc
|
|
"deliveryLoc": "B-100 / Level 3 / Sector East — Shark cage 7"}
|
|
data.update(extra or {})
|
|
return api(base, "/api/wps", tok, "POST", {
|
|
"id": "wpKN1", "project_id": "projA", "number": "KN-1",
|
|
"subject": "kit notify host", "status": "In Progress", "data": data})
|
|
|
|
|
|
def main():
|
|
exe = cdp.find_browser() # not used, but keeps environment parity checks
|
|
tmpdir = tempfile.mkdtemp(prefix="wpsuite-kitnotify-")
|
|
db_path = os.path.join(tmpdir, "check.db")
|
|
server = None
|
|
sink = SmtpSink()
|
|
sink.start()
|
|
try:
|
|
tok = seed(db_path)
|
|
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. shipped OFF, and the burst coalesces while held ───────────────
|
|
print("\n1. off by default, and no burst")
|
|
save_wp(base, root, "Not Started")
|
|
for st in ("Picking", "Staged", "In Transit"): # three rapid changes
|
|
code, _ = save_wp(base, root, st)
|
|
chk("kitting change to %s saves" % st, code == 200, code)
|
|
_, rows = api(base, "/api/notifications?all=true", root)
|
|
kit_rows = [r for r in (rows or []) if r.get("kind") == "kitting_status"]
|
|
chk("with email OFF the rows are skipped, not silent, and COALESCED - "
|
|
"one per recipient, not one per change",
|
|
len(kit_rows) == 2 and all(r.get("status") == "skipped" for r in kit_rows),
|
|
ascii_([(r.get("email"), r.get("status")) for r in kit_rows]))
|
|
chk("...rewritten to the NEWEST transition",
|
|
all("In Transit" in (r.get("subject") or "") for r in kit_rows),
|
|
ascii_([r.get("subject") for r in kit_rows]))
|
|
chk("...and the sink received nothing", len(sink.messages) == 0)
|
|
_, ev = api(base, "/api/audit?entity_type=wp&entity_id=wpKN1"
|
|
"&action=kitting_status_changed", root)
|
|
chk("every change is in the audit history, uncoalesced",
|
|
len(ev or []) == 3, len(ev or []))
|
|
|
|
# ── 2. the T7.6 gate, reused ─────────────────────────────────────────
|
|
print("\n2. the same gate")
|
|
src = open(os.path.join(ROOT, "server", "notify.py"), encoding="utf-8").read()
|
|
chk("no second email flag was invented - the T7.6 gate is THE gate",
|
|
src.count("email_enabled") >= 1
|
|
and "kitting_email" not in src
|
|
and "kitting_email" not in open(os.path.join(ROOT, "server", "app.py"),
|
|
encoding="utf-8").read())
|
|
code, _ = api(base, "/api/settings", pat, "PUT", {"email_enabled": True})
|
|
chk("a non-administrator still cannot turn it on (D10)", 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)
|
|
|
|
# ── 3. the send ──────────────────────────────────────────────────────
|
|
print("\n3. the send, against the sink")
|
|
code, _ = save_wp(base, root, "Delivered")
|
|
chk("the change lands", code == 200, code)
|
|
chk("the sink captures the distribution list + the warehouse owner: two",
|
|
wait_for(lambda: len(sink.messages) == 2, 12), len(sink.messages))
|
|
rcpts = sorted(m["to"][0] for m in sink.messages)
|
|
chk("...exactly them, actor excluded",
|
|
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts))
|
|
body = sink.messages[0]["text"] if sink.messages else ""
|
|
chk("the mail says old status, new status and who",
|
|
"In Transit" in body and "Delivered" in body and "Root" in body,
|
|
ascii_(body, 260))
|
|
chk("...and the delivery location - CR-012's composed value, not the "
|
|
"mimoLoc fallback", "Shark cage 7" in body and "Staging 04" not in body)
|
|
chk("...and a deep link to THAT package, not the app root",
|
|
"/wp-creation-index.html?project=projA&wp=wpKN1" in body)
|
|
chk("...in the house convention (greeting + automated-message footer)",
|
|
body.count("Hi ") >= 1 and "automated message from the Work Package Suite" in body)
|
|
|
|
finally:
|
|
sink.stop()
|
|
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())
|