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")