Files
2026-05-25 00:19:09 +02:00

484 lines
17 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.
"""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