- 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
545 lines
19 KiB
Python
545 lines
19 KiB
Python
"""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
|