mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
archive_dir.mkdir() and the archive path lookup ran outside the try/except that decides whether to revert mark_reminder_sent (reminder mail) or keep a SEPA batch running for the other debits. A failure there (read-only store, full disk) left a reminder booked as "sent" with no mail ever having gone out, and could still abort an entire SEPA batch for one member's directory problem. Moved that setup inside the same try blocks so it's treated exactly like any other pre-delivery failure: reminder_mail reverts to draft, and sepa_mail records a warning and continues with the remaining debits. Also moved debit_mail_bytes() into the per-debit try in sepa_mail for the same reason. Added a targeted mkdir-failure test for each.
416 lines
14 KiB
Python
416 lines
14 KiB
Python
from contextlib import contextmanager
|
|
from email.parser import BytesParser
|
|
from email.policy import default
|
|
|
|
import pytest
|
|
from test_reminders import _overdue_claim_repository
|
|
|
|
from ccma.services.reminder_mail import generate_and_send_reminder_mail
|
|
from ccma.storage.repository import RepositoryError
|
|
|
|
|
|
def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
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)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
detail="Bitte Mitgliedsnummer angeben.",
|
|
)
|
|
export_path = tmp_path / "Zahlungserinnerung.eml"
|
|
|
|
generated = generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="local",
|
|
output_path=export_path,
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
assert generated.export_path.read_bytes() == generated.archive_path.read_bytes()
|
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
|
assert message["To"] == "reminder@example.org"
|
|
assert "Zahlungserinnerung" in message["Subject"]
|
|
assert "100.00 Euro" in message.get_content()
|
|
assert "DE98670505050038907751" in message.get_content()
|
|
data = repository.get_contributions(member.member_id)
|
|
assert data.reminders[0]["status"] == "sent"
|
|
assert data.reminders[0]["payment_deadline"]
|
|
assert repository.get_events(member.member_id)[-1].event_type == "reminder_email_sent"
|
|
assert generated.archive_path.parent.name == "Mahnungen"
|
|
|
|
|
|
def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Erste Mahnung",
|
|
payment_deadline_days=14,
|
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
|
)
|
|
|
|
generated = generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Mahnung.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
|
assert "Offener Betrag: 105.00 Euro" in message.get_content()
|
|
assert "Mahngebühr: 5.00 Euro" in message.get_content()
|
|
|
|
|
|
def test_reminder_mail_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
|
|
import ccma.services.reminder_mail as reminder_mail_module
|
|
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
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="",
|
|
)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
)
|
|
|
|
sent = []
|
|
fake_smtp_session = contextmanager(lambda settings: iter([object()]))
|
|
monkeypatch.setattr(reminder_mail_module, "smtp_session", fake_smtp_session)
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
|
)
|
|
|
|
generated = generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_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.is_file()
|
|
assert len(sent) == 1
|
|
data = repository.get_contributions(member.member_id)
|
|
assert data.reminders[0]["status"] == "sent"
|
|
|
|
|
|
def test_reminder_mail_send_mode_also_copies_to_sent_folder_when_enabled(tmp_path, monkeypatch):
|
|
import ccma.services.reminder_mail as reminder_mail_module
|
|
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
repository.save_email_settings(
|
|
delivery_mode="send",
|
|
smtp_host="mail.example.org",
|
|
smtp_port=587,
|
|
smtp_security="none",
|
|
smtp_username="",
|
|
smtp_password="",
|
|
imap_host="mail.example.org",
|
|
imap_port=993,
|
|
imap_security="ssl",
|
|
imap_username="",
|
|
imap_password="",
|
|
imap_drafts_folder="",
|
|
imap_sent_enabled=True,
|
|
imap_sent_folder="INBOX.Gesendet",
|
|
)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
|
)
|
|
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "imap_session", contextmanager(lambda settings: iter(["imap-client"]))
|
|
)
|
|
ensured = []
|
|
appended = []
|
|
monkeypatch.setattr(
|
|
reminder_mail_module, "ensure_imap_folder", lambda client, folder: ensured.append((client, folder))
|
|
)
|
|
monkeypatch.setattr(
|
|
reminder_mail_module,
|
|
"append_message",
|
|
lambda client, content, *, folder, flags: appended.append((client, folder, flags)),
|
|
)
|
|
|
|
generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="send",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
assert ensured == [("imap-client", "INBOX.Gesendet")]
|
|
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")]
|
|
|
|
|
|
def test_reminder_mail_send_failure_reverts_sent_status(tmp_path, monkeypatch):
|
|
import ccma.services.reminder_mail as reminder_mail_module
|
|
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
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="",
|
|
)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
|
)
|
|
|
|
@contextmanager
|
|
def _fake_smtp_session(settings):
|
|
yield "smtp-client"
|
|
|
|
monkeypatch.setattr(reminder_mail_module, "smtp_session", _fake_smtp_session)
|
|
|
|
def _failing_send(client, content):
|
|
raise RepositoryError("E-Mail konnte nicht versandt werden: connection reset")
|
|
|
|
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", _failing_send)
|
|
|
|
with pytest.raises(RepositoryError):
|
|
generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="send",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
data = repository.get_contributions(member.member_id)
|
|
stored_reminder = data.reminders[0]
|
|
assert stored_reminder["status"] == "draft"
|
|
assert not stored_reminder.get("fee_item_ids")
|
|
assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent_reverted"
|
|
_data, claim = repository.get_claim(member.member_id, "claim-1")
|
|
assert not any(
|
|
item.get("description") == "Mahngebühr" for item in claim.get("items", [])
|
|
)
|
|
|
|
|
|
def test_reminder_mail_local_write_failure_reverts_sent_status(tmp_path, monkeypatch):
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
)
|
|
|
|
import ccma.services.reminder_mail as reminder_mail_module
|
|
|
|
def _boom(*args, **kwargs):
|
|
raise OSError("disk full")
|
|
|
|
monkeypatch.setattr(reminder_mail_module.os, "replace", _boom)
|
|
|
|
with pytest.raises(OSError):
|
|
generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Zahlungserinnerung.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
data = repository.get_contributions(member.member_id)
|
|
assert data.reminders[0]["status"] == "draft"
|
|
|
|
|
|
def test_reminder_mail_archiving_failure_after_smtp_success_does_not_revert(tmp_path, monkeypatch):
|
|
import ccma.services.reminder_mail as reminder_mail_module
|
|
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
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="",
|
|
)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
|
)
|
|
|
|
@contextmanager
|
|
def _fake_smtp_session(settings):
|
|
yield "smtp-client"
|
|
|
|
monkeypatch.setattr(reminder_mail_module, "smtp_session", _fake_smtp_session)
|
|
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
|
|
|
|
real_replace = reminder_mail_module.os.replace
|
|
|
|
def _flaky_replace(src, dst):
|
|
# Only the final archive move (into the "Mahnungen" folder) should fail --
|
|
# the mail has already been handed to send_via_smtp by that point.
|
|
if str(dst).endswith(".eml") and "Mahnungen" in str(dst):
|
|
raise OSError("disk full")
|
|
return real_replace(src, dst)
|
|
|
|
monkeypatch.setattr(reminder_mail_module.os, "replace", _flaky_replace)
|
|
|
|
with pytest.raises(RepositoryError, match="zugestellt"):
|
|
generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="send",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
data = repository.get_contributions(member.member_id)
|
|
stored_reminder = data.reminders[0]
|
|
assert stored_reminder["status"] == "sent", "mail was already sent -- must not be un-sent"
|
|
assert stored_reminder.get("fee_item_ids")
|
|
events = repository.get_events(member.member_id)
|
|
assert events[-1].event_type == "reminder_email_sent"
|
|
assert events[-1].data["archive_error"]
|
|
assert "document" not in events[-1].references
|
|
|
|
|
|
def test_reminder_mail_archive_dir_creation_failure_reverts_sent_status(tmp_path, monkeypatch):
|
|
from pathlib import Path
|
|
|
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
|
member.email = "reminder@example.org"
|
|
repository.save_member(member)
|
|
reminder = repository.create_reminder_draft(
|
|
member.member_id,
|
|
"claim-1",
|
|
level=1,
|
|
name="Zahlungserinnerung",
|
|
payment_deadline_days=14,
|
|
items=[{"description": "Mahngebühr", "amount": "5.00"}],
|
|
)
|
|
|
|
original_mkdir = Path.mkdir
|
|
|
|
def _flaky_mkdir(self, *args, **kwargs):
|
|
if self.name == "Mahnungen":
|
|
raise OSError("permission denied")
|
|
return original_mkdir(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "mkdir", _flaky_mkdir)
|
|
|
|
with pytest.raises(OSError):
|
|
generate_and_send_reminder_mail(
|
|
repository,
|
|
member.member_id,
|
|
"claim-1",
|
|
reminder["reminder_id"],
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Zahlungserinnerung.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
data = repository.get_contributions(member.member_id)
|
|
stored_reminder = data.reminders[0]
|
|
assert stored_reminder["status"] == "draft"
|
|
assert not stored_reminder.get("fee_item_ids")
|