Files
timemaster/backend/tests/test_absences.py
T
patrickandClaude Opus 4.8 450dbdb64a
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled
feat: agent-12 – Zwei-Stufen-Genehmigung für Abwesenheiten
Optionale zweistufige Freigabe (Feature-Parität mit Urlaubsverwaltung,
Second-Stage-Authority), ohne SSO:

- Firmen-Opt-in companies.two_stage_approval_enabled + two_stage_min_days
  (nur Anträge ab X Arbeitstagen brauchen Stufe 2; 0 = alle).
- Ablauf PENDING → FIRST_APPROVED → APPROVED: erste Stufe durch Manager-Rollen,
  finale Stufe nur HR/Admin und zwingend eine ANDERE Person als Stufe 1.
- Urlaubs-/FZA-Abzug, CalDAV-Sync und Vertreter-Mail erst bei finaler Genehmigung.
  Ablehnen in beiden Stufen möglich; Eigentümer darf FIRST_APPROVED noch stornieren.
- pending_days, Kalender und Reminder-Digest berücksichtigen FIRST_APPROVED.
- Neuer Status-Wert + absences.first_approved_by; System-Kommentar bei Stufe 1.

Frontend: CompanySettingsPage (Toggle + Schwellwert), AbsencesPage
("Endgültig genehmigen"/Ablehnen für HR/Admin ≠ Erstgenehmiger, Status-Badge).

Migration 0038. 190/190 Tests grün. Deployed 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:17:32 +02:00

