Files
CCMA/tests/test_read_only_store.py
T
Marcel PeterkauandClaude Opus 5 9e9bb7d668 Ask every member to check the data the club stores about them
The club has to keep its member data current, and until now that meant writing
to each member by hand. "Datenüberprüfung anfragen" in the members tab sends one
mail per member, each listing that member's own record: number, name, nickname,
birth date, contact data, address, status, member since, payment frequency, and
the bank details only for members who have any.

Two decisions the record itself forced:

A field with no value is printed as "(nicht hinterlegt)" rather than left out.
The point of the mail is to have gaps filled in, and a missing line is a gap
nobody sees.

The IBAN is masked down to its country code and last four digits. That is enough
to recognise the account, and it keeps a full account number out of a mail the
club sends to dozens of people at once.

Every member is listed as a recipient, with the live memberships that have an
address preselected -- a member who resigned at year's end may still need to
confirm their address, so the board can add them by hand. Members without an
address are skipped and reported instead of failing the run.

Delivery reuses the existing mail machinery: the configured delivery mode, one
SMTP/IMAP connection for the whole run, an archive copy in the member file, a
"data_review_email_sent" event, and the read-only guard before anything is
rendered. A member whose mail fails is reported as a warning and the run
continues -- one bad address must not stop a mailing to the whole club halfway
through. Because the run cannot be taken back, the recipient count is confirmed
once more before it starts.

