Files
CCMA/tests/test_welcome_mail.py
T
Marcel PeterkauandClaude Opus 5 aa0c16347b Send new members a welcome mail that doubles as their first invoice
Accepting a member left the board writing the "welcome, you are a member from
the 1st, and this is what it costs" mail by hand. The member file now offers it
directly: it greets the member, names the membership number and the start of the
membership, and lists every claim that is still open -- admission fee and first
contribution -- with a payment hint that adapts to whether a direct debit
mandate is active.

"Mitglied seit" rejects future dates, so a member accepted this month cannot
carry their actual start date yet; {{membership.start_date}} therefore falls
back to the 1st of the month following the acceptance decision. Individual
claims can be deselected before sending, the finished mail can be previewed,
and the result is archived in the member file like every other generated mail.

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

222 lines
7.6 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",
)