dodil/erp-brick-time-off
DODIL ERP brick: time off
tests/test_stories.py
"""One test per story of slice A, named for it, written from the spec's Given/When/Then (module.yaml stories).
TOF-01 my balance . TOF-02 ask . TOF-03 a day once . TOF-04 approve . TOF-05 cancel . TOF-06 a part-timer in hours . TOF-15 entitlement derived .
TOF-19 hr's answers . TOF-21 hr's leaver . TOF-22 payroll reads the balance.
The people are the specification's own (employee 4410 in London, manager 2280, then 1904; 5120 works Monday to Wednesday; 4500 in Edinburgh).
Business 'today' is 2026-11-02 unless a test moves it."""
from datetime import date
from conftest import (
EDINBURGH,
EMPLOYEE,
HR_ADMIN,
HR_CLIENT,
MANAGER,
MANAGER2,
NOBODY,
PARTTIMER,
PAYROLL_CLIENT,
SECOND_LOGIN_OF_2280,
api,
approve,
ask,
balance,
decided,
entries_of,
get,
must,
on_day,
put_entry,
refused,
request,
)
from brick_time_off.application import use_cases
def holiday_year_4410():
"""TOF-01's person: 25 days, 2 carried from 2025, 19 days taken in February (2 carried + 17), nothing booked: 8.00 available."""
put_entry(4410, "carry:4410:ANNUAL:2025", "carry", "2.00")
decided("2026-02-02", "2026-02-26") # 19 working days, approved by 2280
def claim_rows():
from sqlalchemy import func, select
from brick_time_off.infrastructure import tables as t
with use_cases.repository() as repo:
return repo.conn.execute(select(func.count()).select_from(t.tof_day_claim)).scalar()
# ── TOF-01 ──────────────────────────────────────────────────────────────────────────────────────
def test_TOF_01_my_balance_shows_what_it_is_made_of_and_never_stores_the_entitlement(engine):
holiday_year_4410()
b = balance()
assert (b["entitled"], b["carried"], b["taken"], b["booked"], b["pending"], b["available"], b["unit"]) == (
"25.00", "2.00", "19.00", "0.00", "0.00", "8.00", "days")
assert b["on"] == "2026-11-02" and b["leave_type_code"] == "ANNUAL" and b["policy_code"] == "UK-ANNUAL"
assert [e["entry_ref"] for e in b["entries"]] == ["carry:4410:ANNUAL:2025", "req:UK01-TOF-2026-00001:r1"] # each figure opens to its entries
assert [e["kind"] for e in entries_of(4410) and b["entries"]] == ["carry", "take"]
assert all("entitlement" not in ref for ref, _, _ in entries_of(4410)) # derived: no entry holds it
assert b["bank_holidays_deducted"] == "0.00" # on top: never deducted from the 25
def test_TOF_01_the_figures_are_as_at_the_business_date():
holiday_year_4410()
assert balance(on_date="2026-02-10")["taken"] == "7.00" and balance(on_date="2026-02-10")["booked"] == "12.00"
assert balance(on_date="2026-03-01")["taken"] == "19.00"
# ── TOF-02 ──────────────────────────────────────────────────────────────────────────────────────
def test_TOF_02_a_request_costs_working_days_only_and_claims_one_row_per_half_day():
holiday_year_4410()
r = request("2026-12-21", "2027-01-01")
assert r["cost"] == "7.00" and r["status"] == "requested" and r["unit"] == "days" and r["request_id"] == "UK01-TOF-2026-00002"
assert [d["date"] for d in r["days"] if d["part"] == "am"] == ["2026-12-21", "2026-12-22", "2026-12-23", "2026-12-24", "2026-12-29", "2026-12-30", "2026-12-31"]
assert len(r["days"]) == 14 and claim_rows() == 19 * 2 + 14 # 25 and 28 Dec, 1 Jan, the weekend: no rows
b = balance()
assert (b["available"], b["pending"], b["taken"]) == ("1.00", "7.00", "19.00")
assert r["approver_person_id"] == "2280"
def test_TOF_02_an_edinburgh_request_costs_4_days_because_st_andrews_day_is_theirs_alone():
assert request("2026-11-30", "2026-12-04", EDINBURGH)["cost"] == "4.00"
assert request("2026-11-30", "2026-12-04")["cost"] == "5.00" # London works the Monday
def test_TOF_02_a_request_that_would_take_the_balance_below_zero_is_refused_with_the_gate():
put_entry(4410, "adj:4410:ANNUAL:2026:1", "adjust", "-19.00") # 6.00 left
body = refused(ask("2026-12-21", "2027-01-01"), 422, "7.00 asked, 6.00 available")
assert body["gate"] == "balance" and claim_rows() == 0 # nothing claimed by a refused ask
def test_TOF_02_unpaid_leave_counts_against_no_balance_so_a_balance_gate_never_stops_it():
put_entry(4410, "adj:4410:ANNUAL:2026:1", "adjust", "-25.00")
assert request("2026-12-21", "2026-12-23", leave_type_code="UNPAID")["cost"] == "3.00"
def test_TOF_02_a_half_day_costs_half_and_a_request_must_name_a_working_day():
assert request("2026-12-21", from_part="am")["cost"] == "0.50"
assert request("2026-12-22", "2026-12-23", from_part="pm", to_part="am")["cost"] == "1.00" # the afternoon of the 22nd and the morning of the 23rd
body = refused(ask("2026-12-26", "2026-12-27"), 422, "no working day")
assert body["gate"] == "empty"
refused(ask("2026-12-25"), 422, "no working day") # a bank holiday
def test_TOF_02_a_person_hr_does_not_know_or_who_is_not_employed_is_refused_in_words(hr):
refused(ask("2026-12-21", who=NOBODY), 403, "tof:request:write_own")
hr.people[4410]["end"] = date(2026, 12, 22)
body = refused(ask("2026-12-21", "2026-12-24"), 409, "not employed on 2026-12-23")
assert body["gate"] == "employed"
hr.by_sub.clear()
refused(ask("2026-12-21"), 409, "hr holds no person with your sign-in")
# ── TOF-03 ──────────────────────────────────────────────────────────────────────────────────────
def test_TOF_03_a_second_request_for_a_day_already_asked_is_refused_naming_the_winner():
first = request("2026-12-21", "2026-12-24")
body = refused(ask("2026-12-24", from_part="am"), 409, f"2026-12-24 am is already in request {first['request_id']}")
assert body["gate"] == "day_free" and claim_rows() == 8
assert request("2026-12-29")["status"] == "requested" # a different day is free
def test_TOF_03_of_two_racing_writes_exactly_one_wins_at_the_claim_row(monkeypatch):
request("2026-12-21")
from brick_time_off.infrastructure.tables import DayClaimRepository
monkeypatch.setattr(DayClaimRepository, "in_range", lambda self, *a, **k: []) # the second tab saw the old balance: blind to the first
refused(ask("2026-12-21"), 409, "Someone else did the same a moment ago")
assert claim_rows() == 2 # the loser wrote no request, no number, no claim
def test_TOF_03_a_withdrawn_or_rejected_request_releases_its_days_in_the_same_transaction():
a = request("2026-12-21", "2026-12-22")["request_id"]
assert claim_rows() == 4
assert must(api("POST", f"/leave-requests/{a}/withdraw", EMPLOYEE))["status"] == "withdrawn" and claim_rows() == 0
b = request("2026-12-21", "2026-12-22")["request_id"]
must(api("POST", f"/leave-requests/{b}/reject", MANAGER, {"reason": "year end freeze"}))
assert claim_rows() == 0 and request("2026-12-21", "2026-12-22")["status"] == "requested"
# ── TOF-04 ──────────────────────────────────────────────────────────────────────────────────────
def test_TOF_04_approving_records_who_and_writes_the_take_entry_in_the_same_transaction():
rid = request("2026-12-21", "2027-01-01")["request_id"]
got = must(approve(rid))
assert got["status"] == "approved" and got["approved_by"] == "mgr2280@uk.example" and got["decided_at"]
assert entries_of(4410) == [(f"req:{rid}:r1", "take", "-7.0000")]
assert balance()["available"] == "18.00" and balance()["booked"] == "7.00"
def test_TOF_04_a_rejection_needs_a_reason_and_releases_the_days_and_writes_nothing_to_the_ledger():
rid = request("2026-12-21", "2026-12-22")["request_id"]
refused(api("POST", f"/leave-requests/{rid}/reject", MANAGER, {"reason": " "}), 422, "reason is required")
got = must(api("POST", f"/leave-requests/{rid}/reject", MANAGER, {"reason": "peak week"}))
assert got["status"] == "rejected" and got["reason"] == "peak week" and entries_of(4410) == [] and claim_rows() == 0
def test_TOF_04_only_the_approver_or_hr_decides_and_a_stranger_does_not_see_the_request():
rid = request("2026-12-21")["request_id"]
refused(approve(rid, MANAGER2), 404, "no leave request") # 1904 manages 2280, not 4410: not theirs to see
refused(approve(rid, EMPLOYEE), 403, "tof:request:approve") # the requester holds no approve permission
refused(approve(rid, EDINBURGH), 404, "no leave request") # another employee: a 404, never a 403
assert must(approve(rid, HR_ADMIN))["status"] == "approved" # HR sees every request and may approve
def test_TOF_04_a_manager_asks_and_it_goes_one_level_up_so_a_second_login_cannot_approve_it():
"""hr's derived manager of 2280 is 2280 himself (the chief of his unit), so the approver is level 2: 1904 (SC-TOF-03)."""
rid = request("2026-12-21", who=MANAGER)["request_id"]
assert get(rid, MANAGER)["approver_person_id"] == "1904"
refused(approve(rid, MANAGER), 409, "you cannot decide it") # the requester of it: second_person, before anything else
body = refused(approve(rid, SECOND_LOGIN_OF_2280), 409, "you cannot decide it") # a second email, the same sub
assert body["gate"] == "second_person"
assert must(approve(rid, MANAGER2))["status"] == "approved"
# ── TOF-05 ──────────────────────────────────────────────────────────────────────────────────────
def test_TOF_05_an_ask_to_cancel_stands_until_the_manager_confirms_then_the_days_come_back():
rid = decided("2026-12-21", "2026-12-31")["request_id"]
with on_day("2026-12-01T09:00:00+00:00"):
asked = must(api("POST", f"/leave-requests/{rid}/ask-cancellation", EMPLOYEE))
assert asked["status"] == "approved" and asked["cancellation_asked"] is True and balance()["booked"] == "7.00" # the leave stands
assert claim_rows() == 14
got = must(api("POST", f"/leave-requests/{rid}/cancel", MANAGER, {"reason": "plans changed"}))
assert got["status"] == "cancelled" and claim_rows() == 0
assert entries_of(4410) == [(f"req:{rid}:r1", "take", "-7.0000"), (f"req:{rid}:r1:cancel", "reverse", "7.0000")]
assert balance()["available"] == "25.00" and balance()["booked"] == "0.00"
def test_TOF_05_a_day_already_past_is_not_cancelled_by_the_employee():
rid = decided("2026-12-21", "2026-12-31")["request_id"]
with on_day("2026-12-22T09:00:00+00:00"):
body = refused(api("POST", f"/leave-requests/{rid}/ask-cancellation", EMPLOYEE), 409, "2026-12-21 is already past")
assert body["gate"] == "past_day"
refused(api("POST", f"/leave-requests/{rid}/cancel", MANAGER, {"reason": "x"}), 409, "already past")
assert get(rid)["status"] == "approved" # nothing moved
def test_TOF_05_the_manager_may_refuse_the_ask_and_the_leave_stands_and_the_requester_never_confirms_their_own():
rid = decided("2026-12-21", "2026-12-23")["request_id"]
refused(api("POST", f"/leave-requests/{rid}/refuse-cancellation", MANAGER, {"reason": "x"}), 409, "nobody asked")
must(api("POST", f"/leave-requests/{rid}/ask-cancellation", EMPLOYEE))
refused(api("POST", f"/leave-requests/{rid}/ask-cancellation", EMPLOYEE), 409, "already asked")
refused(api("POST", f"/leave-requests/{rid}/cancel", EMPLOYEE, {"reason": "x"}), 403, "tof:request:approve")
got = must(api("POST", f"/leave-requests/{rid}/refuse-cancellation", MANAGER, {"reason": "the team needs you"}))
assert got["status"] == "approved" and got["cancellation_asked"] is False and got["cancellation_refused"] == "the team needs you"
assert entries_of(4410) == [(f"req:{rid}:r1", "take", "-3.0000")] # no ledger entry for a refusal
must(api("POST", f"/leave-requests/{rid}/ask-cancellation", EMPLOYEE)) # a new ask is allowed once the first is closed
# ── TOF-06 ──────────────────────────────────────────────────────────────────────────────────────
def test_TOF_06_a_part_timer_books_in_hours_and_loses_only_the_bank_holidays_on_a_day_she_works():
b = balance(PARTTIMER)
assert (b["unit"], b["entitled"], b["policy_code"], b["day_minutes"]) == ("hours", "148.50", "UK-ANNUAL-PT", 450) # (25 + 8) x 7.5 x 0.6
assert b["bank_holidays_deducted"] == "37.50" and b["available"] == "111.00" # five Mondays: 6 Apr, 4 and 25 May, 31 Aug, 28 Dec
r = request("2026-12-29", "2026-12-30", PARTTIMER)
assert (r["cost"], r["unit"]) == ("15.00", "hours")
assert [d["quantity"] for d in r["days"]] == ["3.75"] * 4
refused(ask("2026-12-24", who=PARTTIMER), 422, "no working day") # Thursday: a day she does not work costs 0, refused empty
assert balance(PARTTIMER)["available"] == "96.00"
def test_TOF_06_the_friday_bank_holiday_costs_a_part_timer_nothing():
refused(ask("2026-12-25", who=PARTTIMER), 422, "no working day")
assert balance(PARTTIMER)["bank_holidays_deducted"] == "37.50" # 25 Dec is a Friday: not in the five
# ── TOF-15 ──────────────────────────────────────────────────────────────────────────────────────
def test_TOF_15_a_starter_and_a_change_of_fte_are_right_with_nobody_recalculating(hr):
hr.person(4450, "emp4450@uk.example", start=date(2026, 9, 1), manager=hr.as_manager(2280))
hr.person(4460, "emp4460@uk.example", fte_from=(date(2026, 7, 1), "0.8000"), manager=hr.as_manager(2280))
from conftest import as_role
starter, changer = as_role("employee", "emp4450@uk.example", 4450), as_role("employee", "emp4460@uk.example", 4460)
s = balance(starter)
assert s["entitled"] == "8.50" and s["calculation"]["employed_days"] == 122 # 25 x 122 / 365 = 8.36, up to the next half day
c = balance(changer)
assert c["entitled"] == "22.50" and c["unit"] == "days" # 25 x (181/365 x 1.0 + 184/365 x 0.8) = 22.48
hr.people[4460]["fte_from"] = (date(2026, 7, 1), "0.5000") # a backdated change in hr: the next read, nothing stored
assert balance(changer)["entitled"] == "19.00"
# ── TOF-19 ──────────────────────────────────────────────────────────────────────────────────────
def test_TOF_19_a_request_reads_the_pattern_the_employment_and_the_manager_from_hr_and_keeps_no_copy(hr):
request("2026-12-21", "2026-12-23")
paths = [c[0] for c in hr.calls]
assert {"/persons", "employment", "manager", "work-pattern"} <= set(paths)
assert [c[1]["level"] for c in hr.calls if c[0] == "manager"] == [1]
from brick_time_off.infrastructure.tables import md
banned = ("public_holiday", "holiday_calendar", "contracted_minutes", "work_schedule", "manager", "weekday")
assert not any(b in t.name or b in c.name for b in banned for t in md.sorted_tables for c in t.columns)
def test_TOF_19_a_change_in_hr_reaches_the_next_request_with_no_recalculation(hr):
assert request("2026-12-21", "2026-12-24")["cost"] == "4.00"
hr.people[4410]["calendar"] = {**hr.people[4410]["calendar"], "2026-12-23": "Company day"}
assert request("2026-12-29", "2026-12-31")["cost"] == "3.00"
assert balance()["pending"] == "7.00"
# ── TOF-21 and TOF-22 ───────────────────────────────────────────────────────────────────────────
def leaver_setup(hr):
"""4490 leaves on 2026-10-16: 15 days taken in September, leave approved for 26-30 October."""
from conftest import as_role
hr.person(4490, "emp4490@uk.example", manager=hr.as_manager(2280))
w = as_role("employee", "emp4490@uk.example", 4490)
decided("2026-09-01", "2026-09-21", w) # 15 working days
long = decided("2026-10-26", "2026-10-30", w)
hr.people[4490]["end"] = date(2026, 10, 16) # hr's Left event has committed
return w, long["request_id"]
def left(person_id=4490, last_day="2026-10-16", ref="hr:left:4490:1"):
return api("POST", f"/employees/{person_id}/leaver", HR_CLIENT, {"last_day": last_day, "source_ref": ref})
def test_TOF_21_a_leaver_loses_the_leave_after_the_last_day_once_and_payroll_reads_what_is_owed(hr):
w, rid = leaver_setup(hr)
first = must(left())
assert first["cancelled_requests"] == [rid] and first["balance"] == {"leave_type_code": "ANNUAL", "unit": "days", "available": "4.79"}
again = must(left())
assert again == first # the repeat answers the same
assert [e for e in entries_of(4490) if e[0].endswith(":cancel")] == [(f"req:{rid}:r1:cancel", "reverse", "5.0000")] # cancelled once
assert get(rid, w)["status"] == "cancelled" and get(rid, w)["reason"] == "left" and claim_rows() == 30
got = must(api("GET", "/persons/4490/balance", PAYROLL_CLIENT, {"on": "2026-10-16"})) # TOF-22
assert got == {"person_id": "4490", "on": "2026-10-16", "leave_type_code": "ANNUAL", "unit": "days", "entitled": "19.79", "carried": "0.00",
"taken": "15.00", "booked": "0.00", "adjustments": "0.00", "available": "4.79", "day_minutes": 450}
def test_TOF_21_a_request_that_runs_past_the_last_day_is_cut_back_to_it_and_a_pending_one_is_rejected(hr):
from conftest import as_role
hr.person(4490, "emp4490@uk.example", manager=hr.as_manager(2280))
w = as_role("employee", "emp4490@uk.example", 4490)
span = decided("2026-10-14", "2026-10-20", w)["request_id"] # Wed 14 .. Tue 20: 5 days
pending = request("2026-10-26", "2026-10-27", w)["request_id"]
hr.people[4490]["end"] = date(2026, 10, 16)
out = must(left())
assert out["cancelled_requests"] == [pending] and out["changed_requests"][0] == {"request_id": span, "action": "shortened", "revision": 2}
r = get(span, w)
assert (r["status"], r["revision"], r["to_date"], r["cost"]) == ("approved", 2, "2026-10-16", "3.00")
assert get(pending, w)["status"] == "rejected"
assert entries_of(4490) == [(f"req:{span}:r1", "take", "-5.0000"), (f"req:{span}:r1:cancel", "reverse", "5.0000"), (f"req:{span}:r2", "take", "-3.0000")]
def test_TOF_21_a_moved_last_day_arrives_as_the_next_number_and_a_wrong_reference_is_a_422(hr):
leaver_setup(hr)
must(left())
hr.people[4490]["end"] = date(2026, 10, 23)
assert must(left(last_day="2026-10-23", ref="hr:left:4490:2"))["balance"]["available"] == "5.27" # 25 x 296 / 365 = 20.27, less the 15 taken
refused(left(ref="hr:left:9999:1"), 422, "source_ref is hr:left:4490")
refused(left(ref="whatever"), 422, "source_ref")
def test_TOF_22_only_payrolls_client_reads_the_balance_and_only_hrs_client_sends_a_leaver(hr):
leaver_setup(hr)
refused(api("GET", "/persons/4490/balance", EMPLOYEE, {"on": "2026-10-16"}), 403, "tof:balance:read_for_payroll")
refused(api("GET", "/persons/4490/balance", HR_CLIENT, {"on": "2026-10-16"}), 403, "tof:balance:read_for_payroll")
refused(api("POST", "/employees/4490/leaver", PAYROLL_CLIENT, {"last_day": "2026-10-16", "source_ref": "hr:left:4490:1"}), 403, "tof:leaver:intake")
refused(api("POST", "/employees/4490/leaver", MANAGER, {"last_day": "2026-10-16", "source_ref": "hr:left:4490:1"}), 403, "tof:leaver:intake")