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["current_date"] assert values["document.created_at"] == values["current_datetime"] assert "document.date" not in values assert "document.datetime" not in values 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", )