mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-25 15:05:18 +02:00
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>
448 lines
18 KiB
Python
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
|