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