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:
Claude
2026-02-22 21:23:32 +00:00
parent 1632433b6a
commit b2e04fa07b
9 changed files with 756 additions and 19 deletions
+131 -1
View File
@@ -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"
+17
View File
@@ -157,3 +157,20 @@ class TestRegistrationVersioning:
store.save_registration(fresh_state)
registrations = store.list_registrations()
assert len(registrations) == 1
def test_save_registration_persists_language(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
fresh_state.language = "en"
store.save_registration(fresh_state)
current = store.get_current_registration(fresh_state.parent_email)
assert current is not None
assert current["metadata"]["language"] == "en"
def test_save_registration_defaults_language_to_de(self, store, fresh_state, complete_registration):
fresh_state.registration = complete_registration
fresh_state.completed = True
# language defaults to "de" in ConversationState
store.save_registration(fresh_state)
current = store.get_current_registration(fresh_state.parent_email)
assert current["metadata"]["language"] == "de"