dodil/erp-platform
tests/test_change.py
"""The signed BPMN drives every change (0.8): propose → a change view and a plain-words summary, pending until
signed; the sign-off is numbered, append-only version control; CI fails both ways (drift / not implemented)."""
import json
import shutil
import pytest
from erp_platform import bpmn, change, compose, gate, signoff
from test_bpmn import MD, XSD, valid # noqa: F401
from test_compose import bricks, composition, make_brick # noqa: F401
OLD_ROW = "| cancel_draft | draft | cancelled | biller | | no number used | | |"
NEW_ROW = "| cancel_draft | draft | cancelled | approver | | no number used | | |\n" \
"| hold_draft | draft | | biller | a question to the customer | parked | | |"
@pytest.fixture
def signed(bricks):
make_brick(bricks, "zz_a", "a", ["list_a"], ["a_rows"])
path = composition(bricks, ("a", "zz_a", "0.1.0"))
shutil.copy(MD, bricks / "ERP_INVOICING.md")
signoff.record("invoicing", None, None, None, "Dana Owner, MD", directory=bricks, source="ERP_INVOICING.md")
return bricks, path
def edit(root):
md = root / "ERP_INVOICING.md"
md.write_text(md.read_text().replace(OLD_ROW, NEW_ROW))
def test_propose_writes_a_marked_change_view_and_a_plain_summary_and_blocks_the_build(signed, capsys):
root, path = signed
edit(root)
assert compose.main(["change", "propose", "--module", "invoicing", "--from", "ERP_INVOICING.md",
"--dir", str(root)]) == 0
out = capsys.readouterr().out
assert '"Are you confirming these changes?"' in out
assert 'Add step "Hold the invoice"' not in out and "Task_hold_draft" in out and "Task_cancel_draft" in out
view = (root / "signoff" / "invoicing" / "invoicing.change.bpmn").read_text()
valid(view)
assert 'id="Task_hold_draft"' in view and 'dodil:change="added"' in view and 'dodil:change="changed"' in view
pend = json.loads((root / "signoff" / "invoicing" / "invoicing.pending.json").read_text())
assert any("Task_cancel_draft" in c and "lane" in c for c in pend["changes"])
assert gate.status(root, "invoicing")["state"] == "pending"
assert compose.main(["plan", str(path)]) == 1 # refused while pending
assert signoff.main(["verify", "--all", "--dir", str(root)]) == 1
def test_a_removed_step_is_drawn_back_in_marked_removed(signed):
root, _ = signed
md = root / "ERP_INVOICING.md"
md.write_text(md.read_text().replace(OLD_ROW + "\n", ""))
p = change.propose("invoicing", bpmn.generate(md).encode(), directory=root)
view = (root / "signoff" / "invoicing" / "invoicing.change.bpmn").read_text()
assert 'id="Task_cancel_draft"' in view and 'dodil:change="removed"' in view
assert any(c.startswith("Remove step") and "Task_cancel_draft" in c for c in p["changes"])
def test_signing_the_proposal_makes_v2_keeps_v1_and_a_revert_is_v3(signed):
root, path = signed
edit(root)
change.propose("invoicing", bpmn.generate(root / "ERP_INVOICING.md").encode(), directory=root)
with pytest.raises(signoff.SignoffError, match="needs --reason"):
signoff.record("invoicing", None, None, None, "Dana", directory=root, source="ERP_INVOICING.md")
rec = signoff.record("invoicing", None, None, None, "Dana", directory=root, source="ERP_INVOICING.md",
reason="CR-2: approvers cancel; drafts can be parked", implemented_by=["00007_hold_draft"])
assert rec["signed_version"] == 2 and rec["supersedes"]["signed_version"] == 1
assert rec["implemented_by"] == ["00007_hold_draft"]
hist = root / "signoff" / "invoicing" / "history"
assert {f.name for f in hist.iterdir()} >= {"invoicing.v1.json", "invoicing.v1.bpmn", "invoicing.v2.json",
"invoicing.v2.bpmn", "invoicing.v2.change.bpmn"}
assert not (root / "signoff" / "invoicing" / "invoicing.pending.json").exists()
assert gate.status(root, "invoicing")["state"] == "signed"
md = root / "ERP_INVOICING.md" # forward only: back = a new version
md.write_text(md.read_text().replace(NEW_ROW, OLD_ROW))
rec = signoff.record("invoicing", None, None, None, "Dana", directory=root, source="ERP_INVOICING.md",
reason="CR-3: undo CR-2")
assert rec["signed_version"] == 3 and rec["reverts_to"] == 1
assert json.loads((hist / "invoicing.v2.json").read_text())["signed_version"] == 2 # history untouched
def test_ci_fails_both_ways_and_says_which(signed):
root, _ = signed
edit(root) # the design changed, nobody signed: DRIFT
row = gate.status(root, "invoicing")
assert row["state"] == "drifted" and row["detail"].startswith("DRIFT: the process changed without a new sign-off")
v2 = bpmn.generate(root / "ERP_INVOICING.md")
md = root / "ERP_INVOICING.md"
md.write_text(md.read_text().replace(NEW_ROW, OLD_ROW))
p = root / "v2.bpmn"
p.write_text(v2) # the customer signs v2, the build still draws v1: NOT IMPLEMENTED
rec = signoff.record("invoicing", p, None, None, "Dana", directory=root, reason="CR-2")
rec_path = root / "signoff" / "invoicing" / "invoicing.json"
data = json.loads(rec_path.read_text())
data["source"] = {"kind": "bpmn", "from": "ERP_INVOICING.md", "workflow": None}
rec_path.write_text(json.dumps(data))
row = gate.status(root, "invoicing")
assert rec["signed_version"] == 2 and row["state"] == "not_implemented"
assert row["detail"].startswith("NOT IMPLEMENTED: the customer signed v2 (CR-2")
assert "still draws v1" in row["detail"]