diff --git a/backend/tests/test_coverage_gaps.py b/backend/tests/test_coverage_gaps.py new file mode 100644 index 0000000..1bfe4e1 --- /dev/null +++ b/backend/tests/test_coverage_gaps.py @@ -0,0 +1,521 @@ +"""Gezielte Tests für kritische, ungetestete Pfade (postgres-expert Audit). + +Deckt: +1. Carryover-Expiry über create_absence (kein Fehlsignal "Konto reicht") +2. Teilzeit + Pro-rata gleichzeitig (_compute_entitlement kombiniert) +3. FZA-Rückbuchung via Cancellation-Request-Flow (statt Admin-Direkt-Storno) +4. Zwei-Stufen-Genehmigung: kein Doppelabzug/-gutschrift bei Storno in FIRST_APPROVED + + Schwellwert-Grenzfall working_days == two_stage_min_days +5. DATEV-Export: Feiertag-Kürzel "F" schlägt Urlaubs-Kürzel "U"; working_days konsistent +6. _categorize_hours: Nachtschicht über Mitternacht + Sonntag+Feiertag (höchster Zuschlag gewinnt) +""" +import pytest +import pytest_asyncio +from datetime import date, time, timedelta +from decimal import Decimal +from httpx import AsyncClient +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.company import Company +from app.models.overtime_balance import OvertimeBalance +from app.models.user import User +from app.models.vacation_balance import VacationBalance + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def cov_company(client: AsyncClient): + resp = await client.post("/api/v1/auth/register", json={ + "company_name": "Coverage Gaps GmbH", + "first_name": "Cov", + "last_name": "Admin", + "email": "admin@covgaps.de", + "password": "Secret123", + }) + assert resp.status_code == 201, resp.text + tokens = resp.json() + me = await client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + return {"tokens": tokens, "user": me.json()} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def cov_headers(cov_company): + return {"Authorization": f"Bearer {cov_company['tokens']['access_token']}"} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def cov_approver_headers(client: AsyncClient, cov_headers): + """Zweiter Admin – für Genehmigungen, die nicht self-approval sein dürfen.""" + resp = await client.post("/api/v1/users/invite", json={ + "first_name": "Cov", "last_name": "Approver", + "email": "approver@covgaps.de", "role": "COMPANY_ADMIN", + "initial_password": "Secret123", + }, headers=cov_headers) + assert resp.status_code == 201, resp.text + login = await client.post("/api/v1/auth/login", json={ + "email": "approver@covgaps.de", "password": "Secret123", + }) + assert login.status_code == 200, login.text + return {"Authorization": f"Bearer {login.json()['access_token']}"} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def cov_third_approver_headers(client: AsyncClient, cov_headers): + """Dritter Admin – für die finale Zwei-Stufen-Genehmigung (andere Person als Stufe 1).""" + resp = await client.post("/api/v1/users/invite", json={ + "first_name": "Cov", "last_name": "Third", + "email": "third@covgaps.de", "role": "HR", + "initial_password": "Secret123", + }, headers=cov_headers) + assert resp.status_code == 201, resp.text + login = await client.post("/api/v1/auth/login", json={ + "email": "third@covgaps.de", "password": "Secret123", + }) + assert login.status_code == 200, login.text + return {"Authorization": f"Bearer {login.json()['access_token']}"} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def cov_vacation_type_id(client: AsyncClient, cov_headers): + resp = await client.get("/api/v1/absence-types/", headers=cov_headers) + types = resp.json() + vacation = next((t for t in types if t["name"] == "Urlaub"), types[0]) + return vacation["id"] + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def cov_fza_type_id(client: AsyncClient, cov_headers): + resp = await client.post("/api/v1/absence-types/", json={ + "name": "FZA Coverage", + "category": "overtime_comp", + "color": "#f97316", + "requires_approval": True, + "deducts_vacation": False, + "affects_overtime_balance": True, + }, headers=cov_headers) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + +def _future_monday(weeks: int) -> date: + return date.today() + timedelta(days=(7 - date.today().weekday()) + 7 * weeks) + + +# ── 1. Carryover-Expiry über echten Integrationspfad ─────────────────────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_carryover_expiry_via_create_absence( + client: AsyncClient, db_session: AsyncSession, cov_company, cov_headers, cov_vacation_type_id, +): + """Ein User mit abgelaufenem carried_over-Betrag darf im create_absence-Pfad + nicht das Signal 'Konto reicht' bekommen, obwohl entitled+carried_over rechnerisch + reichen würde – der verfallene Übertrag darf effective_available nicht aufblähen.""" + user_id = cov_company["user"]["id"] + year = date.today().year + + # Firma: Verfallsdatum in der Vergangenheit setzen (z.B. 31.01. dieses Jahr) + r = await client.patch("/api/v1/companies/me", json={ + "settings": {"carryover_expires_month": 1, "carryover_expires_day": 31}, + }, headers=cov_headers) + assert r.status_code == 200, r.text + + # VacationBalance direkt präparieren: wenig entitled, hoher (verfallener) Übertrag, + # kaum genutzt -> ohne Verfalls-Logik würde das Konto "reichen" + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + balance = await db_session.scalar( + select(VacationBalance).where(VacationBalance.user_id == user_id, VacationBalance.year == year) + ) + if balance is None: + balance = VacationBalance(user_id=user_id, year=year) + db_session.add(balance) + await db_session.flush() + balance.entitled_days = 5 + balance.carried_over = 20 + balance.used_days = 0 + balance.special_days = 0 + await db_session.commit() + + # Antrag über 10 Arbeitstage (mehr als die 5 "echten" Tage, aber weniger als + # der rechnerische Gesamtsaldo 25 -- Verfall muss dennoch greifen und warnen) + start = _future_monday(23) + resp = await client.post("/api/v1/absences/", json={ + "type_id": str(cov_vacation_type_id), + "start_date": str(start), "end_date": str(start + timedelta(days=13)), # 2 volle Wochen = 10 Werktage + }, headers=cov_headers) + assert resp.status_code == 201, resp.text + data = resp.json() + assert data["working_days"] == 10 + + warnings = data.get("warnings", []) + assert any("Urlaubskonto reicht" in w for w in warnings), ( + f"Erwartete Verfalls-Warnung fehlt (Bug: effective_available berücksichtigt Verfall nicht " + f"im create_absence-Pfad?), warnings={warnings}" + ) + + # Reset für nachfolgende Tests + await client.patch("/api/v1/companies/me", json={"settings": {}}, headers=cov_headers) + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + bal2 = await db_session.scalar( + select(VacationBalance).where(VacationBalance.user_id == user_id, VacationBalance.year == year) + ) + if bal2: + bal2.entitled_days = 30 + bal2.carried_over = 0 + await db_session.commit() + + +# ── 2. Teilzeit + Pro-rata gleichzeitig ───────────────────────────────────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_part_time_prorate_combined(client: AsyncClient, cov_headers): + """company.vacation_from_schedule=True UND vacation_prorate_first_year=True gleichzeitig: + Anspruch = default_days * (Arbeitstage/Woche / 5) * (Monate ab Eintritt / 12), gerundet.""" + sched = await client.post("/api/v1/time/schedules", json={ + "name": "Teilzeit 3 Tage Cov", "mon_h": 8, "tue_h": 8, "wed_h": 8, + "thu_h": 0, "fri_h": 0, "valid_from": "2026-01-01", + }, headers=cov_headers) + assert sched.status_code == 201, sched.text + sid = sched.json()["id"] + + await client.patch("/api/v1/companies/me", json={ + "vacation_from_schedule": True, + "vacation_prorate_first_year": True, + "vacation_default_days": 30, + }, headers=cov_headers) + + yr = date.today().year + inv = await client.post("/api/v1/users/invite", json={ + "first_name": "Combo", "last_name": "Case", "email": "combo@covgaps.de", + "role": "EMPLOYEE", "initial_password": "Secret123", + }, headers=cov_headers) + assert inv.status_code == 201, inv.text + uid = inv.json()["id"] + await client.patch(f"/api/v1/users/{uid}", json={ + "entry_date": f"{yr}-07-01", "work_schedule_id": sid, + }, headers=cov_headers) + + bal = await client.get(f"/api/v1/absences/balance/{uid}?year={yr}", headers=cov_headers) + assert bal.status_code == 200, bal.text + + # 30 * 3/5 = 18 (Teilzeit) * 6/12 (Eintritt Juli -> Monate Jul..Dez = 6) = 9 + # math.floor(9 + 0.5) = 9 + days = 30.0 * 3 / 5.0 + days = days * 6 / 12.0 + import math + expected = int(math.floor(days + 0.5)) + assert bal.json()["entitled_days"] == expected == 9 + + await client.patch("/api/v1/companies/me", json={ + "vacation_from_schedule": False, "vacation_prorate_first_year": False, + }, headers=cov_headers) + + +# ── 3. FZA-Rückbuchung via Cancellation-Request-Flow ──────────────────────────── + +async def _seed_overtime_balance(db_session: AsyncSession, user_id: str, company_id: str, total_hours: float) -> None: + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + ob = await db_session.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id)) + if ob is None: + ob = OvertimeBalance(user_id=user_id, company_id=company_id, total_hours=Decimal(str(total_hours))) + db_session.add(ob) + else: + ob.total_hours = Decimal(str(total_hours)) + ob.taken_hours = Decimal("0") + await db_session.flush() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_fza_refund_via_cancellation_request_flow( + client: AsyncClient, db_session: AsyncSession, cov_company, cov_headers, + cov_approver_headers, cov_fza_type_id, +): + """Kompletter Storno-Antrags-Flow (nicht der Admin-Direkt-Pfad) für einen + FZA-Antrag: Antrag -> genehmigt (Konto sinkt) -> Storno beantragt -> Storno + genehmigt -> Konto muss zurückgebucht sein.""" + emp = await client.post("/api/v1/users/invite", json={ + "first_name": "Fza", "last_name": "Requestor", "email": "fzareq@covgaps.de", + "role": "EMPLOYEE", "initial_password": "Secret123", + }, headers=cov_headers) + assert emp.status_code == 201, emp.text + emp_id = emp.json()["id"] + company_id = cov_company["user"]["company_id"] + + await _seed_overtime_balance(db_session, emp_id, company_id, total_hours=40.0) + await db_session.commit() + + login = await client.post("/api/v1/auth/login", json={ + "email": "fzareq@covgaps.de", "password": "Secret123", + }) + emp_headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + + start = _future_monday(25) + resp = await client.post("/api/v1/absences/", json={ + "type_id": cov_fza_type_id, "start_date": str(start), "end_date": str(start), + }, headers=emp_headers) + assert resp.status_code == 201, resp.text + aid = resp.json()["id"] + + approve = await client.post(f"/api/v1/absences/{aid}/approve", headers=cov_approver_headers) + assert approve.status_code == 200, approve.text + + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + ob = await db_session.scalar( + select(OvertimeBalance).where(OvertimeBalance.user_id == emp_id) + .execution_options(populate_existing=True) + ) + taken_after_approve = float(ob.taken_hours) + assert taken_after_approve == pytest.approx(8.0, abs=0.1) + + # Mitarbeiter beantragt Storno statt Admin-Direktstorno + req = await client.post( + f"/api/v1/absences/{aid}/request-cancellation", json={"reason": "Plan geaendert"}, + headers=emp_headers, + ) + assert req.status_code == 200, req.text + assert req.json()["status"] == "cancellation_requested" + + ok = await client.post(f"/api/v1/absences/{aid}/approve", headers=cov_approver_headers) + assert ok.status_code == 200, ok.text + assert ok.json()["status"] == "cancelled" + + import asyncio + await asyncio.sleep(0.15) # fire-and-forget CalDAV + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + ob2 = await db_session.scalar( + select(OvertimeBalance).where(OvertimeBalance.user_id == emp_id) + .execution_options(populate_existing=True) + ) + taken_after_cancel = float(ob2.taken_hours) + assert taken_after_cancel == pytest.approx(0.0, abs=0.1), ( + f"FZA-Rückbuchung über Cancellation-Request-Flow fehlgeschlagen, got {taken_after_cancel}" + ) + + +# ── 4. Zwei-Stufen-Genehmigung + Storno-Grenzfälle ────────────────────────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_two_stage_no_double_credit_on_cancellation( + client: AsyncClient, db_session: AsyncSession, cov_company, cov_headers, + cov_approver_headers, cov_vacation_type_id, +): + """Antrag -> FIRST_APPROVED (Stufe 1, KEIN Urlaubsabzug) -> Storno in diesem + Zustand darf keine Rückbuchung auslösen (es wurde ja nichts gebucht) -> Antrag + entweder direkt stornierbar oder Cancellation-Request nicht möglich (Business-Regel: + request_cancellation verlangt Status APPROVED). Konto wird bei jedem Schritt geprüft.""" + await client.patch("/api/v1/companies/me", + json={"two_stage_approval_enabled": True, "two_stage_min_days": 0}, + headers=cov_headers) + + inv = await client.post("/api/v1/users/invite", json={ + "first_name": "Two", "last_name": "Cov", "email": "twocov@covgaps.de", + "role": "EMPLOYEE", "initial_password": "Secret123", + }, headers=cov_headers) + assert inv.status_code == 201, inv.text + emp_id = inv.json()["id"] + login = await client.post("/api/v1/auth/login", json={ + "email": "twocov@covgaps.de", "password": "Secret123", + }) + emp_headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + + yr = date.today().year + start = _future_monday(27) + used_before = (await client.get( + f"/api/v1/absences/balance/{emp_id}?year={yr}", headers=cov_headers + )).json()["used_days"] + + cr = await client.post("/api/v1/absences/", json={ + "type_id": str(cov_vacation_type_id), "start_date": str(start), + "end_date": str(start + timedelta(days=2)), # 3 Werktage + }, headers=emp_headers) + assert cr.status_code == 201, cr.text + aid = cr.json()["id"] + + # Stufe 1 + r1 = await client.post(f"/api/v1/absences/{aid}/approve", headers=cov_approver_headers) + assert r1.status_code == 200, r1.text + assert r1.json()["status"] == "first_approved" + + used_after_first = (await client.get( + f"/api/v1/absences/balance/{emp_id}?year={yr}", headers=cov_headers + )).json()["used_days"] + assert used_after_first == used_before, ( + "Urlaub wurde bereits bei FIRST_APPROVED abgezogen -- Doppelgutschriftsrisiko bei Storno!" + ) + + # Business-Regel prüfen: Storno-Antrag ist bei FIRST_APPROVED (noch nicht APPROVED) + # explizit nicht erlaubt (nur genehmigte Anträge, siehe request_cancellation). + req = await client.post( + f"/api/v1/absences/{aid}/request-cancellation", json={}, headers=emp_headers, + ) + assert req.status_code == 409, ( + f"Erwartet: Cancellation-Request bei FIRST_APPROVED wird abgelehnt (409), got {req.status_code}" + ) + + # Der Owner kann den Antrag aber direkt stornieren (Storno von PENDING/FIRST_APPROVED + # ist über cancel_absence/DELETE erlaubt und darf NICHTS zurückbuchen, da nie + # etwas abgezogen wurde). + cancel = await client.delete(f"/api/v1/absences/{aid}", headers=emp_headers) + assert cancel.status_code == 200, cancel.text + assert cancel.json()["status"] == "cancelled" + + used_after_cancel = (await client.get( + f"/api/v1/absences/balance/{emp_id}?year={yr}", headers=cov_headers + )).json()["used_days"] + assert used_after_cancel == used_before, ( + f"Doppelgutschrift-Bug: used_days veränderte sich durch Storno eines nie abgezogenen " + f"FIRST_APPROVED-Antrags ({used_before} -> {used_after_cancel})" + ) + + await client.patch("/api/v1/companies/me", + json={"two_stage_approval_enabled": False}, headers=cov_headers) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_two_stage_threshold_boundary_equal_triggers_two_stage( + client: AsyncClient, cov_headers, cov_approver_headers, cov_third_approver_headers, cov_vacation_type_id, +): + """Grenzfall working_days == two_stage_min_days: Code nutzt >= -> muss zwei Stufen auslösen.""" + await client.patch("/api/v1/companies/me", + json={"two_stage_approval_enabled": True, "two_stage_min_days": 3}, + headers=cov_headers) + + inv = await client.post("/api/v1/users/invite", json={ + "first_name": "Boundary", "last_name": "Case", "email": "boundary@covgaps.de", + "role": "EMPLOYEE", "initial_password": "Secret123", + }, headers=cov_headers) + assert inv.status_code == 201, inv.text + login = await client.post("/api/v1/auth/login", json={ + "email": "boundary@covgaps.de", "password": "Secret123", + }) + emp_headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + + start = _future_monday(29) + cr = await client.post("/api/v1/absences/", json={ + "type_id": str(cov_vacation_type_id), "start_date": str(start), + "end_date": str(start + timedelta(days=2)), # exakt 3 Werktage = Schwelle + }, headers=emp_headers) + assert cr.status_code == 201, cr.text + assert cr.json()["working_days"] == 3 + aid = cr.json()["id"] + + r1 = await client.post(f"/api/v1/absences/{aid}/approve", headers=cov_approver_headers) + assert r1.status_code == 200, r1.text + assert r1.json()["status"] == "first_approved", ( + "working_days == two_stage_min_days sollte zwei Stufen auslösen (Code nutzt >=)" + ) + + r2 = await client.post(f"/api/v1/absences/{aid}/approve", headers=cov_third_approver_headers) + assert r2.status_code == 200, r2.text + assert r2.json()["status"] == "approved" + + await client.patch("/api/v1/companies/me", + json={"two_stage_approval_enabled": False}, headers=cov_headers) + + +# ── 5. DATEV-Export: Feiertag vs. Urlaub, working_days-Konsistenz ───────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_datev_holiday_takes_precedence_over_vacation( + client: AsyncClient, db_session: AsyncSession, cov_company, cov_headers, cov_vacation_type_id, +): + """Absence, die exakt auf einen Feiertag fällt: DATEV-Zeile zeigt 'F' statt 'U', + und working_days (Antrag) rechnet den Feiertag korrekt heraus.""" + from app.services.holiday_service import ensure_holidays_for_year + from app.services.report_service import report_service + + company_id = cov_company["user"]["company_id"] + user_id = cov_company["user"]["id"] + + r = await client.patch("/api/v1/companies/me", json={"state": "BY"}, headers=cov_headers) + assert r.status_code == 200, r.text + r2 = await client.patch(f"/api/v1/users/{user_id}", json={"personnel_number": "4711"}, headers=cov_headers) + assert r2.status_code == 200, r2.text + + # Feiertage für 2026/2027 vorab befüllen, damit absence_service._get_holiday_dates + # (fragt PublicHoliday direkt ab, ohne Auto-Generierung) sie kennt. + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + await ensure_holidays_for_year(2026, "BY", db_session) + await ensure_holidays_for_year(2027, "BY", db_session) + await db_session.commit() + + # Mo 28.12.2026 - Fr 01.01.2027: 5 Wochentage, davon Neujahr (Fr 01.01.) Feiertag + start = date(2026, 12, 28) + end = date(2027, 1, 1) + resp = await client.post("/api/v1/absences/", json={ + "type_id": str(cov_vacation_type_id), "start_date": str(start), "end_date": str(end), + }, headers=cov_headers) + assert resp.status_code == 201, resp.text + data = resp.json() + assert data["working_days"] == 4, "Feiertag (Neujahr) sollte working_days rausrechnen" + + # Selbst genehmigen geht nicht -> zweiter Admin holen wäre Overkill hier; + # DATEV-Report berücksichtigt PENDING nicht (nur APPROVED/FIRST_APPROVED), + # daher separat genehmigen. + resp2 = await client.post("/api/v1/users/invite", json={ + "first_name": "Datev", "last_name": "Approver", "email": "datevapprover@covgaps.de", + "role": "COMPANY_ADMIN", "initial_password": "Secret123", + }, headers=cov_headers) + assert resp2.status_code == 201, resp2.text + login = await client.post("/api/v1/auth/login", json={ + "email": "datevapprover@covgaps.de", "password": "Secret123", + }) + approver_headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + approve = await client.post(f"/api/v1/absences/{data['id']}/approve", headers=approver_headers) + assert approve.status_code == 200, approve.text + + sheet = await report_service.datev_monthly_report(company_id, user_id, 2027, 1, db_session) + jan1_row = next(row for row in sheet.rows if row.day == 1) + assert jan1_row.code == "F", f"Feiertag muss Vorrang vor Urlaubs-Kürzel haben, got code={jan1_row.code!r}" + assert "Neujahr" in (jan1_row.note or "") + + # working_days-Konsistenz: Feiertag zählt nicht als Urlaubstag -> im Dezember-Sheet + # muss der 28.-31.12. (4 Werktage) als 'U' erscheinen, nicht Jan 1. + sheet_dec = await report_service.datev_monthly_report(company_id, user_id, 2026, 12, db_session) + dec_codes = {row.day: row.code for row in sheet_dec.rows if row.day >= 28} + assert dec_codes.get(28) == "U" + assert dec_codes.get(31) == "U" + + await client.patch("/api/v1/companies/me", json={"state": None}, headers=cov_headers) + + +# ── 6. _categorize_hours: Nacht über Mitternacht + Sonntag/Feiertag ───────────── + +def test_categorize_hours_night_shift_over_midnight(): + from app.services.report_service import _categorize_hours + + # 22:00 - 06:00, kein Feiertag, kein Sonntag (Mo -> Di) + entry_date = date(2026, 6, 1) # Montag + result = _categorize_hours(entry_date, time(22, 0), time(6, 0), 0, {}) + + # 22:00-24:00 (2h) = night_25, 00:00-04:00 (4h) = night_40, 04:00-06:00 (2h) = night_25 + assert result.night_40_hours == pytest.approx(4.0) + assert result.night_25_hours == pytest.approx(4.0) + assert result.normal_hours == pytest.approx(0.0) + total = (result.normal_hours + result.night_25_hours + result.night_40_hours + + result.sunday_hours + result.holiday_125_hours + result.holiday_150_hours) + assert total == pytest.approx(8.0) + + +def test_categorize_hours_sunday_and_holiday_highest_wins(): + from app.services.report_service import _categorize_hours + + # Sonntag, der gleichzeitig Feiertag (hoher Zuschlag) ist -> holiday_150, nicht + # sunday + holiday addiert. + sunday_holiday = date(2026, 12, 25) # 1. Weihnachtsfeiertag, Freitag in 2026 -> nutze echten Sonntag + # 2026-12-25 ist ein Freitag; für einen Sonntags-Feiertag nehmen wir ein + # künstliches Beispiel (Datum ist beliebig, Zuschlagslogik hängt nur vom + # holidays-dict + weekday ab) + d = date(2026, 11, 1) # ein Sonntag + assert d.weekday() == 6 + holidays = {d: ("Fiktiver Feiertag hoch", True)} + + result = _categorize_hours(d, time(10, 0), time(18, 0), 0, holidays) # 8h, tagsüber + + assert result.holiday_150_hours == pytest.approx(8.0) + assert result.sunday_hours == pytest.approx(0.0) + assert result.holiday_125_hours == pytest.approx(0.0)