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