- 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
488 lines
17 KiB
Python
488 lines
17 KiB
Python
"""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"
|