Files
timemaster/backend/tests/test_coverage_gaps.py
T
patrickandClaude Sonnet 5 21b65087d3
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
Security Audit / Frontend Build (tsc + vite) (push) Canceled after 0s
fix(tests): re-set RLS bypass per transaction in coverage gap tests
SET LOCAL app.bypass_rls only holds for the current transaction; a prior
commit() silently re-enabled RLS, causing db.get(User/Company, ...) to
return None on the raw db_session (no app.company_id context).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
2026-09-02 23:31:20 +02:00

547 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.
Ruft absence_service.create_absence() direkt auf (statt über HTTP), weil der
Router POST /absences/ die von create_absence() zurückgegebenen `warnings`
**verwirft** (siehe app/routers/absences.py::create_absence nur approve_absence
reicht `warnings` über AbsenceOut.warnings durch). Das ist ein eigenständiger,
hier entdeckter Bug (siehe Abschlussbericht), separat vom hier geprüften
Verfalls-Verhalten von effective_available()."""
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
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 2027 vorab befüllen, damit absence_service._get_holiday_dates
# (fragt PublicHoliday direkt ab, ohne Auto-Generierung) sie kennt.
# NB: _get_holiday_dates(company_id, data.start_date.year, db) holt Feiertage
# NUR für das Jahr von start_date -- ein Zeitraum über den Jahreswechsel (z.B.
# 28.12.-01.01.) würde den Neujahrs-Feiertag daher NICHT rausrechnen (eigener,
# hier entdeckter Bug, siehe Abschlussbericht). Um genau den hier zu testenden
# Feiertag-vs-Urlaub-Vorrang isoliert zu prüfen, bleibt der Zeitraum bewusst
# innerhalb eines einzigen Jahres (Januar 2027).
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.
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
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)
# ── 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)