dodil/erp-brick-time-off
DODIL ERP brick: time off
tests/test_doors.py
"""Both doors over the real app: the same use case answers the same through the API and through MCP (the agent door), a request of another person is a
404, a missing permission a 403 naming it, no identity a 401, and the routes only another module calls are not tools."""
import anyio
import httpx
from conftest import EDINBURGH, EMPLOYEE, MANAGER, NOBODY, api, app, approve, decided, must, refused, request, tool, tools
TOOLS = sorted(["approve_leave", "ask_leave_cancellation", "cancel_leave", "get_time_off_balance", "get_time_off_request", "list_time_off_requests",
"recompute_leave_request", "refuse_leave_cancellation", "reject_leave", "request_leave", "withdraw_leave",
"record_sickness", "extend_sickness", "add_fit_note", "add_late_fit_note", "confirm_return", "void_sickness", "list_time_off_sickness",
"get_time_off_sickness", "list_time_off_lapses", "publish_time_off_closure_day", "list_time_off_closure_days",
"load_tof_from_odoo", "reconcile_tof_migration", "sign_tof_migration", "discard_tof_migration", "explain_tof_check", "get_tof_migration_run",
"list_tof_migration_runs", "get_tof_migration_map", "change_time_off_setting", "list_time_off_settings", "put_time_off_leave_type", "list_time_off_leave_types", "put_time_off_policy",
"list_time_off_policies", "record_family_leave", "record_keeping_in_touch_day", "get_time_off_family_leave", "propose_time_off_adjustment", "approve_time_off_adjustment", "reject_time_off_adjustment", "list_time_off_adjustments"])
def test_the_agent_door_offers_the_use_cases_and_never_the_seam_only_routes():
assert tools(EMPLOYEE) == TOOLS
for seam_only in ("intake_tof_leaver", "get_tof_balance_for_payroll", "list_tof_absences_for_payroll", "show_process_bpmn"):
assert seam_only not in tools(EMPLOYEE)
def test_tool_names_are_use_cases_never_table_crud_and_carry_no_name_another_brick_could_have():
for name in TOOLS:
assert not name.startswith(("keep_", "update_", "create_", "set_")), name
assert {"get_time_off_balance", "list_time_off_requests", "get_time_off_request"} <= set(TOOLS) # <verb>_<module noun>_<object>
def test_time_off_is_asked_for_decided_and_read_through_the_agent_door_alone():
status, r = tool(EMPLOYEE, "request_leave", {"from_date": "2026-12-21", "to_date": "2026-12-24", "note": "Christmas"})
assert status == "ok" and r["status"] == "requested" and r["cost"] == "4.00"
rid = r["request_id"]
assert tool(MANAGER, "list_time_off_requests", {"scope": "waiting"})[1]["requests"][0]["request_id"] == rid
assert tool(MANAGER, "approve_leave", {"request_id": rid})[1]["status"] == "approved"
assert tool(EMPLOYEE, "get_time_off_balance", {})[1]["available"] == "21.00"
assert tool(EMPLOYEE, "get_time_off_request", {"request_id": rid})[1]["days"][0] == {"date": "2026-12-21", "part": "am", "quantity": "0.50"}
def test_the_two_doors_agree_on_every_refusal():
rid = request("2026-12-21")["request_id"]
cases = [("approve_leave", {"request_id": rid}, "POST", f"/leave-requests/{rid}/approve", EMPLOYEE),
("reject_leave", {"request_id": rid, "reason": "x"}, "POST", f"/leave-requests/{rid}/reject", EMPLOYEE),
("ask_leave_cancellation", {"request_id": rid}, "POST", f"/leave-requests/{rid}/ask-cancellation", EMPLOYEE),
("get_time_off_request", {"request_id": rid}, "GET", f"/leave-requests/{rid}", EDINBURGH)]
for name, args, method, path, who in cases:
status, text = tool(who, name, args)
code, body = api(method, path, who, args if method == "POST" else None)
assert status == "refused" and code in (403, 404, 409), (name, code, body)
assert body["detail"] in text or text in body["detail"], (name, text, body)
def test_no_identity_is_a_401_and_no_permission_a_403_naming_it_and_another_persons_request_a_404():
async def bare():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://brick") as c:
return [(await c.get("/leave-requests")).status_code, (await c.post("/leave-requests", json={"from_date": "2026-12-21"})).status_code]
assert anyio.run(bare) == [401, 401]
refused(api("POST", "/leave-requests", NOBODY, {"from_date": "2026-12-21"}), 403, "tof:request:write_own")
refused(api("GET", "/leave-requests", NOBODY), 403, "tof:request:read_own")
refused(api("GET", "/balance", NOBODY), 403, "tof:request:read_own")
rid = request("2026-12-21")["request_id"]
refused(api("GET", f"/leave-requests/{rid}", EDINBURGH), 404, "no leave request")
assert must(api("GET", f"/leave-requests/{rid}", MANAGER))["request_id"] == rid # its approver reads it
def test_an_employee_neither_approves_nor_reads_a_sickness_reason_or_another_balance():
rid = request("2026-12-21")["request_id"]
refused(approve(rid, EMPLOYEE), 403, "tof:request:approve")
refused(api("GET", "/balance", EMPLOYEE, {"person_id": 5120}), 404, "no person")
refused(api("GET", "/leave-requests", EMPLOYEE, {"scope": "all"}), 403, "tof:request:read_all")
refused(api("GET", "/leave-requests", EMPLOYEE, {"scope": "waiting"}), 403, "tof:request:read_team")
def test_a_manager_sees_only_the_requests_routed_to_them_and_an_employee_only_their_own():
mine = request("2026-12-21")["request_id"]
other = request("2026-12-01", who=EDINBURGH)["request_id"]
assert [r["request_id"] for r in must(api("GET", "/leave-requests", EMPLOYEE))["requests"]] == [mine]
assert sorted(r["request_id"] for r in must(api("GET", "/leave-requests", MANAGER, {"scope": "waiting"}))["requests"]) == sorted([mine, other])
decided("2026-12-22")
assert len(must(api("GET", "/leave-requests", MANAGER, {"scope": "waiting"}))["requests"]) == 2 # the decided one is no longer waiting