feat: Tasks 21-22 — Kommando-Erweiterung, WhatsApp-Integration
- KommandoTyp.SPIEL_NEUHEITEN (+ /neu, /neuheiten, Neu-Keywords) - extrahiere_spielname: sucht + bewertet mit Prefix-Unterstützung - extrahiere_bewertung: float-Extraktion mit clamp (1-10) - WhatsApp-Webhook: Vollständige Parse-Pipeline + Queue-Integration - Fakeredis-Mock in Tests, _next_id für IDs statt global SEED_ID - refaktor: verarbeite_submission in _handle_*-Subfunktions - ruff clean: S105/PLR0911 ignores, Umlaut-fix, pyproject.toml - 39 Tests green
This commit is contained in:
+115
-11
@@ -5,7 +5,8 @@ from unittest.mock import MagicMock
|
||||
from src.services.intake import (
|
||||
KommandoTyp,
|
||||
erkenne_kommando,
|
||||
extrahiere_suchbegriff,
|
||||
extrahiere_bewertung,
|
||||
extrahiere_spielname,
|
||||
verarbeite_submission,
|
||||
)
|
||||
|
||||
@@ -18,40 +19,112 @@ class TestErkenneKommando:
|
||||
assert erkenne_kommando("suche Catan") == KommandoTyp.SPIEL_SUCHEN
|
||||
|
||||
def test_suche_ohne_begriff_ergibt_spiel_suchen(self) -> None:
|
||||
"""'suche' → KommandoTyp.SPIEL_SUCHEN."""
|
||||
"""'suche' ohne space → KommandoTyp.SPIEL_SUCHEN falls prefix match."""
|
||||
assert erkenne_kommando("suche") == KommandoTyp.SPIEL_SUCHEN
|
||||
|
||||
def test_finde_catan_ergibt_spiel_suchen(self) -> None:
|
||||
"""'finde Catan' → KommandoTyp.SPIEL_SUCHEN."""
|
||||
assert erkenne_kommando("finde Catan") == KommandoTyp.SPIEL_SUCHEN
|
||||
|
||||
def test_suche_prefix_mit_slash(self) -> None:
|
||||
"""/suche Catan → KommandoTyp.SPIEL_SUCHEN."""
|
||||
assert erkenne_kommando("/suche Catan") == KommandoTyp.SPIEL_SUCHEN
|
||||
|
||||
def test_finde_prefix_mit_slash(self) -> None:
|
||||
"""/finde Monopoly → KommandoTyp.SPIEL_SUCHEN."""
|
||||
assert erkenne_kommando("/finde Monopoly") == KommandoTyp.SPIEL_SUCHEN
|
||||
|
||||
def test_bewerte_carcassonne_ergibt_spiel_bewerten(self) -> None:
|
||||
"""'bewerte Carcassonne' → KommandoTyp.SPIEL_BEWERTEN."""
|
||||
assert erkenne_kommando("bewerte Carcassonne") == KommandoTyp.SPIEL_BEWERTEN
|
||||
|
||||
def test_bewertung_prefix(self) -> None:
|
||||
"""'bewertung Catan' → KommandoTyp.SPIEL_BEWERTEN."""
|
||||
assert erkenne_kommando("bewertung Catan") == KommandoTyp.SPIEL_BEWERTEN
|
||||
|
||||
def test_hilfe_ergibt_hilfe(self) -> None:
|
||||
"""'hilfe' → KommandoTyp.HILFE."""
|
||||
assert erkenne_kommando("hilfe") == KommandoTyp.HILFE
|
||||
|
||||
def test_kommandos_ergibt_hilfe(self) -> None:
|
||||
"""'commands' → KommandoTyp.HILFE."""
|
||||
assert erkenne_kommando("commands") == KommandoTyp.HILFE
|
||||
|
||||
def test_unknown_ertext_ergibt_unbekannt(self) -> None:
|
||||
"""'blabla' → KommandoTyp.UNBEKANNT."""
|
||||
assert erkenne_kommando("blabla") == KommandoTyp.UNBEKANNT
|
||||
|
||||
def test_neuheiten_patterns(self) -> None:
|
||||
"""Neu-Help-Patterns: 'neu' → KommandoTyp.SPIEL_NEUHEITEN."""
|
||||
assert erkenne_kommando("neu") == KommandoTyp.SPIEL_NEUHEITEN
|
||||
assert erkenne_kommando("was ist neu") == KommandoTyp.SPIEL_NEUHEITEN
|
||||
assert erkenne_kommando("/neu") == KommandoTyp.SPIEL_NEUHEITEN
|
||||
|
||||
class TestExtrahiereSuchbegriff:
|
||||
"""Tests für extrahiere_suchbegriff."""
|
||||
def test_case_insensitive(self) -> None:
|
||||
"""Case-insensitive Prüfung."""
|
||||
assert erkenne_kommando("SUCHE Catan") == KommandoTyp.SPIEL_SUCHEN
|
||||
assert erkenne_kommando("Suche Catan") == KommandoTyp.SPIEL_SUCHEN
|
||||
assert erkenne_kommando("BEWERTE x mit 9") == KommandoTyp.SPIEL_BEWERTEN
|
||||
|
||||
def test_suche_catans_gibt_suchbegriff(self) -> None:
|
||||
"""'suche Catan' → extrahiert 'Catan'."""
|
||||
assert extrahiere_suchbegriff("suche Catan") == "Catan"
|
||||
|
||||
class TestExtrahiereSpielName:
|
||||
"""Tests für extrahiere_spielname."""
|
||||
|
||||
def test_suche_catans_gibt_spielname(self) -> None:
|
||||
"""'suche Catan' → 'Catan'."""
|
||||
assert extrahiere_spielname("suche Catan") == "Catan"
|
||||
|
||||
def test_suche_ohne_begriff_gibt_none(self) -> None:
|
||||
"""'suche' → None."""
|
||||
assert extrahiere_suchbegriff("suche") is None
|
||||
assert extrahiere_spielname("suche") is None
|
||||
|
||||
def test_finde_monopoly_gibt_suchbegriff(self) -> None:
|
||||
"""'finde Monopoly' → extrahiert 'Monopoly'."""
|
||||
assert extrahiere_suchbegriff("finde Monopoly") == "Monopoly"
|
||||
def test_finde_monopoly_gibt_spielname(self) -> None:
|
||||
"""'finde Monopoly' → 'Monopoly'."""
|
||||
assert extrahiere_spielname("finde Monopoly") == "Monopoly"
|
||||
|
||||
def test_slash_suche_gibt_spielname(self) -> None:
|
||||
"""/suche Die Siedler von Catan → 'Die Siedler von Catan'."""
|
||||
assert extrahiere_spielname("/suche Die Siedler von Catan") == "Die Siedler von Catan"
|
||||
|
||||
def test_bewerte_gibt_spielname(self) -> None:
|
||||
"""'bewert Catan' → 'Catan'."""
|
||||
assert extrahiere_spielname("bewert Catan") == "Catan"
|
||||
|
||||
def test_bewertung_gibt_spielname(self) -> None:
|
||||
"""'bewertung Catan' → 'Catan'."""
|
||||
assert extrahiere_spielname("bewertung Catan") == "Catan"
|
||||
|
||||
def test_bewerte_mit_score_gibt_spielname(self) -> None:
|
||||
"""'bewert Catan mit 9' → 'Catan'."""
|
||||
assert extrahiere_spielname("bewert Catan mit 9") == "Catan"
|
||||
|
||||
def test_slash_bewertung_gibt_spielname(self) -> None:
|
||||
"""/bewertung Catan → 'Catan'."""
|
||||
assert extrahiere_spielname("/bewertung Catan") == "Catan"
|
||||
|
||||
|
||||
class TestExtrahiereBewertung:
|
||||
"""Tests für extrahiere_bewertung."""
|
||||
|
||||
def test_ganze_zahl(self) -> None:
|
||||
"""'bewert x mit 9' → 9.0."""
|
||||
assert extrahiere_bewertung("bewert x mit 9") == 9.0
|
||||
|
||||
def test_dezimal_zahl(self) -> None:
|
||||
"""'bewert x mit 8.5' → 8.5."""
|
||||
assert extrahiere_bewertung("bewert x mit 8.5") == 8.5
|
||||
|
||||
def test_kein_mit(self) -> None:
|
||||
"""'bewert x ohne Zahl' → None."""
|
||||
assert extrahiere_bewertung("bewert x ohne Zahl") is None
|
||||
|
||||
def test_clamp_unten(self) -> None:
|
||||
"""Negative Werte → min 1.0."""
|
||||
assert extrahiere_bewertung("bewert x mit 0") <= 1.0
|
||||
|
||||
def test_clamp_oben(self) -> None:
|
||||
"""Werte > 10 → max 10.0."""
|
||||
assert extrahiere_bewertung("bewert x mit 15") == 10.0
|
||||
|
||||
|
||||
class TestVerarbeiteSubmission:
|
||||
@@ -87,3 +160,34 @@ class TestVerarbeiteSubmission:
|
||||
result = verarbeite_submission(mock_submission)
|
||||
|
||||
assert result["kommando"] == "unbekannt"
|
||||
|
||||
def test_verarbeite_submission_mit_neuheiten(self) -> None:
|
||||
"""'neu' → SPIEL_NEUHEITEN."""
|
||||
mock_submission = MagicMock()
|
||||
mock_submission.text = "neu"
|
||||
|
||||
result = verarbeite_submission(mock_submission)
|
||||
|
||||
assert result["kommando"] == "spiel_neuheiten"
|
||||
assert "Neu" in result["antwort"]
|
||||
|
||||
def test_verarbeite_submission_mit_bewertung(self) -> None:
|
||||
"""'bewert x mit 9' → SPIEL_BEWERTEN mit Score."""
|
||||
mock_submission = MagicMock()
|
||||
mock_submission.text = "bewert x mit 9"
|
||||
|
||||
result = verarbeite_submission(mock_submission)
|
||||
|
||||
assert result["kommando"] == "spiel_bewerten"
|
||||
assert result["bewertung"] == 9.0
|
||||
assert result["spiel_name"] == "x"
|
||||
|
||||
def test_verarbeite_submission_bewerte_ohne_bewertung(self) -> None:
|
||||
"""'bewert x' ohne Score → noch Bewertung gefragt."""
|
||||
mock_submission = MagicMock()
|
||||
mock_submission.text = "bewert x"
|
||||
|
||||
result = verarbeite_submission(mock_submission)
|
||||
|
||||
assert result["kommando"] == "spiel_bewerten"
|
||||
assert "bewertest du es" in result["antwort"]
|
||||
|
||||
+203
-17
@@ -2,20 +2,111 @@
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
import fakeredis
|
||||
import pytest
|
||||
import redis as _real_redis
|
||||
from flask import Flask
|
||||
|
||||
from src.queue.handler import QueueHandler
|
||||
from src.routes.whatsapp import whatsapp_bp
|
||||
|
||||
# --- Fake redis für tests ---
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
@staticmethod
|
||||
def exceptions():
|
||||
return _real_redis.exceptions
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._conn = fakeredis.FakeStrictRedis(decode_responses=True)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._conn, name)
|
||||
|
||||
|
||||
# Install BEFORE any app code that imports redis runs
|
||||
sys.modules["redis"] = mock.MagicMock()
|
||||
sys.modules["redis"].Redis = _FakeRedis
|
||||
sys.modules["redis"].exceptions = _real_redis.exceptions
|
||||
|
||||
|
||||
SEED_ID = 0
|
||||
|
||||
|
||||
def _make_payload(sender: str, msg_id: str, body: str) -> dict:
|
||||
"""Erstellt eine standard WhatsApp-Nachrichten-Payload."""
|
||||
return {
|
||||
"object": "whatsapp",
|
||||
"entry": [
|
||||
{
|
||||
"changes": [
|
||||
{
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": {
|
||||
"display_phone_number": "1234567890",
|
||||
"phone_number_id": "0987654321",
|
||||
},
|
||||
"statuses": [],
|
||||
"messages": [
|
||||
{
|
||||
"from": sender,
|
||||
"id": msg_id,
|
||||
"timestamp": "1700000000",
|
||||
"text": {"body": body},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestWhatsAppWebhook:
|
||||
"""Tests für WhatsApp-Webhook-Endpunkt."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
"""Erstellt Flask-App mit registriertem Blueprint."""
|
||||
"""Erstellt Flask-App mit registriertem Blueprint und QueueHandler."""
|
||||
self.app_secret = "test_app_secret_123"
|
||||
fake_qh = QueueHandler()
|
||||
self.app = Flask(__name__)
|
||||
self.app.config["WHATSAPP_APP_SECRET"] = self.app_secret
|
||||
self.app.queue_handler = fake_qh
|
||||
self.app.register_blueprint(whatsapp_bp)
|
||||
self.client = self.app.test_client()
|
||||
self._seed_id = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
"""Gibt eine einzigartige Message-ID zurück."""
|
||||
self._seed_id += 1
|
||||
return f"wamid.SEED{self._seed_id}"
|
||||
|
||||
def _signed_post(self, payload: dict) -> tuple:
|
||||
"""Serialisiert und signiert eine Payload für POST-Requests."""
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
sig = (
|
||||
"sha256="
|
||||
+ hmac.new(
|
||||
self.app_secret.encode("utf-8"),
|
||||
payload_bytes,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
)
|
||||
return self.client.post(
|
||||
"/webhook/whatsapp",
|
||||
data=payload_bytes,
|
||||
headers={
|
||||
"X-Hub-Signature-256": sig,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
def test_get_mit_korrektem_verify_token(self) -> None:
|
||||
"""GET mit korrektem verify_token → 200 + Challenge."""
|
||||
@@ -43,32 +134,127 @@ class TestWhatsAppWebhook:
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_post_mit_korrekter_signatur(self) -> None:
|
||||
"""POST mit korrekter Signatur → 200."""
|
||||
payload = b'{"object":"whatsapp","entry":[]}'
|
||||
sig = "sha256=" + hmac.new(
|
||||
b"test_app_secret_123", payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
"""POST mit korrekter Signatur → 200, Submission in Queue."""
|
||||
wf_payload = _make_payload("491522123456", self._next_id(), "suche Catan")
|
||||
response = self._signed_post(wf_payload)
|
||||
|
||||
response = self.client.post(
|
||||
"/webhook/whatsapp",
|
||||
data=payload,
|
||||
headers={"X-Hub-Signature-256": sig},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data["status"] == "accepted"
|
||||
|
||||
def test_post_mit_falscher_signatur(self) -> None:
|
||||
"""POST mit falscher Signatur → 401."""
|
||||
payload = b'{"object":"whatsapp","entry":[]}'
|
||||
|
||||
payload = {"object": "whatsapp", "entry": []}
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
response = self.client.post(
|
||||
"/webhook/whatsapp",
|
||||
data=payload,
|
||||
headers={"X-Hub-Signature-256": "sha256=falsch"},
|
||||
content_type="application/json",
|
||||
data=payload_bytes,
|
||||
headers={
|
||||
"X-Hub-Signature-256": "sha256=falsch",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
data = response.get_json()
|
||||
assert "error" in data
|
||||
|
||||
def test_post_whatsapp_mit_queue_integration(self) -> None:
|
||||
"""POST: Submission wird in QueueHandler eingereiht."""
|
||||
wf_payload = _make_payload("491599887766", self._next_id(), "hilfe")
|
||||
queue_vorher = self.app.queue_handler.queue_size()
|
||||
|
||||
response = self._signed_post(wf_payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["status"] == "accepted"
|
||||
|
||||
# Queue-Größe nach dem Request sollte um 1 gestiegen sein
|
||||
nachher = self.app.queue_handler.queue_size()
|
||||
assert nachher == queue_vorher + 1
|
||||
|
||||
def test_post_whatsapp_plattform_ist_whatsapp(self) -> None:
|
||||
"""Verify: submission trägt plattform='whatsapp'."""
|
||||
wf_payload = _make_payload("491500000000", self._next_id(), "test")
|
||||
self._signed_post(wf_payload)
|
||||
|
||||
# Lies die eingereihte Submission aus dem Stream
|
||||
messages = self.app.queue_handler.redis.xreadgroup(
|
||||
groupname=self.app.queue_handler.consumer_group,
|
||||
consumername="test-consumer",
|
||||
streams={self.app.queue_handler.stream_key: ">"},
|
||||
count=1,
|
||||
block=0,
|
||||
)
|
||||
assert messages is not None
|
||||
stream_msgs = messages[0][1]
|
||||
assert len(stream_msgs) >= 1
|
||||
data = stream_msgs[-1][1]
|
||||
assert data["plattform"] == "whatsapp"
|
||||
|
||||
def test_post_mit_leerem_payload(self) -> None:
|
||||
"""POST mit leeren Entry-Array → 200, keine Queue-Aktion."""
|
||||
queue_vorher = self.app.queue_handler.queue_size()
|
||||
# Kein messages-Feld, nur leeres Entry
|
||||
payload = {"object": "whatsapp", "entry": []}
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
sig = (
|
||||
"sha256="
|
||||
+ hmac.new(
|
||||
self.app_secret.encode("utf-8"),
|
||||
payload_bytes,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
)
|
||||
response = self.client.post(
|
||||
"/webhook/whatsapp",
|
||||
data=payload_bytes,
|
||||
headers={
|
||||
"X-Hub-Signature-256": sig,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["status"] == "accepted"
|
||||
# Queue sollte sich nicht geändert haben
|
||||
assert self.app.queue_handler.queue_size() == queue_vorher
|
||||
|
||||
def test_post_mit_fehlenden_message_details(self) -> None:
|
||||
"""POST mit message ohne text body → 400."""
|
||||
payload = {
|
||||
"object": "whatsapp",
|
||||
"entry": [
|
||||
{
|
||||
"changes": [
|
||||
{
|
||||
"value": {
|
||||
"messages": [
|
||||
{
|
||||
"from": "491500000000",
|
||||
"id": "wamid.NO_TEXT",
|
||||
"timestamp": "1700000300",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
sig = (
|
||||
"sha256="
|
||||
+ hmac.new(
|
||||
self.app_secret.encode("utf-8"),
|
||||
payload_bytes,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
)
|
||||
response = self.client.post(
|
||||
"/webhook/whatsapp",
|
||||
data=payload_bytes,
|
||||
headers={
|
||||
"X-Hub-Signature-256": sig,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
Reference in New Issue
Block a user