implement registration-confirmation-email
- Add qrbill and pillow dependencies for Swiss QR-bill PNG generation - Add _STRINGS_DE / _STRINGS_EN bilingual string tables to AdminNotifier - Add _generate_qr_bill_png(): Swiss QR code with cross overlay (PNG bytes) - Add _build_parent_html(): HTML confirmation with inline cid:qrbill image - Add _build_parent_text(): plain-text fallback with IBAN in full - Add notify_parent(): multipart/mixed MIME email to parent on completion - Persist metadata.language in _build_record() in json_store.py - Wire notify_parent() into src/agent/core.py and chat_app.py completion events - Add 9 tests for notify_parent, _generate_qr_bill_png, language fallback - Update tasks.md: tasks 1–5 complete; task 6 (smoke test) remains manual 112 tests passing. https://claude.ai/code/session_01LjjK7RjKVnC8bETtccfgna
This commit is contained in:
+131
-1
@@ -1,11 +1,25 @@
|
||||
"""Tests for AdminNotifier helper methods."""
|
||||
|
||||
import email
|
||||
from email.header import decode_header
|
||||
|
||||
import pytest
|
||||
|
||||
from src.notifications.notifier import AdminNotifier
|
||||
from src.notifications.notifier import AdminNotifier, _STRINGS_DE, _STRINGS_EN
|
||||
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
|
||||
def notifier():
|
||||
return AdminNotifier(
|
||||
@@ -135,3 +149,119 @@ class TestSend:
|
||||
recipients = call_args[0][1] # positional arg: to_addrs
|
||||
assert "a@example.com" in recipients
|
||||
assert "b@example.com" in recipients
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notify_parent — parent confirmation email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
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."""
|
||||
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
|
||||
):
|
||||
"""Unsupported language codes fall back to German."""
|
||||
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_notify_parent_text_body_contains_iban(self, notifier_no_smtp, complete_registration):
|
||||
"""Plain-text body includes the IBAN so payment is possible without the QR image."""
|
||||
text = notifier_no_smtp._build_parent_text(complete_registration, _STRINGS_DE)
|
||||
assert "CH14" in text
|
||||
|
||||
def test_notify_parent_text_body_english_contains_iban(
|
||||
self, notifier_no_smtp, complete_registration
|
||||
):
|
||||
"""English plain-text body also includes the IBAN."""
|
||||
text = notifier_no_smtp._build_parent_text(complete_registration, _STRINGS_EN)
|
||||
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()
|
||||
# PNG files start with the 8-byte signature \x89PNG\r\n\x1a\n
|
||||
assert png[:4] == b"\x89PNG"
|
||||
|
||||
Reference in New Issue
Block a user