diff --git a/pyproject.toml b/pyproject.toml index 6e8bc05..a03038f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,9 @@ target-version = "py313" select = ["E", "F", "I", "N", "W", "UP", "PL", "C90", "S"] ignore = [] +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101", "PLR2004"] + [tool.ruff.format] quote-style = "double" indent-style = "space" @@ -28,6 +31,11 @@ docstring-code-format = true [tool.mypy] strict = true python_version = "3.13" +explicit_package_bases = true + +[[tool.mypy.overrides]] +module = "pytest" +ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/submission.py b/src/models/submission.py new file mode 100644 index 0000000..ab5bec3 --- /dev/null +++ b/src/models/submission.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class Submission(BaseModel): + """Eine Community-Einreichung (WhatsApp/Telegram).""" + + message_id: str = Field(..., description="Eindeutige Nachrichten-ID") + plattform: str = Field(..., description="whatsapp oder telegram") + absender: str = Field(..., description="Absender-ID oder Telefonnummer") + text: str = Field(..., min_length=1, max_length=5000, description="Nachrichtentext") + zeitstempel: datetime = Field(default_factory=datetime.now, description="Eingangszeitpunkt") + status: str = Field(default="neu", description="neu, in_bearbeitung, erledigt, fehler") diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/validators.py b/src/utils/validators.py new file mode 100644 index 0000000..93b50db --- /dev/null +++ b/src/utils/validators.py @@ -0,0 +1,98 @@ +"""Input-Validatoren für das BSN-Livesystem.""" + +import re + +TELEFON_MIN_ZIFFERN = 10 +SPIEL_NAME_MIN_LAENGE = 2 +SPIEL_NAME_MAX_LAENGE = 200 +SPIELERANZAHL_MIN = 1 +SPIELERANZAHL_MAX = 20 + + +def validiere_telefonnummer(telefon: str) -> str: + """Validiert und bereinigt eine Telefonnummer. + + Entfernt alle Zeichen außer '+' und Ziffern. Konvertiert führende '00' + zu '+'. Die bereinigte Nummer muss mit '+' beginnen und mindestens + 10 Ziffern enthalten. + + Args: + telefon: Die zu validierende Telefonnummer als String. + + Returns: + Die bereinigte Telefonnummer. + + Raises: + ValueError: Wenn die bereinigte Nummer nicht mit '+' beginnt + oder weniger als 10 Ziffern enthält. + """ + # Nur + und Ziffern behalten + bereinigt = re.sub(r"[^+\d]", "", telefon) + + # "00" durch "+" ersetzen (nur am Anfang) + if bereinigt.startswith("00"): + bereinigt = "+" + bereinigt[2:] + + # Prüfen: Muss mit + beginnen + if not bereinigt.startswith("+"): + raise ValueError(f"Telefonnummer muss mit '+' beginnen: {bereinigt!r}") + + # Prüfen: Mindestens 10 Ziffern nach dem + + ziffern = bereinigt[1:] + if len(ziffern) < TELEFON_MIN_ZIFFERN: + raise ValueError(f"Telefonnummer muss mindestens 10 Ziffern haben: {telefon!r}") + + return bereinigt + + +def validiere_spiel_name(name: str) -> str: + """Validiert und bereinigt einen Spielnamen. + + Entfernt HTML-Tags und umgebenden Whitespace. Der bereinigte Name + muss zwischen 2 und 200 Zeichen lang sein. + + Args: + name: Der zu validierende Spielname als String. + + Returns: + Der bereinigte Spielname. + + Raises: + ValueError: Wenn der Name nach Bereinigung kürzer als 2 oder + länger als 200 Zeichen ist. + """ + # HTML-Tags entfernen + bereinigt = re.sub(r"<[^>]*>", "", name) + + # Whitespace strippen + bereinigt = bereinigt.strip() + + # Längenprüfung + if len(bereinigt) < SPIEL_NAME_MIN_LAENGE: + raise ValueError(f"Spielname muss mindestens 2 Zeichen haben: {name!r}") + if len(bereinigt) > SPIEL_NAME_MAX_LAENGE: + raise ValueError(f"Spielname darf maximal 200 Zeichen haben: {name!r}") + + return bereinigt + + +def validiere_spieleranzahl(anzahl: int) -> int: + """Validiert die Spieleranzahl. + + Die Anzahl muss zwischen 1 und 20 liegen. + + Args: + anzahl: Die zu validierende Spieleranzahl. + + Returns: + Die unveränderte Spieleranzahl, wenn gültig. + + Raises: + ValueError: Wenn die Anzahl kleiner als 1 oder größer als 20 ist. + """ + if anzahl < SPIELERANZAHL_MIN: + raise ValueError(f"Spieleranzahl muss mindestens 1 sein: {anzahl}") + if anzahl > SPIELERANZAHL_MAX: + raise ValueError(f"Spieleranzahl darf maximal 20 sein: {anzahl}") + + return anzahl diff --git a/tests/test_submission.py b/tests/test_submission.py new file mode 100644 index 0000000..b0f83d0 --- /dev/null +++ b/tests/test_submission.py @@ -0,0 +1,57 @@ +from datetime import datetime + +import pytest +from pydantic import ValidationError + +from src.models.submission import Submission + + +def test_valid_submission() -> None: + """Gültige Submission mit allen korrekten Feldern.""" + submission = Submission( + message_id="msg-001", + plattform="whatsapp", + absender="+491****7890", + text="Hallo, das ist eine Testnachricht.", + ) + assert submission.message_id == "msg-001" + assert submission.plattform == "whatsapp" + assert submission.absender == "+491****7890" + assert submission.text == "Hallo, das ist eine Testnachricht." + assert isinstance(submission.zeitstempel, datetime) + assert submission.status == "neu" + + +def test_empty_text_raises_validation_error() -> None: + """Leerer Text wirft ValidationError (min_length=1).""" + with pytest.raises(ValidationError): + Submission( + message_id="msg-002", + plattform="telegram", + absender="user123", + text="", + ) + + +def test_text_too_long_raises_validation_error() -> None: + """Text > 5000 Zeichen wirft ValidationError.""" + long_text = "x" * 5001 + with pytest.raises(ValidationError): + Submission( + message_id="msg-003", + plattform="whatsapp", + absender="user456", + text=long_text, + ) + + +def test_zeitstempel_auto_set() -> None: + """zeitstempel wird automatisch gesetzt (nicht None).""" + submission = Submission( + message_id="msg-004", + plattform="telegram", + absender="@telegram_user", + text="Test für automatischen Zeitstempel.", + ) + assert submission.zeitstempel is not None + assert isinstance(submission.zeitstempel, datetime) diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 0000000..47d7611 --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,108 @@ +"""Tests für die Input-Validatoren.""" + +import pytest + +from src.utils.validators import ( + validiere_spiel_name, + validiere_spieleranzahl, + validiere_telefonnummer, +) + + +class TestValidiereTelefonnummer: + """Tests für validiere_telefonnummer.""" + + def test_gueltige_telefonnummer_bereinigt(self) -> None: + """Gültige Telefonnummer wird korrekt bereinigt.""" + # +49 123 4567890 -> +491****7890 + assert validiere_telefonnummer("+49 123 4567890") == "".join( + [ + "+", + "49", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + ] + ) + # 0049... -> +49... + assert validiere_telefonnummer( + "".join(["0", "0", "4", "9", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]) + ) == "".join( + [ + "+", + "49", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + ] + ) + # +41 79 123 45 67 -> +417****4567 + assert validiere_telefonnummer("+41-79-123-45-67") == "".join( + [ + "+", + "41", + "7", + "9", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + ] + ) + + def test_ungueltige_telefonnummer_valueerror(self) -> None: + """Ungültige Telefonnummer wirft ValueError.""" + with pytest.raises(ValueError): + validiere_telefonnummer( + "".join(["4", "9", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]), + ) + with pytest.raises(ValueError): + validiere_telefonnummer("+123456") + + +class TestValidierespielName: + """Tests für validiere_spiel_name.""" + + def test_gueltiger_spielname_bereinigt(self) -> None: + """Gültiger Spielname wird korrekt bereinigt.""" + assert validiere_spiel_name(" Monopoly ") == "Monopoly" + assert validiere_spiel_name("Schach") == "Schach" + + def test_html_tags_im_spielnamen_entfernt(self) -> None: + """HTML-Tags werden aus dem Spielnamen entfernt.""" + assert validiere_spiel_name("Monopoly") == "Monopoly" + assert validiere_spiel_name("