"""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) 7. POST /absences/ reicht warnings jetzt durch (API-Konsistenz-Fix) Nebenbei gefixte Bugs (während dieser Coverage-Arbeit gefunden): - _get_holiday_dates() rechnete Feiertage im Folgejahr bei Anträgen über den Jahreswechsel nicht aus working_days heraus (siehe test_holiday_dates_cover_year_boundary + absence_service.py year_to-Parameter) - POST /absences/ verwarf die warnings-Liste von create_absence() (siehe Test 7) """ 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. Ruft absence_service.create_absence() direkt auf (statt über HTTP) um den warnings-Rückgabewert isoliert vom HTTP-Layer zu prüfen (Test 7 unten deckt den HTTP-Pfad inkl. warnings-Durchreichung separat ab).""" from app.services.absence_service import absence_service from app.schemas.absence import AbsenceCreate 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 # RLS-Bypass gilt nur innerhalb der aktuellen Transaktion (SET LOCAL) -- muss # daher vor JEDER Query auf diesem "rohen" db_session (ohne app.company_id- # Kontext eines eingeloggten Requests) neu gesetzt werden, siehe # project_rls_bypass_commit_pitfall in Memory. await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) user = await db_session.get(User, user_id) assert user is not None # VacationBalance direkt präparieren: wenig entitled, hoher (verfallener) Übertrag, # kaum genutzt -> ohne Verfalls-Logik würde das Konto "reichen" 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.flush() # Antrag über 10 Arbeitstage im selben Jahr wie die präparierte Bilanz (nah # in der Zukunft, damit wir nicht ins nächste Jahr rutschen) start = _future_monday(1) assert start.year == year, "Testannahme verletzt: Zeitraum muss im selben Jahr wie die Bilanz liegen" create_data = AbsenceCreate( type_id=cov_vacation_type_id, start_date=start, end_date=start + timedelta(days=13), # 2 volle Wochen = 10 Werktage ) absence, warnings = await absence_service.create_absence(create_data, user, db_session) assert absence.working_days == 10 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}" ) await db_session.commit() # 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 from uuid import UUID as _UUID company_id = _UUID(cov_company["user"]["company_id"]) user_id = _UUID(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 2027 vorab befüllen, damit absence_service._get_holiday_dates # (fragt PublicHoliday direkt ab, ohne Auto-Generierung) sie kennt. # Jahreswechsel-Fall (Feiertag im Folgejahr) siehe separat # test_holiday_dates_cover_year_boundary weiter unten -- hier bewusst # innerhalb eines einzigen Jahres, um Feiertag-vs-Urlaub-Vorrang isoliert # zu prüfen. await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) await ensure_holidays_for_year(2027, "BY", db_session) await db_session.commit() # Fr 01.01.2027 (Neujahr, Feiertag) - Di 05.01.2027: 3 Wochentage (Fr/Mo/Di), # davon Neujahr ein Feiertag -> 2 Werktage start = date(2027, 1, 1) end = date(2027, 1, 5) 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"] == 2, "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 # RLS-Bypass gilt nur innerhalb der aktuellen Transaktion (SET LOCAL) -- der # vorherige Bypass ist mit dem commit() oben verfallen. expire_all() erzwingt # frische Queries unter dem neuen Bypass (statt gecachter Identity-Map-Objekte). await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) db_session.expire_all() 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 -> Mo/Di (4./5.1.) # müssen als 'U' erscheinen (die tatsächlich abgezogenen Werktage). day_codes = {row.day: row.code for row in sheet.rows} assert day_codes.get(4) == "U" assert day_codes.get(5) == "U" await client.patch("/api/v1/companies/me", json={"state": None}, headers=cov_headers) async def test_holiday_dates_cover_year_boundary( client: AsyncClient, db_session: AsyncSession, cov_company, cov_headers, cov_vacation_type_id, ): """Regressionstest für den in test_datev_holiday_takes_precedence_over_vacation dokumentierten Bug: ein Antrag über den Jahreswechsel muss den Feiertag im Folgejahr (Neujahr) korrekt aus working_days herausrechnen. Fix: _get_holiday_dates(..., year_to=end_date.year) statt nur start_date.year.""" from app.services.holiday_service import ensure_holidays_for_year r = await client.patch("/api/v1/companies/me", json={"state": "BY"}, headers=cov_headers) assert r.status_code == 200, r.text await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) await ensure_holidays_for_year(2028, "BY", db_session) await ensure_holidays_for_year(2029, "BY", db_session) await db_session.commit() # Do 28.12.2028 - Mo 01.01.2029: Wochentage Do/Fr (28./29.12.) + Mo (01.01.), # Sa/So (30./31.12.) fallen ohnehin raus. Montag 01.01.2029 ist Neujahr # (Feiertag) -> 2 Werktage. start = date(2028, 12, 28) end = date(2029, 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"] == 2, ( f"Neujahr (Folgejahr) muss aus working_days herausgerechnet werden, " f"got {data['working_days']}" ) 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) # ── 7. POST /absences/ gibt warnings zurück (API-Konsistenz-Fix) ──────────────── async def test_create_absence_returns_warnings( client: AsyncClient, cov_headers, cov_vacation_type_id, ): """Regressionstest: POST /absences/ verwarf bisher die von create_absence() zurückgegebenen warnings (im Unterschied zu approve_absence, das sie über AbsenceApproveOut.warnings durchreicht). Fix: neues AbsenceCreateOut-Schema. Trigger hier: Überschneidungs-Warnung bei zwei sich überlappenden Anträgen.""" start = _future_monday(20) end = start + timedelta(days=2) resp1 = 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 resp1.status_code == 201, resp1.text assert resp1.json().get("warnings") == [] # Überlappender zweiter Antrag im selben Zeitraum -> Warnung erwartet resp2 = await client.post("/api/v1/absences/", json={ "type_id": str(cov_vacation_type_id), "start_date": str(start), "end_date": str(end + timedelta(days=1)), }, headers=cov_headers) assert resp2.status_code == 201, resp2.text data2 = resp2.json() assert "warnings" in data2 assert any("Überschneidung" in w for w in data2["warnings"]), data2["warnings"]