Files
timemaster/backend/tests/test_absences.py
T
patrickandClaude Opus 4.8 e8bed43570 feat: agent-11 PR2 – Urlaubsanspruch (Verfall scharf, Pro-rata, Teilzeit)
Korrektheit der Urlaubskonten (Feature-Parität mit Urlaubsverwaltung):

- Verfall scharfgeschaltet: neuer effektiv verfügbarer Saldo (available_days)
  schließt verfallenen, noch nicht verbrauchten Resturlaub aus; Konto-Warnung
  beim Antrag nutzt jetzt available statt remaining. (Verfallsdatum bleibt in
  company.settings, UI bereits vorhanden.)
- Anteilige Berechnung (Zwölftel) im Ein-/Austrittsjahr anhand neuer Felder
  users.entry_date / exit_date; opt-in pro Firma (vacation_prorate_first_year).
- Teilzeit: Anspruch optional aus Arbeitstagen/Woche des WorkSchedule abgeleitet
  (vacation_from_schedule), Basis vacation_default_days.
- _get_or_create_balance berechnet den Grundanspruch jetzt frisch
  (_compute_entitlement); _carryover_expired/effective_available als Service-API,
  Router delegiert.

Frontend: CompanySettingsPage (Jahresurlaub + Pro-rata- und Teilzeit-Toggles),
UsersPage (Ein-/Austrittsdatum im Edit-Modal), AbsencesPage ("Verfügbar" + Hinweis
bei verfallenem Resturlaub).

Migration 0036. 183/183 Tests grün. Deployed auf 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:03:42 +02:00

695 lines
26 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)