diff --git a/DEVLOG.md b/DEVLOG.md index 214e348..a4c38ac 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -3349,3 +3349,552 @@ Keine Commits in dieser Session. - frontend/src/pages/KioskStampPage.tsx | 279 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- --- +## 2026-09-02 15:07 – 15:08 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- b8073fa docs(devlog): Sessions vom 2026-08-05 bis 2026-09-02 nachtragen + +### Geänderte Dateien +- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-02 15:09 – 15:09 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-02 15:09 – 15:10 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-02 15:11 – 15:11 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-02 22:20 – 22:20 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-02 22:21 – 22:23 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 545 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-02 22:27 – 22:28 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 52ecd9e chore(routers): toten Router absence.py entfernen + +### Geänderte Dateien +- backend/app/routers/absence.py | 159 --------------------------------------------------------------------------------------- + +--- +## 2026-09-02 22:28 – 22:29 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- c733ddf fix(redis): gepoolten async-Redis-Client statt Connect/Close pro Request + +### Geänderte Dateien +- backend/app/core/kiosk_security.py | 5 ++--- +- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++-- +- backend/app/main.py | 2 ++ +- backend/app/routers/auth.py | 32 ++++++++++++++------------------ +- frontend/src/api/client.ts | 6 ++++++ + +--- +## 2026-09-02 22:32 – 22:37 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/core/kiosk_security.py | 5 ++--- +- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++-- +- backend/app/main.py | 2 ++ +- backend/app/routers/auth.py | 32 ++++++++++++++------------------ +- frontend/src/api/client.ts | 6 ++++++ + +--- +## 2026-09-02 22:38 – 22:38 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/core/kiosk_security.py | 5 ++--- +- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++-- +- backend/app/main.py | 2 ++ +- backend/app/routers/auth.py | 32 ++++++++++++++------------------ +- frontend/src/api/client.ts | 6 ++++++ + +--- +## 2026-09-02 22:38 – 22:41 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/core/kiosk_security.py | 5 ++--- +- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++-- +- backend/app/main.py | 2 ++ +- backend/app/routers/auth.py | 32 ++++++++++++++------------------ +- frontend/src/api/client.ts | 6 ++++++ + +--- +## 2026-09-02 22:41 – 22:41 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/core/kiosk_security.py | 5 ++--- +- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++-- +- backend/app/main.py | 2 ++ +- backend/app/routers/auth.py | 32 ++++++++++++++------------------ +- frontend/src/api/client.ts | 6 ++++++ + +--- +## 2026-09-02 22:42 – 22:42 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/core/kiosk_security.py | 5 ++--- +- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++-- +- backend/app/main.py | 2 ++ +- backend/app/routers/auth.py | 32 ++++++++++++++------------------ +- frontend/src/api/client.ts | 6 ++++++ + +--- +## 2026-09-02 22:42 – 22:46 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/core/kiosk_security.py | 5 ++--- +- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++-- +- backend/app/main.py | 2 ++ +- backend/app/routers/auth.py | 32 ++++++++++++++------------------ +- frontend/src/api/client.ts | 6 ++++++ + +--- +## 2026-09-02 22:46 – 22:47 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- ad0bc62 ci(frontend): Build-Job als Gate im Security-Workflow ergänzen + +### Geänderte Dateien +- .gitea/workflows/security.yml | 23 +++++++++++++++++++++++ + +--- +## 2026-09-02 22:47 – 22:49 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 538cbb0 test(coverage): pytest-cov einbauen (term-missing Report) + +### Geänderte Dateien +- .gitignore | 3 +++ +- backend/pytest.ini | 2 +- + +--- +## 2026-09-02 22:53 – 22:53 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .gitignore | 3 +++ +- backend/pytest.ini | 2 +- + +--- +## 2026-09-02 22:54 – 22:54 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .gitignore | 3 +++ +- backend/pytest.ini | 2 +- + +--- +## 2026-09-02 23:03 – 23:03 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .gitignore | 3 +++ +- backend/pytest.ini | 2 +- + +--- +## 2026-09-02 23:03 – 23:04 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .gitignore | 3 +++ +- backend/pytest.ini | 2 +- + +--- +## 2026-09-02 23:05 – 23:05 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- c1572c3 docs(api): 7 fehlende Router nachgezogen + +### Geänderte Dateien +- docs/api.md | 427 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-02 23:07 – 23:08 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- docs/api.md | 427 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-02 23:09 – 23:09 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- docs/api.md | 427 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-02 23:09 – 23:13 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- aba0b53 fix(docs): StaticFiles-Mount-Pfad korrigieren (static statt app/static) +- 4e30c55 fix(docs): Swagger-Assets nach backend/static/ verschoben (nginx-Alias) +- 9f9dfb5 fix(docs): Swagger/ReDoc-Assets lokal statt von cdn.jsdelivr.net + +### Geänderte Dateien +- backend/app/main.py | 2 +- + +--- +## 2026-09-02 23:15 – 23:17 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/main.py | 2 +- + +--- +## 2026-09-02 23:17 – 23:17 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- b7bec79 fix(setup): nginx-Template im Installer auf Prod-Stand bringen + +### Geänderte Dateien +- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- + +--- +## 2026-09-02 23:17 – 23:17 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- + +--- +## 2026-09-02 23:19 – 23:19 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- + +--- +## 2026-09-02 23:22 – 23:22 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- + +--- +## 2026-09-02 23:24 – 23:24 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- + +--- +## 2026-09-02 23:25 – 23:25 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- setup_server.sh | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- + +--- +## 2026-09-02 23:35 – 23:35 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/tests/test_coverage_gaps.py | 5 +++-- + +--- +## 2026-09-02 23:49 – 23:55 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- ac496a5 fix(absences): POST /absences/ reicht warnings jetzt an den Client durch +- 3cf396b fix(tests): Testdaten für Jahreswechsel-Regressionstest korrigieren +- 548738c fix(absences): Feiertage über Jahreswechsel korrekt aus working_days rausrechnen + +### Geänderte Dateien +- backend/app/routers/absences.py | 10 ++++++++-- +- backend/tests/test_coverage_gaps.py | 44 ++++++++++++++++++++++++++++++++++++++------ + +--- +## 2026-09-02 23:55 – 23:55 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/routers/absences.py | 10 ++++++++-- +- backend/tests/test_coverage_gaps.py | 44 ++++++++++++++++++++++++++++++++++++++------ + +--- +## 2026-09-02 23:56 – 00:01 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** nexarch + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/routers/absences.py | 10 ++++++++-- +- backend/tests/test_coverage_gaps.py | 44 ++++++++++++++++++++++++++++++++++++++------ + +--- +## 2026-09-03 00:02 – 00:02 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/routers/absences.py | 10 ++++++++-- +- backend/tests/test_coverage_gaps.py | 44 ++++++++++++++++++++++++++++++++++++++------ + +--- +## 2026-09-03 00:03 – 00:03 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 5ba5a99 docs: README erstellt, Debian statt Ubuntu korrigiert + +### Geänderte Dateien +- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- setup_server.sh | 2 +- + +--- +## 2026-09-03 00:03 – 00:03 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- setup_server.sh | 2 +- + +--- +## 2026-09-03 00:04 – 00:04 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- setup_server.sh | 2 +- + +--- +## 2026-09-03 00:04 – 00:04 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- setup_server.sh | 2 +- + +--- +## 2026-09-03 00:05 – 00:05 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- setup_server.sh | 2 +- + +--- +## 2026-09-03 00:06 – 00:09 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/routers/auth.py | 29 +++++---- +- backend/app/services/auth_service.py | 210 ++++++++++++++++++++++++++++++++--------------------------------- +- backend/app/services/kiosk_auth_service.py | 191 ++++++++++++++++++++++++++++------------------------------- +- backend/app/services/kiosk_session_service.py | 26 +++----- +- backend/app/services/public_stamp_session_service.py | 16 ++--- + +--- +## 2026-09-03 00:12 – 00:14 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 107cba9 fix(redis): INCR+EXPIRE atomar via Pipeline (MULTI/EXEC) + +### Geänderte Dateien +- backend/app/routers/auth.py | 8 ++++++-- +- backend/app/services/auth_service.py | 8 ++++++-- +- backend/app/services/kiosk_auth_service.py | 8 ++++++-- + +--- +## 2026-09-03 01:59 – 02:00 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/core/redis.py | 14 +++++++++++++- +- backend/app/main.py | 11 +++++++++++ + +--- +## 2026-09-03 02:04 – 02:05 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/core/redis.py | 14 +++++++++++++- +- backend/app/main.py | 11 +++++++++++ + +--- diff --git a/Stundenaufzeichnungen Muster blanko DATEV ab 2015.pdf b/Stundenaufzeichnungen Muster blanko DATEV ab 2015.pdf new file mode 100644 index 0000000..5d2ea77 Binary files /dev/null and b/Stundenaufzeichnungen Muster blanko DATEV ab 2015.pdf differ diff --git a/backend/tests/test_coverage_gaps.py b/backend/tests/test_coverage_gaps.py index bdde645..c88b575 100644 --- a/backend/tests/test_coverage_gaps.py +++ b/backend/tests/test_coverage_gaps.py @@ -20,6 +20,7 @@ import pytest import pytest_asyncio from datetime import date, time, timedelta from decimal import Decimal +from uuid import UUID from httpx import AsyncClient from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession @@ -612,3 +613,352 @@ async def test_create_absence_returns_warnings( data2 = resp2.json() assert "warnings" in data2 assert any("Überschneidung" in w for w in data2["warnings"]), data2["warnings"] + + +# ── P1-Rest: postgres-expert Audit, 4 gezielte Tests ──────────────────────────── +# +# 1. Scheduler-Idempotenz Überstunden-Verfall (report_service.apply_overtime_expiry_if_needed) +# 2. Bradford-Faktor Fensterrand + MANAGER-Department-Scope (absence_service.get_sick_stats) +# 3. Sondervertretungs-Faktor + Überstunden-Kappung kombiniert (report_service._recalculate_overtime_balance) +# 4. Cross-Company-RLS bei Vertretern (absence_service.list_absences as_substitute=True) + +from types import SimpleNamespace + + +# ── 1. Scheduler-Idempotenz (Überstunden-Verfall) ─────────────────────────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_overtime_expiry_idempotent_second_call_noop(db_session: AsyncSession): + """Ein zweiter Aufruf am selben Tag darf keinen zweiten Abzug auslösen + (verhindert Doppel-Kappung bei doppeltem Scheduler-Lauf).""" + from app.services.report_service import apply_overtime_expiry_if_needed + + company = SimpleNamespace( + overtime_expiry_enabled=True, + overtime_expiry_month=1, + overtime_expiry_day=1, + overtime_max_carryover_hours=5, + ) + bal = SimpleNamespace(total_hours=Decimal("20"), taken_hours=Decimal("0"), last_expiry_applied_at=None) + + changed_first = await apply_overtime_expiry_if_needed(bal, company, db_session) + assert changed_first is True + assert bal.total_hours == Decimal("5"), "Kappung auf max_carryover_hours erwartet" + + # Zweiter Aufruf am selben Tag (last_expiry_applied_at wurde eben gesetzt) -> no-op + changed_second = await apply_overtime_expiry_if_needed(bal, company, db_session) + assert changed_second is False + assert bal.total_hours == Decimal("5"), "Kein zweiter Abzug bei erneutem Aufruf am selben Tag" + + +@pytest.mark.asyncio(loop_scope="session") +async def test_overtime_expiry_invalid_date_silent_skip(db_session: AsyncSession): + """Ungültiges Verfallsdatum (29. Februar in einem Nicht-Schaltjahr) darf nicht + crashen (kein propagierter ValueError), sondern still übersprungen werden. + + Hinweis: nutzt das reale Systemdatum (Funktion ruft intern date.today() auf, + kein Mock-Punkt vorhanden). Das aktuelle Jahr (Testlauf 2026) ist kein + Schaltjahr, ebenso das Vorjahr 2025 -- Test bleibt bis 2027 gültig + (2028 ist wieder ein Schaltjahr, dann müsste der Test angepasst werden).""" + from app.services.report_service import apply_overtime_expiry_if_needed + + company = SimpleNamespace( + overtime_expiry_enabled=True, + overtime_expiry_month=2, + overtime_expiry_day=29, + overtime_max_carryover_hours=5, + ) + bal = SimpleNamespace(total_hours=Decimal("20"), taken_hours=Decimal("0"), last_expiry_applied_at=None) + + changed = await apply_overtime_expiry_if_needed(bal, company, db_session) + assert changed is False + assert bal.total_hours == Decimal("20"), "Bei ungültigem Datum darf keine Kappung stattfinden" + + +# ── 2. Bradford-Faktor Fensterrand + MANAGER-Department-Scope ────────────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_bradford_window_boundary_and_manager_department_scope( + client: AsyncClient, db_session: AsyncSession, +): + """(a) Eine Krank-Episode die GENAU am Rand des 365-Tage-Fensters endet + (end_date == window_start) muss mitgezählt werden, eine die einen Tag davor + endet nicht (Off-by-one, quadratisch empfindlich für den Bradford-Faktor). + (b) Ein MANAGER mit department_id sieht nur Bradford-Stats des eigenen + Departments, nicht firmenweit (DSGVO-Datensparsamkeit).""" + from app.models.absence import Absence, AbsenceStatus + from app.models.absence_type import AbsenceCategory, AbsenceType + from app.models.user import UserRole + from app.services.absence_service import absence_service + + reg = await client.post("/api/v1/auth/register", json={ + "company_name": "Bradford Edge GmbH", + "first_name": "Brad", "last_name": "Admin", + "email": "admin@bradfordedge.de", "password": "Secret123", + }) + assert reg.status_code == 201, reg.text + admin_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"} + me = (await client.get("/api/v1/auth/me", headers=admin_headers)).json() + company_id = me["company_id"] + + dep_a = await client.post("/api/v1/companies/me/departments", json={"name": "Abteilung A"}, headers=admin_headers) + assert dep_a.status_code == 201, dep_a.text + dep_b = await client.post("/api/v1/companies/me/departments", json={"name": "Abteilung B"}, headers=admin_headers) + assert dep_b.status_code == 201, dep_b.text + dep_a_id = dep_a.json()["id"] + dep_b_id = dep_b.json()["id"] + + mgr = await client.post("/api/v1/users/invite", json={ + "first_name": "Manager", "last_name": "A", "email": "manager.a@bradfordedge.de", + "role": "MANAGER", "department_id": dep_a_id, "initial_password": "Secret123", + }, headers=admin_headers) + assert mgr.status_code == 201, mgr.text + mgr_id = mgr.json()["id"] + + emp_a = await client.post("/api/v1/users/invite", json={ + "first_name": "Emp", "last_name": "A", "email": "emp.a@bradfordedge.de", + "role": "EMPLOYEE", "department_id": dep_a_id, "initial_password": "Secret123", + }, headers=admin_headers) + assert emp_a.status_code == 201, emp_a.text + emp_a_id = emp_a.json()["id"] + + emp_b = await client.post("/api/v1/users/invite", json={ + "first_name": "Emp", "last_name": "B", "email": "emp.b@bradfordedge.de", + "role": "EMPLOYEE", "department_id": dep_b_id, "initial_password": "Secret123", + }, headers=admin_headers) + assert emp_b.status_code == 201, emp_b.text + emp_b_id = emp_b.json()["id"] + + types_resp = await client.get("/api/v1/absence-types/", headers=admin_headers) + sick_type = next(t for t in types_resp.json() if t["category"] == "sick") + sick_type_id = sick_type["id"] + + ref_date = date.today() + window_start = ref_date - timedelta(days=365) + + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + + # Emp A: eine Episode GENAU am Fensterrand (muss zählen) + eine Episode + # einen Tag VOR dem Fensterrand (darf nicht zählen). + in_window = Absence( + user_id=emp_a_id, type_id=sick_type_id, + start_date=window_start, end_date=window_start, + working_days=Decimal("1"), status=AbsenceStatus.APPROVED, + ) + out_of_window = Absence( + user_id=emp_a_id, type_id=sick_type_id, + start_date=window_start - timedelta(days=3), end_date=window_start - timedelta(days=1), + working_days=Decimal("3"), status=AbsenceStatus.APPROVED, + ) + # Emp B (anderes Department): Episode innerhalb des Fensters. + emp_b_absence = Absence( + user_id=emp_b_id, type_id=sick_type_id, + start_date=ref_date - timedelta(days=10), end_date=ref_date - timedelta(days=8), + working_days=Decimal("3"), status=AbsenceStatus.APPROVED, + ) + db_session.add_all([in_window, out_of_window, emp_b_absence]) + await db_session.flush() + + # (a) Fensterrand: Admin sieht firmenweit -> Emp A hat genau 1 Episode + # (nur die am Rand liegende zählt, die davor liegende fällt raus). + admin_ns = SimpleNamespace(role=UserRole.COMPANY_ADMIN, department_id=None) + stats_admin = await absence_service.get_sick_stats( + UUID(company_id) if not isinstance(company_id, UUID) else company_id, + admin_ns, ref_date, db_session, + ) + emp_a_stats = next(s for s in stats_admin if str(s["user_id"]) == emp_a_id) + assert emp_a_stats["episodes"] == 1, ( + f"Off-by-one am Fensterrand: erwartet genau 1 Episode (window_start selbst zählt, " + f"ein Tag davor nicht), got {emp_a_stats['episodes']}" + ) + assert emp_a_stats["total_days"] == pytest.approx(1.0) + # Admin sieht auch Emp B (firmenweit, keine Department-Einschränkung). + assert any(str(s["user_id"]) == emp_b_id for s in stats_admin) + + # (b) MANAGER mit department_id sieht nur sein eigenes Department. + mgr_ns = SimpleNamespace(role=UserRole.MANAGER, department_id=UUID(dep_a_id)) + stats_mgr = await absence_service.get_sick_stats( + UUID(company_id) if not isinstance(company_id, UUID) else company_id, + mgr_ns, ref_date, db_session, + ) + assert any(str(s["user_id"]) == emp_a_id for s in stats_mgr), "Manager muss eigenes Department sehen" + assert not any(str(s["user_id"]) == emp_b_id for s in stats_mgr), ( + "DSGVO-Lücke: MANAGER sieht Bradford-Stats von Mitarbeitern außerhalb des eigenen Departments" + ) + + await db_session.commit() + + +# ── 3. Sondervertretungs-Faktor + Überstunden-Kappung kombiniert ─────────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_special_assignment_factor_combined_with_overtime_cap( + client: AsyncClient, db_session: AsyncSession, +): + """SpecialAssignment mit Faktor 1.5 (mode=fza) UND company.overtime_cap_hours + gleichzeitig: prüft anhand des tatsächlichen Codes, dass die Kappung auf den + FAKTORISIERTEN Wert angewendet wird (hours_by_date wird bereits mit dem Faktor + multipliziert berechnet, siehe report_service.py Zeile ~239, bevor die Kappung + in Zeile ~253 greift). Das ist kein Bug, sondern dokumentiertes Ist-Verhalten: + die Sondervertretungs-Gewichtung fließt VOR die Kappung ein.""" + from app.models.special_assignment import AssignmentMode, SpecialAssignment + from app.models.time_entry import EntryStatus, TimeEntry + from app.services.report_service import _recalculate_overtime_balance + + reg = await client.post("/api/v1/auth/register", json={ + "company_name": "FZA Cap Edge GmbH", + "first_name": "Cap", "last_name": "Admin", + "email": "admin@fzacapedge.de", "password": "Secret123", + }) + assert reg.status_code == 201, reg.text + admin_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"} + + cap_resp = await client.patch("/api/v1/companies/me", json={"overtime_cap_hours": 5}, headers=admin_headers) + assert cap_resp.status_code == 200, cap_resp.text + + emp = await client.post("/api/v1/users/invite", json={ + "first_name": "Cap", "last_name": "Employee", "email": "emp@fzacapedge.de", + "role": "EMPLOYEE", "initial_password": "Secret123", + }, headers=admin_headers) + assert emp.status_code == 201, emp.text + emp_id = emp.json()["id"] + + # Vergangener Werktag (Mo-Fr), weit genug in der Vergangenheit um nicht + # mit anderen Tests zu kollidieren. + workday = date.today() - timedelta(days=90) + while workday.weekday() >= 5: + workday -= timedelta(days=1) + + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + + user = await db_session.get(User, emp_id) + assert user is not None + + # 10h gearbeitet (08:00-18:00, keine Pause) an einem Standard-8h-Tag. + entry = TimeEntry( + user_id=emp_id, date=workday, + start_time=time(8, 0), end_time=time(18, 0), + break_minutes=0, status=EntryStatus.APPROVED, + ) + db_session.add(entry) + + sa = SpecialAssignment( + user_id=emp_id, company_id=UUID(str(user.company_id)), + date_from=workday, date_to=workday, + factor=Decimal("1.5"), mode=AssignmentMode.fza, + label="Nachtschicht-Zuschlag Test", + ) + db_session.add(sa) + await db_session.flush() + + bal = await _recalculate_overtime_balance(user, None, db_session) + + # Roh: 10h - 8h Soll = 2h Überstunden. Faktorisiert: 10h*1.5=15h - 8h = 7h. + # Kappung bei 5h -> falls auf faktorisierten Wert angewendet (Ist-Verhalten + # laut Code) landet total_hours bei genau 5, NICHT bei den rohen 2h. + assert bal.total_hours == Decimal("5"), ( + f"Erwartetes Ist-Verhalten (Kappung greift auf den faktorisierten Wert, " + f"nicht auf den rohen ArbZG-Überstundenwert) nicht bestätigt, got {bal.total_hours}. " + f"Falls dies fachlich falsch ist, ist das ein Bug in report_service._recalculate_overtime_balance " + f"(Kappung sollte ggf. vor Faktor-Anwendung greifen) -- hier nur dokumentiert, nicht gefixt." + ) + + await db_session.commit() + + +# ── 4. Cross-Company-RLS bei Vertretern (list_absences as_substitute) ────────── + +@pytest.mark.asyncio(loop_scope="session") +async def test_list_absences_as_substitute_cross_company_isolation( + client: AsyncClient, db_session: AsyncSession, +): + """Defense-in-Depth auf Service-Ebene (nicht nur Erstell-Validierung): + list_absences(..., as_substitute=True) mit company_id von Firma B darf + KEINE Anträge aus Firma A liefern, selbst wenn (hypothetisch, z.B. durch + einen Bug anderswo) ein User aus Firma A als substitute_id auf einen + Antrag aus Firma A gesetzt wurde und ein gleich benannter/verwechselter + User aus Firma B abgefragt wird. Der Query joint über User.company_id == + company_id -- geprüft wird hier direkt auf Service-Ebene.""" + from app.models.absence import Absence, AbsenceStatus + from app.models.user import UserRole + from app.services.absence_service import absence_service + + # Firma A: Antragsteller + Vertreter, beide in Firma A. + reg_a = await client.post("/api/v1/auth/register", json={ + "company_name": "Substitute Cross A GmbH", + "first_name": "SubA", "last_name": "Admin", + "email": "admin@subcrossa.de", "password": "Secret123", + }) + assert reg_a.status_code == 201, reg_a.text + headers_a = {"Authorization": f"Bearer {reg_a.json()['access_token']}"} + me_a = (await client.get("/api/v1/auth/me", headers=headers_a)).json() + company_a_id = me_a["company_id"] + + sub_a = await client.post("/api/v1/users/invite", json={ + "first_name": "Sub", "last_name": "InA", "email": "sub.a@subcrossa.de", + "role": "EMPLOYEE", "initial_password": "Secret123", + }, headers=headers_a) + assert sub_a.status_code == 201, sub_a.text + sub_a_id = sub_a.json()["id"] + + types_a = await client.get("/api/v1/absence-types/", headers=headers_a) + vac_type_a = next(t for t in types_a.json() if t["name"] == "Urlaub")["id"] + + # Firma B: eigener Vertreter-Kandidat (gleiche Rolle, andere Firma). + reg_b = await client.post("/api/v1/auth/register", json={ + "company_name": "Substitute Cross B GmbH", + "first_name": "SubB", "last_name": "Admin", + "email": "admin@subcrossb.de", "password": "Secret123", + }) + assert reg_b.status_code == 201, reg_b.text + headers_b = {"Authorization": f"Bearer {reg_b.json()['access_token']}"} + me_b = (await client.get("/api/v1/auth/me", headers=headers_b)).json() + company_b_id = me_b["company_id"] + + # Absence in Firma A, mit sub_a (Firma A) als Vertretung -- über Model + # direkt angelegt um Overlap-/Validierungslogik des Create-Endpunkts zu + # umgehen und die Service-Query isoliert zu prüfen. + start = date.today() + timedelta(days=200) + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + absence_a = Absence( + user_id=me_a["id"], type_id=vac_type_a, + start_date=start, end_date=start, + working_days=Decimal("1"), status=AbsenceStatus.PENDING, + substitute_id=sub_a_id, + ) + db_session.add(absence_a) + await db_session.flush() + + # Query 1: sub_a (echter Vertreter, Firma A) mit company_id=Firma A -> sieht den Antrag. + sub_a_user = await db_session.get(User, sub_a_id) + total_a, absences_a = await absence_service.list_absences( + UUID(company_a_id), sub_a_user, db_session, as_substitute=True, + ) + assert total_a == 1 + assert absences_a[0].id == absence_a.id + + # Query 2: Admin von Firma B (current_user.id != sub_a_id, aber selbst wenn + # ein Aufrufer aus Firma B versucht mit fremder company_id_a zu fragen, ist + # der Sicherheits-Layer die current_user.id im WHERE Absence.substitute_id + # == current_user.id -- entscheidend ist aber der company_id-Join). Hier: + # regulärer Aufruf mit Firma B's eigener company_id liefert 0 Treffer, + # obwohl der Antrag aus Firma A existiert. + admin_b_user = await db_session.get(User, me_b["id"]) + # current_user hier künstlich auf sub_a_id "verfälscht" simulieren ist nicht + # möglich ohne DB-User -- stattdessen der eigentliche Defense-in-Depth-Check: + # company_id explizit auf Firma B gesetzt, obwohl ein (hypothetischer) + # Vertreter mit gleicher User-Id in Firma A existieren würde. Da UUIDs global + # eindeutig sind, testen wir die Kernaussage direkt: Query mit company_b_id + # + sub_a_user (der in Wahrheit zu Firma A gehört, aber hier als current_user + # übergeben wird) liefert trotzdem 0 Treffer, weil der Join User.company_id + # == company_id (Firma B) den Antrag aus Firma A aussortiert. + total_cross, absences_cross = await absence_service.list_absences( + UUID(company_b_id), sub_a_user, db_session, as_substitute=True, + ) + assert total_cross == 0, ( + "RLS-Lücke: Vertreter-Query lieferte einen Antrag aus einer anderen Firma zurück " + "(company_id-Join in list_absences greift nicht als Defense-in-Depth)" + ) + assert absences_cross == [] + + await db_session.commit()