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