Files
CCMA/tests/test_rules.py
T
Marcel PeterkauandClaude Sonnet 5 ee99577b20 Run the housekeeper for a single member, optionally retroactively
Add a "Hausmeister für dieses Mitglied" button to the member view. It
runs every rule scoped to just that member -- e.g. after editing a
contribution override or payment frequency, to recompute their claims on
demand instead of waiting for or triggering a full run over every
member. A checkbox lets the board force retroactive claim creation for
just that one run, without changing the application-wide default
setting.

Housekeeper.run() gained member_id and retroactive_claims parameters.
Scoping to one member skips the asset checks and leaves every other
member's tasks completely untouched, since their rules never ran this
pass and so fall outside the resolved-scope set that
_resolve_stale_tasks uses to decide what to clear.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 00:59:01 +02:00

448 lines
18 KiB
Python

import json
import shutil
from datetime import date
from decimal import Decimal
import pytest
import ccma.services.housekeeper as housekeeper_module
from ccma.rules.loader import RuleLoadError
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError
def test_housekeeper_optionally_waits_between_members(tmp_path, monkeypatch) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.create_member(first_name="First", last_name="Member")
repository.create_member(first_name="Second", last_name="Member")
delays: list[float] = []
monkeypatch.setattr(housekeeper_module.time, "sleep", delays.append)
Housekeeper(repository).run(today=date(2026, 6, 21), member_delay=0.25)
assert delays == [0.25]
def test_store_rule_overrides_builtin_rule_with_same_filename(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Override", last_name="Test")
(repository.root / "rules" / "birthdate_check.py").write_text(
"""
from ccma.rules.api import RuleAction
RULE_ID = "birthdate-check"
def evaluate(context):
return [RuleAction(
key=f"birthdate-check:{context.member.member_id}:override",
action="task",
member_id=context.member.member_id,
payload={
"code": "override_active",
"severity": "info",
"title": "Store-Override aktiv",
"detail": "Die eingebaute Regel wurde ersetzt.",
},
)]
""".strip(),
encoding="utf-8",
)
findings = Housekeeper(repository).run(today=date(2026, 6, 21))
state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
assert not (repository.root / "hausmeister.json").exists()
assert not (repository.root / ".hausmeister.lock").exists()
assert any(finding.code == "override_active" for finding in findings)
rule = next(item for item in state["rules"] if item["filename"] == "birthdate_check.py")
assert rule["source"] == "store-override"
assert rule["script_hash"].startswith("sha256:")
assert member.member_id in {item["member_id"] for item in state["items"]}
def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Contribution", last_name="Test", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2026-04-15"
member.membership_started_at = "2026-04-15"
member.payment_frequency = "semiannual"
repository.save_member(member)
housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 4, 15))
first_claims = repository.get_contributions(member.member_id).claims
housekeeper.run(today=date(2026, 4, 15))
second_claims = repository.get_contributions(member.member_id).claims
state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
assert {claim["claim_key"] for claim in first_claims} == {
"admission-fee",
"membership-fee:2026:first-half",
"membership-fee:2026:second-half",
}
assert len(second_claims) == len(first_claims) == 3
amounts = {claim["claim_key"]: claim["amount"] for claim in first_claims}
assert amounts["membership-fee:2026:first-half"] == "37.50"
assert amounts["membership-fee:2026:second-half"] == "75.00"
descriptions = {
claim["claim_key"]: claim["items"][0]["description"] for claim in first_claims
}
assert descriptions["membership-fee:2026:first-half"].endswith("(3 Monate)")
assert descriptions["membership-fee:2026:second-half"] == "Mitgliedsbeitrag 2. Halbjahr 2026"
assert state["run_counter"] == 2
assert state["last_completed_run"] == "2026-04-15:000002"
@pytest.mark.parametrize(
("frequency", "expected_suffixes", "expected_amount"),
[
("monthly", [f"month-{month:02d}" for month in range(1, 13)], "12.50"),
("quarterly", [f"quarter-{quarter}" for quarter in range(1, 5)], "37.50"),
("semiannual", ["first-half", "second-half"], "75.00"),
("annual", ["annual"], "150.00"),
],
)
def test_housekeeper_creates_claims_for_each_payment_frequency(
tmp_path, frequency, expected_suffixes, expected_amount
) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(
first_name=frequency,
last_name="Frequency",
birth_date="1990-01-01",
)
member.status = "active"
member.accepted_at = "2026-01-01"
member.membership_started_at = "2026-01-01"
member.payment_frequency = frequency
repository.save_member(member)
Housekeeper(repository).run(today=date(2026, 12, 31))
membership_claims = [
claim
for claim in repository.get_contributions(member.member_id).claims
if claim.get("type") == "membership_fee"
]
assert [claim["claim_key"].rsplit(":", 1)[-1] for claim in membership_claims] == expected_suffixes
assert {claim["amount"] for claim in membership_claims} == {expected_amount}
assert sum(Decimal(claim["amount"]) for claim in membership_claims) == Decimal("150.00")
def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Retro", last_name="Claims", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2024-04-15"
member.membership_started_at = "2024-04-15"
member.payment_frequency = "annual"
repository.save_member(member)
settings = housekeeper_module.HousekeeperSettings.from_values(
birthday_days_before=0,
birthday_days_after=0,
anniversary_days_before=0,
anniversary_days_after=0,
anniversary_intervals="1Y",
retroactive_claims=True,
)
Housekeeper(repository, settings).run(today=date(2026, 6, 21))
claims = repository.get_contributions(member.member_id).claims
claims_by_key = {claim["claim_key"]: claim for claim in claims}
assert set(claims_by_key) == {
"admission-fee",
"membership-fee:2024:annual",
"membership-fee:2025:annual",
"membership-fee:2026:annual",
}
assert claims_by_key["membership-fee:2024:annual"]["amount"] == "112.50"
assert claims_by_key["membership-fee:2024:annual"]["items"][0]["description"].endswith(
"(9 Monate)"
)
def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Legacy", last_name="Rates", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2021-04-15"
member.membership_started_at = "2021-04-15"
member.payment_frequency = "annual"
repository.save_member(member)
settings = housekeeper_module.HousekeeperSettings.from_values(
birthday_days_before=0,
birthday_days_after=0,
anniversary_days_before=0,
anniversary_days_after=0,
anniversary_intervals="1Y",
retroactive_claims=True,
)
Housekeeper(repository, settings).run(today=date(2022, 6, 21))
claims = repository.get_contributions(member.member_id).claims
claims_by_key = {claim["claim_key"]: claim for claim in claims}
assert claims_by_key["membership-fee:2021:annual"]["amount"] == "90.00"
assert claims_by_key["membership-fee:2022:annual"]["amount"] == "150.00"
def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Current", last_name="Only", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2024-04-15"
member.membership_started_at = "2024-04-15"
member.payment_frequency = "annual"
repository.save_member(member)
Housekeeper(repository).run(today=date(2026, 6, 21))
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
assert claim_keys == {
"admission-fee",
"membership-fee:2026:annual",
}
def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Former", last_name="Member", birth_date="1990-01-01")
member.status = "resigned_end_of_year"
member.accepted_at = "2024-01-01"
member.membership_started_at = "2024-01-01"
member.membership_ended_at = "2026-12-31"
member.payment_frequency = "annual"
repository.save_member(member)
Housekeeper(repository).run(today=date(2026, 6, 21))
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
assert "membership-fee:2026:annual" in claim_keys
assert "membership-fee:2027:annual" not in claim_keys
def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Missing", last_name="Birthday")
housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 6, 21))
member.birth_date = "1990-01-01"
repository.save_member(member)
housekeeper.run(today=date(2026, 6, 21))
state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
task = next(item for item in state["items"] if item["key"].endswith(":missing:birth_date"))
assert task["status"] == "resolved"
assert task["first_seen_run"] == "2026-06-21:000001"
assert task["resolved_run"] == "2026-06-21:000002"
def test_failed_run_does_not_advance_persisted_run_id(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.create_member(first_name="Failed", last_name="Rule")
housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 6, 21))
state_before = (repository.root / "housekeeper.json").read_bytes()
(repository.root / "rules" / "broken.py").write_text("this is not python !!!", encoding="utf-8")
with pytest.raises(RuleLoadError):
housekeeper.run(today=date(2026, 6, 21))
assert (repository.root / "housekeeper.json").read_bytes() == state_before
assert not (repository.root / ".housekeeper.lock").exists()
def test_broken_contributions_file_creates_task_without_overwriting_file(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Broken", last_name="Contributions", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2026-01-01"
member.membership_started_at = "2026-01-01"
repository.save_member(member)
path = repository.members_root / member.member_id / "contributions.json"
path.write_bytes(b"")
findings = Housekeeper(repository).run(today=date(2026, 6, 21))
assert path.read_bytes() == b""
assert [finding.code for finding in findings] == ["invalid_member_record"]
state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
task = next(item for item in state["items"] if item["code"] == "invalid_member_record")
assert task["status"] == "open"
def test_preflight_skips_all_rules_for_broken_member_file(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Broken", last_name="Member")
member_path = repository.members_root / member.member_id / "member.json"
contributions_path = repository.members_root / member.member_id / "contributions.json"
contributions_before = contributions_path.read_bytes()
member_path.write_text("{", encoding="utf-8")
findings = Housekeeper(repository).run(today=date(2026, 6, 21))
state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
assert [finding.code for finding in findings] == ["invalid_member_record"]
assert len(state["items"]) == 1
assert contributions_path.read_bytes() == contributions_before
def test_preflight_blocks_rules_for_broken_event_log(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Broken", last_name="Events", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2026-01-01"
member.membership_started_at = "2026-01-01"
repository.save_member(member)
events_path = repository.members_root / member.member_id / "events.jsonl"
with events_path.open("a", encoding="utf-8") as handle:
handle.write("not-json\n")
findings = Housekeeper(repository).run(today=date(2026, 6, 21))
assert [finding.code for finding in findings] == ["invalid_member_record"]
assert repository.get_contributions(member.member_id).claims == []
def test_preflight_task_resolves_after_record_is_repaired(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Repair", last_name="Record", birth_date="1990-01-01")
contributions_path = repository.members_root / member.member_id / "contributions.json"
original = contributions_path.read_bytes()
contributions_path.write_bytes(b"")
housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 6, 21))
contributions_path.write_bytes(original)
housekeeper.run(today=date(2026, 6, 21))
state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
task = next(item for item in state["items"] if item["code"] == "invalid_member_record")
assert task["status"] == "resolved"
assert task["resolved_run"] == "2026-06-21:000002"
def test_housekeeper_task_can_be_deleted_and_returns_on_next_run(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.create_member(first_name="Manual", last_name="Delete")
housekeeper = Housekeeper(repository)
findings = housekeeper.run(today=date(2026, 6, 21))
finding = next(item for item in findings if item.code == "missing_birth_date")
remaining = housekeeper.delete_task(finding.key)
state_after_delete = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
assert remaining == []
assert state_after_delete["run_counter"] == 1
assert state_after_delete["items"] == []
recreated = housekeeper.run(today=date(2026, 6, 21))
recreated_finding = next(item for item in recreated if item.code == "missing_birth_date")
state_after_run = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
recreated_task = next(item for item in state_after_run["items"] if item["key"] == recreated_finding.key)
assert recreated_finding.key == finding.key
assert recreated_task["first_seen_run"] == "2026-06-21:000002"
def test_housekeeper_removes_items_for_deleted_member_directory(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Deleted", last_name="Member")
housekeeper = Housekeeper(repository)
assert housekeeper.run(today=date(2026, 6, 21))
shutil.rmtree(repository.members_root / member.member_id)
findings = housekeeper.run(today=date(2026, 6, 21))
state = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
assert findings == []
assert not any(item.get("member_id") == member.member_id for item in state["items"])
def test_housekeeper_run_can_target_a_single_member(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member_a = repository.create_member(first_name="Alpha", last_name="Member")
member_b = repository.create_member(first_name="Beta", last_name="Member")
housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 6, 21))
state_before = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
b_task_before = next(
item for item in state_before["items"] if item.get("member_id") == member_b.member_id
)
member_a.birth_date = "1990-01-01"
repository.save_member(member_a)
findings = housekeeper.run(today=date(2026, 6, 21), member_id=member_a.member_id)
a_findings = [item for item in findings if item.member_id == member_a.member_id]
assert not any(item.code == "missing_birth_date" for item in a_findings)
state_after = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
b_task_after = next(
item for item in state_after["items"] if item.get("member_id") == member_b.member_id
)
assert b_task_after == b_task_before
def test_housekeeper_run_rejects_unknown_member_id(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
housekeeper = Housekeeper(repository)
with pytest.raises(RepositoryError, match="nicht gefunden"):
housekeeper.run(today=date(2026, 6, 21), member_id="does-not-exist")
def test_housekeeper_run_retroactive_override_applies_only_to_that_run(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Retro", last_name="Member", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2024-04-15"
member.membership_started_at = "2024-04-15"
member.payment_frequency = "annual"
repository.save_member(member)
housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 6, 21), member_id=member.member_id, retroactive_claims=True)
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
assert "membership-fee:2024:annual" in claim_keys
assert housekeeper.settings.retroactive_claims is False
other = repository.create_member(first_name="NoRetro", last_name="Member", birth_date="1990-01-01")
other.status = "active"
other.accepted_at = "2024-04-15"
other.membership_started_at = "2024-04-15"
other.payment_frequency = "annual"
repository.save_member(other)
housekeeper.run(today=date(2026, 6, 21), member_id=other.member_id)
other_claim_keys = {claim["claim_key"] for claim in repository.get_contributions(other.member_id).claims}
assert "membership-fee:2024:annual" not in other_claim_keys
assert "membership-fee:2026:annual" in other_claim_keys