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"]