761 lines
30 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.
"""Tests für agent-03-abwesenheit"""
import pytest
import pytest_asyncio
from datetime import date, timedelta
from httpx import AsyncClient
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def abs_company(client: AsyncClient):
"""Eigene Company für Absence-Tests."""
resp = await client.post("/api/v1/auth/register", json={
"company_name": "Absence AG",
"first_name": "Admin",
"last_name": "Test",
"email": "admin@absenceag.de",
"password": "Secret123",
})
assert resp.status_code == 201
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 abs_headers(abs_company):
return {"Authorization": f"Bearer {abs_company['tokens']['access_token']}"}
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def abs_approver_headers(client: AsyncClient, abs_headers):
"""Zweiter Admin in Absence AG kann Abwesenheiten anderer genehmigen."""
resp = await client.post("/api/v1/users/invite", json={
"first_name": "Approver",
"last_name": "Admin",
"email": "approver@absenceag.de",
"role": "COMPANY_ADMIN",
"initial_password": "Secret123",
}, headers=abs_headers)
assert resp.status_code == 201, resp.text
login = await client.post("/api/v1/auth/login", json={
"email": "approver@absenceag.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 vacation_type_id(client: AsyncClient, abs_headers):
"""Urlaubs-Typ der Company holen."""
resp = await client.get("/api/v1/absence-types/", headers=abs_headers)
assert resp.status_code == 200
types = resp.json()
assert len(types) > 0, "Default-Abwesenheitstypen fehlen"
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 sick_type_id(client: AsyncClient, abs_headers):
resp = await client.get("/api/v1/absence-types/", headers=abs_headers)
types = resp.json()
sick = next((t for t in types if t["name"] == "Krankheit"), types[0])
return sick["id"]
# ── Default Types ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_default_absence_types_created_on_register(client: AsyncClient, abs_headers):
"""Nach der Registrierung müssen Default-Typen vorhanden sein."""
resp = await client.get("/api/v1/absence-types/", headers=abs_headers)
assert resp.status_code == 200
types = resp.json()
names = {t["name"] for t in types}
assert "Urlaub" in names
assert "Krankheit" in names
assert "Homeoffice" in names
assert "Dienstreise" in names
assert "Sonderurlaub" in names
# ── Absence Types CRUD ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_absence_type(client: AsyncClient, abs_headers):
resp = await client.post(
"/api/v1/absence-types/",
json={
"name": "Elternzeit",
"color": "#F59E0B",
"requires_approval": True,
"deducts_vacation": False,
"is_paid": False,
},
headers=abs_headers,
)
assert resp.status_code == 201
assert resp.json()["name"] == "Elternzeit"
@pytest.mark.asyncio
async def test_update_absence_type(client: AsyncClient, abs_headers, vacation_type_id):
resp = await client.patch(
f"/api/v1/absence-types/{vacation_type_id}",
json={"max_days_per_year": 30},
headers=abs_headers,
)
assert resp.status_code == 200
assert resp.json()["max_days_per_year"] == 30
# ── Absences ───────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_absence_vacation(client: AsyncClient, abs_headers, vacation_type_id):
next_monday = date.today() + timedelta(days=(7 - date.today().weekday()))
resp = await client.post(
"/api/v1/absences/",
json={
"type_id": str(vacation_type_id),
"start_date": str(next_monday),
"end_date": str(next_monday + timedelta(days=4)),
},
headers=abs_headers,
)
assert resp.status_code == 201
data = resp.json()
assert data["status"] == "pending"
assert data["working_days"] > 0
@pytest.mark.asyncio
async def test_create_absence_sick_auto_approved(client: AsyncClient, abs_headers, sick_type_id):
"""Krankheit hat requires_approval=False → sofort approved."""
resp = await client.post(
"/api/v1/absences/",
json={
"type_id": str(sick_type_id),
"start_date": str(date.today()),
"end_date": str(date.today()),
},
headers=abs_headers,
)
assert resp.status_code == 201
# Krankheit erfordert keine Genehmigung → approved
data = resp.json()
assert data["status"] == "approved"
@pytest.mark.asyncio
async def test_list_absences(client: AsyncClient, abs_headers):
resp = await client.get("/api/v1/absences/", headers=abs_headers)
assert resp.status_code == 200
data = resp.json()
assert "total" in data
assert "items" in data
@pytest.mark.asyncio
async def test_get_absence_by_id(client: AsyncClient, abs_headers, vacation_type_id):
next_monday = date.today() + timedelta(days=(7 - date.today().weekday()) + 7)
create_resp = await client.post(
"/api/v1/absences/",
json={
"type_id": str(vacation_type_id),
"start_date": str(next_monday),
"end_date": str(next_monday + timedelta(days=2)),
},
headers=abs_headers,
)
absence_id = create_resp.json()["id"]
resp = await client.get(f"/api/v1/absences/{absence_id}", headers=abs_headers)
assert resp.status_code == 200
assert resp.json()["id"] == absence_id
@pytest.mark.asyncio
async def test_approve_absence(client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id):
next_monday = date.today() + timedelta(days=(7 - date.today().weekday()) + 14)
create_resp = await client.post(
"/api/v1/absences/",
json={
"type_id": str(vacation_type_id),
"start_date": str(next_monday),
"end_date": str(next_monday + timedelta(days=4)),
},
headers=abs_headers,
)
absence_id = create_resp.json()["id"]
resp = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=abs_approver_headers)
assert resp.status_code == 200
assert resp.json()["status"] == "approved"
@pytest.mark.asyncio
async def test_reject_absence(client: AsyncClient, abs_headers, vacation_type_id):
next_monday = date.today() + timedelta(days=(7 - date.today().weekday()) + 21)
create_resp = await client.post(
"/api/v1/absences/",
json={
"type_id": str(vacation_type_id),
"start_date": str(next_monday),
"end_date": str(next_monday + timedelta(days=4)),
},
headers=abs_headers,
)
absence_id = create_resp.json()["id"]
resp = await client.post(
f"/api/v1/absences/{absence_id}/reject",
json={"rejection_reason": "Urlaubssperre in diesem Zeitraum"},
headers=abs_headers,
)
assert resp.status_code == 200
assert resp.json()["status"] == "rejected"
assert resp.json()["rejection_reason"] == "Urlaubssperre in diesem Zeitraum"
@pytest.mark.asyncio
async def test_cancel_absence(client: AsyncClient, abs_headers, vacation_type_id):
"""Eigenen ausstehenden Antrag stornieren."""
next_monday = date.today() + timedelta(days=(7 - date.today().weekday()) + 28)
create_resp = await client.post(
"/api/v1/absences/",
json={
"type_id": str(vacation_type_id),
"start_date": str(next_monday),
"end_date": str(next_monday + timedelta(days=4)),
},
headers=abs_headers,
)
absence_id = create_resp.json()["id"]
resp = await client.delete(f"/api/v1/absences/{absence_id}", headers=abs_headers)
assert resp.status_code == 200
assert resp.json()["status"] == "cancelled"
@pytest.mark.asyncio
async def test_calendar(client: AsyncClient, abs_headers):
resp = await client.get(
"/api/v1/absences/calendar",
params={"year": 2026, "month": 4},
headers=abs_headers,
)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
# ── Vacation Balance ───────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_own_balance(client: AsyncClient, abs_headers):
resp = await client.get("/api/v1/absences/balance", params={"year": 2026}, headers=abs_headers)
assert resp.status_code == 200
data = resp.json()
assert "entitled_days" in data
assert "remaining_days" in data
assert data["entitled_days"] == 30
@pytest.mark.asyncio
async def test_balance_deducted_after_approve(client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id, abs_company):
"""Urlaubskonto wird nach Genehmigung abgezogen."""
user_id = abs_company["user"]["id"]
# Initiales Konto
before = await client.get("/api/v1/absences/balance", params={"year": 2026}, headers=abs_headers)
used_before = before.json()["used_days"]
# Urlaub beantragen (Mo-Fr = 5 Tage)
next_monday = date.today() + timedelta(days=(7 - date.today().weekday()) + 35)
create_resp = await client.post(
"/api/v1/absences/",
json={
"type_id": str(vacation_type_id),
"start_date": str(next_monday),
"end_date": str(next_monday + timedelta(days=4)),
},
headers=abs_headers,
)
absence_id = create_resp.json()["id"]
working_days = create_resp.json()["working_days"]
# Genehmigen (zweiter Admin, nicht self-approval)
approve_resp = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=abs_approver_headers)
assert approve_resp.status_code == 200, approve_resp.text
# Konto prüfen
after = await client.get("/api/v1/absences/balance", params={"year": 2026}, headers=abs_headers)
assert after.json()["used_days"] == used_before + working_days
# ── Public Holidays ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_and_list_public_holiday(client: AsyncClient, abs_headers):
resp = await client.post(
"/api/v1/public-holidays/",
json={
"country": "DE",
"state": None,
"date": "2026-01-01",
"name": "Neujahr",
},
headers=abs_headers,
)
assert resp.status_code == 201
assert resp.json()["name"] == "Neujahr"
list_resp = await client.get(
"/api/v1/public-holidays/",
params={"year": 2026, "country": "DE"},
headers=abs_headers,
)
assert list_resp.status_code == 200
holidays = list_resp.json()
assert any(h["name"] == "Neujahr" for h in holidays)
# ── Working Days Calculation ───────────────────────────────────────────────────
def test_calc_working_days_full_week():
from app.services.absence_service import AbsenceService
svc = AbsenceService()
# Mo-Fr
days = svc._calc_working_days(
date(2026, 3, 30), date(2026, 4, 3), set(), False, False
)
assert days == 5.0
def test_calc_working_days_with_holiday():
from app.services.absence_service import AbsenceService
svc = AbsenceService()
# Mo-Fr mit Feiertag am Mittwoch
days = svc._calc_working_days(
date(2026, 3, 30), date(2026, 4, 3),
{date(2026, 4, 1)}, # Mittwoch = Feiertag
False, False
)
assert days == 4.0
def test_calc_working_days_half_day():
from app.services.absence_service import AbsenceService
svc = AbsenceService()
# Mo-Fr, halber Montag
days = svc._calc_working_days(
date(2026, 3, 30), date(2026, 4, 3), set(), True, False
)
assert days == 4.5
def test_calc_working_days_weekend_only():
from app.services.absence_service import AbsenceService
svc = AbsenceService()
days = svc._calc_working_days(
date(2026, 3, 28), date(2026, 3, 29), set(), False, False
)
assert days == 0.0
# ── Krankmeldung (agent-05) ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_quick_sick_creates_approved_absence(client: AsyncClient, abs_headers):
"""POST /absences/quick-sick → sofort approved, ohne Typ-Auswahl."""
today = date.today()
resp = await client.post(
"/api/v1/absences/quick-sick",
json={"start_date": str(today), "end_date": str(today)},
headers=abs_headers,
)
assert resp.status_code == 201
data = resp.json()
assert data["status"] == "approved"
assert data["certificate_required_by"] is not None
@pytest.mark.asyncio
async def test_certificate_required_by_uses_type_threshold(
client: AsyncClient, abs_headers, sick_type_id
):
"""certificate_required_by = start_date + AbsenceType.certificate_after_days (default 3)."""
start = date(2027, 6, 1)
resp = await client.post(
"/api/v1/absences/",
json={
"type_id": str(sick_type_id),
"start_date": str(start),
"end_date": str(start),
},
headers=abs_headers,
)
assert resp.status_code == 201
data = resp.json()
expected = start + timedelta(days=3)
assert data["certificate_required_by"] == str(expected)
@pytest.mark.asyncio
async def test_certificate_required_by_respects_type_override(
client: AsyncClient, abs_headers, sick_type_id
):
"""PATCH AbsenceType.certificate_after_days = 7 → neue Absences nutzen 7."""
upd = await client.patch(
f"/api/v1/absence-types/{sick_type_id}",
json={"certificate_after_days": 7},
headers=abs_headers,
)
assert upd.status_code == 200
assert upd.json()["certificate_after_days"] == 7
start = date(2027, 7, 5)
resp = await client.post(
"/api/v1/absences/",
json={
"type_id": str(sick_type_id),
"start_date": str(start),
"end_date": str(start),
},
headers=abs_headers,
)
assert resp.status_code == 201
expected = start + timedelta(days=7)
assert resp.json()["certificate_required_by"] == str(expected)
# Reset auf default für nachfolgende Tests
await client.patch(
f"/api/v1/absence-types/{sick_type_id}",
json={"certificate_after_days": 3},
headers=abs_headers,
)
@pytest.mark.asyncio
async def test_mark_certificate_received(client: AsyncClient, abs_headers, sick_type_id):
"""HR/Admin markiert Attest-Eingang per PATCH /absences/{id}/certificate."""
start = date(2027, 8, 10)
create = await client.post(
"/api/v1/absences/",
json={
"type_id": str(sick_type_id),
"start_date": str(start),
"end_date": str(start),
},
headers=abs_headers,
)
absence_id = create.json()["id"]
assert create.json()["certificate_received_at"] is None
resp = await client.patch(
f"/api/v1/absences/{absence_id}/certificate",
json={}, # default = today
headers=abs_headers,
)
assert resp.status_code == 200
assert resp.json()["certificate_received_at"] == str(date.today())
@pytest.mark.asyncio
async def test_sick_stats_bradford_factor(client: AsyncClient, abs_headers):
"""GET /absences/sick-stats: Bradford = episodes² × total_days, rolling 12 Monate."""
resp = await client.get("/api/v1/absences/sick-stats", headers=abs_headers)
assert resp.status_code == 200
stats = resp.json()
# Vorherige Tests haben mindestens eine Krankmeldung im 12-Monats-Fenster erzeugt
assert len(stats) >= 1
row = stats[0]
assert row["episodes"] >= 1
assert row["total_days"] >= 0.0
# Bradford-Formel verifizieren
expected = float(row["episodes"]) ** 2 * row["total_days"]
assert abs(row["bradford_factor"] - expected) < 0.001
# ── agent-11 PR1: Vertretung · Stornierung · Kommentare ────────────────────────
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def substitute_user_id(client: AsyncClient, abs_headers):
"""Mitarbeiter, der als Vertretung eingetragen werden kann."""
resp = await client.post("/api/v1/users/invite", json={
"first_name": "Sub", "last_name": "Stitute",
"email": "sub@absenceag.de", "role": "EMPLOYEE",
"initial_password": "Secret123",
}, headers=abs_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)
@pytest.mark.asyncio
async def test_cancellation_request_flow(
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id
):
"""Genehmigten Antrag → Stornoantrag → Manager genehmigt → cancelled + Urlaub zurück."""
start = _future_monday(7)
create = await client.post("/api/v1/absences/", json={
"type_id": str(vacation_type_id),
"start_date": str(start), "end_date": str(start + timedelta(days=4)),
}, headers=abs_headers)
aid = create.json()["id"]
working_days = create.json()["working_days"]
approve = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
assert approve.status_code == 200
used_after_approve = (await client.get(
"/api/v1/absences/balance", params={"year": start.year}, headers=abs_headers
)).json()["used_days"]
# Stornoantrag durch Mitarbeiter (Owner)
req = await client.post(
f"/api/v1/absences/{aid}/request-cancellation",
json={"reason": "Plan geaendert"}, headers=abs_headers,
)
assert req.status_code == 200, req.text
assert req.json()["status"] == "cancellation_requested"
# Manager genehmigt die Stornierung
ok = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
assert ok.status_code == 200, ok.text
assert ok.json()["status"] == "cancelled"
used_after_cancel = (await client.get(
"/api/v1/absences/balance", params={"year": start.year}, headers=abs_headers
)).json()["used_days"]
assert used_after_cancel == used_after_approve - int(working_days)
@pytest.mark.asyncio
async def test_cancellation_request_rejected_keeps_approved(
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id
):
start = _future_monday(9)
create = await client.post("/api/v1/absences/", json={
"type_id": str(vacation_type_id),
"start_date": str(start), "end_date": str(start + timedelta(days=2)),
}, headers=abs_headers)
aid = create.json()["id"]
await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
await client.post(f"/api/v1/absences/{aid}/request-cancellation", json={}, headers=abs_headers)
rej = await client.post(
f"/api/v1/absences/{aid}/reject",
json={"rejection_reason": "Vertretung fehlt"}, headers=abs_approver_headers,
)
assert rej.status_code == 200, rej.text
assert rej.json()["status"] == "approved"
@pytest.mark.asyncio
async def test_request_cancellation_requires_approved(
client: AsyncClient, abs_headers, vacation_type_id
):
"""PENDING-Antrag kann nicht zur Stornierung eingereicht werden (nur direkt löschen)."""
start = _future_monday(11)
create = await client.post("/api/v1/absences/", json={
"type_id": str(vacation_type_id),
"start_date": str(start), "end_date": str(start + timedelta(days=1)),
}, headers=abs_headers)
aid = create.json()["id"]
req = await client.post(f"/api/v1/absences/{aid}/request-cancellation", json={}, headers=abs_headers)
assert req.status_code == 409
@pytest.mark.asyncio
async def test_substitute_filter_and_notification(
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id, substitute_user_id
):
"""Antrag mit Vertretung → Vertreter sieht ihn unter ?as_substitute=true."""
start = _future_monday(13)
create = await client.post("/api/v1/absences/", json={
"type_id": str(vacation_type_id),
"start_date": str(start), "end_date": str(start + timedelta(days=2)),
"substitute_id": substitute_user_id,
}, headers=abs_headers)
assert create.status_code == 201, create.text
aid = create.json()["id"]
assert create.json()["substitute_id"] == substitute_user_id
await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
sub_login = await client.post("/api/v1/auth/login", json={
"email": "sub@absenceag.de", "password": "Secret123",
})
sub_headers = {"Authorization": f"Bearer {sub_login.json()['access_token']}"}
lst = await client.get("/api/v1/absences/?as_substitute=true", headers=sub_headers)
assert lst.status_code == 200, lst.text
assert any(a["id"] == aid for a in lst.json()["items"])
@pytest.mark.asyncio
async def test_absence_comments(
client: AsyncClient, abs_headers, vacation_type_id
):
"""Kommentar posten + System-Kommentar bei Stornoantrag erscheint im Thread."""
start = _future_monday(15)
create = await client.post("/api/v1/absences/", json={
"type_id": str(vacation_type_id),
"start_date": str(start), "end_date": str(start + timedelta(days=1)),
"note": "Brueckentag",
}, headers=abs_headers)
aid = create.json()["id"]
add = await client.post(
f"/api/v1/absences/{aid}/comments",
json={"body": "Bitte zuegig pruefen"}, headers=abs_headers,
)
assert add.status_code == 201, add.text
assert add.json()["is_system"] is False
assert add.json()["author_name"]
lst = await client.get(f"/api/v1/absences/{aid}/comments", headers=abs_headers)
assert lst.status_code == 200
bodies = [c["body"] for c in lst.json()]
assert "Bitte zuegig pruefen" in bodies
# ── agent-11 PR2: Urlaubsanspruch (Verfall · Pro-rata · Teilzeit) ──────────────
def test_effective_available_not_expired():
import uuid as _u
from app.services.absence_service import AbsenceService
from app.models.vacation_balance import VacationBalance
b = VacationBalance(user_id=_u.uuid4(), year=2026, entitled_days=30, special_days=0, carried_over=5, used_days=3)
assert AbsenceService.effective_available(b, False) == 32 # 30 + 5 - 3
assert AbsenceService.effective_available(b, True) == 30 # Resturlaub verfällt (3 < 5)
def test_effective_available_expired_overdraw():
import uuid as _u
from app.services.absence_service import AbsenceService
from app.models.vacation_balance import VacationBalance
b = VacationBalance(user_id=_u.uuid4(), year=2026, entitled_days=30, special_days=0, carried_over=5, used_days=8)
assert AbsenceService.effective_available(b, True) == 27 # 30 - max(0, 8 - 5)
@pytest.mark.asyncio
async def test_company_vacation_settings_roundtrip(client: AsyncClient, abs_headers):
r = await client.patch("/api/v1/companies/me", json={
"vacation_default_days": 28, "vacation_prorate_first_year": True, "vacation_from_schedule": False,
}, headers=abs_headers)
assert r.status_code == 200, r.text
assert r.json()["vacation_default_days"] == 28
await client.patch("/api/v1/companies/me", json={"vacation_default_days": 30}, headers=abs_headers)
@pytest.mark.asyncio
async def test_prorate_entry_year(client: AsyncClient, abs_headers):
inv = await client.post("/api/v1/users/invite", json={
"first_name": "Pro", "last_name": "Rata", "email": "prorata@absenceag.de",
"role": "EMPLOYEE", "initial_password": "Secret123",
}, headers=abs_headers)
uid = inv.json()["id"]
yr = date.today().year
await client.patch(f"/api/v1/users/{uid}", json={"entry_date": f"{yr}-07-01"}, headers=abs_headers)
bal = await client.get(f"/api/v1/absences/balance/{uid}?year={yr}", headers=abs_headers)
assert bal.status_code == 200, bal.text
assert bal.json()["entitled_days"] == 15 # 30 * 6/12 (Eintritt Juli)
@pytest.mark.asyncio
async def test_part_time_entitlement_from_schedule(client: AsyncClient, abs_headers):
sched = await client.post("/api/v1/time/schedules", json={
"name": "Teilzeit 3 Tage", "mon_h": 8, "tue_h": 8, "wed_h": 8,
"thu_h": 0, "fri_h": 0, "valid_from": "2026-01-01",
}, headers=abs_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_default_days": 30,
}, headers=abs_headers)
inv = await client.post("/api/v1/users/invite", json={
"first_name": "Teil", "last_name": "Zeit", "email": "teilzeit@absenceag.de",
"role": "EMPLOYEE", "initial_password": "Secret123",
}, headers=abs_headers)
uid = inv.json()["id"]
await client.patch(f"/api/v1/users/{uid}", json={"work_schedule_id": sid}, headers=abs_headers)
yr = date.today().year
bal = await client.get(f"/api/v1/absences/balance/{uid}?year={yr}", headers=abs_headers)
assert bal.status_code == 200, bal.text
assert bal.json()["entitled_days"] == 18 # 30 * 3/5
await client.patch("/api/v1/companies/me", json={"vacation_from_schedule": False}, headers=abs_headers)
# ── agent-12: Zwei-Stufen-Genehmigung ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_two_stage_approval_flow(
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id
):
await client.patch("/api/v1/companies/me",
json={"two_stage_approval_enabled": True}, headers=abs_headers)
inv = await client.post("/api/v1/users/invite", json={
"first_name": "Two", "last_name": "Stage", "email": "twostage@absenceag.de",
"role": "EMPLOYEE", "initial_password": "Secret123",
}, headers=abs_headers)
assert inv.status_code == 201, inv.text
login = await client.post("/api/v1/auth/login", json={
"email": "twostage@absenceag.de", "password": "Secret123"})
emp_h = {"Authorization": f"Bearer {login.json()['access_token']}"}
start = _future_monday(17)
cr = await client.post("/api/v1/absences/", json={
"type_id": str(vacation_type_id), "start_date": str(start),
"end_date": str(start + timedelta(days=2)),
}, headers=emp_h)
aid = cr.json()["id"]
# Erste Stufe: Approver A → first_approved (noch kein Abzug)
r1 = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
assert r1.status_code == 200, r1.text
assert r1.json()["status"] == "first_approved"
# Gleicher Genehmiger darf die finale Stufe nicht erteilen
same = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
assert same.status_code == 409
# Finale Stufe: anderer Admin → approved
r2 = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_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=abs_headers)
@pytest.mark.asyncio
async def test_two_stage_reject_at_first_stage(
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id
):
await client.patch("/api/v1/companies/me",
json={"two_stage_approval_enabled": True}, headers=abs_headers)
login = await client.post("/api/v1/auth/login", json={
"email": "twostage@absenceag.de", "password": "Secret123"})
emp_h = {"Authorization": f"Bearer {login.json()['access_token']}"}
start = _future_monday(19)
cr = await client.post("/api/v1/absences/", json={
"type_id": str(vacation_type_id), "start_date": str(start),
"end_date": str(start + timedelta(days=1)),
}, headers=emp_h)
aid = cr.json()["id"]
await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
rej = await client.post(f"/api/v1/absences/{aid}/reject",
json={"rejection_reason": "Doch nicht"}, headers=abs_headers)
assert rej.status_code == 200, rej.text
assert rej.json()["status"] == "rejected"
await client.patch("/api/v1/companies/me",
json={"two_stage_approval_enabled": False}, headers=abs_headers)