mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
dev gained the delivery/rollback semantics for the dunning and SEPA mails while the mail texts were being moved into templates here. Both services keep dev's flow -- roll back only while nothing has gone out, log the event even when archiving fails afterwards -- and render their content from the template. The welcome mail follows the same rule now, including its archiving-failure path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
9.3 KiB
Python
270 lines
9.3 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
|