Files
timemaster/backend/tests/test_coverage_gaps.py
patrickandClaude Sonnet 5 fd752878b0
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
test: P1-Coverage für Scheduler-Idempotenz, Bradford-Fensterrand, FZA-Faktor+Kappung, Cross-Company-RLS
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
2026-09-03 02:10:19 +02:00

965 lines
45 KiB
Python
Raw Permalink 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)
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 uuid import UUID
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"]
# ── P1-Rest: postgres-expert Audit, 4 gezielte Tests ────────────────────────────
#
# 1. Scheduler-Idempotenz Überstunden-Verfall (report_service.apply_overtime_expiry_if_needed)
# 2. Bradford-Faktor Fensterrand + MANAGER-Department-Scope (absence_service.get_sick_stats)
# 3. Sondervertretungs-Faktor + Überstunden-Kappung kombiniert (report_service._recalculate_overtime_balance)
# 4. Cross-Company-RLS bei Vertretern (absence_service.list_absences as_substitute=True)
from types import SimpleNamespace
# ── 1. Scheduler-Idempotenz (Überstunden-Verfall) ───────────────────────────────
@pytest.mark.asyncio(loop_scope="session")
async def test_overtime_expiry_idempotent_second_call_noop(db_session: AsyncSession):
"""Ein zweiter Aufruf am selben Tag darf keinen zweiten Abzug auslösen
(verhindert Doppel-Kappung bei doppeltem Scheduler-Lauf)."""
from app.services.report_service import apply_overtime_expiry_if_needed
company = SimpleNamespace(
overtime_expiry_enabled=True,
overtime_expiry_month=1,
overtime_expiry_day=1,
overtime_max_carryover_hours=5,
)
bal = SimpleNamespace(total_hours=Decimal("20"), taken_hours=Decimal("0"), last_expiry_applied_at=None)
changed_first = await apply_overtime_expiry_if_needed(bal, company, db_session)
assert changed_first is True
assert bal.total_hours == Decimal("5"), "Kappung auf max_carryover_hours erwartet"
# Zweiter Aufruf am selben Tag (last_expiry_applied_at wurde eben gesetzt) -> no-op
changed_second = await apply_overtime_expiry_if_needed(bal, company, db_session)
assert changed_second is False
assert bal.total_hours == Decimal("5"), "Kein zweiter Abzug bei erneutem Aufruf am selben Tag"
@pytest.mark.asyncio(loop_scope="session")
async def test_overtime_expiry_invalid_date_silent_skip(db_session: AsyncSession):
"""Ungültiges Verfallsdatum (29. Februar in einem Nicht-Schaltjahr) darf nicht
crashen (kein propagierter ValueError), sondern still übersprungen werden.
Hinweis: nutzt das reale Systemdatum (Funktion ruft intern date.today() auf,
kein Mock-Punkt vorhanden). Das aktuelle Jahr (Testlauf 2026) ist kein
Schaltjahr, ebenso das Vorjahr 2025 -- Test bleibt bis 2027 gültig
(2028 ist wieder ein Schaltjahr, dann müsste der Test angepasst werden)."""
from app.services.report_service import apply_overtime_expiry_if_needed
company = SimpleNamespace(
overtime_expiry_enabled=True,
overtime_expiry_month=2,
overtime_expiry_day=29,
overtime_max_carryover_hours=5,
)
bal = SimpleNamespace(total_hours=Decimal("20"), taken_hours=Decimal("0"), last_expiry_applied_at=None)
changed = await apply_overtime_expiry_if_needed(bal, company, db_session)
assert changed is False
assert bal.total_hours == Decimal("20"), "Bei ungültigem Datum darf keine Kappung stattfinden"
# ── 2. Bradford-Faktor Fensterrand + MANAGER-Department-Scope ──────────────────
@pytest.mark.asyncio(loop_scope="session")
async def test_bradford_window_boundary_and_manager_department_scope(
client: AsyncClient, db_session: AsyncSession,
):
"""(a) Eine Krank-Episode die GENAU am Rand des 365-Tage-Fensters endet
(end_date == window_start) muss mitgezählt werden, eine die einen Tag davor
endet nicht (Off-by-one, quadratisch empfindlich für den Bradford-Faktor).
(b) Ein MANAGER mit department_id sieht nur Bradford-Stats des eigenen
Departments, nicht firmenweit (DSGVO-Datensparsamkeit)."""
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceCategory, AbsenceType
from app.models.user import UserRole
from app.services.absence_service import absence_service
reg = await client.post("/api/v1/auth/register", json={
"company_name": "Bradford Edge GmbH",
"first_name": "Brad", "last_name": "Admin",
"email": "admin@bradfordedge.de", "password": "Secret123",
})
assert reg.status_code == 201, reg.text
admin_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
me = (await client.get("/api/v1/auth/me", headers=admin_headers)).json()
company_id = me["company_id"]
dep_a = await client.post("/api/v1/companies/me/departments", json={"name": "Abteilung A"}, headers=admin_headers)
assert dep_a.status_code == 201, dep_a.text
dep_b = await client.post("/api/v1/companies/me/departments", json={"name": "Abteilung B"}, headers=admin_headers)
assert dep_b.status_code == 201, dep_b.text
dep_a_id = dep_a.json()["id"]
dep_b_id = dep_b.json()["id"]
mgr = await client.post("/api/v1/users/invite", json={
"first_name": "Manager", "last_name": "A", "email": "manager.a@bradfordedge.de",
"role": "MANAGER", "department_id": dep_a_id, "initial_password": "Secret123",
}, headers=admin_headers)
assert mgr.status_code == 201, mgr.text
mgr_id = mgr.json()["id"]
emp_a = await client.post("/api/v1/users/invite", json={
"first_name": "Emp", "last_name": "A", "email": "emp.a@bradfordedge.de",
"role": "EMPLOYEE", "department_id": dep_a_id, "initial_password": "Secret123",
}, headers=admin_headers)
assert emp_a.status_code == 201, emp_a.text
emp_a_id = emp_a.json()["id"]
emp_b = await client.post("/api/v1/users/invite", json={
"first_name": "Emp", "last_name": "B", "email": "emp.b@bradfordedge.de",
"role": "EMPLOYEE", "department_id": dep_b_id, "initial_password": "Secret123",
}, headers=admin_headers)
assert emp_b.status_code == 201, emp_b.text
emp_b_id = emp_b.json()["id"]
types_resp = await client.get("/api/v1/absence-types/", headers=admin_headers)
sick_type = next(t for t in types_resp.json() if t["category"] == "sick")
sick_type_id = sick_type["id"]
ref_date = date.today()
window_start = ref_date - timedelta(days=365)
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
# Emp A: eine Episode GENAU am Fensterrand (muss zählen) + eine Episode
# einen Tag VOR dem Fensterrand (darf nicht zählen).
in_window = Absence(
user_id=emp_a_id, type_id=sick_type_id,
start_date=window_start, end_date=window_start,
working_days=Decimal("1"), status=AbsenceStatus.APPROVED,
)
out_of_window = Absence(
user_id=emp_a_id, type_id=sick_type_id,
start_date=window_start - timedelta(days=3), end_date=window_start - timedelta(days=1),
working_days=Decimal("3"), status=AbsenceStatus.APPROVED,
)
# Emp B (anderes Department): Episode innerhalb des Fensters.
emp_b_absence = Absence(
user_id=emp_b_id, type_id=sick_type_id,
start_date=ref_date - timedelta(days=10), end_date=ref_date - timedelta(days=8),
working_days=Decimal("3"), status=AbsenceStatus.APPROVED,
)
db_session.add_all([in_window, out_of_window, emp_b_absence])
await db_session.flush()
# (a) Fensterrand: Admin sieht firmenweit -> Emp A hat genau 1 Episode
# (nur die am Rand liegende zählt, die davor liegende fällt raus).
admin_ns = SimpleNamespace(role=UserRole.COMPANY_ADMIN, department_id=None)
stats_admin = await absence_service.get_sick_stats(
UUID(company_id) if not isinstance(company_id, UUID) else company_id,
admin_ns, ref_date, db_session,
)
emp_a_stats = next(s for s in stats_admin if str(s["user_id"]) == emp_a_id)
assert emp_a_stats["episodes"] == 1, (
f"Off-by-one am Fensterrand: erwartet genau 1 Episode (window_start selbst zählt, "
f"ein Tag davor nicht), got {emp_a_stats['episodes']}"
)
assert emp_a_stats["total_days"] == pytest.approx(1.0)
# Admin sieht auch Emp B (firmenweit, keine Department-Einschränkung).
assert any(str(s["user_id"]) == emp_b_id for s in stats_admin)
# (b) MANAGER mit department_id sieht nur sein eigenes Department.
mgr_ns = SimpleNamespace(role=UserRole.MANAGER, department_id=UUID(dep_a_id))
stats_mgr = await absence_service.get_sick_stats(
UUID(company_id) if not isinstance(company_id, UUID) else company_id,
mgr_ns, ref_date, db_session,
)
assert any(str(s["user_id"]) == emp_a_id for s in stats_mgr), "Manager muss eigenes Department sehen"
assert not any(str(s["user_id"]) == emp_b_id for s in stats_mgr), (
"DSGVO-Lücke: MANAGER sieht Bradford-Stats von Mitarbeitern außerhalb des eigenen Departments"
)
await db_session.commit()
# ── 3. Sondervertretungs-Faktor + Überstunden-Kappung kombiniert ───────────────
@pytest.mark.asyncio(loop_scope="session")
async def test_special_assignment_factor_combined_with_overtime_cap(
client: AsyncClient, db_session: AsyncSession,
):
"""SpecialAssignment mit Faktor 1.5 (mode=fza) UND company.overtime_cap_hours
gleichzeitig: prüft anhand des tatsächlichen Codes, dass die Kappung auf den
FAKTORISIERTEN Wert angewendet wird (hours_by_date wird bereits mit dem Faktor
multipliziert berechnet, siehe report_service.py Zeile ~239, bevor die Kappung
in Zeile ~253 greift). Das ist kein Bug, sondern dokumentiertes Ist-Verhalten:
die Sondervertretungs-Gewichtung fließt VOR die Kappung ein."""
from app.models.special_assignment import AssignmentMode, SpecialAssignment
from app.models.time_entry import EntryStatus, TimeEntry
from app.services.report_service import _recalculate_overtime_balance
reg = await client.post("/api/v1/auth/register", json={
"company_name": "FZA Cap Edge GmbH",
"first_name": "Cap", "last_name": "Admin",
"email": "admin@fzacapedge.de", "password": "Secret123",
})
assert reg.status_code == 201, reg.text
admin_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
cap_resp = await client.patch("/api/v1/companies/me", json={"overtime_cap_hours": 5}, headers=admin_headers)
assert cap_resp.status_code == 200, cap_resp.text
emp = await client.post("/api/v1/users/invite", json={
"first_name": "Cap", "last_name": "Employee", "email": "emp@fzacapedge.de",
"role": "EMPLOYEE", "initial_password": "Secret123",
}, headers=admin_headers)
assert emp.status_code == 201, emp.text
emp_id = emp.json()["id"]
# Vergangener Werktag (Mo-Fr), weit genug in der Vergangenheit um nicht
# mit anderen Tests zu kollidieren.
workday = date.today() - timedelta(days=90)
while workday.weekday() >= 5:
workday -= timedelta(days=1)
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
user = await db_session.get(User, emp_id)
assert user is not None
# 10h gearbeitet (08:00-18:00, keine Pause) an einem Standard-8h-Tag.
entry = TimeEntry(
user_id=emp_id, date=workday,
start_time=time(8, 0), end_time=time(18, 0),
break_minutes=0, status=EntryStatus.APPROVED,
)
db_session.add(entry)
sa = SpecialAssignment(
user_id=emp_id, company_id=UUID(str(user.company_id)),
date_from=workday, date_to=workday,
factor=Decimal("1.5"), mode=AssignmentMode.fza,
label="Nachtschicht-Zuschlag Test",
)
db_session.add(sa)
await db_session.flush()
bal = await _recalculate_overtime_balance(user, None, db_session)
# Roh: 10h - 8h Soll = 2h Überstunden. Faktorisiert: 10h*1.5=15h - 8h = 7h.
# Kappung bei 5h -> falls auf faktorisierten Wert angewendet (Ist-Verhalten
# laut Code) landet total_hours bei genau 5, NICHT bei den rohen 2h.
assert bal.total_hours == Decimal("5"), (
f"Erwartetes Ist-Verhalten (Kappung greift auf den faktorisierten Wert, "
f"nicht auf den rohen ArbZG-Überstundenwert) nicht bestätigt, got {bal.total_hours}. "
f"Falls dies fachlich falsch ist, ist das ein Bug in report_service._recalculate_overtime_balance "
f"(Kappung sollte ggf. vor Faktor-Anwendung greifen) -- hier nur dokumentiert, nicht gefixt."
)
await db_session.commit()
# ── 4. Cross-Company-RLS bei Vertretern (list_absences as_substitute) ──────────
@pytest.mark.asyncio(loop_scope="session")
async def test_list_absences_as_substitute_cross_company_isolation(
client: AsyncClient, db_session: AsyncSession,
):
"""Defense-in-Depth auf Service-Ebene (nicht nur Erstell-Validierung):
list_absences(..., as_substitute=True) mit company_id von Firma B darf
KEINE Anträge aus Firma A liefern, selbst wenn (hypothetisch, z.B. durch
einen Bug anderswo) ein User aus Firma A als substitute_id auf einen
Antrag aus Firma A gesetzt wurde und ein gleich benannter/verwechselter
User aus Firma B abgefragt wird. Der Query joint über User.company_id ==
company_id -- geprüft wird hier direkt auf Service-Ebene."""
from app.models.absence import Absence, AbsenceStatus
from app.models.user import UserRole
from app.services.absence_service import absence_service
# Firma A: Antragsteller + Vertreter, beide in Firma A.
reg_a = await client.post("/api/v1/auth/register", json={
"company_name": "Substitute Cross A GmbH",
"first_name": "SubA", "last_name": "Admin",
"email": "admin@subcrossa.de", "password": "Secret123",
})
assert reg_a.status_code == 201, reg_a.text
headers_a = {"Authorization": f"Bearer {reg_a.json()['access_token']}"}
me_a = (await client.get("/api/v1/auth/me", headers=headers_a)).json()
company_a_id = me_a["company_id"]
sub_a = await client.post("/api/v1/users/invite", json={
"first_name": "Sub", "last_name": "InA", "email": "sub.a@subcrossa.de",
"role": "EMPLOYEE", "initial_password": "Secret123",
}, headers=headers_a)
assert sub_a.status_code == 201, sub_a.text
sub_a_id = sub_a.json()["id"]
types_a = await client.get("/api/v1/absence-types/", headers=headers_a)
vac_type_a = next(t for t in types_a.json() if t["name"] == "Urlaub")["id"]
# Firma B: eigener Vertreter-Kandidat (gleiche Rolle, andere Firma).
reg_b = await client.post("/api/v1/auth/register", json={
"company_name": "Substitute Cross B GmbH",
"first_name": "SubB", "last_name": "Admin",
"email": "admin@subcrossb.de", "password": "Secret123",
})
assert reg_b.status_code == 201, reg_b.text
headers_b = {"Authorization": f"Bearer {reg_b.json()['access_token']}"}
me_b = (await client.get("/api/v1/auth/me", headers=headers_b)).json()
company_b_id = me_b["company_id"]
# Absence in Firma A, mit sub_a (Firma A) als Vertretung -- über Model
# direkt angelegt um Overlap-/Validierungslogik des Create-Endpunkts zu
# umgehen und die Service-Query isoliert zu prüfen.
start = date.today() + timedelta(days=200)
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
absence_a = Absence(
user_id=me_a["id"], type_id=vac_type_a,
start_date=start, end_date=start,
working_days=Decimal("1"), status=AbsenceStatus.PENDING,
substitute_id=sub_a_id,
)
db_session.add(absence_a)
await db_session.flush()
# Query 1: sub_a (echter Vertreter, Firma A) mit company_id=Firma A -> sieht den Antrag.
sub_a_user = await db_session.get(User, sub_a_id)
total_a, absences_a = await absence_service.list_absences(
UUID(company_a_id), sub_a_user, db_session, as_substitute=True,
)
assert total_a == 1
assert absences_a[0].id == absence_a.id
# Query 2: Admin von Firma B (current_user.id != sub_a_id, aber selbst wenn
# ein Aufrufer aus Firma B versucht mit fremder company_id_a zu fragen, ist
# der Sicherheits-Layer die current_user.id im WHERE Absence.substitute_id
# == current_user.id -- entscheidend ist aber der company_id-Join). Hier:
# regulärer Aufruf mit Firma B's eigener company_id liefert 0 Treffer,
# obwohl der Antrag aus Firma A existiert.
admin_b_user = await db_session.get(User, me_b["id"])
# current_user hier künstlich auf sub_a_id "verfälscht" simulieren ist nicht
# möglich ohne DB-User -- stattdessen der eigentliche Defense-in-Depth-Check:
# company_id explizit auf Firma B gesetzt, obwohl ein (hypothetischer)
# Vertreter mit gleicher User-Id in Firma A existieren würde. Da UUIDs global
# eindeutig sind, testen wir die Kernaussage direkt: Query mit company_b_id
# + sub_a_user (der in Wahrheit zu Firma A gehört, aber hier als current_user
# übergeben wird) liefert trotzdem 0 Treffer, weil der Join User.company_id
# == company_id (Firma B) den Antrag aus Firma A aussortiert.
total_cross, absences_cross = await absence_service.list_absences(
UUID(company_b_id), sub_a_user, db_session, as_substitute=True,
)
assert total_cross == 0, (
"RLS-Lücke: Vertreter-Query lieferte einen Antrag aus einer anderen Firma zurück "
"(company_id-Join in list_absences greift nicht als Defense-in-Depth)"
)
assert absences_cross == []
await db_session.commit()