"""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