mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Merge branch 'dev' into feature/read-only-store
dev gained the editable mail templates, which live in the store like everything else -- so they follow the same read-only rules: the options dialog skips saving them, save_mail_template() reports the store instead of a raw PermissionError, and a store that cannot keep its own copy simply renders from the shipped default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -129,6 +129,7 @@ def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path
|
||||
"Forderung mit Positionen.fodt",
|
||||
"Mahnung.fodt",
|
||||
"Mitglied.fodt",
|
||||
"mail",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import pytest
|
||||
|
||||
from ccma.domain.mail_templates import (
|
||||
MAIL_TEMPLATES,
|
||||
MailTemplate,
|
||||
MailTemplateBlock,
|
||||
MailTemplateError,
|
||||
MailTemplateSpec,
|
||||
default_mail_template,
|
||||
parse_mail_template,
|
||||
placeholder_entries,
|
||||
render_mail_template,
|
||||
serialize_mail_template,
|
||||
validate_block_markers,
|
||||
validate_mail_template,
|
||||
)
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
def test_subject_header_round_trips_through_file_format() -> None:
|
||||
text = serialize_mail_template("Hallo {{member.first_name}}", "Zeile eins\nZeile zwei")
|
||||
|
||||
template = parse_mail_template(text)
|
||||
|
||||
assert text.startswith("Betreff: Hallo {{member.first_name}}\n\n")
|
||||
assert template == MailTemplate("Hallo {{member.first_name}}", "Zeile eins\nZeile zwei")
|
||||
|
||||
|
||||
def test_shipped_defaults_only_use_declared_placeholders() -> None:
|
||||
for spec in MAIL_TEMPLATES:
|
||||
template = default_mail_template(spec.key)
|
||||
validate_mail_template(spec.key, template.subject, template.body)
|
||||
|
||||
|
||||
def test_rendering_fills_values_and_expands_repeat_blocks() -> None:
|
||||
template = MailTemplate(
|
||||
"Beitrag für {{member.first_name}}",
|
||||
"Hallo {{member.first_name}},\n"
|
||||
"{{#claims}}- {{claim.title}}: {{claim.amount}}{{/claims}}\n"
|
||||
"Summe: {{claims.total}}",
|
||||
)
|
||||
|
||||
rendered = render_mail_template(
|
||||
template,
|
||||
{"member.first_name": "Ada", "claims.total": "75.00"},
|
||||
{
|
||||
"claims": [
|
||||
{"claim.title": "Aufnahmegebühr", "claim.amount": "15.00"},
|
||||
{"claim.title": "Mitgliedsbeitrag", "claim.amount": "60.00"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert rendered.subject == "Beitrag für Ada"
|
||||
assert rendered.body == (
|
||||
"Hallo Ada,\n- Aufnahmegebühr: 15.00\n- Mitgliedsbeitrag: 60.00\nSumme: 75.00"
|
||||
)
|
||||
|
||||
|
||||
def test_line_holding_only_an_empty_placeholder_is_dropped() -> None:
|
||||
template = MailTemplate("Betreff", "Erste Zeile\n{{reminder.hint}}\nLetzte Zeile")
|
||||
|
||||
assert render_mail_template(template, {"reminder.hint": ""}).body == "Erste Zeile\nLetzte Zeile"
|
||||
assert (
|
||||
render_mail_template(template, {"reminder.hint": "Hinweis: X"}).body
|
||||
== "Erste Zeile\nHinweis: X\nLetzte Zeile"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_placeholder_is_rejected_while_editing() -> None:
|
||||
with pytest.raises(MailTemplateError, match="member.favourite_colour"):
|
||||
validate_mail_template("welcome", "Betreff", "Hallo {{member.favourite_colour}}")
|
||||
|
||||
|
||||
def test_store_copies_are_seeded_editable_and_resettable(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
path = repository.mail_templates_root / "willkommen.txt"
|
||||
assert path.is_file()
|
||||
|
||||
repository.save_mail_template("welcome", subject="Moin {{member.first_name}}", body="Kurz und knapp.")
|
||||
assert repository.get_mail_template("welcome") == MailTemplate(
|
||||
"Moin {{member.first_name}}", "Kurz und knapp."
|
||||
)
|
||||
assert "Moin {{member.first_name}}" in path.read_text(encoding="utf-8")
|
||||
|
||||
repository.reset_mail_template("welcome")
|
||||
assert repository.get_mail_template("welcome") == default_mail_template("welcome")
|
||||
|
||||
|
||||
def test_missing_template_file_falls_back_to_shipped_default(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
(repository.mail_templates_root / "mahnung.txt").unlink()
|
||||
|
||||
template = repository.get_mail_template("reminder")
|
||||
|
||||
assert template == default_mail_template("reminder")
|
||||
assert (repository.mail_templates_root / "mahnung.txt").is_file()
|
||||
|
||||
|
||||
def test_saving_a_broken_template_is_refused(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
with pytest.raises(RepositoryError, match="Platzhalter"):
|
||||
repository.save_mail_template("reminder", subject="Mahnung", body="Hallo {{member.nonsense}}")
|
||||
with pytest.raises(RepositoryError, match="Betreff"):
|
||||
repository.save_mail_template("reminder", subject=" ", body="Hallo")
|
||||
|
||||
|
||||
def test_block_rows_insert_a_working_block_not_its_description() -> None:
|
||||
entries = placeholder_entries("welcome")
|
||||
block = next(entry for entry in entries if entry.label.startswith("{{#claims}}"))
|
||||
|
||||
# The label may abbreviate the block, the inserted snippet may not.
|
||||
assert block.label == "{{#claims}} … {{/claims}}"
|
||||
assert block.snippet.startswith("{{#claims}}\n")
|
||||
assert block.snippet.rstrip().endswith("{{/claims}}")
|
||||
assert "{{claim.description}}" in block.snippet
|
||||
assert "…" not in block.snippet
|
||||
assert all("…" not in entry.snippet for entry in entries)
|
||||
|
||||
|
||||
def test_inserted_block_validates_and_renders_one_line_per_claim() -> None:
|
||||
block = next(
|
||||
entry for entry in placeholder_entries("welcome") if entry.label.startswith("{{#claims}}")
|
||||
)
|
||||
body = f"Hallo {{{{member.first_name}}}},\n\n{block.snippet}\nSumme: {{{{claims.total}}}}"
|
||||
|
||||
validate_mail_template("welcome", "Betreff", body)
|
||||
rendered = render_mail_template(
|
||||
MailTemplate("Betreff", body),
|
||||
{"member.first_name": "Ada", "claims.total": "75.00"},
|
||||
{
|
||||
"claims": [
|
||||
{
|
||||
"claim.description": "Aufnahmegebühr",
|
||||
"claim.due_date": "17.09.2026",
|
||||
"claim.balance": "15.00",
|
||||
},
|
||||
{
|
||||
"claim.description": "Mitgliedsbeitrag 2. Halbjahr 2026",
|
||||
"claim.due_date": "17.09.2026",
|
||||
"claim.balance": "60.00",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in rendered.body
|
||||
assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in rendered.body
|
||||
assert "…" not in rendered.body
|
||||
|
||||
|
||||
def test_every_block_snippet_is_valid_in_its_own_template() -> None:
|
||||
for spec in MAIL_TEMPLATES:
|
||||
for block in spec.blocks:
|
||||
validate_mail_template(spec.key, "Betreff", block.snippet)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "expected"),
|
||||
[
|
||||
("Hallo\n{{#claims}}\n{{claim.title}}", "wird nicht geschlossen"),
|
||||
("Hallo\n{{/claims}}\nGruß", "ohne zugehöriges"),
|
||||
("{{#claims}}\n{{claim.title}}\n{{/claims}}\n{{/claims}}", "ohne zugehöriges"),
|
||||
("{{#claims}}\n{{#claims}}\nx\n{{/claims}}\n{{/claims}}", "verschachtelt"),
|
||||
("{{#members}}\nx\n{{/members}}", "Unbekannter Wiederholungsblock"),
|
||||
("{{ # claims }}\nx", "wird nicht geschlossen"),
|
||||
],
|
||||
)
|
||||
def test_unbalanced_repeat_markers_are_rejected(body, expected) -> None:
|
||||
with pytest.raises(MailTemplateError, match=expected):
|
||||
validate_mail_template("welcome", "Betreff", body)
|
||||
|
||||
|
||||
def test_repeat_markers_in_the_subject_are_rejected() -> None:
|
||||
with pytest.raises(MailTemplateError, match="Betreff"):
|
||||
validate_mail_template("welcome", "Beitrag {{#claims}}", "Hallo")
|
||||
|
||||
|
||||
def test_a_template_without_blocks_rejects_every_marker() -> None:
|
||||
with pytest.raises(MailTemplateError, match="Verfügbar: keine"):
|
||||
validate_mail_template("sepa", "Betreff", "{{#claims}}\nx\n{{/claims}}")
|
||||
|
||||
|
||||
def test_a_closing_marker_must_match_the_open_one(monkeypatch) -> None:
|
||||
two_blocks = MailTemplateSpec(
|
||||
key="two-blocks",
|
||||
label="Test",
|
||||
filename="test.txt",
|
||||
description="",
|
||||
placeholders=(),
|
||||
blocks=(
|
||||
MailTemplateBlock("first", "", (("item.a", ""),), "{{item.a}}"),
|
||||
MailTemplateBlock("second", "", (("item.b", ""),), "{{item.b}}"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("ccma.domain.mail_templates.MAIL_TEMPLATES", (two_blocks,))
|
||||
|
||||
with pytest.raises(MailTemplateError, match="Erwartet wird \\{\\{/first\\}\\}"):
|
||||
validate_block_markers("two-blocks", "Betreff", "{{#first}}\nx\n{{/second}}")
|
||||
|
||||
|
||||
def test_a_broken_template_is_not_stored(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
path = repository.mail_templates_root / "willkommen.txt"
|
||||
before = path.read_text(encoding="utf-8")
|
||||
|
||||
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
||||
repository.save_mail_template(
|
||||
"welcome", subject="Willkommen", body="Hallo\n{{#claims}}\n{{claim.title}}"
|
||||
)
|
||||
|
||||
assert path.read_text(encoding="utf-8") == before
|
||||
@@ -0,0 +1,177 @@
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
|
||||
import pytest
|
||||
|
||||
tk = pytest.importorskip("tkinter")
|
||||
|
||||
from ccma.config import AppConfig # noqa: E402
|
||||
from ccma.domain.mail_templates import placeholder_entries # noqa: E402
|
||||
from ccma.services.welcome_mail import generate_and_send_welcome_mail # noqa: E402
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError # noqa: E402
|
||||
|
||||
|
||||
# One root for the whole session: the icon library binds its images to the first
|
||||
# Tk instance, so tearing a root down between tests would invalidate them.
|
||||
@pytest.fixture(scope="session")
|
||||
def tk_root():
|
||||
from ccma.ui.theme import load_theme
|
||||
|
||||
try:
|
||||
root = tk.Tk()
|
||||
except tk.TclError as exc: # headless CI has no display to build widgets on
|
||||
pytest.skip(f"kein Display verfügbar: {exc}")
|
||||
root.withdraw()
|
||||
load_theme(root, "dark")
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(tmp_path):
|
||||
repository = MemberRepository(tmp_path / "store")
|
||||
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)
|
||||
return repository
|
||||
|
||||
|
||||
def _open_options(tk_root, repository):
|
||||
from ccma.ui.options_dialog import OptionsDialog
|
||||
|
||||
dialog = OptionsDialog(tk_root, AppConfig(store_path=str(repository.root)), repository)
|
||||
tk_root.update()
|
||||
return dialog
|
||||
|
||||
|
||||
def _block_row(dialog):
|
||||
for row in dialog.mail_template_placeholders.get_children():
|
||||
label = str(dialog.mail_template_placeholders.item(row, "values")[0])
|
||||
if label.startswith("{{#claims}}"):
|
||||
return row
|
||||
raise AssertionError("Der Wiederholungsblock fehlt in der Platzhalterliste.")
|
||||
|
||||
|
||||
def test_double_click_on_a_block_is_wired_up(tk_root, repository):
|
||||
dialog = _open_options(tk_root, repository)
|
||||
try:
|
||||
assert dialog.mail_template_placeholders.bind("<Double-1>")
|
||||
assert dialog.mail_template_snippets[_block_row(dialog)] == next(
|
||||
entry.snippet
|
||||
for entry in placeholder_entries("welcome")
|
||||
if entry.label.startswith("{{#claims}}")
|
||||
)
|
||||
finally:
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
|
||||
|
||||
def test_inserting_a_block_puts_editable_template_code_into_the_body(tk_root, repository):
|
||||
dialog = _open_options(tk_root, repository)
|
||||
try:
|
||||
dialog.mail_template_body.delete("1.0", "end")
|
||||
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:")
|
||||
dialog.mail_template_placeholders.selection_set(_block_row(dialog))
|
||||
dialog._insert_mail_placeholder()
|
||||
tk_root.update()
|
||||
body = dialog.mail_template_body.get("1.0", "end-1c")
|
||||
finally:
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
|
||||
assert "…" not in body
|
||||
assert "{{#claims}}" in body and "{{/claims}}" in body
|
||||
assert "{{claim.description}}" in body
|
||||
# The block was appended behind existing text and still starts on its own line.
|
||||
assert body.splitlines()[0] == "Offen sind aktuell:"
|
||||
assert body.splitlines()[1] == "{{#claims}}"
|
||||
|
||||
|
||||
def test_edited_template_survives_saving_and_produces_real_lines_in_the_mail(
|
||||
tk_root, repository, tmp_path
|
||||
):
|
||||
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"
|
||||
)
|
||||
|
||||
dialog = _open_options(tk_root, repository)
|
||||
try:
|
||||
dialog.mail_template_subject_var.set("Willkommen, {{member.first_name}}")
|
||||
dialog.mail_template_body.delete("1.0", "end")
|
||||
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:\n")
|
||||
dialog.mail_template_placeholders.selection_set(_block_row(dialog))
|
||||
dialog._insert_mail_placeholder()
|
||||
tk_root.update()
|
||||
dialog._save_mail_templates()
|
||||
finally:
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
|
||||
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 "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
|
||||
assert "…" not in content
|
||||
|
||||
|
||||
def test_the_dialog_refuses_to_save_an_unbalanced_block(tk_root, repository):
|
||||
stored = repository.mail_templates_root / "willkommen.txt"
|
||||
before = stored.read_text(encoding="utf-8")
|
||||
|
||||
dialog = _open_options(tk_root, repository)
|
||||
try:
|
||||
dialog.mail_template_body.delete("1.0", "end")
|
||||
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:\n{{#claims}}\n{{claim.title}}")
|
||||
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
||||
dialog._save_mail_templates()
|
||||
finally:
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
|
||||
assert stored.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_a_broken_template_does_not_half_save_the_others(tk_root, repository):
|
||||
reminder_file = repository.mail_templates_root / "mahnung.txt"
|
||||
before = reminder_file.read_text(encoding="utf-8")
|
||||
|
||||
dialog = _open_options(tk_root, repository)
|
||||
try:
|
||||
# Edit a valid template first, then break the second one.
|
||||
dialog.mail_template_var.set("Mahnung")
|
||||
dialog._select_mail_template()
|
||||
dialog.mail_template_body.insert("end", "\nP.S. Bitte Mitgliedsnummer angeben.")
|
||||
dialog.mail_template_var.set("Willkommen & Erstrechnung")
|
||||
dialog._select_mail_template()
|
||||
dialog.mail_template_body.insert("end", "\n{{#claims}}\n{{claim.title}}")
|
||||
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
||||
dialog._save_mail_templates()
|
||||
finally:
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
|
||||
assert reminder_file.read_text(encoding="utf-8") == before
|
||||
@@ -169,3 +169,20 @@ def test_startup_sequence_completes_without_the_housekeeper(read_only_store):
|
||||
assert [item.member_id for item in members] == [member.member_id]
|
||||
assert findings == []
|
||||
assert repository.member_count() == 1
|
||||
|
||||
|
||||
def test_mail_templates_fall_back_to_the_shipped_defaults(read_only_store):
|
||||
"""A store that predates mail templates has no templates/mail/ directory, and a
|
||||
read-only one cannot get it -- the shipped default has to carry the session."""
|
||||
from ccma.domain.mail_templates import default_mail_template
|
||||
|
||||
repository, _member = read_only_store
|
||||
_set_writable(repository.root, True)
|
||||
for path in repository.mail_templates_root.glob("*.txt"):
|
||||
path.unlink()
|
||||
_set_writable(repository.root, False)
|
||||
|
||||
assert repository.get_mail_template("welcome") == default_mail_template("welcome")
|
||||
assert not list(repository.mail_templates_root.glob("*.txt")), "nothing was written"
|
||||
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||
repository.save_mail_template("welcome", subject="Moin", body="Kurz.")
|
||||
|
||||
+3
-3
@@ -178,11 +178,11 @@ def test_debit_mail_is_thunderbird_draft(tmp_path):
|
||||
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
|
||||
|
||||
content = debit_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
repository,
|
||||
member=member,
|
||||
debit=debit,
|
||||
collection_date=date(2026, 8, 3),
|
||||
creditor_id="DE98ZZZ09999999999",
|
||||
organization={"name": "C3MA", "creditor_id": "DE98ZZZ09999999999"},
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user