dodil/erp-brick-manufacturing
DODIL ERP brick: manufacturing
tests/test_doors.py
"""Both doors serve the same use cases (every route has its tool but the machine's seams), who may do what (module.yaml tests.access),
and which rows a person sees (an order they may not see is a 404, never a 403)."""
import re
from conftest import (
ACCOUNTANT,
BOSS,
ENGINEER,
NOBODY,
OPERATOR,
OWNER,
PLANNER,
QUALITY,
ROLES,
SYSTEM,
api,
get_order,
issue,
line,
must,
new_order,
person,
release,
released_order,
roast_bom,
tool,
tools,
)
from brick_manufacturing.application.use_cases import MFG
MACHINE_ONLY = {"explode_bom", "dispatch_outbox"}
def test_every_use_case_is_a_route_and_a_tool_except_the_machine_seams():
names = {u.name for u in MFG.use_cases if u.tool}
listed = set(tools(BOSS))
assert names <= listed and MACHINE_ONLY.isdisjoint(listed)
assert MACHINE_ONLY <= {u.name for u in MFG.use_cases}
for u in MFG.use_cases:
if not u.tool and u.name != "show_process_bpmn": # the process view is a route, one per brick (BRICKS.md)
assert u.permission in MFG.machine_permissions, u.name
def test_tools_are_use_cases_never_table_crud():
for name in tools(BOSS):
assert not re.match(r"^(create|update|delete|insert|upsert|put|patch)_(mfg_|bom_row|order_row|table)", name), name
assert {"write_bom", "release_bom", "obsolete_bom", "create_order", "release_order", "issue_components", "report_output", "complete_order",
"approve_variance", "close_order", "cancel_order", "set_mfg_setting"} <= set(tools(BOSS))
def test_the_same_refusal_and_the_same_record_through_both_doors():
roast_bom()
body = {"company_code": "UK01", "product_id": "ROAST-ETH", "warehouse_id": "ROASTERY", "planned_quantity": "84.000", "due_on": "2026-11-12"}
status, api_body = api("POST", "/production-orders", PLANNER, body)
kind, text = tool(PLANNER, "create_order", body)
assert status == 409 and kind == "refused" and api_body["detail"] in text
a = must(api("POST", "/production-orders", PLANNER, {**body, "due_on": "2026-11-16"}))
kind, b = tool(PLANNER, "create_order", {**body, "due_on": "2026-11-16"})
assert kind == "ok" and {k: v for k, v in a.items() if k not in ("production_order_id",)} == {k: v for k, v in b.items() if k != "production_order_id"}
def test_the_whole_workflow_runs_through_the_agent_door(nb):
roast_bom()
kind, order = tool(PLANNER, "create_order", {"company_code": "UK01", "product_id": "ROAST-ETH", "warehouse_id": "ROASTERY",
"planned_quantity": "84.000", "due_on": "2026-11-16"})
assert kind == "ok"
from conftest import D16, on_day
with on_day(D16):
oid = order["production_order_id"]
assert tool(PLANNER, "release_order", {"production_order_id": oid})[0] == "ok"
assert tool(OPERATOR, "issue_components", {"production_order_id": oid, "location_id": "ROASTERY/RAW",
"lines": [{"product_id": "GREEN-ETH", "quantity": "100.000", "lot_code": "G-1"}]})[0] == "ok"
assert tool(OPERATOR, "report_output", {"production_order_id": oid, "location_id": "ROASTERY/FG", "quantity": "84.000", "lot_code": "R-1"})[0] == "ok"
assert tool(ACCOUNTANT, "complete_order", {"production_order_id": oid})[0] == "ok"
assert get_order(oid)["status"] == "completed"
# ── who may ─────────────────────────────────────────────────────────────────────────────────────────────
def test_an_operator_cannot_release_close_cost_or_change_boms_or_see_cost_fields():
oid = released_order()
must(issue(oid))
for who, method, path, body in ((OPERATOR, "POST", f"/production-orders/{oid}/release", {}), (OPERATOR, "POST", f"/production-orders/{oid}/close", {}),
(OPERATOR, "POST", "/boms/ROAST-ETH:production/versions", {"version": 2, "valid_from": "2026-12-01", "output_quantity": "1", "output_unit_code": "KGM", "lines": [line("GREEN-ETH", "1")]}),
(OPERATOR, "POST", "/boms/ROAST-ETH:production/versions/1/release", {}),
(OPERATOR, "POST", "/production-orders", {"company_code": "UK01", "product_id": "ROAST-ETH", "warehouse_id": "R", "planned_quantity": "1", "due_on": "2026-11-16"}),
(OPERATOR, "POST", f"/production-orders/{oid}/approve-variance", {}),
(OPERATOR, "POST", "/settings/release_with_shortage", {"value": "true"})):
assert api(method, path, who, body)[0] == 403, path
seen = must(api("GET", f"/production-orders/{oid}", OPERATOR))
assert "wip_balance" not in seen and "settlements" not in seen and "outbox" not in seen
assert all("value" not in m and "unit_cost" not in m for m in seen["movements"])
assert "wip_balance" in get_order(oid)
def test_a_planner_cannot_close_an_order_or_issue_stock_and_a_cost_accountant_cannot_write_a_bom():
oid = released_order()
assert api("POST", f"/production-orders/{oid}/close", PLANNER)[0] == 403
assert api("POST", f"/production-orders/{oid}/issue", PLANNER, {"location_id": "R", "lines": [line("GREEN-ETH", "1")]})[0] == 403
assert api("POST", "/boms/ROAST-ETH:production/versions", ACCOUNTANT, {"version": 2, "valid_from": "2026-12-01", "output_quantity": "1", "output_unit_code": "KGM", "lines": [line("GREEN-ETH", "1")]})[0] == 403
assert api("POST", f"/production-orders/{oid}/complete", PLANNER)[0] == 403
def test_only_the_owner_holds_the_controls():
holders = [r for r, perms in ROLES.items() if "mfg:settings:controls" in perms]
assert holders == ["owner"]
assert api("POST", "/settings/currency", ENGINEER, {"value": "EUR"})[0] == 403
must(api("POST", "/settings/currency", OWNER, {"value": "eur"}))
assert must(api("GET", "/settings", PLANNER))["settings"]["currency"] == "EUR"
def test_the_machine_permissions_go_to_nobody_but_an_exact_grant():
q = {"quantity": "1", "usage": "production", "on": "2026-11-16"}
roast_bom()
wildcard = person("wild@roastery.example", ["mfg:*:*"])
star = person("star@roastery.example", ["*:*:*"])
for who in (PLANNER, ENGINEER, OPERATOR, ACCOUNTANT, OWNER, QUALITY, wildcard, star, NOBODY):
assert api("GET", "/boms/ROAST-ETH/explode", who, q)[0] == 403
assert api("POST", "/outbox/dispatch", who, {})[0] == 403
assert api("GET", "/boms/ROAST-ETH/explode", SYSTEM, q)[0] == 200
pos_client = person("pos-client@roastery.example", ["mfg:bom:explode"]) # pos's service client: this ONE grant
assert api("GET", "/boms/ROAST-ETH/explode", pos_client, q)[0] == 200
assert api("POST", "/outbox/dispatch", pos_client, {})[0] == 403
assert not any("mfg:bom:explode" in ROLES[r] for r in ROLES if r != "system")
# ── which rows ──────────────────────────────────────────────────────────────────────────────────────────
def test_an_operator_sees_released_orders_only_and_a_hidden_order_is_404_never_403():
roast_bom()
created = new_order()
live = new_order()
must(release(live))
assert api("GET", f"/production-orders/{created}", OPERATOR)[0] == 404
assert api("GET", f"/production-orders/{live}", OPERATOR)[0] == 200
assert must(api("GET", "/production-orders", OPERATOR))["production_orders"] == [live]
assert must(api("GET", "/production-orders", OPERATOR, {"status": "created"}))["production_orders"] == []
assert must(api("GET", "/production-orders", PLANNER))["production_orders"] == [created, live]
assert must(api("GET", "/production-orders", PLANNER, {"status": "created"}))["production_orders"] == [created]
assert api("GET", "/production-orders/UK01-MO-2026-09999", PLANNER)[0] == 404
assert api("POST", f"/production-orders/{created}/release", OPERATOR)[0] == 403 # no permission: told so
assert api("POST", f"/production-orders/{created}/issue", OPERATOR, {"location_id": "R", "lines": [line("GREEN-ETH", "1")]})[0] == 404
assert api("GET", "/production-orders", NOBODY)[0] == 403
def test_quality_reads_orders_but_cannot_work_them(nb):
oid = released_order()
assert api("GET", f"/production-orders/{oid}", QUALITY)[0] == 200
assert "wip_balance" not in must(api("GET", f"/production-orders/{oid}", QUALITY))
assert api("POST", f"/production-orders/{oid}/issue", QUALITY, {"location_id": "R", "lines": [line("GREEN-ETH", "1")]})[0] == 403