feat: Submission-Pydantic-Modell mit Validierung
This commit is contained in:
@@ -20,6 +20,9 @@ target-version = "py313"
|
|||||||
select = ["E", "F", "I", "N", "W", "UP", "PL", "C90", "S"]
|
select = ["E", "F", "I", "N", "W", "UP", "PL", "C90", "S"]
|
||||||
ignore = []
|
ignore = []
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**/*.py" = ["S101", "PLR2004"]
|
||||||
|
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
quote-style = "double"
|
quote-style = "double"
|
||||||
indent-style = "space"
|
indent-style = "space"
|
||||||
@@ -28,6 +31,11 @@ docstring-code-format = true
|
|||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
strict = true
|
strict = true
|
||||||
python_version = "3.13"
|
python_version = "3.13"
|
||||||
|
explicit_package_bases = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "pytest"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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("<b>Monopoly</b>") == "Monopoly"
|
||||||
|
assert validiere_spiel_name("<div class='game'>Risiko</div>") == "Risiko"
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidierespieleranzahl:
|
||||||
|
"""Tests für validiere_spieleranzahl."""
|
||||||
|
|
||||||
|
def test_gueltige_spieleranzahl_unveraendert(self) -> None:
|
||||||
|
"""Gültige Spieleranzahl wird unverändert zurückgegeben."""
|
||||||
|
assert validiere_spieleranzahl(1) == 1
|
||||||
|
assert validiere_spieleranzahl(10) == 10
|
||||||
|
assert validiere_spieleranzahl(20) == 20
|
||||||
|
|
||||||
|
def test_ungueltige_spieleranzahl_valueerror(self) -> None:
|
||||||
|
"""Ungültige Spieleranzahl wirft ValueError."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validiere_spieleranzahl(0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validiere_spieleranzahl(21)
|
||||||
Reference in New Issue
Block a user