Files
CCMA/tests/test_mail_templates.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

218 lines
8.1 KiB
Python

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