dodil/erp-brick-time-off
DODIL ERP brick: time off
tests/test_settings_catalog.py
"""Slice B item 6: what the customer edits as dated rows - the owner's controls on balances (TOF-14), leave types and policies with the statutory minimum (TOF-11)."""
from datetime import date
from conftest import EMPLOYEE, HR_ADMIN, MANAGER, OWNER, SYSTEM, api, balance, entries_of, must, put_entry, refused, request, tool, tools
TODAY = "2026-11-02"
def setting(key, value, valid_from, who=None):
return api("POST", "/settings", who or OWNER, {"key": key, "value": value, "valid_from": valid_from})
def in_force(key, who=None):
return {s["key"]: s for s in must(api("GET", "/settings", who or HR_ADMIN))["settings"]}[key]
# ── TOF-14 ──────────────────────────────────────────────────────────────────────────────────
def test_TOF_14_the_controls_are_the_owners_and_hr_cannot_move_them(hr):
refused(setting("allow_negative_days", "5", TODAY, HR_ADMIN), 403, "tof:settings:controls")
for key, value in (("carry_over_max_days", "9"), ("overlap_mode", "block"), ("manager_records_sickness", "false"), ("business_timezone", "Europe/Paris")):
refused(setting(key, value, TODAY, HR_ADMIN), 403, "tof:settings:controls")
for who in (EMPLOYEE, MANAGER, SYSTEM):
refused(setting("max_days_ahead", "30", TODAY, who), 403, "tof:policy:write")
assert must(setting("max_days_ahead", "30", TODAY, OWNER), 201)["controlled_by_owner"] is False # the owner may set any setting
assert in_force("allow_negative_days")["value"] == "0" and in_force("allow_negative_days")["owner_only"] is True
def test_TOF_14_the_owner_sets_a_cap_from_a_future_date_and_it_is_a_dated_row(hr):
got = must(setting("carry_over_max_days", "3", "2027-01-01"), 201)
assert got == {"key": "carry_over_max_days", "value": "3", "valid_from": "2027-01-01", "set_by": "owner@uk.example", "controlled_by_owner": True}
now = in_force("carry_over_max_days")
assert now["value"] == "5" and now["rows"] == [{"valid_from": "2027-01-01", "value": "3", "set_by": "owner@uk.example"}] # in force from the date, not before
refused(setting("carry_over_max_days", "4", "2027-01-01"), 409, "already has a row from 2027-01-01")
def test_TOF_14_a_row_is_never_dated_in_the_past_and_only_the_first_may_start_today(hr):
refused(setting("carry_over_max_days", "3", "2026-11-01"), 422, "not after today")
must(setting("carry_over_max_days", "4", TODAY), 201) # the first row of a key may start today
assert in_force("carry_over_max_days")["value"] == "4"
refused(setting("carry_over_max_days", "3", TODAY), 409, "already has a row")
must(setting("carry_over_max_days", "3", "2026-12-01"), 201)
other = setting("allow_negative_days", "1", "2026-11-01")
refused(other, 422, "not after today") # never the past, not even the first row
def test_TOF_14_a_setting_is_checked_in_its_type_and_range(hr):
for key, value in (("allow_negative_days", "-1"), ("allow_negative_days", "366"), ("allow_negative_days", "abc"), ("allow_negative_days", "1.00001"),
("allow_negative_days", "NaN"), ("carry_over_expires", "13-40"), ("carry_over_expires", "3-31x"), ("overlap_mode", "maybe"),
("business_timezone", "Mars/Phobos"), ("business_timezone", ""), ("min_booking", "quarter_day"), ("manager_records_sickness", "yes"),
("overlap_threshold_pct", "101"), ("nope", "1"), ("allow_negative_days", "9" * 65)):
got = setting(key, value, "2027-01-01")
assert got[0] == 422, (key, value, got)
assert setting("allow_negative_days", "1", "never")[0] == 422
assert setting("allow_negative_days", "1", "2101-01-01")[0] == 422
def test_TOF_14_a_setting_that_hr_owns_changes_what_a_request_may_do(hr):
must(setting("max_days_ahead", "30", TODAY, HR_ADMIN), 201)
refused(api("POST", "/leave-requests", EMPLOYEE, {"from_date": "2026-12-21"}), 422, "more than 30 days ahead")
assert request("2026-11-23")["status"] == "requested"
def test_TOF_14_the_owner_lets_the_balance_go_below_zero_from_a_date(hr):
put_entry(4410, "take:4410", "take", "-24", on=date(2026, 6, 1)) # 1.00 left
refused(api("POST", "/leave-requests", EMPLOYEE, {"from_date": "2026-11-09", "to_date": "2026-11-10"}), 422, "balance")
must(setting("allow_negative_days", "2", TODAY), 201)
assert request("2026-11-09", "2026-11-10")["cost"] == "2.00"
# ── TOF-11 ──────────────────────────────────────────────────────────────────────────────────
def policy(code="UK-ANNUAL", days="28", valid_from="2027-01-01", who=None, **kw):
return api("POST", "/policies", who or HR_ADMIN, {"company_code": "UK01", "policy_code": code, "leave_type_code": "ANNUAL", "unit": "days", "annual_days": days,
"bank_holidays": "on_top", "valid_from": valid_from, "fte_min": "1.0000", **kw})
def test_TOF_11_a_new_rule_for_next_year_is_a_dated_row_used_by_the_days_that_fall_in_it(hr):
got = must(policy(), 201)
assert (got["policy_code"], got["annual_days"], got["valid_from"], got["applies"]) == ("UK-ANNUAL", "28", "2027-01-01", {"fte_min": "1.0000"})
assert balance(on_date="2026-12-01")["entitled"] == "25.00" # this year is still the old rule
new = balance(on_date="2027-06-01")
assert (new["entitled"], new["policy_code"]) == ("28.00", "UK-ANNUAL")
listed = must(api("GET", "/policies", HR_ADMIN, {"company_code": "UK01", "on_date": "2027-06-01"}))["policies"]
assert [(p["policy_code"], p["annual_days"]) for p in listed] == [("UK-ANNUAL", "28"), ("UK-ANNUAL-PT", "25")]
def test_TOF_11_a_policy_below_five_point_six_weeks_is_refused_with_the_reason(hr):
body = refused(policy("UK-LOW", "18"), 422, "18 days plus 8 bank holidays is below 5.6 weeks (28 days for a five-day week, WTR reg. 13 and 13A)")
assert body["gate"] == "statutory_minimum"
refused(policy("UK-LOW", "27", bank_holidays="included"), 422, "27 days with the bank holidays inside is below 5.6 weeks")
must(policy("UK-FINE", "20"), 201) # 20 + 8 = 28
must(policy("UK-FINE-INC", "28", bank_holidays="included"), 201)
assert api("POST", "/policies", HR_ADMIN, {"company_code": "UK01", "policy_code": "COMP", "leave_type_code": "COMPASSIONATE", "unit": "days", "annual_days": "5",
"bank_holidays": "included", "valid_from": "2027-01-01"})[0] == 409 # a type with no balance has no policy
assert not [p for p in must(api("GET", "/policies", HR_ADMIN, {"company_code": "UK01", "on_date": "2027-06-01"}))["policies"] if p["policy_code"] == "UK-LOW"]
def test_TOF_11_a_policy_row_is_dated_once_checked_and_hrs(hr):
must(policy(), 201)
refused(policy(), 409, "already has a row from 2027-01-01")
refused(policy(valid_from="2026-11-02"), 422, "not after today")
refused(policy("UK-NEW", valid_from="2026-11-01"), 422, "not after today")
for who in (EMPLOYEE, MANAGER, OWNER):
refused(policy(who=who), 403, "tof:policy:write")
for kw in ({"annual_days": "-1"}, {"annual_days": "abc"}, {"annual_days": "367"}, {"carry_over_expires": "99-99"}, {"leave_year_start": "x"}, {"fte_min": "2"},
{"carry_over_max": "-3"}, {"valid_from": "soon"}, {"unit": "weeks"}):
got = policy("UK-BAD", **kw)
assert got[0] == 422, (kw, got)
assert policy("")[0] == 422
refused(api("POST", "/policies", HR_ADMIN, {"company_code": "UK01", "policy_code": "X", "leave_type_code": "NO_SUCH", "unit": "days", "annual_days": "28",
"bank_holidays": "on_top", "valid_from": "2027-01-01"}), 409, "not a leave type")
def test_TOF_11_leave_types_are_dated_rows_with_a_pay_treatment_only_payroll_turns_into_money(hr):
body = {"company_code": "UK01", "leave_type_code": "STUDY", "name": "Study leave", "pay_treatment": "paid", "valid_from": TODAY}
got = must(api("POST", "/leave-types", HR_ADMIN, body), 201)
assert (got["leave_type_code"], got["pay_treatment"], got["counts_against"], got["recorded_or_requested"]) == ("STUDY", "paid", "none", "requested")
assert request("2026-11-23", leave_type_code="STUDY")["leave_type_code"] == "STUDY" # usable at once: asked for like any other
listed = must(api("GET", "/leave-types", EMPLOYEE, {"company_code": "UK01"}))["leave_types"]
assert "STUDY" in [t["leave_type_code"] for t in listed] and "ANNUAL" in [t["leave_type_code"] for t in listed]
refused(api("POST", "/leave-types", HR_ADMIN, body), 409, "already has a row")
must(api("POST", "/leave-types", HR_ADMIN, {**body, "valid_from": "2026-11-20", "name": "Study (renamed)"}), 201)
after = must(api("GET", "/leave-types", EMPLOYEE, {"company_code": "UK01", "on_date": "2026-12-01"}))["leave_types"]
assert [t["name"] for t in after if t["leave_type_code"] == "STUDY"] == ["Study (renamed)"]
def test_TOF_11_a_leave_type_is_checked_and_hrs(hr):
body = {"company_code": "UK01", "leave_type_code": "STUDY", "name": "Study", "pay_treatment": "paid", "valid_from": TODAY}
for who in (EMPLOYEE, MANAGER, OWNER):
refused(api("POST", "/leave-types", who, body), 403, "tof:leave_type:write")
refused(api("POST", "/leave-types", HR_ADMIN, {**body, "name": " "}), 422, "name")
refused(api("POST", "/leave-types", HR_ADMIN, {**body, "counts_against": "NO_SUCH"}), 409, "not a leave type")
refused(api("POST", "/leave-types", HR_ADMIN, {**body, "valid_from": "2026-11-01"}), 422, "not after today")
for kw in ({"pay_treatment": "free"}, {"evidence_rule": "doctor"}, {"max_days_per_year": 0}, {"max_days_per_year": 400}, {"recorded_or_requested": "maybe"},
{"leave_type_code": ""}, {"name": "x" * 501}):
assert api("POST", "/leave-types", HR_ADMIN, {**body, **kw})[0] == 422, kw
def test_the_catalog_use_cases_are_tools_and_no_table_crud(hr):
for name in ("change_time_off_setting", "put_time_off_leave_type", "put_time_off_policy", "list_time_off_settings", "list_time_off_policies",
"list_time_off_leave_types"):
assert name in tools(HR_ADMIN), name
status, got = tool(OWNER, "change_time_off_setting", {"key": "carry_over_max_days", "value": "4", "valid_from": "2027-01-01"})
assert status == "ok" and got["controlled_by_owner"] is True
assert entries_of() == []