Files
Meister-Eder/tests/test_notifier.py
T
Claude 248d4aa5f7 feat(email): set Reply-To headers on confirmation and notification emails
- notify_parent: adds Reply-To set to the first CC (admin) address so
  parent replies to the completion confirmation reach the admin rather
  than re-entering the agent pipeline
- notify_admin / notify_registration_update: Reply-To = parent email
  was already wired through _send(); confirmed with new tests
- Mid-registration send_reply emails carry no Reply-To (verified by test)
- 5 new tests across TestNotifyParentReplyTo and TestNotifyAdminReplyTo;
  1 new test in TestSendReplyNoAdminReplyTo (174 passing, 0 failing)

https://claude.ai/code/session_014NynTjALA5TeP85mbr5ZA8
2026-02-27 17:29:38 +00:00

488 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for AdminNotifier and notification helper functions."""
import email
import json
from email.header import decode_header
import pytest
from src.notifications.notifier import AdminNotifier
from src.notifications.context import (
calculate_age,
calculate_monthly_fee,
format_types,
build_parent_context,
)
from src.notifications.i18n import get_strings, clear_cache
from src.notifications.renderer import render_template
from src.models.registration import RegistrationData, Booking, BookingDay
def _decoded_subject(msg_str: str) -> str:
"""Parse a raw MIME message string and return the decoded Subject header."""
msg = email.message_from_string(msg_str)
raw_subject = msg.get("Subject", "")
parts = decode_header(raw_subject)
return "".join(
chunk.decode(enc or "utf-8") if isinstance(chunk, bytes) else chunk
for chunk, enc in parts
)
@pytest.fixture(autouse=True)
def reset_translation_cache():
"""Clear the in-memory translation cache before every test."""
clear_cache()
yield
clear_cache()
@pytest.fixture
def notifier():
return AdminNotifier(
smtp_host="smtp.example.com",
smtp_port=587,
username="agent@example.com",
password="secret",
use_tls=True,
from_email="agent@example.com",
indoor_email="andrea@example.com",
outdoor_email="barbara@example.com",
cc_emails=["markus@example.com"],
)
@pytest.fixture
def notifier_no_smtp():
"""Notifier in dev mode (no SMTP host)."""
return AdminNotifier(
smtp_host="",
smtp_port=587,
username="",
password="",
from_email="agent@example.com",
)
# ---------------------------------------------------------------------------
# format_types
# ---------------------------------------------------------------------------
class TestFormatTypes:
def test_indoor_label(self):
result = format_types(["indoor"])
assert "Innen" in result or "indoor" in result.lower()
def test_outdoor_label(self):
result = format_types(["outdoor"])
assert "Wald" in result or "outdoor" in result.lower()
def test_both_labels(self):
result = format_types(["indoor", "outdoor"])
assert len(result) > 0
# ---------------------------------------------------------------------------
# calculate_age
# ---------------------------------------------------------------------------
class TestCalculateAge:
def test_returns_age_string(self):
result = calculate_age("2022-01-01")
assert isinstance(result, str)
assert len(result) > 0
def test_invalid_dob_returns_original_string(self):
result = calculate_age("not-a-date")
assert result == "not-a-date"
# ---------------------------------------------------------------------------
# calculate_monthly_fee
# ---------------------------------------------------------------------------
class TestCalculateMonthlyFee:
def test_indoor_one_day(self, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[BookingDay(day="monday", type="indoor")],
)
fee = calculate_monthly_fee(complete_registration)
assert "130" in fee
def test_indoor_two_days(self, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[
BookingDay(day="monday", type="indoor"),
BookingDay(day="wednesday", type="indoor"),
],
)
fee = calculate_monthly_fee(complete_registration)
assert "260" in fee
def test_indoor_three_days(self, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[
BookingDay(day="monday", type="indoor"),
BookingDay(day="wednesday", type="indoor"),
BookingDay(day="thursday", type="indoor"),
],
)
fee = calculate_monthly_fee(complete_registration)
assert "390" in fee
def test_outdoor_one_day(self, complete_registration):
complete_registration.booking = Booking(
playgroup_types=["outdoor"],
selected_days=[BookingDay(day="monday", type="outdoor")],
)
fee = calculate_monthly_fee(complete_registration)
assert "250" in fee
# ---------------------------------------------------------------------------
# _send — SMTP interaction
# ---------------------------------------------------------------------------
class TestSend:
def test_send_calls_smtp(self, notifier, mocker):
mock_smtp_cls = mocker.patch("smtplib.SMTP")
mock_server = mock_smtp_cls.return_value
notifier._send(
to=["admin@example.com"],
cc=["cc@example.com"],
subject="Test",
body="Hello",
)
mock_server.sendmail.assert_called_once()
def test_send_includes_all_recipients(self, notifier, mocker):
mock_smtp_cls = mocker.patch("smtplib.SMTP")
mock_server = mock_smtp_cls.return_value
notifier._send(
to=["a@example.com"],
cc=["b@example.com"],
subject="Test",
body="Hello",
)
call_args = mock_server.sendmail.call_args
recipients = call_args[0][1]
assert "a@example.com" in recipients
assert "b@example.com" in recipients
# ---------------------------------------------------------------------------
# get_strings — i18n / LLM translation
# ---------------------------------------------------------------------------
class TestGetStrings:
def test_german_loads_from_yaml_without_llm(self, mocker):
"""German must never trigger an LLM call."""
mock_litellm = mocker.patch("litellm.completion")
strings = get_strings("de", "some-model")
mock_litellm.assert_not_called()
assert strings["subject"] == "Anmeldebestätigung Spielgruppe Pumuckl"
def test_other_language_calls_llm(self, mocker):
"""Non-German languages should call litellm.completion."""
german = get_strings("de", "some-model")
translated = {**german, "subject": "Registration Confirmation Spielgruppe Pumuckl"}
mock_litellm = mocker.patch("litellm.completion")
mock_litellm.return_value.choices[0].message.content = json.dumps(translated)
result = get_strings("en", "some-model")
mock_litellm.assert_called_once()
assert result["subject"] == "Registration Confirmation Spielgruppe Pumuckl"
def test_result_is_cached(self, mocker):
"""The LLM is only called once per language per process lifetime."""
german = get_strings("de", "some-model")
mock_litellm = mocker.patch("litellm.completion")
mock_litellm.return_value.choices[0].message.content = json.dumps(german)
get_strings("fr", "some-model")
get_strings("fr", "some-model")
assert mock_litellm.call_count == 1
def test_llm_failure_falls_back_to_german(self, mocker):
"""If the LLM raises, the German strings are returned silently."""
mocker.patch("litellm.completion", side_effect=RuntimeError("network error"))
result = get_strings("it", "some-model")
assert result["subject"] == "Anmeldebestätigung Spielgruppe Pumuckl"
def test_passthrough_keys_not_altered(self, mocker):
"""reg_fee_amount and deposit_amount must survive translation unchanged."""
german = get_strings("de", "some-model")
# Return translation that omits passthrough keys (as the LLM would)
without_passthrough = {k: v for k, v in german.items()
if k not in {"reg_fee_amount", "deposit_amount"}}
mocker.patch("litellm.completion").return_value.choices[0].message.content = (
json.dumps(without_passthrough)
)
result = get_strings("en", "some-model")
assert result["reg_fee_amount"] == "CHF 80.00"
assert result["deposit_amount"] == "CHF 50.00"
# ---------------------------------------------------------------------------
# notify_parent — parent confirmation email
# ---------------------------------------------------------------------------
class TestNotifyParent:
def test_notify_parent_calls_send(self, notifier, complete_registration, mocker):
"""notify_parent dispatches an email to the parent address."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
mock_server = mock_smtp_cls.return_value
notifier.notify_parent(complete_registration, language="de")
mock_server.sendmail.assert_called_once()
call_args = mock_server.sendmail.call_args
recipients = call_args[0][1]
assert "anna.muster@example.com" in recipients
def test_notify_parent_german_subject(self, notifier, complete_registration, mocker):
"""German language produces a German subject line without any LLM call."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_parent(complete_registration, language="de")
assert "Anmeldebestätigung" in _decoded_subject(captured["msg"])
def test_notify_parent_english_subject(self, notifier, complete_registration, mocker):
"""English language produces an English subject line via LLM translation."""
german = get_strings("de", "some-model")
english = {**german, "subject": "Registration Confirmation Spielgruppe Pumuckl"}
mocker.patch("litellm.completion").return_value.choices[0].message.content = (
json.dumps(english)
)
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_parent(complete_registration, language="en")
assert "Registration Confirmation" in _decoded_subject(captured["msg"])
def test_notify_parent_unknown_language_falls_back_to_de(
self, notifier, complete_registration, mocker
):
"""When the LLM call fails, the email is sent in German."""
mocker.patch("litellm.completion", side_effect=RuntimeError("timeout"))
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_parent(complete_registration, language="fr")
assert "Anmeldebestätigung" in _decoded_subject(captured["msg"])
def test_notify_parent_no_smtp_skips_send(
self, notifier_no_smtp, complete_registration, mocker
):
"""When SMTP host is empty, no sendmail call is made."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
notifier_no_smtp.notify_parent(complete_registration, language="de")
mock_smtp_cls.assert_not_called()
def test_text_body_contains_iban(self, complete_registration):
"""Rendered plain-text body includes the IBAN regardless of language."""
strings = get_strings("de", "some-model")
ctx = build_parent_context(complete_registration, strings, has_qr=False)
text = render_template("parent_confirmation.txt.j2", ctx)
assert "CH14" in text
# ---------------------------------------------------------------------------
# _generate_qr_bill_png
# ---------------------------------------------------------------------------
class TestGenerateQrBillPng:
def test_returns_nonempty_bytes(self, notifier):
"""_generate_qr_bill_png returns a non-empty bytes object (PNG)."""
png = notifier._generate_qr_bill_png()
assert isinstance(png, bytes)
assert len(png) > 0
def test_returns_png_signature(self, notifier):
"""Output starts with the PNG magic bytes."""
png = notifier._generate_qr_bill_png()
assert png[:4] == b"\x89PNG"
# ---------------------------------------------------------------------------
# Reply-To header — confirmation email to parent (task 1.3)
# ---------------------------------------------------------------------------
class TestNotifyParentReplyTo:
def test_confirmation_email_has_reply_to_admin(
self, notifier, complete_registration, mocker
):
"""Confirmation email sets Reply-To to the first CC (admin) address."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_parent(complete_registration, language="de")
parsed = email.message_from_string(captured["msg"])
assert parsed.get("Reply-To") == "markus@example.com"
def test_confirmation_email_no_reply_to_when_no_cc(
self, complete_registration, mocker
):
"""When no CC emails are configured, no Reply-To header is set."""
notifier_no_cc = AdminNotifier(
smtp_host="smtp.example.com",
smtp_port=587,
username="agent@example.com",
password="secret",
from_email="agent@example.com",
cc_emails=[],
)
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier_no_cc.notify_parent(complete_registration, language="de")
parsed = email.message_from_string(captured["msg"])
assert parsed.get("Reply-To") is None
# ---------------------------------------------------------------------------
# Reply-To header — admin notification emails (tasks 2.3, 2.4, 2.5)
# ---------------------------------------------------------------------------
class TestNotifyAdminReplyTo:
def _capture_msg(self, mocker):
"""Return a side-effect function and a dict that captures the raw MIME string."""
captured = {}
mock_smtp_cls = mocker.patch("smtplib.SMTP")
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
return captured
def test_indoor_notification_reply_to_is_parent_email(
self, notifier, complete_registration, mocker
):
"""Indoor-only notification sets Reply-To to the parent's email."""
from src.models.registration import Booking, BookingDay
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[BookingDay(day="monday", type="indoor")],
)
captured = self._capture_msg(mocker)
notifier.notify_admin(
complete_registration,
registration_id="reg-001",
version=1,
conversation_id="conv-001",
channel="email",
)
parsed = email.message_from_string(captured["msg"])
assert parsed.get("Reply-To") == "anna.muster@example.com"
def test_outdoor_notification_reply_to_is_parent_email(
self, notifier, complete_registration, mocker
):
"""Outdoor-only notification sets Reply-To to the parent's email."""
from src.models.registration import Booking, BookingDay
complete_registration.booking = Booking(
playgroup_types=["outdoor"],
selected_days=[BookingDay(day="monday", type="outdoor")],
)
captured = self._capture_msg(mocker)
notifier.notify_admin(
complete_registration,
registration_id="reg-002",
version=1,
conversation_id="conv-002",
channel="email",
)
parsed = email.message_from_string(captured["msg"])
assert parsed.get("Reply-To") == "anna.muster@example.com"
def test_both_types_notification_reply_to_is_parent_email(
self, notifier, complete_registration, mocker
):
"""Both-types notification sets Reply-To to the parent's email."""
from src.models.registration import Booking, BookingDay
complete_registration.booking = Booking(
playgroup_types=["indoor", "outdoor"],
selected_days=[
BookingDay(day="monday", type="indoor"),
BookingDay(day="monday", type="outdoor"),
],
)
captured = self._capture_msg(mocker)
notifier.notify_admin(
complete_registration,
registration_id="reg-003",
version=1,
conversation_id="conv-003",
channel="email",
)
parsed = email.message_from_string(captured["msg"])
assert parsed.get("Reply-To") == "anna.muster@example.com"