mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-01 03:04:52 +02:00
317 lines
13 KiB
Python
317 lines
13 KiB
Python
import json
|
|
import shutil
|
|
from datetime import date
|
|
|
|
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
|
|
|
|
|
|
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"
|
|
assert state["run_counter"] == 2
|
|
assert state["last_completed_run"] == "2026-04-15:000002"
|
|
|
|
|
|
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"
|
|
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"
|
|
|
|
|
|
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"
|
|
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"
|
|
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_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"])
|