dodil/erp-brick-timesheets

DODIL ERP brick: timesheets

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 week 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 (
    EMPLOYEE,
    EMPLOYEE2,
    FINANCE,
    MANAGER,
    NOBODY,
    P1,
    SHEET,
    api,
    app,
    open_week,
    refused,
    story_week,
    submit,
    tool,
    tools,
)
from erp_platform import seam

TOOLS = ["add_time_line", "approve_project_lines", "approve_timesheet", "cancel_time_billing_run", "create_time_billing_run", "discard_ts_migration",
         "explain_ts_check", "get_project_hours", "get_time_billing_run", "get_timesheet", "get_ts_migration_map", "get_ts_migration_run",
         "hold_time_billing_run", "list_activities", "list_cost_rates", "list_time_billing_runs", "list_timesheets", "list_ts_migration_runs",
         "list_ts_reminders", "load_ts_from_odoo", "open_timesheet", "reconcile_ts_migration", "refresh_timesheet", "release_time_billing_run",
         "remove_time_line", "reopen_timesheet", "return_timesheet", "set_activity", "set_cost_rate", "set_person_rate_group", "set_time_entries",
         "set_ts_control", "sign_ts_migration", "submit_timesheet"]


def test_the_agent_door_offers_the_use_cases_and_never_the_seam_only_routes():
    assert tools(EMPLOYEE) == TOOLS
    for seam_only in ("get_approved_hours", "drain_ts_outbox", "report_deliveries", "tick_time_billing_runs", "send_billing_invoices", "tick_ts_reminders", "show_process_bpmn"):
        assert seam_only not in tools(EMPLOYEE)
    assert len(set(TOOLS)) == len(TOOLS)


def test_a_week_is_recorded_submitted_and_approved_through_the_agent_door_alone(nb):
    status, r = tool(EMPLOYEE, "open_timesheet", {"week": "2026-W47"})
    assert status == "ok" and r["sheet_id"] == SHEET and r["expected_hours"] == "37.50"
    status, r = tool(EMPLOYEE, "add_time_line", {"sheet_id": SHEET, "activity_code": "DEV", "project_id": P1})
    assert status == "ok"
    n = r["lines"][-1]["line_no"]
    entries = [{"work_date": f"2026-11-{d}", "hours": "7.50", "note": "build"} for d in ("16", "17", "18", "19")]
    status, r = tool(EMPLOYEE, "set_time_entries", {"sheet_id": SHEET, "line_no": n, "entries": entries})
    assert status == "ok" and r["worked_hours"] == "30.00" and r["booked_hours"] == "37.50"
    assert tool(EMPLOYEE, "submit_timesheet", {"sheet_id": SHEET})[1]["approver"]["person_id"] == "2280"
    assert [t["sheet_id"] for t in tool(MANAGER, "list_timesheets", {"scope": "waiting"})[1]["timesheets"]] == [SHEET]
    from conftest import on_day
    with on_day("2026-11-23T10:00:00+00:00"):
        assert tool(MANAGER, "approve_timesheet", {"sheet_id": SHEET})[1]["status"] == "approved"
    assert tool(EMPLOYEE, "get_timesheet", {"sheet_id": SHEET})[1]["journal_id"].isdigit()
    assert len(nb.journals) == 1 and len(nb.deliveries) == 1


def test_the_two_doors_agree_on_every_refusal(nb):
    open_week()
    story_week()
    other = open_week(EMPLOYEE2, "2026-W47")["sheet_id"]
    submit()
    cases = [("submit_timesheet", {"sheet_id": SHEET}, EMPLOYEE, "POST", f"/sheets/{SHEET}/submit", None),
             ("approve_timesheet", {"sheet_id": SHEET}, EMPLOYEE, "POST", f"/sheets/{SHEET}/approve", None),
             ("return_timesheet", {"sheet_id": SHEET, "reason": "x"}, EMPLOYEE, "POST", f"/sheets/{SHEET}/return", {"reason": "x"}),
             ("reopen_timesheet", {"sheet_id": SHEET, "reason": "x"}, MANAGER, "POST", f"/sheets/{SHEET}/reopen", {"reason": "x"}),
             ("add_time_line", {"sheet_id": SHEET, "activity_code": "DEV", "project_id": P1}, EMPLOYEE, "POST", f"/sheets/{SHEET}/lines",
              {"activity_code": "DEV", "project_id": P1})]
    for name, args, who, method, path, body in cases:
        status, text = tool(who, name, args)
        code, got = api(method, path, who, body)
        assert status == "refused" and code in (403, 409), (name, code, got)
        assert got["detail"] in text or text in got["detail"], (name, text, got)
    assert tool(MANAGER, "get_timesheet", {"sheet_id": other})[0] == "refused"          # another person's open week: not found, on both doors
    refused(api("GET", f"/sheets/{other}", MANAGER), 404)
    assert tool(FINANCE, "get_timesheet", {"sheet_id": other})[0] == "ok"               # finance reads every week (ts:sheet:read_all)


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("/sheets")).status_code, (await c.post("/sheets", json={"week": "2026-W47"})).status_code]
    assert anyio.run(bare) == [401, 401]
    refused(api("POST", "/sheets", NOBODY, {"week": "2026-W47"}), 403, "ts:sheet:write_own")
    refused(api("GET", "/sheets", NOBODY), 403, "ts:sheet:read_own")
    refused(api("POST", "/outbox/drain", EMPLOYEE), 403, "ts:cost:post_auto")
    refused(api("POST", "/controls", MANAGER, {"key": "approval_route", "value": "manager", "valid_from": "2026-12-01"}), 403, "ts:settings:controls")


def test_the_mcp_door_refuses_what_the_api_refuses_for_a_lost_hr(nb):
    seam.unregister_local("hr")
    status, text = tool(EMPLOYEE, "open_timesheet", {"week": "2026-W47"})
    assert status == "refused" and "hr is not part of this installation" in text