feat(notifications): täglicher Lauf über SMTP und Apprise

- Kanäle SMTP (HTML-Mail mit Logos als CID-Anhang) und Apprise, beide
  blockierenden Bibliotheken laufen in einem Thread
- Vier Anlässe: Fälligkeiten im Vorlauf, Kündigungsfristen in drei Stufen,
  überschrittene Budgets je Monat, Vertragsverlängerungen im Folgemonat
- APScheduler im Anwendungsprozess, täglich 07:00 Europe/Berlin, räumt zugleich
  abgelaufene Sitzungen auf
- Duplikatsschutz über den Zieltag statt den Versandtag; fehlgeschlagener
  Versand wird beim nächsten Lauf erneut versucht
- Endpunkte für Regeln, Protokoll, Testversand und sofortigen Lauf
- Einstellungsseite mit Einrichtungsstand, Regelpflege und Protokoll
- 25 neue Backend-Tests (260 gesamt), 9 neue Frontend-Tests (74 gesamt)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 17:04:30 +02:00
co-authored by Claude Opus 5
parent 0adf154049
commit 54c59c9f71
17 changed files with 2500 additions and 1 deletions
+544
View File
@@ -0,0 +1,544 @@
"""Tests der Benachrichtigungen: Ereignisse, Duplikatsschutz und Versand."""
import smtplib
from datetime import date, timedelta
from email.message import EmailMessage
from typing import ClassVar
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models import NotificationLog, NotificationRule
from app.models.enums import NotificationChannel, NotificationStatus, NotificationType
from app.services import notifications as service
from app.services.channels import AppriseChannel, ChannelError, SmtpChannel
def text_of(nachricht: EmailMessage) -> str:
"""Der reine Textteil einer mehrteiligen Mail."""
teil = nachricht.get_body(preferencelist=("plain",))
assert teil is not None, "Die Mail hat keinen Textteil."
return teil.get_content()
# --- Doppel für die Kanäle ------------------------------------------------------
class FakeSmtp:
"""Ersetzt `smtplib.SMTP` und merkt sich die versendeten Nachrichten."""
versendet: ClassVar[list[EmailMessage]] = []
fehler: ClassVar[Exception | None] = None
def __init__(self, host: str, port: int, timeout: int = 0) -> None:
self.host = host
self.port = port
def __enter__(self) -> "FakeSmtp":
return self
def __exit__(self, *_args: object) -> None:
return None
def starttls(self) -> None:
return None
def login(self, user: str, password: str) -> None:
return None
def send_message(self, nachricht: EmailMessage) -> None:
if FakeSmtp.fehler is not None:
raise FakeSmtp.fehler
FakeSmtp.versendet.append(nachricht)
class FakeApprise:
"""Ersetzt `apprise.Apprise`."""
versendet: ClassVar[list[tuple[str, str]]] = []
erfolg: ClassVar[bool] = True
def __init__(self) -> None:
self.ziele: list[str] = []
def add(self, ziel: str) -> bool:
self.ziele.append(ziel)
return True
def __len__(self) -> int:
return len(self.ziele)
def notify(self, title: str, body: str) -> bool:
if FakeApprise.erfolg:
FakeApprise.versendet.append((title, body))
return FakeApprise.erfolg
@pytest.fixture
def smtp(monkeypatch: pytest.MonkeyPatch) -> type[FakeSmtp]:
"""Richtet SMTP ein und fängt den Versand ab."""
FakeSmtp.versendet = []
FakeSmtp.fehler = None
monkeypatch.setattr(smtplib, "SMTP", FakeSmtp)
monkeypatch.setattr(settings, "smtp_host", "mail.example.org")
monkeypatch.setattr(settings, "smtp_from", "moneyfy@example.org")
monkeypatch.setattr(settings, "smtp_use_tls", False)
monkeypatch.setattr(settings, "smtp_use_ssl", False)
return FakeSmtp
@pytest.fixture
def apprise(monkeypatch: pytest.MonkeyPatch) -> type[FakeApprise]:
"""Richtet Apprise ein und fängt den Versand ab."""
import apprise as apprise_modul
FakeApprise.versendet = []
FakeApprise.erfolg = True
monkeypatch.setattr(apprise_modul, "Apprise", FakeApprise)
monkeypatch.setattr(settings, "apprise_urls", "ntfy://example.org/moneyfy")
return FakeApprise
async def regel(
session: AsyncSession,
typ: NotificationType,
*,
kanal: NotificationChannel = NotificationChannel.SMTP,
lead_days: int = 7,
target: str | None = None,
) -> NotificationRule:
eintrag = NotificationRule(
type=typ, channel=kanal, lead_days=lead_days, target=target, is_active=True
)
session.add(eintrag)
await session.flush()
return eintrag
async def abo(client: AsyncClient, seeded: dict, **overrides) -> dict:
payload = {
"kind": "expense",
"title": "Netflix",
"category_id": seeded["streaming"],
"account_id": seeded["account_id"],
"amount": "13.99",
"rrule": "FREQ=MONTHLY;BYMONTHDAY=15",
"dtstart": "2026-01-15",
"business_day_shift": "none",
}
payload.update(overrides)
antwort = await client.post("/api/recurrences", json=payload)
assert antwort.status_code == 201, antwort.text
return antwort.json()
# --- Kanäle ---------------------------------------------------------------------
async def test_smtp_baut_eine_mail_mit_text_und_html(smtp: type[FakeSmtp]) -> None:
await SmtpChannel().send(service.test_notification(), "jemand@example.org")
assert len(smtp.versendet) == 1
nachricht = smtp.versendet[0]
assert nachricht["To"] == "jemand@example.org"
assert nachricht["From"] == "moneyfy@example.org"
assert nachricht["Subject"] == "moneyfy: Testnachricht"
# Text und HTML liegen als Alternativen nebeneinander.
typen = {teil.get_content_type() for teil in nachricht.walk()}
assert "text/plain" in typen
assert "text/html" in typen
async def test_smtp_ohne_konfiguration_meldet_das(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "smtp_host", None)
assert SmtpChannel().is_configured() is False
with pytest.raises(ChannelError, match="nicht konfiguriert"):
await SmtpChannel().send(service.test_notification())
async def test_smtp_reicht_serverfehler_als_channelerror_durch(
smtp: type[FakeSmtp],
) -> None:
smtp.fehler = smtplib.SMTPRecipientsRefused({})
with pytest.raises(ChannelError, match="Mailversand fehlgeschlagen"):
await SmtpChannel().send(service.test_notification(), "jemand@example.org")
async def test_apprise_versendet_titel_und_text(apprise: type[FakeApprise]) -> None:
await AppriseChannel().send(service.test_notification())
assert len(apprise.versendet) == 1
titel, text = apprise.versendet[0]
assert titel == "moneyfy: Testnachricht"
assert "Testnachricht von moneyfy" in text
async def test_apprise_ohne_ziel_meldet_das(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "apprise_urls", None)
assert AppriseChannel().is_configured() is False
with pytest.raises(ChannelError, match="nicht konfiguriert"):
await AppriseChannel().send(service.test_notification())
async def test_apprise_meldet_fehlgeschlagene_zustellung(
apprise: type[FakeApprise],
) -> None:
apprise.erfolg = False
with pytest.raises(ChannelError, match="kein Ziel"):
await AppriseChannel().send(service.test_notification())
# --- Testversand ----------------------------------------------------------------
async def test_testversand_ueber_beide_kanaele(
auth_client: AsyncClient, smtp: type[FakeSmtp], apprise: type[FakeApprise]
) -> None:
"""Akzeptanzkriterium: der Testversand erreicht beide Kanäle."""
antwort = await auth_client.post("/api/notifications/test", json={})
assert antwort.status_code == 200
body = antwort.json()
assert body["any_sent"] is True
nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["results"]}
assert nach_kanal["smtp"] == {
"channel": "smtp",
"configured": True,
"sent": True,
"error": None,
}
assert nach_kanal["apprise"]["sent"] is True
assert len(smtp.versendet) == 1
assert len(apprise.versendet) == 1
async def test_testversand_meldet_nicht_eingerichtete_kanaele(
auth_client: AsyncClient, smtp: type[FakeSmtp], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "apprise_urls", None)
body = (await auth_client.post("/api/notifications/test", json={})).json()
nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["results"]}
assert nach_kanal["smtp"]["sent"] is True
assert nach_kanal["apprise"]["configured"] is False
assert nach_kanal["apprise"]["sent"] is False
# Ein fehlender Kanal ist kein Fehler.
assert body["any_sent"] is True
async def test_einrichtungsstand_wird_gemeldet(
auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "smtp_host", None)
monkeypatch.setattr(settings, "apprise_urls", None)
body = (await auth_client.get("/api/notifications/settings")).json()
assert body["run_at"] == "07:00"
assert body["timezone"] == "Europe/Berlin"
nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["channels"]}
assert nach_kanal["smtp"]["configured"] is False
assert "SMTP_HOST" in nach_kanal["smtp"]["detail"]
# --- Duplikatsschutz ------------------------------------------------------------
async def test_zweiter_lauf_am_selben_tag_sendet_nicht_erneut(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
"""Akzeptanzkriterium: kein Doppelversand beim zweiten Lauf."""
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
erster = await service.run_all(session, date(2026, 3, 10))
zweiter = await service.run_all(session, date(2026, 3, 10))
assert erster.sent == 1
assert zweiter.sent == 0
assert zweiter.skipped == 1
assert len(smtp.versendet) == 1
async def test_auch_ein_spaeterer_lauf_im_vorlauf_sendet_nicht_erneut(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
"""Der Schlüssel ist der Zieltag, nicht der Versandtag."""
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
await service.run_all(session, date(2026, 3, 10))
await service.run_all(session, date(2026, 3, 11))
await service.run_all(session, date(2026, 3, 12))
assert len(smtp.versendet) == 1
async def test_naechster_monat_wird_wieder_gemeldet(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
await service.run_all(session, date(2026, 3, 10))
await service.run_all(session, date(2026, 4, 10))
assert len(smtp.versendet) == 2
async def test_protokoll_haelt_den_zieltag_fest(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
await service.run_all(session, date(2026, 3, 10))
eintraege = (await session.execute(select(NotificationLog))).scalars().all()
assert len(eintraege) == 1
assert eintraege[0].status is NotificationStatus.SENT
assert eintraege[0].dedupe_day == date(2026, 3, 15)
assert eintraege[0].ref_type == "occurrence"
async def test_fehlgeschlagener_versand_wird_erneut_versucht(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
"""Ein Fehlschlag darf nicht dazu führen, dass die Meldung verloren geht."""
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
smtp.fehler = smtplib.SMTPServerDisconnected("Server weg")
erster = await service.run_all(session, date(2026, 3, 10))
assert erster.sent == 0
eintrag = (await session.execute(select(NotificationLog))).scalar_one()
assert eintrag.status is NotificationStatus.FAILED
assert eintrag.error
smtp.fehler = None
zweiter = await service.run_all(session, date(2026, 3, 11))
assert zweiter.sent == 1
assert len(smtp.versendet) == 1
# Der vorhandene Eintrag wird aktualisiert, kein zweiter angelegt.
eintraege = (await session.execute(select(NotificationLog))).scalars().all()
assert len(eintraege) == 1
assert eintraege[0].status is NotificationStatus.SENT
assert eintraege[0].error is None
# --- Ereignisarten --------------------------------------------------------------
async def test_faelligkeiten_ausserhalb_des_vorlaufs_bleiben_still(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=3)
ergebnis = await service.run_all(session, date(2026, 3, 1))
assert ergebnis.sent == 0
assert smtp.versendet == []
async def test_bestaetigte_faelligkeit_wird_nicht_gemeldet(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
posten = await abo(
auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15"
)
await auth_client.post(
"/api/occurrences/confirm",
json={"recurrence_id": posten["id"], "occurrence_date": "2026-03-15"},
)
await regel(session, NotificationType.DUE_SOON, lead_days=7)
ergebnis = await service.run_all(session, date(2026, 3, 10))
assert ergebnis.sent == 0
async def test_kuendigungsfrist_meldet_sich_in_drei_stufen(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
"""30, 14 und 7 Tage vor dem Termin je einmal."""
await abo(
auth_client,
seeded,
title="Handyvertrag",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
contract_start="2026-01-01",
contract_min_term_months=24,
contract_notice_period_days=90,
)
await regel(session, NotificationType.NOTICE_DEADLINE, lead_days=30)
# Laufzeitende 31.12.2027, Frist also am 02.10.2027.
frist = date(2027, 10, 2)
for versatz in (30, 14, 7):
await service.run_all(session, frist - timedelta(days=versatz))
# Ein weiterer Lauf innerhalb derselben Stufe meldet nichts Neues.
await service.run_all(session, frist - timedelta(days=6))
assert len(smtp.versendet) == 3
assert all("Handyvertrag" in text_of(nachricht) for nachricht in smtp.versendet)
async def test_gekuendigter_vertrag_meldet_keine_frist(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
posten = await abo(
auth_client,
seeded,
title="Handyvertrag",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
contract_start="2026-01-01",
contract_min_term_months=24,
contract_notice_period_days=90,
)
await auth_client.post(f"/api/recurrences/{posten['id']}/cancel")
await regel(session, NotificationType.NOTICE_DEADLINE)
ergebnis = await service.run_all(session, date(2027, 9, 15))
assert ergebnis.sent == 0
async def test_ueberschrittenes_budget_meldet_sich_einmal_im_monat(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await auth_client.post(
"/api/budgets",
json={
"category_id": seeded["lebensmittel"],
"period_month": "2026-03-01",
"limit_amount": "100.00",
},
)
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Großeinkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "150.00",
"booking_date": "2026-03-05",
},
)
await regel(session, NotificationType.BUDGET_EXCEEDED)
erster = await service.run_all(session, date(2026, 3, 10))
zweiter = await service.run_all(session, date(2026, 3, 20))
assert erster.sent == 1
assert zweiter.sent == 0
assert "Lebensmittel" in text_of(smtp.versendet[0])
async def test_vertragsverlaengerung_im_kommenden_monat(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(
auth_client,
seeded,
title="Zeitschrift",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
contract_start="2026-01-01",
contract_min_term_months=12,
contract_auto_renew_months=12,
)
await regel(session, NotificationType.CONTRACT_RENEWAL)
# Verlängerung am 01.01.2027; ein Lauf im Dezember trifft das Fenster.
ergebnis = await service.run_all(session, date(2026, 12, 5))
assert ergebnis.sent == 1
assert "Zeitschrift" in text_of(smtp.versendet[0])
async def test_abgeschaltete_regel_laeuft_nicht(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
eintrag = await regel(session, NotificationType.DUE_SOON, lead_days=7)
eintrag.is_active = False
await session.flush()
ergebnis = await service.run_all(session, date(2026, 3, 10))
assert ergebnis.sent == 0
assert smtp.versendet == []
async def test_abgeschaltete_benachrichtigungen(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch, smtp: type[FakeSmtp]
) -> None:
monkeypatch.setattr(settings, "notifications_enabled", False)
ergebnis = await service.run_all(session, date(2026, 3, 10))
assert ergebnis.checked == 0
assert smtp.versendet == []
# --- Regeln über die API --------------------------------------------------------
async def test_regeln_pflegen(auth_client: AsyncClient) -> None:
angelegt = await auth_client.post(
"/api/notifications/rules",
json={"type": "due_soon", "channel": "apprise", "lead_days": 5},
)
assert angelegt.status_code == 201
eintrag = angelegt.json()
assert eintrag["lead_days"] == 5
geaendert = await auth_client.patch(
f"/api/notifications/rules/{eintrag['id']}", json={"lead_days": 10, "is_active": False}
)
assert geaendert.json()["lead_days"] == 10
assert geaendert.json()["is_active"] is False
liste = (await auth_client.get("/api/notifications/rules")).json()
assert any(regel["id"] == eintrag["id"] for regel in liste)
assert (
await auth_client.delete(f"/api/notifications/rules/{eintrag['id']}")
).status_code == 200
async def test_lauf_ueber_die_api(
auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp]
) -> None:
await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15")
await regel(session, NotificationType.DUE_SOON, lead_days=7)
antwort = await auth_client.post("/api/notifications/run", params={"as_of": "2026-03-10"})
assert antwort.status_code == 200
assert antwort.json()["sent"] == 1
protokoll = (await auth_client.get("/api/notifications/log")).json()
assert len(protokoll) == 1
assert protokoll[0]["status"] == "sent"
async def test_benachrichtigungen_verlangen_anmeldung(client: AsyncClient) -> None:
assert (await client.get("/api/notifications/rules")).status_code == 401
assert (await client.post("/api/notifications/test", json={})).status_code == 401