feat: agent-11 PR3 – Scheduler + Erinnerungs-Mails + Notification-Prefs
Geplante Erinnerungen (Feature-Parität mit Urlaubsverwaltung): - APScheduler (AsyncIOScheduler) in der FastAPI-Lifespan; tägliche Jobs ab settings.reminder_hour. Redis-Tageslock gegen Doppelversand bei mehreren Prozessen; jeder Job mit eigener Session + RLS-Bypass. - Drei Jobs (auch einzeln aufrufbar): offene Anträge an Genehmiger, Resturlaub-Verfall-Vorwarnung an Mitarbeiter, fehlende AU an HR. - Pro-User notification_prefs (JSONB, opt-out); GET/PATCH /users/me/notification-prefs + ProfilePage-UI; Vertreter-Mail respektiert die Prefs. - Manueller Trigger POST /companies/me/run-reminders (Admin) – gleiche Logik, firmen-scoped (testbar ohne Warten). - Bugfix: GET-/PATCH-Urlaubskonto (update_balance) nutzte nicht existente Felder (base_days/carried_over_days/ip_address) → korrigiert auf entitled_days/carried_over/ip + company_id; available_days ergänzt. Migration 0037 (users.notification_prefs). 188/188 Tests grün. Deployed 137 + 164. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""Tests für agent-11 PR3: geplante Erinnerungen + Notification-Prefs."""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from datetime import date, timedelta
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def rem_company(client: AsyncClient):
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"company_name": "Reminder AG", "first_name": "Rita", "last_name": "Admin",
|
||||
"email": "admin@reminderag.de", "password": "Secret123",
|
||||
})
|
||||
assert resp.status_code == 201, resp.text
|
||||
admin_h = {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
inv = await client.post("/api/v1/users/invite", json={
|
||||
"first_name": "Emil", "last_name": "Employee", "email": "emil@reminderag.de",
|
||||
"role": "EMPLOYEE", "initial_password": "Secret123",
|
||||
}, headers=admin_h)
|
||||
assert inv.status_code == 201, inv.text
|
||||
login = await client.post("/api/v1/auth/login", json={
|
||||
"email": "emil@reminderag.de", "password": "Secret123",
|
||||
})
|
||||
emp_h = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
return {"admin": admin_h, "emp": emp_h, "emp_id": inv.json()["id"]}
|
||||
|
||||
|
||||
async def _vacation_type(client, headers):
|
||||
types = (await client.get("/api/v1/absence-types/", headers=headers)).json()
|
||||
return next(t for t in types if t["name"] == "Urlaub")["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_notification_prefs_list(client: AsyncClient, rem_company):
|
||||
r = await client.get("/api/v1/users/me/notification-prefs", headers=rem_company["admin"])
|
||||
assert r.status_code == 200, r.text
|
||||
keys = {p["key"] for p in r.json()}
|
||||
assert "pending_approvals" in keys and "carryover_expiry" in keys
|
||||
assert all(p["enabled"] for p in r.json()) # Default: alle an
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_run_reminders_pending_approvals(client: AsyncClient, rem_company):
|
||||
vt = await _vacation_type(client, rem_company["emp"])
|
||||
start = date.today() + timedelta(days=(7 - date.today().weekday()) + 70)
|
||||
await client.post("/api/v1/absences/", json={
|
||||
"type_id": vt, "start_date": str(start), "end_date": str(start + timedelta(days=2)),
|
||||
}, headers=rem_company["emp"])
|
||||
|
||||
run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"])
|
||||
assert run.status_code == 200, run.text
|
||||
assert run.json()["sent"]["pending_approvals"] >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_run_reminders_certificate_overdue(client: AsyncClient, rem_company):
|
||||
# Krankmeldung in der Vergangenheit → AU längst fällig, nicht eingegangen
|
||||
past = date.today() - timedelta(days=10)
|
||||
sick = await client.post("/api/v1/absences/quick-sick", json={
|
||||
"start_date": str(past), "end_date": str(past),
|
||||
}, headers=rem_company["emp"])
|
||||
assert sick.status_code == 201, sick.text
|
||||
|
||||
run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"])
|
||||
assert run.json()["sent"]["certificate_overdue"] >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_notification_prefs_disable_suppresses_mail(client: AsyncClient, rem_company):
|
||||
# pending_approvals für den Admin abschalten → keine Digest-Mail mehr
|
||||
patch = await client.patch("/api/v1/users/me/notification-prefs",
|
||||
json={"prefs": {"pending_approvals": False}}, headers=rem_company["admin"])
|
||||
assert patch.status_code == 200
|
||||
assert any(p["key"] == "pending_approvals" and p["enabled"] is False for p in patch.json())
|
||||
|
||||
run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"])
|
||||
assert run.json()["sent"]["pending_approvals"] == 0
|
||||
|
||||
# wieder aktivieren (sauberer Zustand)
|
||||
await client.patch("/api/v1/users/me/notification-prefs",
|
||||
json={"prefs": {"pending_approvals": True}}, headers=rem_company["admin"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_run_reminders_carryover_expiry(client: AsyncClient, rem_company):
|
||||
# Verfall auf ~7 Tage in der Zukunft konfigurieren
|
||||
target = date.today() + timedelta(days=7)
|
||||
await client.patch("/api/v1/companies/me", json={
|
||||
"settings": {"carryover_expires_month": target.month, "carryover_expires_day": target.day},
|
||||
}, headers=rem_company["admin"])
|
||||
# Resturlaub fürs aktuelle Jahr beim Mitarbeiter setzen
|
||||
yr = date.today().year
|
||||
await client.patch(f"/api/v1/absences/balance/{rem_company['emp_id']}?year={yr}",
|
||||
json={"carried_over": 5}, headers=rem_company["admin"])
|
||||
|
||||
run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"])
|
||||
assert run.json()["sent"]["carryover_expiry"] >= 1
|
||||
Reference in New Issue
Block a user