- 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
268 lines
9.3 KiB
Python
268 lines
9.3 KiB
Python
"""Tests for AdminNotifier helper methods."""
|
|
|
|
import email
|
|
from email.header import decode_header
|
|
|
|
import pytest
|
|
|
|
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(
|
|
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"],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _format_types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFormatTypes:
|
|
def test_indoor_label(self, notifier):
|
|
assert "Innen" in notifier._format_types(["indoor"]) or "indoor" in notifier._format_types(["indoor"]).lower()
|
|
|
|
def test_outdoor_label(self, notifier):
|
|
assert "Wald" in notifier._format_types(["outdoor"]) or "outdoor" in notifier._format_types(["outdoor"]).lower()
|
|
|
|
def test_both_labels(self, notifier):
|
|
result = notifier._format_types(["indoor", "outdoor"])
|
|
assert len(result) > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _calculate_age
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCalculateAge:
|
|
def test_returns_age_string(self, notifier):
|
|
result = notifier._calculate_age("2022-01-01")
|
|
assert isinstance(result, str)
|
|
assert len(result) > 0
|
|
|
|
def test_invalid_dob_returns_original_string(self, notifier):
|
|
result = notifier._calculate_age("not-a-date")
|
|
assert result == "not-a-date"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _calculate_monthly_fee
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCalculateMonthlyFee:
|
|
def test_indoor_one_day(self, notifier, complete_registration):
|
|
complete_registration.booking = Booking(
|
|
playgroup_types=["indoor"],
|
|
selected_days=[BookingDay(day="monday", type="indoor")],
|
|
)
|
|
fee = notifier._calculate_monthly_fee(complete_registration)
|
|
assert "130" in fee
|
|
|
|
def test_indoor_two_days(self, notifier, complete_registration):
|
|
complete_registration.booking = Booking(
|
|
playgroup_types=["indoor"],
|
|
selected_days=[
|
|
BookingDay(day="monday", type="indoor"),
|
|
BookingDay(day="wednesday", type="indoor"),
|
|
],
|
|
)
|
|
fee = notifier._calculate_monthly_fee(complete_registration)
|
|
assert "260" in fee
|
|
|
|
def test_indoor_three_days(self, notifier, 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 = notifier._calculate_monthly_fee(complete_registration)
|
|
assert "390" in fee
|
|
|
|
def test_outdoor_one_day(self, notifier, complete_registration):
|
|
complete_registration.booking = Booking(
|
|
playgroup_types=["outdoor"],
|
|
selected_days=[BookingDay(day="monday", type="outdoor")],
|
|
)
|
|
fee = notifier._calculate_monthly_fee(complete_registration)
|
|
assert "250" in fee
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _send — SMTP interaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSend:
|
|
def test_send_calls_smtp(self, notifier, mocker):
|
|
# _send uses smtplib.SMTP directly (not as context manager)
|
|
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] # 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"
|