from __future__ import annotations
from datetime import date
from xml.etree import ElementTree
import pytest
import ccma.services.documents as document_module
from ccma.domain.models import ContributionData, Member
from ccma.services.documents import (
DocumentError,
DocumentService,
_replace_xml_placeholders,
_template_values,
)
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError
def test_placeholder_replacement_crosses_formatted_odf_spans() -> None:
source = b"""
Hello {{member.full_name}}!
"""
rendered = _replace_xml_placeholders(source, {"member.full_name": "Ada Lovelace"})
text = "".join(ElementTree.fromstring(rendered).itertext())
assert "Hello Ada Lovelace!" in text
assert "{{" not in text
def test_unknown_placeholder_is_reported() -> None:
source = b"""{{member.unknown}}
"""
with pytest.raises(DocumentError, match="member.unknown"):
_replace_xml_placeholders(source, {})
def test_document_and_claim_creation_time_placeholders() -> None:
member = Member("member-1", "CCMA-1", "Ada", "Lovelace")
claim = {
"claim_id": "claim-1",
"title": "Test",
"amount": "10.00",
"created_at": "2026-06-21T14:35:00+02:00",
}
data = ContributionData(claims=[claim])
values, _repeats = _template_values(member, data=data, claim=claim)
assert values["document.created_date"] == values["document.date"] == values["current_date"]
assert values["document.created_at"] == values["document.datetime"] == values["current_datetime"]
assert values["claim.created_date"] in {"21.06.2026", "2026-06-21"}
assert values["claim.created_at"] == "21.06.2026 14:35"
def test_claim_item_loop_clones_formatted_table_row() -> None:
source = b"""
{{#claim.items}}{{item.description}}
{{item.amount}}{{/claim.items}}
"""
items = [
{"item.description": "Beitrag", "item.amount": "75.00"},
{"item.description": "Gebühr", "item.amount": "5.00"},
]
rendered = _replace_xml_placeholders(source, {}, {"claim.items": items})
root = ElementTree.fromstring(rendered)
rows = [element for element in root.iter() if element.tag.endswith("table-row")]
row_text = ["".join("".join(row.itertext()).split()) for row in rows]
assert row_text == ["Beitrag75.00", "Gebühr5.00"]
assert all(next(iter(row.attrib.values())) == "item-row" for row in rows)
def test_claim_item_loop_removes_row_for_empty_collection() -> None:
source = b"""
{{#claim.items}}{{item.description}}
{{/claim.items}}"""
rendered = _replace_xml_placeholders(source, {}, {"claim.items": []})
assert not any(element.tag.endswith("table-row") for element in ElementTree.fromstring(rendered).iter())
def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
template = repository.root / "templates" / "Mitglied.fodt"
template.write_text("custom", encoding="utf-8")
repository.initialize()
assert template.read_text(encoding="utf-8") == "custom"
assert {path.name for path in (repository.root / "templates").iterdir()} == {
"Forderung mit Positionen.fodt",
"Mahnung.fodt",
"Mitglied.fodt",
}
def test_templates_are_filtered_by_available_context(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
service = DocumentService(repository)
member_templates = {
item.path.name for item in service.compatible_templates(has_claim=False, has_reminder=False)
}
claim_templates = {
item.path.name for item in service.compatible_templates(has_claim=True, has_reminder=False)
}
reminder_templates = {
item.path.name for item in service.compatible_templates(has_claim=True, has_reminder=True)
}
assert member_templates == {"Mitglied.fodt"}
assert claim_templates == {"Forderung mit Positionen.fodt", "Mitglied.fodt"}
assert reminder_templates == {
"Forderung mit Positionen.fodt",
"Mahnung.fodt",
"Mitglied.fodt",
}
def test_generated_reminder_pdf_is_stored_audited_and_linked(tmp_path, monkeypatch) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Ada", last_name="Lovelace")
member.status = "active"
member.accepted_at = "2026-01-01"
member.membership_started_at = "2026-01-01"
repository.save_member(member)
Housekeeper(repository).run(today=date(2026, 1, 1))
claim = repository.get_contributions(member.member_id).claims[0]
claim_id = str(claim["claim_id"])
reminder = repository.create_reminder_draft(
member.member_id,
claim_id,
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
)
def fake_convert(source, output_directory):
destination = output_directory / f"{source.stem}.pdf"
destination.write_bytes(b"%PDF-1.7\ntest")
return destination
monkeypatch.setattr(document_module, "_convert_to_pdf", fake_convert)
service = DocumentService(repository)
template = next(item for item in service.list_templates() if item.path.name == "Mahnung.fodt")
generated = service.generate(
template,
member.member_id,
output_name="Mahnung.pdf",
claim_id=claim_id,
reminder_id=str(reminder["reminder_id"]),
)
assert generated.path.read_bytes().startswith(b"%PDF-")
assert generated.relative_path == "documents/Mahnung.pdf"
stored = repository.get_contributions(member.member_id)
updated = next(item for item in stored.reminders if item["reminder_id"] == reminder["reminder_id"])
assert updated["status"] == "generated"
assert updated["document"]["path"] == generated.relative_path
assert updated["document"]["sha256"] == generated.sha256
assert repository.get_events(member.member_id)[-1].event_type == "reminder_document_generated"
with pytest.raises(RepositoryError, match="innerhalb"):
repository.register_reminder_document(
member.member_id,
claim_id,
str(reminder["reminder_id"]),
relative_path="../outside.pdf",
sha256="invalid",
template="Mahnung.fodt",
)