Files
CCMA/tests/test_welcome_mail.py
T
Marcel PeterkauandClaude Opus 5 c46662561e Reject unbalanced repeat markers in mail templates
validate_mail_template() only looked at placeholder names, and "{{#claims}}" is
not a placeholder -- so an unclosed block or a stray "{{/claims}}" passed the
check and rendered as itself: the member would read the marker in their mail.

Repeat markers are now checked structurally: every one names a block the template
actually has, openers and closers pair up in order, blocks do not nest (the
renderer does not support it either), and the subject takes no markers at all.
Each case explains what is wrong and what is missing.

The same check runs before sending, not just before saving: the templates are
plain files in the store and can be edited outside CCMA, where refusing to send
beats mailing a marker. Saving several edited templates now validates all of
them before writing the first, so a mistake in one no longer leaves the others
half-saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:19:35 +02:00

296 lines
10 KiB
Python

from contextlib import contextmanager
from email.parser import BytesParser
from email.policy import default
import pytest
from ccma.services.welcome_mail import generate_and_send_welcome_mail, open_claims
from ccma.storage.repository import MemberRepository, RepositoryError
def _new_member_repository(tmp_path):
repository = MemberRepository(tmp_path)
repository.initialize()
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE98670505050038907751",
"bic": "MANSDE66XXX",
}
)
repository.save_organization(organization)
member = repository.create_member(
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
)
member.email = "ada@example.org"
member.accepted_at = "2026-08-20"
repository.save_member(member)
repository.create_manual_claim(
member.member_id, title="Aufnahmegebühr", amount="15.00", due_date="2026-09-17"
)
repository.create_manual_claim(
member.member_id,
title="Mitgliedsbeitrag 2. Halbjahr 2026",
amount="60.00",
due_date="2026-09-17",
)
return repository, member
def test_welcome_mail_lists_open_claims_and_bank_details(tmp_path):
repository, member = _new_member_repository(tmp_path)
export_path = tmp_path / "Willkommen.eml"
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=export_path,
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
content = message.get_content()
assert message["To"] == "ada@example.org"
assert "Willkommen" in message["Subject"]
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in content
assert "Gesamtbetrag: 75.00 Euro" in content
assert "DE98670505050038907751" in content
assert content.rstrip().endswith("Der Vorstand")
# No "Mitglied seit" is stored yet -- a member accepted in August starts on 1 September.
assert "Ab dem 01.09.2026 bist du offiziell Mitglied" in content
def test_welcome_mail_is_archived_and_logged(tmp_path):
repository, member = _new_member_repository(tmp_path)
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert generated.archive_path.parent.name == "Willkommen"
assert generated.archive_path.read_bytes() == generated.export_path.read_bytes()
event = repository.get_events(member.member_id)[-1]
assert event.event_type == "welcome_email_sent"
assert event.data["recipient"] == "ada@example.org"
assert len(event.data["claim_ids"]) == 2
def test_only_selected_claims_are_billed(tmp_path):
repository, member = _new_member_repository(tmp_path)
admission = next(
claim for claim in open_claims(repository, member.member_id)
if claim["title"] == "Aufnahmegebühr"
)
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
claim_ids=[admission["claim_id"]],
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
content = BytesParser(policy=default).parsebytes(
generated.export_path.read_bytes()
).get_content()
assert "Aufnahmegebühr" in content
assert "Mitgliedsbeitrag 2. Halbjahr 2026" not in content
assert "Gesamtbetrag: 15.00 Euro" in content
def test_members_with_mandate_are_told_the_money_is_collected(tmp_path):
repository, member = _new_member_repository(tmp_path)
member.account_holder = "Ada Lovelace"
member.iban = "DE98670505050038907751"
member.mandate_reference = "MANDAT-42"
member.mandate_signed_at = "2026-08-20"
member.mandate_active = True
repository.save_member(member)
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
content = BytesParser(policy=default).parsebytes(
generated.export_path.read_bytes()
).get_content()
assert "SEPA-Lastschriftmandat erteilt (Mandatsreferenz MANDAT-42)" in content
assert "Bitte überweise" not in content
def test_edited_template_is_used_for_the_mail(tmp_path):
repository, member = _new_member_repository(tmp_path)
repository.save_mail_template(
"welcome",
subject="Servus {{member.first_name}}",
body="Du schuldest uns {{claims.total}} Euro.\n{{#claims}}* {{claim.title}}{{/claims}}",
)
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
assert message["Subject"] == "Servus Ada"
assert message.get_content().replace("\r\n", "\n").strip() == (
"Du schuldest uns 75.00 Euro.\n* Aufnahmegebühr\n* Mitgliedsbeitrag 2. Halbjahr 2026"
)
def test_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
import ccma.services.welcome_mail as welcome_mail_module
repository, member = _new_member_repository(tmp_path)
repository.save_email_settings(
delivery_mode="send",
smtp_host="smtp.example.org",
smtp_port=587,
smtp_security="starttls",
smtp_username="verwaltung",
smtp_password="secret",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="INBOX.Entwürfe",
)
sent = []
monkeypatch.setattr(
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
)
monkeypatch.setattr(
welcome_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
)
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert generated.export_path is None
assert generated.archive_path.read_bytes() == sent[0]
assert repository.get_events(member.member_id)[-1].data["delivery_mode"] == "send"
def test_member_without_email_is_refused(tmp_path):
repository, member = _new_member_repository(tmp_path)
member.email = ""
repository.save_member(member)
with pytest.raises(RepositoryError, match="E-Mail-Adresse"):
generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
def test_archiving_failure_after_smtp_success_still_logs_the_sent_mail(tmp_path, monkeypatch):
import ccma.services.welcome_mail as welcome_mail_module
repository, member = _new_member_repository(tmp_path)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
monkeypatch.setattr(
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
)
monkeypatch.setattr(welcome_mail_module, "send_via_smtp", lambda client, content: None)
real_replace = welcome_mail_module.os.replace
def _flaky_replace(src, dst):
# Only the final move into the member file fails -- the mail is out by then.
if str(dst).endswith(".eml") and "Willkommen" in str(dst):
raise OSError("disk full")
return real_replace(src, dst)
monkeypatch.setattr(welcome_mail_module.os, "replace", _flaky_replace)
with pytest.raises(RepositoryError, match="zugestellt"):
generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
event = repository.get_events(member.member_id)[-1]
assert event.event_type == "welcome_email_sent"
assert event.data["archive_error"]
assert "document" not in event.references
def test_a_template_broken_outside_ccma_stops_the_send(tmp_path):
repository, member = _new_member_repository(tmp_path)
# The templates are plain files in the store, so they can be edited (and broken)
# with any text editor -- the send has to notice instead of mailing the marker.
(repository.mail_templates_root / "willkommen.txt").write_text(
"Betreff: Willkommen\n\nHallo,\n{{#claims}}\n{{claim.title}}\n",
encoding="utf-8",
)
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert not (tmp_path / "Willkommen.eml").exists()
archive = repository.members_root / member.member_id / "files" / "documents" / "Willkommen"
assert not archive.exists()
assert repository.get_events(member.member_id)[-1].event_type != "welcome_email_sent"