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:
@@ -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
|
||||
Reference in New Issue
Block a user