Subject and text come from a new "Datenüberprüfung" template, editable like the
others, with {{data.sheet}} for the whole record and a {{#data}} block for a
layout of the board's own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:05:38 +02:00

254 lines
9.6 KiB
Python

import os
import stat
from datetime import date
import pytest
from ccma.domain.models import ContributionData
from ccma.services.documents import DocumentService
from ccma.services.housekeeper import Housekeeper
from ccma.services.member_data_mail import generate_data_review_mails
from ccma.services.reminder_mail import generate_and_send_reminder_mail
from ccma.services.sepa_mail import generate_debit_mails
from ccma.services.welcome_mail import generate_and_send_welcome_mail
from ccma.storage.repository import MemberRepository, ReadOnlyStoreError
# Root ignores the permission bits this test relies on, so the read-only mount it
# stands in for cannot be simulated there.
pytestmark = pytest.mark.skipif(
hasattr(os, "geteuid") and os.geteuid() == 0, reason="root bypasses file permissions"
)
def _set_writable(root, writable: bool) -> None:
mode_add = stat.S_IWUSR
for path in sorted(root.rglob("*"), reverse=True) + [root]:
current = stat.S_IMODE(path.stat().st_mode)
path.chmod(current | mode_add if writable else current & ~mode_add)
@pytest.fixture
def read_only_store(tmp_path):
root = tmp_path / "store"
repository = MemberRepository(root)
repository.initialize()
member = repository.create_member(
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
)
# A complete member: without an address the mail services would bail out early
# for that reason, and the write they are actually being tested for is never
# reached.
member.email = "ada@example.org"
repository.save_member(member)
repository.save_contributions(
member.member_id,
ContributionData(
claims=[
{
"claim_id": "claim-1",
"claim_key": "overdue",
"title": "Offene Forderung",
"amount": "100.00",
"due_date": "2026-01-31",
"status": "open",
}
]
),
)
_set_writable(root, False)
yield MemberRepository(root), member
_set_writable(root, True)
def test_a_writable_store_is_not_reported_as_read_only(tmp_path):
repository = MemberRepository(tmp_path / "store")
repository.initialize()
assert repository.read_only is False
def test_store_directory_that_does_not_exist_yet_counts_as_writable(tmp_path):
assert MemberRepository(tmp_path / "not" / "created" / "yet").read_only is False
def test_read_only_store_opens_and_stays_readable(read_only_store):
repository, member = read_only_store
assert repository.read_only is True
repository.initialize()
assert repository.validate() == []
assert [item.member_id for item in repository.list_members()] == [member.member_id]
assert repository.get_member(member.member_id).first_name == "Ada"
assert repository.get_contributions(member.member_id).claims
assert repository.get_events(member.member_id)
assert repository.get_configuration()["organization"]["name"]
def test_every_write_is_refused_with_one_clear_message(read_only_store):
repository, member = read_only_store
stored = repository.get_member(member.member_id)
for action in (
lambda: repository.save_member(stored),
lambda: repository.create_member(first_name="Grace", last_name="Hopper"),
lambda: repository.create_manual_claim(
member.member_id, title="Beitrag", amount="10.00", due_date="2026-09-30"
),
lambda: repository.save_contributions(member.member_id, ContributionData()),
lambda: repository.append_event(
member.member_id, event_type="comment", summary="Test", actor_type="user"
),
lambda: repository.save_organization({"name": "Test", "iban": "", "bic": ""}),
lambda: repository.create_asset(label="Beamer"),
):
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
action()
def test_housekeeper_refuses_to_run_instead_of_failing_on_its_lock(read_only_store):
repository, _member = read_only_store
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
Housekeeper(repository).run(today=date(2026, 2, 10))
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
Housekeeper(repository).delete_task("some-key")
assert not (repository.root / ".housekeeper.lock").exists()
def test_documents_and_mails_refuse_before_touching_the_member_file(read_only_store, tmp_path):
repository, member = read_only_store
service = DocumentService(repository)
template = next(item for item in service.list_templates() if item.name == "Mitglied")
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
service.generate(template, member.member_id, output_name="Test")
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
"reminder-1",
delivery_mode="local",
output_path=tmp_path / "Mahnung.eml",
sender_name="Verwaltung",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
generate_debit_mails(
repository,
[],
collection_date=date(2026, 9, 1),
delivery_mode="local",
output_directory=tmp_path / "sepa",
sender_name="Verwaltung",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
generate_data_review_mails(
repository,
[member.member_id],
delivery_mode="local",
output_directory=tmp_path / "datenpruefung",
sender_name="Verwaltung",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
def test_the_welcome_mail_writes_nothing_at_all_when_refused(read_only_store, tmp_path):
"""The refusal has to come before rendering, sending and archiving -- not out of
the failing write at the end, by which point the mail would already be out."""
repository, member = read_only_store
export_path = tmp_path / "Willkommen.eml"
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=export_path,
sender_name="Verwaltung",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert not export_path.exists()
files = repository.members_root / member.member_id / "files"
assert not (files / "documents" / "Willkommen").exists()
assert all(
event.event_type != "welcome_email_sent"
for event in repository.get_events(member.member_id)
)
def test_uninitialized_read_only_store_reports_why_it_cannot_be_opened(tmp_path):
root = tmp_path / "empty-store"
root.mkdir()
root.chmod(stat.S_IRUSR | stat.S_IXUSR)
try:
with pytest.raises(ReadOnlyStoreError, match="repository.json"):
MemberRepository(root).initialize()
finally:
root.chmod(stat.S_IRWXU)
def test_remounting_writable_is_picked_up_without_a_restart(read_only_store):
repository, _member = read_only_store
assert repository.read_only is True
_set_writable(repository.root, True)
assert repository.read_only is True, "the cached answer must stay put until refreshed"
assert repository.refresh_read_only() is False
repository.append_event(
_member.member_id,
event_type="comment",
summary="Wieder beschreibbar",
actor_type="user",
)
def test_startup_sequence_completes_without_the_housekeeper(read_only_store):
"""Mirrors what the splash screen's worker thread does -- this is the sequence
that used to abort the whole start with a PermissionError."""
repository, member = read_only_store
repository.initialize()
errors = repository.validate()
members = repository.list_members()
findings = [] if repository.read_only else Housekeeper(repository).run()
assert errors == []
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.")