mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-07-02 11:40:13 +02:00
feat: initialize CCMA member administration
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
from ccma import __version__
|
||||
from ccma.ui.changelog_view import load_changelog
|
||||
|
||||
|
||||
def test_changelog_contains_current_version() -> None:
|
||||
entries = load_changelog()
|
||||
assert entries
|
||||
assert entries[0]["version"] == __version__
|
||||
assert entries[0]["changes"]
|
||||
@@ -0,0 +1,37 @@
|
||||
import json
|
||||
|
||||
from ccma.config import AppConfig, load_config
|
||||
|
||||
|
||||
def test_paths_and_automation_settings_round_trip(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("CCMA_CONFIG_DIR", str(tmp_path / "config"))
|
||||
expected = AppConfig(
|
||||
store_path=str(tmp_path / "members"),
|
||||
gnucash_path=str(tmp_path / "club.gnucash"),
|
||||
theme_mode="light",
|
||||
run_housekeeper_on_startup=False,
|
||||
birthday_days_before=10,
|
||||
birthday_days_after=3,
|
||||
anniversary_days_before=21,
|
||||
anniversary_days_after=5,
|
||||
anniversary_intervals="30D;2M;1Y;10Y",
|
||||
window_geometry="1200x800-1800+40",
|
||||
window_state="maximized",
|
||||
monitor_bounds=(-1920, 0, 1920, 1080),
|
||||
)
|
||||
expected.save()
|
||||
|
||||
loaded = load_config()
|
||||
assert loaded == expected
|
||||
raw = json.loads(expected.path.read_text(encoding="utf-8"))
|
||||
assert raw["schema_version"] == 1
|
||||
assert raw["monitor_bounds"] == [-1920, 0, 1920, 1080]
|
||||
|
||||
|
||||
def test_legacy_c3ma_environment_variables_are_still_read(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.delenv("CCMA_CONFIG_DIR", raising=False)
|
||||
monkeypatch.delenv("CCMA_STORE", raising=False)
|
||||
monkeypatch.setenv("C3MA_CONFIG_DIR", str(tmp_path / "legacy-config"))
|
||||
monkeypatch.setenv("C3MA_STORE", str(tmp_path / "legacy-store"))
|
||||
|
||||
assert load_config().store_path == str(tmp_path / "legacy-store")
|
||||
@@ -0,0 +1,63 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.domain.dates import (
|
||||
DateValidationError,
|
||||
age_label,
|
||||
calculate_age,
|
||||
format_date_for_display,
|
||||
normalize_date_input,
|
||||
parse_date_input,
|
||||
parse_iso_date,
|
||||
validate_birth_date,
|
||||
validate_member_dates,
|
||||
)
|
||||
|
||||
|
||||
def test_iso_dates_are_strict_and_real() -> None:
|
||||
assert parse_iso_date("2024-02-29", "Datum") == date(2024, 2, 29)
|
||||
for value in ("29.02.2024", "2024-2-29", "2023-02-29", "irgendwas"):
|
||||
with pytest.raises(DateValidationError):
|
||||
parse_iso_date(value, "Datum")
|
||||
|
||||
|
||||
def test_date_input_accepts_german_and_iso_formats() -> None:
|
||||
expected = date(2024, 2, 29)
|
||||
assert parse_date_input("29.02.2024", "Datum") == expected
|
||||
assert parse_date_input("2024-02-29", "Datum") == expected
|
||||
assert normalize_date_input("29.02.2024", "Datum") == "2024-02-29"
|
||||
|
||||
|
||||
def test_date_display_uses_system_pattern(monkeypatch) -> None:
|
||||
monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%d.%m.%Y")
|
||||
assert format_date_for_display("2024-02-29") == "29.02.2024"
|
||||
monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%Y-%m-%d")
|
||||
assert format_date_for_display("2024-02-29") == "2024-02-29"
|
||||
|
||||
|
||||
def test_birth_date_checks_future_and_plausibility() -> None:
|
||||
today = date(2026, 6, 21)
|
||||
assert validate_birth_date("2000-06-22", today=today) == date(2000, 6, 22)
|
||||
with pytest.raises(DateValidationError, match="Zukunft"):
|
||||
validate_birth_date("2026-06-22", today=today)
|
||||
with pytest.raises(DateValidationError, match="120"):
|
||||
validate_birth_date("1900-01-01", today=today)
|
||||
|
||||
|
||||
def test_member_dates_must_be_chronological() -> None:
|
||||
with pytest.raises(DateValidationError, match="Aufnahmebeschluss"):
|
||||
validate_member_dates(
|
||||
birth_date="2000-01-01",
|
||||
accepted_at="2020-01-02",
|
||||
membership_started_at="2020-01-01",
|
||||
today=date(2026, 6, 21),
|
||||
)
|
||||
|
||||
|
||||
def test_age_calculation_and_label() -> None:
|
||||
today = date(2026, 6, 21)
|
||||
assert calculate_age(date(2000, 6, 21), today) == 26
|
||||
assert calculate_age(date(2000, 6, 22), today) == 25
|
||||
assert age_label("2000-06-21", today=today) == "Alter: 26 Jahre"
|
||||
assert age_label("nein", today=today) == "UNGÜLTIGES DATUM"
|
||||
@@ -0,0 +1,93 @@
|
||||
from datetime import date
|
||||
|
||||
from ccma.domain.models import ContributionData
|
||||
from ccma.services.housekeeper import Housekeeper, HousekeeperSettings
|
||||
from ccma.storage.repository import MemberRepository
|
||||
|
||||
|
||||
def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Test", last_name="Person")
|
||||
member.status = "accepted_pending_payment"
|
||||
member.accepted_at = "2026-01-01"
|
||||
repository.save_member(member)
|
||||
repository.save_contributions(
|
||||
member.member_id,
|
||||
ContributionData(
|
||||
claims=[
|
||||
{
|
||||
"claim_id": "claim-1",
|
||||
"title": "Mitgliedsbeitrag 2026",
|
||||
"amount": "150.00",
|
||||
"due_date": "2026-01-31",
|
||||
"status": "open",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
findings = Housekeeper(repository).run(today=date(2026, 2, 10))
|
||||
assert {finding.code for finding in findings} == {"initial_payment_overdue", "claim_overdue"}
|
||||
|
||||
|
||||
def test_housekeeper_reports_birthdays_before_today_and_after(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
dates = ("1990-06-20", "1990-06-21", "1990-06-22")
|
||||
for index, birth_date in enumerate(dates):
|
||||
member = repository.create_member(
|
||||
first_name=f"Birthday{index}",
|
||||
last_name="Member",
|
||||
birth_date=birth_date,
|
||||
)
|
||||
member.status = "active"
|
||||
repository.save_member(member)
|
||||
settings = HousekeeperSettings.from_values(
|
||||
birthday_days_before=2,
|
||||
birthday_days_after=2,
|
||||
anniversary_days_before=0,
|
||||
anniversary_days_after=0,
|
||||
anniversary_intervals="1Y",
|
||||
)
|
||||
|
||||
findings = [
|
||||
finding
|
||||
for finding in Housekeeper(repository, settings).run(today=date(2026, 6, 21))
|
||||
if finding.code == "birthday"
|
||||
]
|
||||
assert {finding.title for finding in findings} == {
|
||||
"Birthday0 Member hatte vor 1 Tag Geburtstag",
|
||||
"Birthday1 Member hat heute Geburtstag",
|
||||
"Birthday2 Member hat in 1 Tag Geburtstag",
|
||||
}
|
||||
|
||||
|
||||
def test_housekeeper_reports_day_month_and_year_anniversaries(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
starts = ("2026-05-22", "2026-04-21", "2025-06-21", "2016-06-22")
|
||||
for index, started_at in enumerate(starts):
|
||||
member = repository.create_member(first_name=f"Anniversary{index}", last_name="Member")
|
||||
member.status = "active"
|
||||
member.membership_started_at = started_at
|
||||
repository.save_member(member)
|
||||
settings = HousekeeperSettings.from_values(
|
||||
birthday_days_before=0,
|
||||
birthday_days_after=0,
|
||||
anniversary_days_before=2,
|
||||
anniversary_days_after=2,
|
||||
anniversary_intervals="30D;2M;1Y;10Y",
|
||||
)
|
||||
|
||||
findings = [
|
||||
finding
|
||||
for finding in Housekeeper(repository, settings).run(today=date(2026, 6, 21))
|
||||
if finding.code == "membership_anniversary"
|
||||
]
|
||||
assert {finding.title for finding in findings} == {
|
||||
"Anniversary0 Member hat heute 30-Tage-Mitgliedsjubiläum",
|
||||
"Anniversary1 Member hat heute 2-Monats-Mitgliedsjubiläum",
|
||||
"Anniversary2 Member hat heute 1-jähriges Mitgliedsjubiläum",
|
||||
"Anniversary3 Member hat in 1 Tag 10-jähriges Mitgliedsjubiläum",
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.services.intervals import (
|
||||
IntervalValidationError,
|
||||
normalize_anniversary_intervals,
|
||||
parse_anniversary_intervals,
|
||||
)
|
||||
|
||||
|
||||
def test_intervals_accept_commas_semicolons_and_units() -> None:
|
||||
intervals = parse_anniversary_intervals("30d, 2M;1;10Y;10y")
|
||||
assert [interval.token for interval in intervals] == ["30D", "2M", "1Y", "10Y"]
|
||||
assert normalize_anniversary_intervals("30d, 2M;1Y") == "30D;2M;1Y"
|
||||
|
||||
|
||||
def test_month_and_year_intervals_use_calendar_arithmetic() -> None:
|
||||
intervals = parse_anniversary_intervals("1M;1Y")
|
||||
assert intervals[0].target_date(date(2024, 1, 31)) == date(2024, 2, 29)
|
||||
assert intervals[1].target_date(date(2024, 2, 29)) == date(2025, 2, 28)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "D", "0D", "-1Y", "101Y"])
|
||||
def test_invalid_intervals_are_rejected(value) -> None:
|
||||
with pytest.raises(IntervalValidationError):
|
||||
parse_anniversary_intervals(value)
|
||||
@@ -0,0 +1,13 @@
|
||||
from ccma.ui.monitors import MonitorBounds, centered_geometry, ensure_visible_geometry, parse_geometry
|
||||
|
||||
|
||||
def test_centered_geometry_supports_monitor_left_of_primary() -> None:
|
||||
monitor = MonitorBounds(-1920, 0, 1920, 1080)
|
||||
geometry = centered_geometry(620, 330, monitor)
|
||||
assert parse_geometry(geometry) == (620, 330, -1270, 375)
|
||||
|
||||
|
||||
def test_saved_geometry_is_clamped_to_selected_monitor() -> None:
|
||||
monitor = MonitorBounds(1920, 0, 1920, 1080)
|
||||
geometry = ensure_visible_geometry("1500x860-1600+100", monitor)
|
||||
assert parse_geometry(geometry) == (1500, 860, 1920, 100)
|
||||
@@ -0,0 +1,143 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from ccma.storage.repository import (
|
||||
MemberRepository,
|
||||
RepositoryError,
|
||||
format_member_number,
|
||||
validate_member_number_pattern,
|
||||
)
|
||||
|
||||
|
||||
def test_repository_creates_transparent_member_record(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path / "store")
|
||||
repository.initialize()
|
||||
|
||||
member = repository.create_member(
|
||||
first_name="Ada",
|
||||
last_name="Lovelace",
|
||||
email="ada@example.org",
|
||||
birth_date="1990-12-10",
|
||||
member_number="0042",
|
||||
)
|
||||
|
||||
member_dir = repository.members_root / member.member_id
|
||||
assert (member_dir / "member.json").is_file()
|
||||
assert (member_dir / "contributions.json").is_file()
|
||||
assert (member_dir / "events.jsonl").is_file()
|
||||
assert (member_dir / "files").is_dir()
|
||||
assert repository.validate() == []
|
||||
|
||||
raw = json.loads((member_dir / "member.json").read_text(encoding="utf-8"))
|
||||
assert raw["person"]["first_name"] == "Ada"
|
||||
assert raw["schema_version"] == 1
|
||||
|
||||
|
||||
def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(
|
||||
first_name="Jörg",
|
||||
last_name="Müller",
|
||||
email="joerg.mueller@example.org",
|
||||
birth_date="1990-04-23",
|
||||
member_number="C3-007",
|
||||
)
|
||||
|
||||
for query in ("Jorg Muller", "mueller@example.org", "C3-007", "23.04.1990"):
|
||||
assert [result.member_id for result in repository.search(query)] == [member.member_id]
|
||||
|
||||
|
||||
def test_events_are_appended_and_changes_do_not_leak_values(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Alice", last_name="Example", email="old@example.org")
|
||||
member.email = "new@example.org"
|
||||
repository.save_member(member)
|
||||
repository.append_event(
|
||||
member.member_id,
|
||||
event_type="board_comment",
|
||||
summary="Telefonisch erreicht",
|
||||
actor_type="user",
|
||||
actor_name="Vorstand",
|
||||
)
|
||||
|
||||
events = repository.get_events(member.member_id)
|
||||
assert [event.event_type for event in events] == [
|
||||
"member_created",
|
||||
"member_data_changed",
|
||||
"board_comment",
|
||||
]
|
||||
assert "E-Mail-Adresse" in events[1].summary
|
||||
assert "old@example.org" not in events[1].summary
|
||||
assert "new@example.org" not in events[1].summary
|
||||
|
||||
|
||||
def test_status_change_audit_contains_old_and_new_status(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Status", last_name="Test")
|
||||
member.status = "active"
|
||||
repository.save_member(member)
|
||||
|
||||
event = repository.get_events(member.member_id)[-1]
|
||||
assert event.summary == "Mitgliedsdaten geändert: Status von ANTRAG zu AKTIV"
|
||||
|
||||
|
||||
def test_repository_accepts_local_date_input_and_rejects_invalid_dates(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
member = repository.create_member(first_name="Local", last_name="Date", birth_date="31.12.2000")
|
||||
assert member.birth_date == "2000-12-31"
|
||||
assert repository.get_member(member.member_id).birth_date == "2000-12-31"
|
||||
with pytest.raises(RepositoryError, match="gültiges Datum"):
|
||||
repository.create_member(first_name="Invalid", last_name="Date", birth_date="31.02.2000")
|
||||
|
||||
|
||||
def test_member_path_rejects_traversal(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
with pytest.raises(RepositoryError):
|
||||
repository.get_member("../outside")
|
||||
|
||||
|
||||
def test_automatic_member_numbers_are_sequential_and_preview_does_not_consume(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
assert repository.preview_member_number() == "CCMA-0001"
|
||||
assert repository.preview_member_number() == "CCMA-0001"
|
||||
first = repository.create_member(first_name="First", last_name="Member")
|
||||
second = repository.create_member(first_name="Second", last_name="Member")
|
||||
|
||||
assert first.member_number == "CCMA-0001"
|
||||
assert second.member_number == "CCMA-0002"
|
||||
assert repository.preview_member_number() == "CCMA-0003"
|
||||
|
||||
|
||||
def test_custom_pattern_and_manual_mode(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
repository.save_member_number_policy(mode="automatic", pattern="MA-{year}-{number:03d}")
|
||||
automatic = repository.create_member(first_name="Auto", last_name="Member")
|
||||
assert automatic.member_number.startswith("MA-")
|
||||
assert automatic.member_number.endswith("-001")
|
||||
|
||||
repository.save_member_number_policy(mode="manual", pattern="MA-{number:03d}")
|
||||
with pytest.raises(RepositoryError, match="erforderlich"):
|
||||
repository.create_member(first_name="Missing", last_name="Number")
|
||||
manual = repository.create_member(first_name="Manual", last_name="Member", member_number="SPECIAL-7")
|
||||
assert manual.member_number == "SPECIAL-7"
|
||||
with pytest.raises(RepositoryError, match="bereits vergeben"):
|
||||
repository.create_member(first_name="Duplicate", last_name="Member", member_number="special-7")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pattern", ["", "CCMA-{year}", "{unknown}-{number}", "{number!r}"])
|
||||
def test_invalid_member_number_patterns_are_rejected(pattern) -> None:
|
||||
with pytest.raises(RepositoryError):
|
||||
validate_member_number_pattern(pattern)
|
||||
|
||||
|
||||
def test_member_number_formatter_supports_padding_and_year() -> None:
|
||||
assert format_member_number("CCMA-{year}-{number:05d}", 42, year=2026) == "CCMA-2026-00042"
|
||||
@@ -0,0 +1,40 @@
|
||||
def test_ui_modules_import_without_creating_root_window() -> None:
|
||||
import ccma.app # noqa: F401
|
||||
import ccma.ui.main_window # noqa: F401
|
||||
import ccma.ui.member_tab # noqa: F401
|
||||
import ccma.ui.splash # noqa: F401
|
||||
|
||||
|
||||
def test_splash_position_centers_on_pointer_and_stays_on_screen() -> None:
|
||||
from ccma.ui.splash import centered_position
|
||||
|
||||
assert centered_position(
|
||||
width=620,
|
||||
height=330,
|
||||
pointer_x=2500,
|
||||
pointer_y=600,
|
||||
screen_x=0,
|
||||
screen_y=0,
|
||||
screen_width=3840,
|
||||
screen_height=1080,
|
||||
) == (2190, 435)
|
||||
assert centered_position(
|
||||
width=620,
|
||||
height=330,
|
||||
pointer_x=10,
|
||||
pointer_y=10,
|
||||
screen_x=0,
|
||||
screen_y=0,
|
||||
screen_width=1920,
|
||||
screen_height=1080,
|
||||
) == (0, 0)
|
||||
|
||||
|
||||
def test_event_labels_hide_board_actor_but_keep_automatic_marker() -> None:
|
||||
from ccma.domain.models import Event
|
||||
from ccma.ui.member_tab import _event_label
|
||||
|
||||
user_event = Event("1", "2026-01-01T00:00:00+01:00", "comment", "Kommentar", "user", "Vorstand")
|
||||
system_event = Event("2", "2026-01-01T00:00:00+01:00", "automatic", "Automatisch")
|
||||
assert _event_label(user_event) == "Kommentar"
|
||||
assert _event_label(system_event) == "[AUTO] Automatisch"
|
||||
@@ -0,0 +1,8 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ccma import __version__
|
||||
|
||||
|
||||
def test_ui_version_matches_version_file() -> None:
|
||||
expected = (Path(__file__).resolve().parents[1] / "VERSION").read_text(encoding="utf-8").strip()
|
||||
assert __version__ == expected == "0.0.1-dev0"
|
||||
Reference in New Issue
Block a user