dodil/erp-brick-payroll

DODIL ERP brick: payroll

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 payslip
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 ADMIN, EMP4410, MANAGER, NOBODY, api, app, onboard, open_run, refused, setup_company, tool, tools

TOOLS = ["approve_approver_payslip", "approve_pay_run", "calculate_pay_run", "cancel_pay_run", "complete_manual_submission", "complete_payroll_details", "discard_ytd", "enrol_pension",
         "get_p45_document", "get_pay_run", "get_payroll_employee", "get_payslip", "get_payslip_document", "get_ytd_load", "list_my_payslips", "list_pay_runs",
         "list_payroll_employees", "list_rti_submissions", "list_statutory_rates", "list_ytd_loads", "open_pay_run", "put_pay_schedule", "put_paye_scheme", "put_pension_scheme",
         "record_p45_in", "record_starter_declaration", "resend_fps", "sign_ytd", "use_emergency_code"]


def test_the_agent_door_offers_the_use_cases_and_never_the_seam_only_routes():
    assert tools(ADMIN) == TOOLS
    for seam_only in ("confirm_payout", "drain_outbox", "load_ytd", "reconcile_ytd", "run_eps", "show_process_bpmn"):
        assert seam_only not in tools(ADMIN)


def test_a_run_is_opened_calculated_and_approved_through_the_agent_door_alone(nb):
    setup_company()
    status, run = tool(ADMIN, "open_pay_run", {"company_code": "UK01", "schedule_code": "M", "period": "2026-11"})
    assert status == "ok" and run["pay_run_id"] == "UK01-M-2026-27-M08"
    status, text = tool(ADMIN, "calculate_pay_run", {"pay_run_id": run["pay_run_id"]})
    assert status == "refused" and "has no tax code or NI category" in text
    onboard(run["pay_run_id"])
    assert tool(MANAGER, "approve_pay_run", {"pay_run_id": run["pay_run_id"]})[0] == "refused"          # own payslip first
    from conftest import PEER
    assert tool(PEER, "approve_approver_payslip", {"pay_run_id": run["pay_run_id"], "payroll_id": "2101-1"})[0] == "ok"
    status, got = tool(MANAGER, "approve_pay_run", {"pay_run_id": run["pay_run_id"]})
    assert status == "ok" and got["status"] == "approved" and got["journal_status"] == "posted"


def test_the_two_doors_agree_on_every_refusal(nb):
    setup_company()
    run = open_run()["pay_run_id"]
    onboard(run)
    cases = [(ADMIN, "approve_pay_run", {"pay_run_id": run}, "POST", f"/pay-runs/{run}/approve", None),
             (MANAGER, "approve_pay_run", {"pay_run_id": run}, "POST", f"/pay-runs/{run}/approve", None),
             (EMP4410, "get_payslip", {"pay_run_id": run, "payroll_id": "2101-1"}, "GET", f"/pay-runs/{run}/payslips/2101-1", None),
             (MANAGER, "cancel_pay_run", {"pay_run_id": run, "reason": ""}, "POST", f"/pay-runs/{run}/cancel", {"reason": ""})]
    for who, name, args, method, path, body in cases:
        status, text = tool(who, name, args)
        code, answer = api(method, path, who, body)
        assert status == "refused" and code in (403, 404, 409, 422), (name, code, answer)
        assert answer["detail"] in text or text in answer["detail"], (name, text, answer)


def test_no_identity_is_a_401_and_no_permission_a_403_naming_it():
    async def bare():
        async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://brick") as c:
            return [(await c.get("/pay-runs")).status_code, (await c.post("/pay-runs", json={})).status_code]
    assert anyio.run(bare) == [401, 401]
    refused(api("GET", "/pay-runs", NOBODY), 403, "prl:pay_run:read")
    refused(api("POST", "/pay-runs", EMP4410, {"company_code": "UK01", "schedule_code": "M", "period": "2026-11"}), 403, "prl:pay_run:open")
    refused(api("POST", "/outbox/drain", MANAGER), 403, "prl:journal:post_auto")