Files

392 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 Freizeitausgleich (FZA) Lücken-Fixes.
Gap-1: Überstunden-Überziehschutz (configurable: allow/block + warning threshold)
Gap-2: Überstundenkonto wird bei Zeiteintrag-Genehmigung neu berechnet
Gap-3: Stornierung eines genehmigten FZA-Antrags bucht taken_hours zurück
"""
import pytest
import pytest_asyncio
from datetime import date, time
from decimal import Decimal
from httpx import AsyncClient
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceType, AbsenceCategory
from app.models.company import Company
from app.models.overtime_balance import OvertimeBalance
from app.models.time_entry import TimeEntry, EntryStatus
from app.models.user import User, UserRole
# ─────────────────────────────────────────────────────────────────────────────
# Fixtures
# ─────────────────────────────────────────────────────────────────────────────
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def fza_company(client: AsyncClient):
"""Eigene Company für FZA-Tests."""
resp = await client.post("/api/v1/auth/register", json={
"company_name": "FZA Test GmbH",
"first_name": "FZA",
"last_name": "Admin",
"email": "admin@fza-test.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 fza_admin_headers(fza_company):
return {"Authorization": f"Bearer {fza_company['tokens']['access_token']}"}
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def fza_hr_headers(client: AsyncClient, fza_admin_headers):
"""HR-User der FZA Test GmbH."""
resp = await client.post("/api/v1/users/invite", json={
"first_name": "HR",
"last_name": "Manager",
"email": "hr@fza-test.de",
"role": "HR",
"initial_password": "Secret123",
}, headers=fza_admin_headers)
assert resp.status_code == 201, resp.text
login = await client.post("/api/v1/auth/login", json={
"email": "hr@fza-test.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 fza_employee_headers(client: AsyncClient, fza_admin_headers):
"""Mitarbeiter der FZA Test GmbH."""
resp = await client.post("/api/v1/users/invite", json={
"first_name": "Franz",
"last_name": "Feierabend",
"email": "franz@fza-test.de",
"role": "EMPLOYEE",
"initial_password": "Secret123",
}, headers=fza_admin_headers)
assert resp.status_code == 201, resp.text
login = await client.post("/api/v1/auth/login", json={
"email": "franz@fza-test.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 fza_type_id(client: AsyncClient, fza_admin_headers):
"""FZA-Abwesenheitstyp der Company erstellen."""
resp = await client.post("/api/v1/absence-types/", json={
"name": "Freizeitausgleich",
"category": "overtime_comp",
"color": "#f97316",
"requires_approval": True,
"deducts_vacation": False,
"affects_overtime_balance": True,
}, headers=fza_admin_headers)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
async def _seed_overtime_balance(
db_session: AsyncSession,
admin_user: dict,
total_hours: float,
) -> None:
"""Setzt total_hours in OvertimeBalance direkt (ohne API)."""
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
user_id = admin_user["id"]
company_id = admin_user["company_id"]
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()
# ─────────────────────────────────────────────────────────────────────────────
# Gap-1a: Überziehen erlaubt (default) geht durch
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio(loop_scope="session")
async def test_fza_overdraft_allowed_by_default(
client: AsyncClient,
db_session: AsyncSession,
fza_company: dict,
fza_admin_headers: dict,
fza_hr_headers: dict,
fza_employee_headers: dict,
fza_type_id: str,
):
"""Standardmäßig (overdraft_allowed=True) darf FZA auch bei leerem Konto genehmigt werden."""
emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json()
# Überstundenkonto auf 4h setzen (1 Tag = 8h benötigt)
await _seed_overtime_balance(db_session, emp_me, total_hours=4.0)
await db_session.commit()
# HR-User für Genehmigung holen
hr_me = (await client.get("/api/v1/auth/me", headers=fza_hr_headers)).json()
emp_id = emp_me["id"]
company_id = emp_me["company_id"]
# Antrag als Mitarbeiter stellen
resp = await client.post("/api/v1/absences/", json={
"type_id": fza_type_id,
"start_date": "2026-07-01",
"end_date": "2026-07-01",
}, headers=fza_employee_headers)
assert resp.status_code == 201, resp.text
absence_id = resp.json()["id"]
# HR genehmigt sollte trotz Überziehen funktionieren
resp2 = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=fza_hr_headers)
assert resp2.status_code == 200, resp2.text
body = resp2.json()
assert body["status"] == "approved"
# ─────────────────────────────────────────────────────────────────────────────
# Gap-1b: Überziehen blockiert
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio(loop_scope="session")
async def test_fza_overdraft_blocked(
client: AsyncClient,
db_session: AsyncSession,
fza_company: dict,
fza_admin_headers: dict,
fza_hr_headers: dict,
fza_employee_headers: dict,
fza_type_id: str,
):
"""Mit overtime_overdraft_allowed=False wird Genehmigung abgelehnt wenn Konto leer."""
emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json()
await _seed_overtime_balance(db_session, emp_me, total_hours=2.0) # nur 2h
await db_session.commit()
# Firma: kein Überziehen erlauben
resp = await client.patch("/api/v1/companies/me", json={
"overtime_overdraft_allowed": False,
}, headers=fza_admin_headers)
assert resp.status_code == 200, resp.text
# Antrag für 1 Tag (= 8h) mehr als verfügbar
resp2 = await client.post("/api/v1/absences/", json={
"type_id": fza_type_id,
"start_date": "2026-07-02",
"end_date": "2026-07-02",
}, headers=fza_employee_headers)
assert resp2.status_code == 201, resp2.text
absence_id = resp2.json()["id"]
# Genehmigung muss fehlschlagen
resp3 = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=fza_hr_headers)
assert resp3.status_code == 422, resp3.text
assert "Nicht genug Überstunden" in resp3.json()["detail"]
# Reset
await client.patch("/api/v1/companies/me", json={
"overtime_overdraft_allowed": True,
}, headers=fza_admin_headers)
# ─────────────────────────────────────────────────────────────────────────────
# Gap-1c: Warnschwelle
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio(loop_scope="session")
async def test_fza_warning_threshold(
client: AsyncClient,
db_session: AsyncSession,
fza_company: dict,
fza_admin_headers: dict,
fza_hr_headers: dict,
fza_employee_headers: dict,
fza_type_id: str,
):
"""Warnschwelle: Genehmigung geht durch, aber warnings werden zurückgegeben."""
emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json()
await _seed_overtime_balance(db_session, emp_me, total_hours=16.0)
await db_session.commit()
# Warnschwelle auf 10h setzen
await client.patch("/api/v1/companies/me", json={
"overtime_warning_threshold_hours": 10,
}, headers=fza_admin_headers)
# Antrag für 1 Tag (8h) → verbleibend 8h < 10h Schwelle → Warnung
resp = await client.post("/api/v1/absences/", json={
"type_id": fza_type_id,
"start_date": "2026-07-03",
"end_date": "2026-07-03",
}, headers=fza_employee_headers)
assert resp.status_code == 201, resp.text
absence_id = resp.json()["id"]
resp2 = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=fza_hr_headers)
assert resp2.status_code == 200, resp2.text
body = resp2.json()
assert body["status"] == "approved"
assert len(body.get("warnings", [])) > 0, "Warnschwellen-Warnung erwartet"
assert "Warnschwelle" in body["warnings"][0]
# Reset
await client.patch("/api/v1/companies/me", json={
"overtime_warning_threshold_hours": 0,
}, headers=fza_admin_headers)
# ─────────────────────────────────────────────────────────────────────────────
# Gap-3: Stornierung genehmigter FZA → Rückbuchung
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio(loop_scope="session")
async def test_fza_cancel_approved_refunds(
client: AsyncClient,
db_session: AsyncSession,
fza_company: dict,
fza_admin_headers: dict,
fza_hr_headers: dict,
fza_employee_headers: dict,
fza_type_id: str,
):
"""HR storniert genehmigten FZA → taken_hours werden zurückgebucht."""
emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json()
await _seed_overtime_balance(db_session, emp_me, total_hours=40.0)
await db_session.commit()
# Antrag stellen
resp = await client.post("/api/v1/absences/", json={
"type_id": fza_type_id,
"start_date": "2026-07-07",
"end_date": "2026-07-07",
}, headers=fza_employee_headers)
assert resp.status_code == 201, resp.text
absence_id = resp.json()["id"]
# Genehmigen
r2 = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=fza_hr_headers)
assert r2.status_code == 200, r2.text
# OvertimeBalance: taken_hours sollte jetzt 8h sein
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
ob = await db_session.scalar(
select(OvertimeBalance).where(OvertimeBalance.user_id == emp_me["id"])
)
await db_session.refresh(ob)
taken_after_approve = float(ob.taken_hours)
assert taken_after_approve == pytest.approx(8.0, abs=0.1), f"Erwartet 8h, got {taken_after_approve}"
# HR storniert
r3 = await client.delete(f"/api/v1/absences/{absence_id}", headers=fza_hr_headers)
assert r3.status_code == 200, r3.text
assert r3.json()["status"] == "cancelled"
# Rückbuchung prüfen frischen Query statt refresh (vermeidet Race mit CalDAV-Background-Task)
import asyncio
await asyncio.sleep(0.15) # CalDAV fire-and-forget abwarten
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
ob2 = await db_session.scalar(
select(OvertimeBalance).where(OvertimeBalance.user_id == emp_me["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"Rückbuchung fehlgeschlagen, got {taken_after_cancel}"
@pytest.mark.asyncio(loop_scope="session")
async def test_fza_employee_cannot_cancel_approved(
client: AsyncClient,
db_session: AsyncSession,
fza_admin_headers: dict,
fza_hr_headers: dict,
fza_employee_headers: dict,
fza_type_id: str,
):
"""Mitarbeiter kann genehmigten FZA-Antrag nicht selbst stornieren."""
emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json()
await _seed_overtime_balance(db_session, emp_me, total_hours=40.0)
await db_session.commit()
resp = await client.post("/api/v1/absences/", json={
"type_id": fza_type_id,
"start_date": "2026-07-08",
"end_date": "2026-07-08",
}, headers=fza_employee_headers)
assert resp.status_code == 201, resp.text
absence_id = resp.json()["id"]
# Genehmigen (durch HR)
r2 = await client.post(f"/api/v1/absences/{absence_id}/approve", headers=fza_hr_headers)
assert r2.status_code == 200, r2.text
# Mitarbeiter versucht zu stornieren → 409
r3 = await client.delete(f"/api/v1/absences/{absence_id}", headers=fza_employee_headers)
assert r3.status_code == 409, r3.text
assert "HR/Admin" in r3.json()["detail"]
# ─────────────────────────────────────────────────────────────────────────────
# Gap-2: Zeiteintrag-Genehmigung aktualisiert OvertimeBalance.total_hours
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio(loop_scope="session")
async def test_time_entry_approval_updates_overtime_balance(
client: AsyncClient,
db_session: AsyncSession,
fza_admin_headers: dict,
fza_hr_headers: dict,
fza_employee_headers: dict,
):
"""Nach Genehmigung eines Zeiteintrags wird OvertimeBalance.total_hours neu berechnet."""
emp_me = (await client.get("/api/v1/auth/me", headers=fza_employee_headers)).json()
# Manuelle Zeiterfassung für diesen User freischalten
emp_id = emp_me["id"]
r = await client.patch(f"/api/v1/users/{emp_id}", json={"can_manual_time_entry": True}, headers=fza_admin_headers)
assert r.status_code == 200, f"Manual entry freischalten fehlgeschlagen: {r.text}"
# Eintrag manuell anlegen (10h = 2h Überstunden bei 8h Soll-Tag)
resp = await client.post("/api/v1/time/entries", json={
"date": "2026-06-01",
"start_time": "08:00",
"end_time": "18:00",
"break_minutes": 0,
}, headers=fza_employee_headers)
assert resp.status_code == 201, resp.text
entry_id = resp.json()["entry"]["id"]
# Genehmigen (HR, kein Self-Approval)
r2 = await client.post(f"/api/v1/time/entries/{entry_id}/approve", headers=fza_hr_headers)
assert r2.status_code == 200, r2.text
# OvertimeBalance.last_calculated sollte jetzt gesetzt sein
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
ob = await db_session.scalar(
select(OvertimeBalance).where(OvertimeBalance.user_id == emp_me["id"])
.execution_options(populate_existing=True)
)
assert ob is not None, "OvertimeBalance wurde nicht angelegt"
assert ob.last_calculated is not None, "last_calculated sollte nach Genehmigung gesetzt sein"