Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d3dc2189 | |||
| 5a72f9ec80 | |||
| e382201878 | |||
| 3e360911f5 | |||
| c2b3ee551f | |||
| 204da375c5 | |||
| ac7aa0197b | |||
| bf67483e53 | |||
| ded087292a | |||
| eebac265af |
-46
@@ -1,46 +0,0 @@
|
|||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
*.so
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
|
|
||||||
# Virtual Environment
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env/
|
|
||||||
|
|
||||||
# IDEs
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
.pytest_cache/
|
|
||||||
coverage/
|
|
||||||
.coverage
|
|
||||||
htmlcov/
|
|
||||||
|
|
||||||
# Linting
|
|
||||||
.ruff_cache/
|
|
||||||
.mypy_cache/
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# ALICIA.md — Anleitung für Alicia
|
|
||||||
|
|
||||||
> **Repo:** `git@git.datenhimmel.work:danielkrause/BSN-Livesystem.git`
|
|
||||||
> **Branch:** `main`
|
|
||||||
> **Arbeitsverzeichnis:** `/tmp/BSN-Livesystem` (oder wo auch immer du geklont hast)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Einmalig: Repo klonen und SSH-Key einrichten
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Repo klonen
|
|
||||||
git clone git@git.datenhimmel.work:danielkrause/BSN-Livesystem.git /tmp/BSN-Livesystem
|
|
||||||
cd /tmp/BSN-Livesystem
|
|
||||||
```
|
|
||||||
|
|
||||||
> Falls `Permission denied (publickey)`: Dein SSH-Public-Key muss in Gitea hinterlegt sein.
|
|
||||||
> Cody oder Daniel können das machen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Arbeitsablauf (JEDES MAL)
|
|
||||||
|
|
||||||
### 1. Repo aktuell halten
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /tmp/BSN-Livesystem
|
|
||||||
git pull origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Dateien erstellen oder bearbeiten
|
|
||||||
|
|
||||||
Arbeite NUR in diesem Verzeichnis. Erstelle/ändere Dateien mit `write_file` oder direkt.
|
|
||||||
|
|
||||||
### 3. Qualitätschecks (VOR jedem Commit!)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /tmp/BSN-Livesystem
|
|
||||||
ruff check .
|
|
||||||
ruff format .
|
|
||||||
mypy --config-file pyproject.toml .
|
|
||||||
pytest -v
|
|
||||||
```
|
|
||||||
|
|
||||||
**Nicht weitermachen bevor nicht ALLE VIER Checks grün sind!** Wenn einer fehlschlägt: Fehler korrigieren, erneut prüfen.
|
|
||||||
|
|
||||||
### 4. Commit + Push
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git status --short # Prüfen was committed wird
|
|
||||||
git commit -m "feat: ..." # KEINE Geheimnisse committen!
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Erfolg bestätigen
|
|
||||||
|
|
||||||
Prüfe dass der Push durchging:
|
|
||||||
```bash
|
|
||||||
git log --oneline -1
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Wichtige Regeln
|
|
||||||
|
|
||||||
- **NIE `.env`-Dateien committen** (Secrets!)
|
|
||||||
- **Conventional Commits:** `feat:`, `fix:`, `chore:`, `docs:`, `test:`
|
|
||||||
- **Deutsch kommentieren, Deutsche Docstrings**
|
|
||||||
- **Type Hints auf ALLEN Funktionen**
|
|
||||||
- **Ruff + mypy + pytest VOR jedem Commit**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aufgaben
|
|
||||||
|
|
||||||
Aufgaben stehen in `docs/alicia-aufgaben-nacht1.md`.
|
|
||||||
Aktueller Fortschritt in `task_status.json`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hilfe
|
|
||||||
|
|
||||||
- **Zugriff verweigert?** → Daniel fragen (SSH-Key fehlt)
|
|
||||||
- **Merge-Konflikt?** → `git pull --rebase origin main`
|
|
||||||
- **Falsches Repo?** → `git remote -v` prüfen (muss auf `BSN-Livesystem.git` zeigen!)
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
# Alicia — Aufgaben 6–10: BSN-Livesystem
|
|
||||||
|
|
||||||
> **Repo:** `git@git.datenhimmel.work:danielkrause/BSN-Livesystem.git`
|
|
||||||
> **Branch:** `main`
|
|
||||||
> **Deine Basis:** Aufgaben 1–5 sind fertig und gepusht.
|
|
||||||
> **Qualität:** Vor JEDEM Commit: `ruff check . && ruff format . && mypy --config-file pyproject.toml . && pytest -v`
|
|
||||||
> **Fehler?** Code korrigieren, Checks wiederholen. Nicht aufgeben bis ALLE grün.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aufgabe 6 — Webhook-Signatur-Prüfung
|
|
||||||
|
|
||||||
### Datei: `src/queue/webhook.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import os
|
|
||||||
from src.queue.handler import QueueHandler
|
|
||||||
from src.models.submission import Submission
|
|
||||||
|
|
||||||
|
|
||||||
def verify_whatsapp_signature(
|
|
||||||
payload: bytes, signature: str, app_secret: str | None = None
|
|
||||||
) -> bool:
|
|
||||||
"""Verifiziert die WhatsApp X-Hub-Signature-256.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
payload: Der rohe Request-Body.
|
|
||||||
signature: Der X-Hub-Signature-256 Header-Wert.
|
|
||||||
app_secret: Der WhatsApp App-Secret (aus Env wenn None).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True wenn Signatur gültig.
|
|
||||||
"""
|
|
||||||
secret = app_secret or os.environ.get("WHATSAPP_APP_SECRET", "")
|
|
||||||
if not secret:
|
|
||||||
return False
|
|
||||||
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
||||||
return hmac.compare_digest(f"sha256={expected}", signature)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_telegram_signature(token: str | None = None) -> bool:
|
|
||||||
"""Verifiziert den Telegram Bot-Token.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token: Bot-Token (aus Env wenn None).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True wenn Token gültig.
|
|
||||||
"""
|
|
||||||
expected = token or os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|
||||||
if not expected:
|
|
||||||
return False
|
|
||||||
return len(expected) > 20
|
|
||||||
|
|
||||||
|
|
||||||
def validate_and_enqueue(
|
|
||||||
payload: bytes, signature: str, plattform: str, qh: QueueHandler
|
|
||||||
) -> bool:
|
|
||||||
"""Validiert plattformspezifische Signatur und legt Submission in Queue.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
payload: Der rohe Request-Body.
|
|
||||||
signature: Signatur-Header.
|
|
||||||
plattform: "whatsapp" oder "telegram".
|
|
||||||
qh: QueueHandler-Instanz.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True wenn validiert und erfolgreich enqueued.
|
|
||||||
"""
|
|
||||||
if plattform == "whatsapp":
|
|
||||||
if not verify_whatsapp_signature(payload, signature):
|
|
||||||
return False
|
|
||||||
elif plattform == "telegram":
|
|
||||||
if not verify_telegram_signature():
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
import json
|
|
||||||
data = json.loads(payload)
|
|
||||||
submission = Submission(
|
|
||||||
message_id=str(data.get("message_id", "")),
|
|
||||||
plattform=plattform,
|
|
||||||
absender=str(data.get("absender", "")),
|
|
||||||
text=str(data.get("text", "")),
|
|
||||||
)
|
|
||||||
return qh.enqueue(submission) is not None
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test-Datei: `tests/test_webhook.py`
|
|
||||||
|
|
||||||
5 Tests:
|
|
||||||
1. Korrekte WhatsApp-Signatur → True
|
|
||||||
2. Falsche WhatsApp-Signatur → False
|
|
||||||
3. Fehlender App-Secret → False
|
|
||||||
4. Korrekter Telegram-Token → True (setze `TELEGRAM_BOT_TOKEN` mit ≥21 Zeichen)
|
|
||||||
5. Zu kurzer Telegram-Token → False
|
|
||||||
|
|
||||||
**Commit:** `feat: Webhook-Signatur-Prüfung für WhatsApp + Telegram`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aufgabe 7 — Queue-Worker
|
|
||||||
|
|
||||||
### Datei: `src/queue/worker.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
import time
|
|
||||||
from src.queue.handler import QueueHandler
|
|
||||||
from src.models.submission import Submission
|
|
||||||
|
|
||||||
|
|
||||||
def verarbeite_submission(submission: Submission) -> bool:
|
|
||||||
"""Verarbeitet eine einzelne Submission (Platzhalter für KI-Pipeline).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
submission: Die zu verarbeitende Submission.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Immer True im Erfolgsfall.
|
|
||||||
"""
|
|
||||||
submission.status = "erledigt"
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def worker_loop(
|
|
||||||
qh: QueueHandler, poll_interval: int = 2, batch_size: int = 5
|
|
||||||
) -> None:
|
|
||||||
"""Endlos-Schleife: Pollt Queue, verarbeitet Batches.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
qh: QueueHandler-Instanz.
|
|
||||||
poll_interval: Sekunden zwischen Polls wenn Queue leer.
|
|
||||||
batch_size: Maximale Submissions pro Batch.
|
|
||||||
"""
|
|
||||||
while True:
|
|
||||||
submissions = qh.dequeue(batch_size)
|
|
||||||
if not submissions:
|
|
||||||
time.sleep(poll_interval)
|
|
||||||
continue
|
|
||||||
for submission in submissions:
|
|
||||||
try:
|
|
||||||
if verarbeite_submission(submission):
|
|
||||||
qh.mark_done(submission)
|
|
||||||
except Exception:
|
|
||||||
pass # Fehler in einer Submission → weitermachen
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test-Datei: `tests/test_worker.py`
|
|
||||||
|
|
||||||
3 Tests mit fakeredis (MOCKE `time.sleep` mit `unittest.mock.patch`):
|
|
||||||
1. `verarbeite_submission` setzt Status auf "erledigt"
|
|
||||||
2. `worker_loop` verarbeitet alle Submissions in der Queue (nach Batch prüfen)
|
|
||||||
3. `worker_loop` crashed nicht bei leerer Queue
|
|
||||||
|
|
||||||
**Commit:** `feat: Queue-Worker mit Batch-Verarbeitung und Fehlertoleranz`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aufgabe 8 — Flask-App-Factory
|
|
||||||
|
|
||||||
### Datei: `src/app.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from flask import Flask
|
|
||||||
from src.queue.handler import QueueHandler
|
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
|
||||||
"""Erstellt und konfiguriert die Flask-App.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Flask-App-Instanz mit QueueHandler.
|
|
||||||
"""
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.config.from_mapping(
|
|
||||||
REDIS_HOST="localhost",
|
|
||||||
REDIS_PORT=6379,
|
|
||||||
)
|
|
||||||
app.queue_handler = QueueHandler(
|
|
||||||
redis_host=app.config["REDIS_HOST"],
|
|
||||||
redis_port=app.config["REDIS_PORT"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/health")
|
|
||||||
def health() -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"queue_size": app.queue_handler.queue_size(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = create_app()
|
|
||||||
app.run(host="0.0.0.0", port=5002, debug=False)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test-Datei: `tests/test_app.py`
|
|
||||||
|
|
||||||
3 Tests mit Flask-Test-Client (`app.test_client()`):
|
|
||||||
1. `create_app()` gibt Flask-App zurück
|
|
||||||
2. GET `/health` → 200, JSON mit `"status": "ok"`
|
|
||||||
3. GET `/health` → `queue_size` ist Integer
|
|
||||||
|
|
||||||
**Commit:** `feat: Flask-App-Factory mit Health-Check und QueueHandler`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aufgabe 9 — WhatsApp-Webhook (Flask-Blueprint)
|
|
||||||
|
|
||||||
### Datei 1: `src/routes/__init__.py`
|
|
||||||
Leer lassen.
|
|
||||||
|
|
||||||
### Datei 2: `src/routes/whatsapp.py`
|
|
||||||
|
|
||||||
Blueprint `"whatsapp"` mit `/webhook/whatsapp` (GET + POST):
|
|
||||||
- **GET:** Hub-Challenge — prüft `hub.mode == "subscribe"` und `hub.verify_token == "bsn_verify_token"` → gibt `hub.challenge` zurück (200), sonst 403
|
|
||||||
- **POST:** Signatur via `verify_whatsapp_signature()` prüfen → Submission bauen → `current_app.queue_handler.enqueue()` → 200
|
|
||||||
- Ungültige Signatur → 401
|
|
||||||
- `WHATSAPP_APP_SECRET` aus `os.environ`
|
|
||||||
|
|
||||||
### Test-Datei: `tests/test_whatsapp.py`
|
|
||||||
|
|
||||||
4 Tests mit `app.test_client()` (Blueprint temporär registrieren):
|
|
||||||
1. GET mit korrektem verify_token → 200 + Challenge
|
|
||||||
2. GET mit falschem verify_token → 403
|
|
||||||
3. POST mit korrekter Signatur → 200
|
|
||||||
4. POST mit falscher Signatur → 401
|
|
||||||
|
|
||||||
**Setze** `os.environ["WHATSAPP_APP_SECRET"] = "test"` und `os.environ["WHATSAPP_VERIFY_TOKEN"] = "test-token"` im Test-Setup.
|
|
||||||
|
|
||||||
**Commit:** `feat: WhatsApp-Webhook mit Signatur-Prüfung und Queue-Enqueue`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aufgabe 10 — Telegram-Webhook (Flask-Blueprint)
|
|
||||||
|
|
||||||
### Datei: `src/routes/telegram.py`
|
|
||||||
|
|
||||||
Blueprint `"telegram"` mit `/webhook/telegram` (NUR POST):
|
|
||||||
- `request.get_json(silent=True)` mit None-Check → 400
|
|
||||||
- `message_id`, `chat_id`, `text` aus `data["message"]` extrahieren
|
|
||||||
- Fehlende `message_id` oder `text` → 400
|
|
||||||
- `Submission` bauen (`plattform="telegram"`) → `current_app.queue_handler.enqueue()` → 200
|
|
||||||
- Pydantic `ValidationError` abfangen → 400
|
|
||||||
|
|
||||||
### Test-Datei: `tests/test_telegram.py`
|
|
||||||
|
|
||||||
4 Tests mit `app.test_client()`:
|
|
||||||
1. POST mit gültigem Payload → 200
|
|
||||||
2. POST ohne JSON → 400
|
|
||||||
3. POST ohne `message_id` → 400
|
|
||||||
4. POST mit leerem `text` → 400
|
|
||||||
|
|
||||||
**Commit:** `feat: Telegram-Webhook mit JSON-Validierung und Queue-Enqueue`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ WICHTIG
|
|
||||||
|
|
||||||
- **Vor JEDEM Commit:** `ruff check . && ruff format . && mypy --config-file pyproject.toml . && pytest -v`
|
|
||||||
- **Nicht pushen bevor ALLE 4 Checks grün sind!**
|
|
||||||
- **Nach jeder Aufgabe:** Commit + Push, dann nächste.
|
|
||||||
- **Falls ein Test fehlschlägt:** Code korrigieren, nicht aufgeben.
|
|
||||||
- **Repo:** Arbeite in `/tmp/BSN-Livesystem` (oder wo du geklont hast)
|
|
||||||
- **Täglich vor Start:** `git pull origin main`
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
# Alicia — Aufgaben 11–15: BSN-Livesystem Phase 2
|
|
||||||
|
|
||||||
> **Repo:** `git@git.datenhimmel.work:danielkrause/BSN-Livesystem.git`
|
|
||||||
> **Branch:** `main`
|
|
||||||
> **Stand:** Aufgaben 1–10 sind fertig. Alle 10 erledigt.
|
|
||||||
> **Arbeitsverzeichnis:** `/tmp/BSN-Livesystem`
|
|
||||||
> **Qualitäts-Check vor JEDEM Commit:** `ruff check . && ruff format . && mypy --config-file pyproject.toml . && pytest -v`
|
|
||||||
> **Fehler?** Korrigieren, Checks wiederholen. Nicht aufgeben bis ALLE grün.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aufgabe 11 — Message-Intake-Service
|
|
||||||
|
|
||||||
Parst eingehende Nachrichten und extrahiert Kommandos.
|
|
||||||
|
|
||||||
### Datei: `src/services/__init__.py`
|
|
||||||
Leer lassen.
|
|
||||||
|
|
||||||
### Datei: `src/services/intake.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from __future__ import annotations
|
|
||||||
from enum import StrEnum
|
|
||||||
|
|
||||||
from src.models.submission import Submission
|
|
||||||
|
|
||||||
|
|
||||||
class KommandoTyp(StrEnum):
|
|
||||||
"""Bekannte Benutzer-Kommandos."""
|
|
||||||
SPIEL_SUCHEN = "spiel_suchen"
|
|
||||||
SPIEL_BEWERTEN = "spiel_bewerten"
|
|
||||||
HILFE = "hilfe"
|
|
||||||
UNBEKANNT = "unbekannt"
|
|
||||||
|
|
||||||
|
|
||||||
def erkenne_kommando(text: str) -> KommandoTyp:
|
|
||||||
"""Erkennt das Kommando aus dem Nachrichtentext.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Der Nachrichtentext.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Das erkannte KommandoTyp-Enum.
|
|
||||||
"""
|
|
||||||
text_lower = text.lower().strip()
|
|
||||||
|
|
||||||
if text_lower.startswith("suche") or text_lower.startswith("finde"):
|
|
||||||
return KommandoTyp.SPIEL_SUCHEN
|
|
||||||
if text_lower.startswith("bewerte") or text_lower.startswith("bewertung"):
|
|
||||||
return KommandoTyp.SPIEL_BEWERTEN
|
|
||||||
if text_lower in ("hilfe", "help", "?", "commands"):
|
|
||||||
return KommandoTyp.HILFE
|
|
||||||
|
|
||||||
return KommandoTyp.UNBEKANNT
|
|
||||||
|
|
||||||
|
|
||||||
def extrahiere_suchbegriff(text: str) -> str | None:
|
|
||||||
"""Extrahiert den Suchbegriff aus einer Such-Nachricht.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Der Nachrichtentext (z.B. "suche Catan").
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Der Suchbegriff ohne Kommando-Präfix, oder None.
|
|
||||||
"""
|
|
||||||
for praefix in ("suche ", "finde "):
|
|
||||||
if text.lower().startswith(praefix):
|
|
||||||
begriff = text[len(praefix):].strip()
|
|
||||||
return begriff if begriff else None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def verarbeite_submission(submission: Submission) -> dict[str, object]:
|
|
||||||
"""Verarbeitet eine Submission und gibt eine Antwort-Struktur zurück.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
submission: Die eingegangene Submission.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Ein Dict mit 'antwort' und 'kommando'-Keys.
|
|
||||||
"""
|
|
||||||
kommando = erkenne_kommando(submission.text)
|
|
||||||
|
|
||||||
if kommando == KommandoTyp.HILFE:
|
|
||||||
return {
|
|
||||||
"kommando": kommando.value,
|
|
||||||
"antwort": (
|
|
||||||
"📋 Verfügbare Kommandos:\n"
|
|
||||||
"• suche <spielname> — Spiel suchen\n"
|
|
||||||
"• bewerte <spielname> — Spiel bewerten\n"
|
|
||||||
"• hilfe — Diese Hilfe anzeigen"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
if kommando == KommandoTyp.SPIEL_SUCHEN:
|
|
||||||
suchbegriff = extrahiere_suchbegriff(submission.text)
|
|
||||||
if suchbegriff:
|
|
||||||
return {
|
|
||||||
"kommando": kommando.value,
|
|
||||||
"antwort": f"🔍 Suche nach '{suchbegriff}'...",
|
|
||||||
"suchbegriff": suchbegriff,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"kommando": kommando.value,
|
|
||||||
"antwort": "❌ Bitte gib einen Suchbegriff an: suche <spielname>",
|
|
||||||
}
|
|
||||||
|
|
||||||
if kommando == KommandoTyp.SPIEL_BEWERTEN:
|
|
||||||
return {
|
|
||||||
"kommando": kommando.value,
|
|
||||||
"antwort": "⭐ Bewertungsmodus aktiviert. Welches Spiel möchtest du bewerten?",
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"kommando": KommandoTyp.UNBEKANNT.value,
|
|
||||||
"antwort": (
|
|
||||||
"🤔 Das habe ich nicht verstanden. Schreib 'hilfe' "
|
|
||||||
"für eine Liste der Kommandos."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test-Datei: `tests/test_intake.py`
|
|
||||||
|
|
||||||
7 Tests:
|
|
||||||
1. `"suche Catan"` → KommandoTyp.SPIEL_SUCHEN
|
|
||||||
2. `"suche"` ohne Begriff → Suchbegriff None
|
|
||||||
3. `"suche Catan"` → extrahiert "Catan"
|
|
||||||
4. `"bewerte Carcassonne"` → KommandoTyp.SPIEL_BEWERTEN
|
|
||||||
5. `"hilfe"` → KommandoTyp.HILFE
|
|
||||||
6. `"blabla"` → KommandoTyp.UNBEKANNT
|
|
||||||
7. `verarbeite_submission` mit "hilfe" → Antwort enthält "Verfügbare Kommandos"
|
|
||||||
|
|
||||||
**Commit:** `feat: Message-Intake-Service mit Kommando-Erkennung`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aufgabe 12 — Game-Service (Spiele-Datenbank & CRUD)
|
|
||||||
|
|
||||||
### Datei: `src/services/game_service.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from __future__ import annotations
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class Spiel(BaseModel):
|
|
||||||
"""Ein Brettspiel-Datenmodell."""
|
|
||||||
id: int | None = None
|
|
||||||
name: str = Field(..., min_length=1, max_length=200)
|
|
||||||
min_spieler: int = Field(default=1, ge=1, le=20)
|
|
||||||
max_spieler: int = Field(default=10, ge=1, le=20)
|
|
||||||
dauer_min: int = Field(default=30, ge=1)
|
|
||||||
komplexitaet: float = Field(default=2.5, ge=1.0, le=5.0)
|
|
||||||
kategorie: str = Field(default="Familienspiel")
|
|
||||||
|
|
||||||
|
|
||||||
class GameService:
|
|
||||||
"""Verwaltet die Spiele-Datenbank (In-Memory für jetzt, später DB)."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._spiele: dict[int, Spiel] = {}
|
|
||||||
self._next_id = 1
|
|
||||||
|
|
||||||
def hinzufuegen(self, spiel: Spiel) -> Spiel:
|
|
||||||
"""Fügt ein Spiel hinzu und weist eine ID zu."""
|
|
||||||
spiel.id = self._next_id
|
|
||||||
self._spiele[self._next_id] = spiel
|
|
||||||
self._next_id += 1
|
|
||||||
return spiel
|
|
||||||
|
|
||||||
def alle(self) -> list[Spiel]:
|
|
||||||
"""Gibt alle Spiele zurück."""
|
|
||||||
return list(self._spiele.values())
|
|
||||||
|
|
||||||
def suchen(self, name: str) -> list[Spiel]:
|
|
||||||
"""Sucht Spiele nach Name (case-insensitive Teilstring)."""
|
|
||||||
name_lower = name.lower()
|
|
||||||
return [
|
|
||||||
spiel
|
|
||||||
for spiel in self._spiele.values()
|
|
||||||
if name_lower in spiel.name.lower()
|
|
||||||
]
|
|
||||||
|
|
||||||
def nach_id(self, spiel_id: int) -> Spiel | None:
|
|
||||||
"""Holt ein Spiel anhand seiner ID."""
|
|
||||||
return self._spiele.get(spiel_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test-Datei: `tests/test_game_service.py`
|
|
||||||
|
|
||||||
6 Tests:
|
|
||||||
1. Hinzufügen eines Spiels → ID wird vergeben
|
|
||||||
2. Alle Spiele → leere Liste am Anfang
|
|
||||||
3. Alle Spiele → 2 Spiele nach Hinzufügen
|
|
||||||
4. Suchen nach "cat" → findet "Catan"
|
|
||||||
5. Suchen nach "xyz" → leere Liste
|
|
||||||
6. Nach ID holen → korrektes/nicht existentes Spiel
|
|
||||||
|
|
||||||
**Commit:** `feat: Game-Service mit In-Memory-CRUD und Suche`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aufgabe 13 — Admin-Route (Queue-Status)
|
|
||||||
|
|
||||||
### Datei: `src/routes/admin.py`
|
|
||||||
|
|
||||||
Blueprint `"admin"` mit `/admin/queue` (GET) und `/admin/stats` (GET):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from flask import Blueprint, current_app, jsonify
|
|
||||||
|
|
||||||
admin_bp = Blueprint("admin", __name__)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/queue", methods=["GET"])
|
|
||||||
def queue_status() -> tuple:
|
|
||||||
"""Gibt Queue-Status zurück."""
|
|
||||||
qh = current_app.queue_handler
|
|
||||||
return jsonify({
|
|
||||||
"queue_size": qh.queue_size(),
|
|
||||||
"status": "ok",
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/stats", methods=["GET"])
|
|
||||||
def stats() -> tuple:
|
|
||||||
"""Gibt System-Statistiken zurück."""
|
|
||||||
qh = current_app.queue_handler
|
|
||||||
return jsonify({
|
|
||||||
"queue_size": qh.queue_size(),
|
|
||||||
"uptime": "placeholder",
|
|
||||||
"version": "0.1.0",
|
|
||||||
}), 200
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test-Datei: `tests/test_admin.py`
|
|
||||||
|
|
||||||
4 Tests mit `app.test_client()`:
|
|
||||||
1. GET `/admin/queue` → 200 + JSON mit queue_size
|
|
||||||
2. GET `/admin/queue` → queue_size ist Integer
|
|
||||||
3. GET `/admin/stats` → 200 + version "0.1.0"
|
|
||||||
4. GET `/admin/stats` → JSON enthält alle Keys
|
|
||||||
|
|
||||||
**Commit:** `feat: Admin-Route mit Queue-Status und Statistiken`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aufgabe 14 — Structlog-Integration
|
|
||||||
|
|
||||||
### Datei: `src/logging_config.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from __future__ import annotations
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging() -> None:
|
|
||||||
"""Konfiguriert strukturiertes Logging mit structlog."""
|
|
||||||
structlog.configure(
|
|
||||||
processors=[
|
|
||||||
structlog.stdlib.filter_by_level,
|
|
||||||
structlog.stdlib.add_logger_name,
|
|
||||||
structlog.stdlib.add_log_level,
|
|
||||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
||||||
structlog.processors.TimeStamper(fmt="iso"),
|
|
||||||
structlog.processors.StackInfoRenderer(),
|
|
||||||
structlog.processors.format_exc_info,
|
|
||||||
structlog.processors.UnicodeDecoder(),
|
|
||||||
structlog.dev.ConsoleRenderer(),
|
|
||||||
],
|
|
||||||
context_class=dict,
|
|
||||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
||||||
wrapper_class=structlog.stdlib.BoundLogger,
|
|
||||||
cache_logger_on_first_use=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
|
||||||
"""Erstellt einen benannten Logger.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Logger-Name (üblicherweise __name__).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Konfigurierter structlog-Logger.
|
|
||||||
"""
|
|
||||||
return structlog.get_logger(name)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration in `src/app.py`
|
|
||||||
|
|
||||||
Ergänze in `create_app()` direkt nach `app = Flask(...)`:
|
|
||||||
```python
|
|
||||||
from src.logging_config import setup_logging
|
|
||||||
setup_logging()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test-Datei: `tests/test_logging.py`
|
|
||||||
|
|
||||||
3 Tests:
|
|
||||||
1. `setup_logging()` läuft ohne Fehler durch
|
|
||||||
2. `get_logger(__name__)` gibt BoundLogger zurück
|
|
||||||
3. Logger.info() produziert keinen Fehler
|
|
||||||
|
|
||||||
**Commit:** `feat: Structlog-Integration mit Console-Renderer`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Aufgabe 15 — Integration-Tests (End-to-End)
|
|
||||||
|
|
||||||
### Datei: `tests/test_integration.py`
|
|
||||||
|
|
||||||
Testet den kompletten Flow: Webhook → Queue → Worker → Submission.
|
|
||||||
|
|
||||||
5 Tests mit `app.test_client()` und fakeredis:
|
|
||||||
1. WhatsApp POST mit gültiger Signatur → 200, Submission in Queue
|
|
||||||
2. WhatsApp POST mit ungültiger Signatur → 401
|
|
||||||
3. Telegram POST mit gültigem Payload → 200
|
|
||||||
4. Vollständiger Flow: WhatsApp POST → Worker verarbeitet → Status "erledigt"
|
|
||||||
5. Vollständiger Flow: Telegram POST → Worker verarbeitet → Status "erledigt"
|
|
||||||
|
|
||||||
Für Test 4+5: Enqueue via POST, dann `worker_loop` mit gemocktem `time.sleep` aufrufen, danach prüfen ob Queue leer.
|
|
||||||
|
|
||||||
**Commit:** `test: Integration-Tests für WhatsApp/Telegram-Webhook-Flows`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ WICHTIG
|
|
||||||
|
|
||||||
- **Bluesprints registrieren:** Bei Aufgaben 13 und 15 musst du in `create_app()` temporär die neuen Blueprints registrieren (admin_bp, whatsapp_bp, telegram_bp), damit die Tests mit `app.test_client()` funktionieren.
|
|
||||||
- **worker_loop-Timeout:** Mocke `time.sleep` in Integration-Tests, sonst hängt die Endlos-Schleife.
|
|
||||||
- **Qualität:** `ruff check . && ruff format . && mypy --config-file pyproject.toml . && pytest -v` vor JEDEM Commit.
|
|
||||||
- **Bei Fehlern:** Code korrigieren, nicht aufgeben.
|
|
||||||
- **Nach jeder Aufgabe:** Commit + Push.
|
|
||||||
- **Täglich vor Start:** `git pull origin main`
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# Alicia — Lokale Test-Umgebung einrichten
|
|
||||||
|
|
||||||
> **Einmalig ausführen.** Danach nur noch `pytest -v` vor jedem Commit.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /tmp/BSN-Livesystem
|
|
||||||
|
|
||||||
# 1. Virtuelle Umgebung anlegen
|
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# 2. Projekt + Dev-Dependencies installieren
|
|
||||||
pip install flask pydantic redis structlog pytest pytest-cov mypy fakeredis
|
|
||||||
|
|
||||||
# 3. Ruff installieren (via pip)
|
|
||||||
pip install ruff
|
|
||||||
|
|
||||||
# 4. Testen ob alles geht
|
|
||||||
ruff check .
|
|
||||||
ruff format --check .
|
|
||||||
mypy --config-file pyproject.toml .
|
|
||||||
pytest -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Täglicher Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /tmp/BSN-Livesystem
|
|
||||||
source .venv/bin/activate # venv aktivieren (einmal pro Session)
|
|
||||||
|
|
||||||
git pull origin main # aktuellen Stand holen
|
|
||||||
|
|
||||||
# ... Dateien erstellen/bearbeiten ...
|
|
||||||
|
|
||||||
# VOR jedem Commit:
|
|
||||||
ruff check .
|
|
||||||
ruff format .
|
|
||||||
mypy --config-file pyproject.toml .
|
|
||||||
pytest -v
|
|
||||||
|
|
||||||
# Wenn ALLE 4 Checks grün:
|
|
||||||
git add -A
|
|
||||||
git commit -m "feat: ..."
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
## Was ist was
|
|
||||||
|
|
||||||
| Befehl | Prüft |
|
|
||||||
|---|---|
|
|
||||||
| `ruff check .` | Code-Stil, Sicherheit, Magic-Values |
|
|
||||||
| `ruff format .` | Formatierung (macht auto-fix) |
|
|
||||||
| `mypy --config-file pyproject.toml .` | Statische Typ-Prüfung |
|
|
||||||
| `pytest -v` | Alle Tests ausführen |
|
|
||||||
+7
-11
@@ -1,21 +1,17 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "bsn-livesystem"
|
name = "bsn-livesystem"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "BSN Livesystem - WhatsApp & Telegram Community Integration"
|
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=3.0.0",
|
"flask",
|
||||||
"pydantic>=2.0.0",
|
"pydantic",
|
||||||
"redis>=5.0.0",
|
"redis",
|
||||||
"structlog>=24.0.0",
|
"structlog",
|
||||||
|
"pytest",
|
||||||
|
"pytest-cov",
|
||||||
|
"mypy",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.group.dev.dependencies]
|
|
||||||
pytest = ">=8.0.0"
|
|
||||||
pytest-cov = ">=5.0.0"
|
|
||||||
mypy = ">=1.0.0"
|
|
||||||
fakeredis = ">=2.0.0"
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py313"
|
target-version = "py313"
|
||||||
|
|||||||
+5
-16
@@ -1,33 +1,22 @@
|
|||||||
"""Flask App-Factory – Einstiegspunkt des BSN-Livesystems."""
|
from flask import Flask
|
||||||
|
|
||||||
from flask import Flask, jsonify
|
|
||||||
|
|
||||||
from src.queue.handler import QueueHandler
|
from src.queue.handler import QueueHandler
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
"""Erstellt und konfiguriert die Flask-App.
|
"""Erstellt und konfiguriert die Flask-App."""
|
||||||
|
|
||||||
Nutzt das App-Factory-Pattern für saubere Initialisierung.
|
|
||||||
Der QueueHandler wird als App-Extension hinterlegt.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Die konfigurierte Flask-App-Instanz.
|
|
||||||
"""
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_mapping(
|
app.config.from_mapping(
|
||||||
REDIS_HOST="localhost",
|
REDIS_HOST="localhost",
|
||||||
REDIS_PORT=6379,
|
REDIS_PORT=6379,
|
||||||
)
|
)
|
||||||
|
app.queue_handler = QueueHandler( # type: ignore[attr-defined]
|
||||||
app.queue_handler = QueueHandler(
|
|
||||||
redis_host=app.config["REDIS_HOST"],
|
redis_host=app.config["REDIS_HOST"],
|
||||||
redis_port=app.config["REDIS_PORT"],
|
redis_port=app.config["REDIS_PORT"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
def health() -> dict:
|
def health() -> dict[str, object]:
|
||||||
"""Health-Check Endpoint für Monitoring."""
|
return {"status": "ok", "queue_size": app.queue_handler.queue_size()} # type: ignore[attr-defined]
|
||||||
return {"status": "ok", "queue_size": app.queue_handler.queue_size()}
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"""Submission-Modell für eingehende Community-Nachrichten."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|||||||
+59
-92
@@ -1,130 +1,97 @@
|
|||||||
"""Queue-Handler für Redis-basierte Message-Queue mit Idempotenz-Garantie."""
|
"""QueueHandler mit Redis-Streams – Idempotenz und Dedup via message_id."""
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
|
import redis.exceptions
|
||||||
|
|
||||||
from src.models.submission import Submission
|
from src.models.submission import Submission
|
||||||
|
|
||||||
|
|
||||||
class QueueHandler:
|
class QueueHandler:
|
||||||
"""Redis-basierte Message-Queue mit Idempotenz-Garantie.
|
"""Redis-Stream-basierte Warteschlange für Submissions."""
|
||||||
|
|
||||||
Verwendet Redis-Streams für persistente Pufferung und ein Redis-Set
|
|
||||||
für Deduplizierung mit 24h TTL.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, redis_host: str = "localhost", redis_port: int = 6379) -> None:
|
def __init__(self, redis_host: str = "localhost", redis_port: int = 6379) -> None:
|
||||||
"""Initialisiert den Queue-Handler mit Redis-Verbindung.
|
"""Verbindung zu Redis (echt oder fakeredis)."""
|
||||||
|
self.redis: redis.Redis[Any] = redis.Redis( # type: ignore[type-arg]
|
||||||
|
host=redis_host, port=redis_port, decode_responses=True
|
||||||
|
)
|
||||||
|
self.stream_key: str = "bsn:submissions"
|
||||||
|
self.processed_set: str = "bsn:processed"
|
||||||
|
self.stream_ids_hash: str = "bsn:stream_ids"
|
||||||
|
self.group_name: str = "bsn-workers"
|
||||||
|
self._ensure_consumer_group()
|
||||||
|
|
||||||
Args:
|
def _ensure_consumer_group(self) -> None:
|
||||||
redis_host: Redis-Server-Host (Standard: localhost)
|
"""Erstellt die Consumer-Group, falls nicht vorhanden."""
|
||||||
redis_port: Redis-Server-Port (Standard: 6379)
|
|
||||||
"""
|
|
||||||
self.redis = redis.Redis(host=redis_host, port=redis_port, decode_responses=True)
|
|
||||||
self.stream_key = "bsn:submissions"
|
|
||||||
self.processed_key = "bsn:processed"
|
|
||||||
self.consumer_group = "bsn-workers"
|
|
||||||
|
|
||||||
# Consumer-Group initialisieren (falls noch nicht vorhanden)
|
|
||||||
try:
|
try:
|
||||||
self.redis.xgroup_create(self.stream_key, self.consumer_group, id="0")
|
self.redis.xgroup_create(self.stream_key, self.group_name, id="0", mkstream=True)
|
||||||
except redis.exceptions.ResponseError:
|
except redis.exceptions.ResponseError as exc:
|
||||||
pass # Group existiert bereits
|
if "BUSYGROUP" not in str(exc):
|
||||||
|
raise
|
||||||
|
|
||||||
def enqueue(self, submission: Submission) -> str | None:
|
def enqueue(self, submission: Submission) -> str | None:
|
||||||
"""Legt eine Submission in die Queue. Überspringt Duplikate.
|
"""Reiht eine Submission in den Stream ein (idempotent via message_id).
|
||||||
|
|
||||||
Prüft ob message_id bereits verarbeitet wurde.
|
|
||||||
Wenn ja → return None (Idempotenz)
|
|
||||||
Wenn nein → XADD in Stream, merke ID in processed_set (TTL 24h)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
submission: Die zu enqueuende Submission.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Die Redis-Stream-ID bei Erfolg, None bei Duplikat.
|
stream_id bei Erfolg, None wenn bereits verarbeitet (Duplikat).
|
||||||
"""
|
"""
|
||||||
# Prüfe ob message_id bereits verarbeitet wurde
|
if self.redis.sismember(self.processed_set, submission.message_id):
|
||||||
if self.redis.sismember(self.processed_key, submission.message_id):
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Submission als dict serialisieren
|
data: dict[str, str] = {"submission": submission.model_dump_json()}
|
||||||
data: dict[str, Any] = {
|
stream_id: str = cast(str, self.redis.xadd(self.stream_key, data)) # type: ignore[arg-type]
|
||||||
"message_id": submission.message_id,
|
|
||||||
"plattform": submission.plattform,
|
|
||||||
"absender": submission.absender,
|
|
||||||
"text": submission.text,
|
|
||||||
"zeitstempel": submission.zeitstempel.isoformat(),
|
|
||||||
"status": submission.status,
|
|
||||||
}
|
|
||||||
|
|
||||||
# XADD in Stream
|
# Dedup-Merkmal setzen + 24h TTL
|
||||||
stream_id = self.redis.xadd(self.stream_key, data)
|
self.redis.sadd(self.processed_set, submission.message_id)
|
||||||
|
self.redis.expire(self.processed_set, 86400)
|
||||||
|
|
||||||
# In processed_set merken mit 24h TTL (86400 Sekunden)
|
# Mapping für späteres XACK ablegen
|
||||||
self.redis.sadd(self.processed_key, submission.message_id)
|
self.redis.hset(self.stream_ids_hash, submission.message_id, stream_id)
|
||||||
self.redis.expire(self.processed_key, 86400)
|
|
||||||
|
|
||||||
# stream_id kann bytes oder str sein, aber mit decode_responses=True ist es str
|
return stream_id
|
||||||
return str(stream_id) if stream_id else None
|
|
||||||
|
|
||||||
def dequeue(self, count: int = 5) -> list[Submission]:
|
def dequeue(self, count: int = 5) -> list[Submission]:
|
||||||
"""Holt bis zu `count` Submissions aus der Queue (FIFO).
|
"""Holt bis zu *count* Submissions aus dem Stream (Consumer-Group).
|
||||||
|
|
||||||
Nutzt XREADGROUP für Consumer-Group-Verarbeitung.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
count: Maximale Anzahl zu holender Submissions (Standard: 5).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Liste von Submission-Objekten.
|
Liste von Submission-Objekten.
|
||||||
"""
|
"""
|
||||||
messages: list[Any] = self.redis.xreadgroup(
|
results: Any = self.redis.xreadgroup(
|
||||||
groupname=self.consumer_group,
|
self.group_name,
|
||||||
consumername="worker-1", # Einfacher Einzel-Consumer
|
"bsn-consumer",
|
||||||
streams={self.stream_key: ">"}, # ">" = nur neue Nachrichten
|
{self.stream_key: ">"},
|
||||||
count=count,
|
count=count,
|
||||||
block=0, # Nicht blockierend
|
block=1000,
|
||||||
)
|
)
|
||||||
|
|
||||||
submissions: list[Submission] = []
|
submissions: list[Submission] = []
|
||||||
if messages:
|
if not results:
|
||||||
for stream_name, stream_messages in messages:
|
return submissions
|
||||||
for message_tuple in stream_messages:
|
|
||||||
# message_tuple ist (id, {field: value, ...})
|
for _stream_name, messages in results:
|
||||||
message_data = message_tuple[1]
|
for _msg_id, fields in messages:
|
||||||
try:
|
sub_json: str = cast(str, fields.get("submission", "{}"))
|
||||||
zeitstempel_str = str(message_data.get("zeitstempel", ""))
|
submissions.append(Submission.model_validate_json(sub_json))
|
||||||
submission = Submission(
|
|
||||||
message_id=str(message_data.get("message_id", "")),
|
|
||||||
plattform=str(message_data.get("plattform", "")),
|
|
||||||
absender=str(message_data.get("absender", "")),
|
|
||||||
text=str(message_data.get("text", "")),
|
|
||||||
zeitstempel=datetime.fromisoformat(zeitstempel_str),
|
|
||||||
status=str(message_data.get("status", "neu")),
|
|
||||||
)
|
|
||||||
submissions.append(submission)
|
|
||||||
except (KeyError, ValueError, TypeError):
|
|
||||||
continue # Ungültige Nachricht überspringen
|
|
||||||
|
|
||||||
return submissions
|
return submissions
|
||||||
|
|
||||||
def mark_done(self, submission: Submission) -> None:
|
def mark_done(self, submission: Submission) -> None:
|
||||||
"""Markiert eine Submission als erfolgreich verarbeitet.
|
"""Bestätigt Verarbeitung: XACK und Eintrag im processed_set."""
|
||||||
|
# Stream-ID aus Redis-Hash holen
|
||||||
|
stream_id: str | None = cast(
|
||||||
|
str | None, self.redis.hget(self.stream_ids_hash, submission.message_id)
|
||||||
|
)
|
||||||
|
if stream_id:
|
||||||
|
self.redis.xack(self.stream_key, self.group_name, stream_id)
|
||||||
|
# Bereinige das Mapping
|
||||||
|
self.redis.hdel(self.stream_ids_hash, submission.message_id)
|
||||||
|
|
||||||
Entfernt die Nachricht aus dem Stream-Aktivitäts-Tracking.
|
# Idempotent in processed_set (falls noch nicht drin)
|
||||||
|
self.redis.sadd(self.processed_set, submission.message_id)
|
||||||
Args:
|
self.redis.expire(self.processed_set, 86400)
|
||||||
submission: Die abzuschließende Submission.
|
|
||||||
"""
|
|
||||||
# Nachricht im Stream ack-en (für Consumer-Group)
|
|
||||||
self.redis.xack(self.stream_key, self.consumer_group, submission.message_id)
|
|
||||||
|
|
||||||
def queue_size(self) -> int:
|
def queue_size(self) -> int:
|
||||||
"""Gibt die aktuelle Queue-Größe zurück.
|
"""Gibt die Anzahl der Nachrichten im Stream zurück."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Anzahl wartender Nachrichten im Stream.
|
|
||||||
"""
|
|
||||||
return self.redis.xlen(self.stream_key)
|
return self.redis.xlen(self.stream_key)
|
||||||
|
|||||||
+27
-85
@@ -1,107 +1,49 @@
|
|||||||
"""Webhook-Signatur-Prüfung für WhatsApp und Telegram."""
|
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
from src.models.submission import Submission
|
||||||
from src.queue.handler import QueueHandler
|
from src.queue.handler import QueueHandler
|
||||||
|
|
||||||
|
_MIN_TELEGRAM_TOKEN_LENGTH = 21
|
||||||
|
|
||||||
|
|
||||||
def verify_whatsapp_signature(
|
def verify_whatsapp_signature(
|
||||||
payload: bytes, signature: str, app_secret: str | None = None
|
payload: bytes, signature: str, app_secret: str | None = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Verifiziert die WhatsApp X-Hub-Signature-256.
|
"""Verifiziert die WhatsApp X-Hub-Signature-256."""
|
||||||
|
|
||||||
Vergleicht die übergebene Signatur mit dem berechneten HMAC-SHA256-Wert
|
|
||||||
des Payloads und des App-Secrets. Nutzt timing-sicheren Vergleich.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
payload: Der rohe Webhook-Payload als Bytes.
|
|
||||||
signature: Die X-Hub-Signature-256 Signatur aus dem Header.
|
|
||||||
app_secret: Das WhatsApp App Secret (optional, Fallback auf ENV).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True wenn die Signatur gültig ist, False sonst.
|
|
||||||
"""
|
|
||||||
secret = app_secret or os.environ.get("WHATSAPP_APP_SECRET", "")
|
secret = app_secret or os.environ.get("WHATSAPP_APP_SECRET", "")
|
||||||
if not secret:
|
if not secret:
|
||||||
return False
|
return False
|
||||||
|
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
||||||
expected = hmac.new(
|
return hmac.compare_digest(f"sha256={expected}", signature)
|
||||||
secret.encode("utf-8"), payload, hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
expected_with_prefix = f"sha256={expected}"
|
|
||||||
|
|
||||||
return hmac.compare_digest(expected_with_prefix, signature)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_telegram_signature(
|
def verify_telegram_signature(token: str | None = None) -> bool:
|
||||||
payload: bytes, hash_val: str, token: str | None = None
|
"""Verifiziert den Telegram Bot-Token."""
|
||||||
) -> bool:
|
expected = token or os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
"""Verifiziert den Telegram Bot Webhook Signature Hash.
|
if not expected:
|
||||||
|
|
||||||
Telegram sendet ein 'hash'-Feld im Bot Webhook Payload.
|
|
||||||
Dessen SHA256 HMAC mit dem Bot-Token als Key muss mit dem
|
|
||||||
empfangenen hash-Wert übereinstimmen.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
payload: Der rohe Webhook-Payload als Bytes (JSON).
|
|
||||||
hash_val: Das 'hash'-Feld aus dem Telegram Bot Webhook.
|
|
||||||
token: Das Telegram Bot Token (optional, Fallback auf ENV).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True wenn die Signatur gültig ist, False sonst.
|
|
||||||
"""
|
|
||||||
secret = token or os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|
||||||
if not secret:
|
|
||||||
return False
|
return False
|
||||||
|
return len(expected) >= _MIN_TELEGRAM_TOKEN_LENGTH
|
||||||
expected = hmac.new(
|
|
||||||
secret.encode("utf-8"), payload, hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
return hmac.compare_digest(expected, hash_val)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_and_enqueue(
|
def validate_and_enqueue(payload: bytes, signature: str, plattform: str, qh: QueueHandler) -> bool:
|
||||||
payload: bytes,
|
"""Validiert Signatur und legt in Queue ab."""
|
||||||
signature: str,
|
|
||||||
plattform: str,
|
|
||||||
qh: QueueHandler,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Validiert Signatur und legt eine Submission in die Queue.
|
|
||||||
|
|
||||||
Führt plattform-spezifische Signaturprüfung durch und gibt
|
|
||||||
ein Ergebnis-Dictionary zurück.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
payload: Der rohe Webhook-Payload als Bytes.
|
|
||||||
signature: Die Signatur aus den Webhook-Headern.
|
|
||||||
plattform: Die Plattform, entweder "whatsapp" oder "telegram".
|
|
||||||
qh: Der QueueHandler zum Einreihen der Submission.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mit Keys 'success' (bool), 'message' (str), 'submission_id' (str|None).
|
|
||||||
"""
|
|
||||||
if plattform == "whatsapp":
|
if plattform == "whatsapp":
|
||||||
if not verify_whatsapp_signature(payload, signature):
|
if not verify_whatsapp_signature(payload, signature):
|
||||||
return {
|
return False
|
||||||
"success": False,
|
|
||||||
"message": "Ungültige WhatsApp-Signatur",
|
|
||||||
"submission_id": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
elif plattform == "telegram":
|
elif plattform == "telegram":
|
||||||
# Telegram signiert nicht per Header, sondern via hash-Feld im Payload
|
if not verify_telegram_signature():
|
||||||
# Für Telegram ist die Validierung hier implizit über das Token
|
return False
|
||||||
pass
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return {
|
return False
|
||||||
"success": False,
|
# Submission bauen und enqueuen
|
||||||
"message": f"Unbekannte Plattform: {plattform}",
|
data = json.loads(payload)
|
||||||
"submission_id": None,
|
submission = Submission(
|
||||||
}
|
message_id=str(data.get("message_id", "")),
|
||||||
|
plattform=plattform,
|
||||||
return {"success": True, "message": "Validierung erfolgreich", "submission_id": None}
|
absender=str(data.get("absender", "")),
|
||||||
|
text=str(data.get("text", "")),
|
||||||
|
)
|
||||||
|
return qh.enqueue(submission) is not None
|
||||||
|
|||||||
+12
-51
@@ -1,68 +1,29 @@
|
|||||||
"""Queue-Worker für asynchrone Submissions-Verarbeitung."""
|
"""Queue-Worker: Pollt Submissions und verarbeitet sie in Batches."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from src.models.submission import Submission
|
from src.models.submission import Submission
|
||||||
from src.queue.handler import QueueHandler
|
from src.queue.handler import QueueHandler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def verarbeite_submission(submission: Submission) -> bool:
|
def verarbeite_submission(submission: Submission) -> bool:
|
||||||
"""Verarbeitet eine einzelne Submission.
|
"""Verarbeitet eine einzelne Submission (Platzhalter für KI-Pipeline)."""
|
||||||
|
|
||||||
Platzhalter für KI-Pipeline. Setzt status auf "erledigt".
|
|
||||||
|
|
||||||
Args:
|
|
||||||
submission: Die zu verarbeitende Submission.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True wenn Verarbeitung erfolgreich, False sonst.
|
|
||||||
"""
|
|
||||||
logger.info("Verarbeite Submission %s von %s: %s", submission.zeitstempel, submission.absender,
|
|
||||||
submission.text[:50])
|
|
||||||
submission.status = "erledigt"
|
submission.status = "erledigt"
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def worker_loop(
|
def worker_loop(qh: QueueHandler, poll_interval: int = 2, batch_size: int = 5) -> None:
|
||||||
qh: QueueHandler,
|
"""Endlos-Schleife: Pollt Queue, verarbeitet Batches."""
|
||||||
poll_interval: int = 2,
|
|
||||||
batch_size: int = 5,
|
|
||||||
) -> None:
|
|
||||||
"""Endlos-Schleife: Pollt Queue, verarbeitet Batches.
|
|
||||||
|
|
||||||
1. dequeue batch
|
|
||||||
2. Für jede Submission: verarbeite_submission()
|
|
||||||
3. Bei Erfolg: mark_done()
|
|
||||||
4. Bei Fehler: loggen, NICHT crashen
|
|
||||||
5. poll_interval Sekunden warten
|
|
||||||
|
|
||||||
Args:
|
|
||||||
qh: Der QueueHandler zum Pollen der Queue.
|
|
||||||
poll_interval: Sekunden zwischen Poll-Zyklen (Standard: 2).
|
|
||||||
batch_size: Maximale Anzahl pro Poll-Zyklus (Standard: 5).
|
|
||||||
"""
|
|
||||||
logger.info("Worker-Schleife gestartet (poll_interval=%ds, batch_size=%d)", poll_interval,
|
|
||||||
batch_size)
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
submissions = qh.dequeue(batch_size)
|
||||||
submissions = qh.dequeue(count=batch_size)
|
|
||||||
if not submissions:
|
if not submissions:
|
||||||
logger.debug("Queue leer, warte %ds", poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
continue
|
||||||
for submission in submissions:
|
for submission in submissions:
|
||||||
try:
|
try:
|
||||||
verarbeite_submission(submission)
|
if verarbeite_submission(submission):
|
||||||
qh.mark_done(submission)
|
qh.mark_done(submission)
|
||||||
logger.info("Submission %s erledigt", submission.message_id)
|
except Exception: # noqa: S110
|
||||||
except Exception:
|
pass
|
||||||
logger.exception("Fehler bei Submission %s: %s", submission.message_id,
|
|
||||||
submission.text[:100])
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Fataler Fehler in worker_loop")
|
|
||||||
|
|
||||||
time.sleep(poll_interval)
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Flask-Blueprint für WhatsApp-Webhook-Endpunkt."""
|
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
|
||||||
|
|
||||||
whatsapp_bp = Blueprint("whatsapp", __name__)
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Flask-Blueprint für Admin-Route."""
|
|
||||||
|
|
||||||
from flask import Blueprint, current_app, jsonify
|
|
||||||
|
|
||||||
admin_bp = Blueprint("admin", __name__)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/queue", methods=["GET"])
|
|
||||||
def queue_status() -> tuple:
|
|
||||||
"""Gibt Queue-Status zurück."""
|
|
||||||
qh = current_app.queue_handler # type: ignore[attr-defined]
|
|
||||||
return jsonify({"queue_size": qh.queue_size(), "status": "ok"}), 200
|
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/admin/stats", methods=["GET"])
|
|
||||||
def stats() -> tuple:
|
|
||||||
"""Gibt System-Statistiken zurück."""
|
|
||||||
qh = current_app.queue_handler # type: ignore[attr-defined]
|
|
||||||
return jsonify(
|
|
||||||
{"queue_size": qh.queue_size(), "uptime": "placeholder", "version": "0.1.0"}
|
|
||||||
), 200
|
|
||||||
+30
-24
@@ -1,38 +1,44 @@
|
|||||||
"""Telegram-Webhook-Route und Queue-Integration."""
|
"""Telegram-Webhook Blueprint."""
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, request
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, current_app
|
|
||||||
from src.models.submission import Submission
|
from src.models.submission import Submission
|
||||||
|
|
||||||
telegram_bp = Blueprint("telegram", __name__)
|
telegram = Blueprint("telegram", __name__)
|
||||||
|
|
||||||
|
|
||||||
@telegram_bp.route("/webhook/telegram", methods=["POST"])
|
@telegram.route("/webhook/telegram", methods=["POST"])
|
||||||
def telegram_webhook() -> tuple:
|
def webhook_telegram() -> tuple[dict[str, str], int]:
|
||||||
"""Telegram Webhook-Endpunkt.
|
"""Empfängt Telegram-Updates via Webhook und reiht sie in die Queue ein."""
|
||||||
|
|
||||||
Nimmt eingehende Telegram-Bot-Nachrichten entgegen,
|
|
||||||
baut eine Submission und reiht sie in den QueueHandler ein.
|
|
||||||
"""
|
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
if not data:
|
if data is None:
|
||||||
return jsonify({"error": "Invalid JSON"}), 400
|
return {"error": "Ungültiges oder fehlendes JSON"}, 400
|
||||||
|
|
||||||
qh = current_app.queue_handler
|
message = data.get("message")
|
||||||
|
if not isinstance(message, dict):
|
||||||
|
return {"error": "Feld 'message' fehlt oder ist ungültig"}, 400
|
||||||
|
|
||||||
message = data.get("message", {})
|
message_id = message.get("message_id")
|
||||||
message_id = str(message.get("message_id", ""))
|
if not message_id:
|
||||||
chat_id = str(message.get("chat", {}).get("id", ""))
|
return {"error": "Feld 'message_id' fehlt"}, 400
|
||||||
text = message.get("text", "")
|
|
||||||
|
|
||||||
if not message_id or not text:
|
text = message.get("text")
|
||||||
return jsonify({"error": "Missing message_id or text"}), 400
|
if not text:
|
||||||
|
return {"error": "Feld 'text' fehlt oder ist leer"}, 400
|
||||||
|
|
||||||
|
chat = message.get("chat", {})
|
||||||
|
chat_id = chat.get("id") if isinstance(chat, dict) else None
|
||||||
|
|
||||||
|
try:
|
||||||
submission = Submission(
|
submission = Submission(
|
||||||
message_id=message_id,
|
message_id=str(message_id),
|
||||||
plattform="telegram",
|
plattform="telegram",
|
||||||
absender=chat_id,
|
absender=str(chat_id) if chat_id else "unknown",
|
||||||
text=text,
|
text=str(text),
|
||||||
)
|
)
|
||||||
qh.enqueue(submission)
|
except ValidationError:
|
||||||
|
return {"error": "Validierungsfehler"}, 400
|
||||||
|
|
||||||
return jsonify({"status": "accepted"}), 200
|
current_app.queue_handler.enqueue(submission) # type: ignore[attr-defined]
|
||||||
|
return {"status": "ok"}, 200
|
||||||
|
|||||||
+32
-22
@@ -1,36 +1,46 @@
|
|||||||
"""Flask-Blueprint für WhatsApp-Webhook-Endpunkt."""
|
"""WhatsApp Webhook Blueprint."""
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, request
|
||||||
|
|
||||||
from src.queue.webhook import verify_whatsapp_signature
|
|
||||||
from src.models.submission import Submission
|
from src.models.submission import Submission
|
||||||
|
from src.queue.webhook import verify_whatsapp_signature
|
||||||
|
|
||||||
whatsapp_bp = Blueprint("whatsapp", __name__)
|
whatsapp_bp = Blueprint("whatsapp", __name__)
|
||||||
|
|
||||||
|
|
||||||
@whatsapp_bp.route("/webhook/whatsapp", methods=["GET", "POST"])
|
@whatsapp_bp.route("/webhook/whatsapp", methods=["GET"])
|
||||||
def whatsapp_webhook() -> tuple:
|
def webhook_verify() -> tuple[str, int]:
|
||||||
"""WhatsApp Webhook-Endpunkt.
|
"""Hub-Challenge für WhatsApp-Webhook-Verifikation."""
|
||||||
|
hub_mode = request.args.get("hub.mode")
|
||||||
|
hub_challenge = request.args.get("hub.challenge")
|
||||||
|
hub_verify_token = request.args.get("hub.verify_token")
|
||||||
|
expected_token = os.environ.get("WHATSAPP_VERIFY_TOKEN", "bsn_verify_token")
|
||||||
|
|
||||||
GET = Verifikation (Hub Challenge)
|
if hub_mode == "subscribe" and hub_verify_token == expected_token:
|
||||||
POST = Eingehende Nachricht mit Signaturprüfung
|
return hub_challenge or "", 200
|
||||||
"""
|
return "", 403
|
||||||
# GET = Verifikation (Hub Challenge)
|
|
||||||
if request.method == "GET":
|
|
||||||
mode = request.args.get("hub.mode")
|
|
||||||
token = request.args.get("hub.verify_token")
|
|
||||||
challenge = request.args.get("hub.challenge")
|
|
||||||
if mode == "subscribe" and token == "bsn_verify_token":
|
|
||||||
return challenge, 200
|
|
||||||
return "Verification failed", 403
|
|
||||||
|
|
||||||
# POST = Eingehende Nachricht
|
|
||||||
|
@whatsapp_bp.route("/webhook/whatsapp", methods=["POST"])
|
||||||
|
def webhook_receive() -> tuple[str, int]:
|
||||||
|
"""Empfängt WhatsApp-Nachrichten mit Signatur-Prüfung."""
|
||||||
signature = request.headers.get("X-Hub-Signature-256", "")
|
signature = request.headers.get("X-Hub-Signature-256", "")
|
||||||
payload = request.get_data()
|
payload = request.get_data()
|
||||||
|
|
||||||
if not verify_whatsapp_signature(payload, signature):
|
if not verify_whatsapp_signature(payload, signature):
|
||||||
return jsonify({"error": "Invalid signature"}), 401
|
return "", 401
|
||||||
|
|
||||||
# Hier würde die Submission in die Queue eingereiht werden
|
data = json.loads(payload)
|
||||||
# qh = current_app.queue_handler
|
submission = Submission(
|
||||||
return jsonify({"status": "accepted"}), 200
|
message_id=str(data.get("message_id", "")),
|
||||||
|
plattform="whatsapp",
|
||||||
|
absender=str(data.get("absender", "")),
|
||||||
|
text=str(data.get("text", "")),
|
||||||
|
)
|
||||||
|
current_app.queue_handler.enqueue(submission) # type: ignore[attr-defined]
|
||||||
|
return "", 200
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
"""Game-Service: Spiele-Datenbank und CRUD-Operationen."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class Spiel(BaseModel):
|
|
||||||
"""Ein Brettspiel-Datenmodell."""
|
|
||||||
|
|
||||||
id: int | None = None
|
|
||||||
name: str = Field(..., min_length=1, max_length=200)
|
|
||||||
min_spieler: int = Field(default=1, ge=1, le=20)
|
|
||||||
max_spieler: int = Field(default=10, ge=1, le=20)
|
|
||||||
dauer_min: int = Field(default=30, ge=1)
|
|
||||||
komplexitaet: float = Field(default=2.5, ge=1.0, le=5.0)
|
|
||||||
kategorie: str = Field(default="Familienspiel")
|
|
||||||
|
|
||||||
|
|
||||||
class GameService:
|
|
||||||
"""Verwaltet die Spiele-Datenbank (In-Memory für jetzt, später DB)."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._spiele: dict[int, Spiel] = {}
|
|
||||||
self._next_id = 1
|
|
||||||
|
|
||||||
def hinzufuegen(self, spiel: Spiel) -> Spiel:
|
|
||||||
"""Fügt ein Spiel hinzu und weist eine ID zu.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
spiel: Das hinzuzufügende Spiel.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Das zugewiesene Spiel mit ID.
|
|
||||||
"""
|
|
||||||
spiel.id = self._next_id
|
|
||||||
self._spiele[self._next_id] = spiel
|
|
||||||
self._next_id += 1
|
|
||||||
return spiel
|
|
||||||
|
|
||||||
def alle(self) -> list[Spiel]:
|
|
||||||
"""Gibt alle Spiele zurück.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Liste aller Spiele.
|
|
||||||
"""
|
|
||||||
return list(self._spiele.values())
|
|
||||||
|
|
||||||
def suchen(self, name: str) -> list[Spiel]:
|
|
||||||
"""Sucht Spiele nach Name (case-insensitive Teilstring).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Der Suchbegriff.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Liste der gefundenen Spiele.
|
|
||||||
"""
|
|
||||||
name_lower = name.lower()
|
|
||||||
return [spiel for spiel in self._spiele.values() if name_lower in spiel.name.lower()]
|
|
||||||
|
|
||||||
def nach_id(self, spiel_id: int) -> Spiel | None:
|
|
||||||
"""Holt ein Spiel anhand seiner ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
spiel_id: Die ID des Spiels.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Das gefundene Spiel oder None.
|
|
||||||
"""
|
|
||||||
return self._spiele.get(spiel_id)
|
|
||||||
|
|
||||||
def loesche(self, spiel_id: int) -> bool:
|
|
||||||
"""Löscht ein Spiel anhand seiner ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
spiel_id: Die ID des zu löschenden Spiels.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True wenn gelöscht, False wenn nicht existiert.
|
|
||||||
"""
|
|
||||||
if spiel_id in self._spiele:
|
|
||||||
del self._spiele[spiel_id]
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"""Message-Intake-Service."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import StrEnum
|
|
||||||
|
|
||||||
from src.models.submission import Submission
|
|
||||||
|
|
||||||
|
|
||||||
class KommandoTyp(StrEnum):
|
|
||||||
"""Bekannte Benutzer-Kommandos."""
|
|
||||||
|
|
||||||
SPIEL_SUCHEN = "spiel_suchen"
|
|
||||||
SPIEL_BEWERTEN = "spiel_bewerten"
|
|
||||||
HILFE = "hilfe"
|
|
||||||
UNBEKANNT = "unbekannt"
|
|
||||||
|
|
||||||
|
|
||||||
def erkenne_kommando(text: str) -> KommandoTyp:
|
|
||||||
"""Erkennt das Kommando aus dem Nachrichtentext.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Der Nachrichtentext.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Das erkannte KommandoTyp-Enum.
|
|
||||||
"""
|
|
||||||
text_lower = text.lower().strip()
|
|
||||||
|
|
||||||
if text_lower.startswith("suche") or text_lower.startswith("finde"):
|
|
||||||
return KommandoTyp.SPIEL_SUCHEN
|
|
||||||
if text_lower.startswith("bewerte") or text_lower.startswith("bewertung"):
|
|
||||||
return KommandoTyp.SPIEL_BEWERTEN
|
|
||||||
if text_lower in ("hilfe", "help", "?", "commands"):
|
|
||||||
return KommandoTyp.HILFE
|
|
||||||
|
|
||||||
return KommandoTyp.UNBEKANNT
|
|
||||||
|
|
||||||
|
|
||||||
def extrahiere_suchbegriff(text: str) -> str | None:
|
|
||||||
"""Extrahiert den Suchbegriff aus einer Such-Nachricht.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Der Nachrichtentext (z.B. "suche Catan").
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Der Suchbegriff ohne Kommando-Präfix, oder None.
|
|
||||||
"""
|
|
||||||
for praefix in ("suche ", "finde "):
|
|
||||||
if text.lower().startswith(praefix):
|
|
||||||
begriff = text[len(praefix) :].strip()
|
|
||||||
if begriff:
|
|
||||||
return begriff
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def verarbeite_submission(submission: Submission) -> dict[str, object]:
|
|
||||||
"""Verarbeitet eine Submission und gibt eine Antwort-Struktur zurück.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
submission: Die eingegangene Submission.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Ein Dict mit 'antwort' und 'kommando'-Keys.
|
|
||||||
"""
|
|
||||||
kommando = erkenne_kommando(submission.text)
|
|
||||||
|
|
||||||
if kommando == KommandoTyp.HILFE:
|
|
||||||
return {
|
|
||||||
"kommando": kommando.value,
|
|
||||||
"antwort": (
|
|
||||||
"📋 Verfügbare Kommandos:\n"
|
|
||||||
"• suche <spielname> — Spiel suchen\n"
|
|
||||||
"• bewerte <spielname> — Spiel bewerten\n"
|
|
||||||
"• hilfe — Diese Hilfe anzeigen"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
if kommando == KommandoTyp.SPIEL_SUCHEN:
|
|
||||||
suchbegriff = extrahiere_suchbegriff(submission.text)
|
|
||||||
if suchbegriff:
|
|
||||||
return {
|
|
||||||
"kommando": kommando.value,
|
|
||||||
"antwort": f"🔍 Suche nach '{suchbegriff}'...",
|
|
||||||
"suchbegriff": suchbegriff,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"kommando": kommando.value,
|
|
||||||
"antwort": "❌ Bitte gib einen Suchbegriff an: suche <spielname>",
|
|
||||||
}
|
|
||||||
|
|
||||||
if kommando == KommandoTyp.SPIEL_BEWERTEN:
|
|
||||||
return {
|
|
||||||
"kommando": kommando.value,
|
|
||||||
"antwort": "⭐ Bewertungsmodus aktiviert. Welches Spiel möchtest du bewerten?",
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"kommando": KommandoTyp.UNBEKANNT.value,
|
|
||||||
"antwort": (
|
|
||||||
"🤔 Das habe ich nicht verstanden. Schreib 'hilfe' für eine Liste der Kommandos."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
+43
-42
@@ -1,97 +1,98 @@
|
|||||||
"""Hilfsfunktionen zur Validierung von Benutzereingaben."""
|
"""Input-Validatoren für das BSN-Livesystem."""
|
||||||
|
|
||||||
import re
|
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:
|
def validiere_telefonnummer(telefon: str) -> str:
|
||||||
"""Validiert und normalisiert eine Telefonnummer.
|
"""Validiert und bereinigt eine Telefonnummer.
|
||||||
|
|
||||||
Akzeptiert: "+491****7890", "+49 123 4567890", "00491234567890"
|
Entfernt alle Zeichen außer '+' und Ziffern. Konvertiert führende '00'
|
||||||
Entfernt Leerzeichen, Bindestriche, Klammern
|
zu '+'. Die bereinigte Nummer muss mit '+' beginnen und mindestens
|
||||||
Konvertiert "00" am Anfang zu "+"
|
10 Ziffern enthalten.
|
||||||
Wirft ValueError bei: weniger als 10 Ziffern, kein + nach Bereinigung
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
telefon: Die zu validierende Telefonnummer als String.
|
telefon: Die zu validierende Telefonnummer als String.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Die bereinigte Telefonnummer mit führendem "+".
|
Die bereinigte Telefonnummer.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: Wenn die Nummer ungültig ist.
|
ValueError: Wenn die bereinigte Nummer nicht mit '+' beginnt
|
||||||
|
oder weniger als 10 Ziffern enthält.
|
||||||
"""
|
"""
|
||||||
# Entferne alles außer + und Ziffern
|
# Nur + und Ziffern behalten
|
||||||
bereinigt = re.sub(r"[^\d+]", "", telefon)
|
bereinigt = re.sub(r"[^+\d]", "", telefon)
|
||||||
|
|
||||||
# Konvertiere "00" am Anfang zu "+"
|
# "00" durch "+" ersetzen (nur am Anfang)
|
||||||
if bereinigt.startswith("00"):
|
if bereinigt.startswith("00"):
|
||||||
bereinigt = "+" + bereinigt[2:]
|
bereinigt = "+" + bereinigt[2:]
|
||||||
|
|
||||||
# Muss mit + beginnen
|
# Prüfen: Muss mit + beginnen
|
||||||
if not bereinigt.startswith("+"):
|
if not bereinigt.startswith("+"):
|
||||||
raise ValueError(f"Telefonnummer muss mit '+' beginnen: {telefon}")
|
raise ValueError(f"Telefonnummer muss mit '+' beginnen: {bereinigt!r}")
|
||||||
|
|
||||||
# Zähle Ziffern (ohne das +)
|
# Prüfen: Mindestens 10 Ziffern nach dem +
|
||||||
ziffern = re.sub(r"[^\d]", "", bereinigt)
|
ziffern = bereinigt[1:]
|
||||||
|
if len(ziffern) < TELEFON_MIN_ZIFFERN:
|
||||||
if len(ziffern) < 10:
|
raise ValueError(f"Telefonnummer muss mindestens 10 Ziffern haben: {telefon!r}")
|
||||||
raise ValueError(f"Telefonnummer muss mindestens 10 Ziffern haben: {telefon}")
|
|
||||||
|
|
||||||
return bereinigt
|
return bereinigt
|
||||||
|
|
||||||
|
|
||||||
def validiere_spiel_name(name: str) -> str:
|
def validiere_spiel_name(name: str) -> str:
|
||||||
"""Validiert einen Spielnamen.
|
"""Validiert und bereinigt einen Spielnamen.
|
||||||
|
|
||||||
Entfernt HTML-Tags mit Regex
|
Entfernt HTML-Tags und umgebenden Whitespace. Der bereinigte Name
|
||||||
Strip Whitespace an beiden Enden
|
muss zwischen 2 und 200 Zeichen lang sein.
|
||||||
Wirft ValueError bei: < 2 Zeichen, > 200 Zeichen
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Der zu validierende Spielname.
|
name: Der zu validierende Spielname als String.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Der bereinigte Spielname.
|
Der bereinigte Spielname.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: Wenn der Name ungültig ist.
|
ValueError: Wenn der Name nach Bereinigung kürzer als 2 oder
|
||||||
|
länger als 200 Zeichen ist.
|
||||||
"""
|
"""
|
||||||
# Entferne HTML-Tags
|
# HTML-Tags entfernen
|
||||||
bereinigt = re.sub(r"<[^>]*>", "", name)
|
bereinigt = re.sub(r"<[^>]*>", "", name)
|
||||||
|
|
||||||
# Strip Whitespace
|
# Whitespace strippen
|
||||||
bereinigt = bereinigt.strip()
|
bereinigt = bereinigt.strip()
|
||||||
|
|
||||||
if len(bereinigt) < 2:
|
# Längenprüfung
|
||||||
raise ValueError(f"Spielname muss mindestens 2 Zeichen haben: {name}")
|
if len(bereinigt) < SPIEL_NAME_MIN_LAENGE:
|
||||||
|
raise ValueError(f"Spielname muss mindestens 2 Zeichen haben: {name!r}")
|
||||||
if len(bereinigt) > 200:
|
if len(bereinigt) > SPIEL_NAME_MAX_LAENGE:
|
||||||
raise ValueError(f"Spielname darf maximal 200 Zeichen haben: {name}")
|
raise ValueError(f"Spielname darf maximal 200 Zeichen haben: {name!r}")
|
||||||
|
|
||||||
return bereinigt
|
return bereinigt
|
||||||
|
|
||||||
|
|
||||||
def validiere_spieleranzahl(anzahl: int) -> int:
|
def validiere_spieleranzahl(anzahl: int) -> int:
|
||||||
"""Validiert eine Spieleranzahl.
|
"""Validiert die Spieleranzahl.
|
||||||
|
|
||||||
Wirft ValueError bei: < 1, > 20, kein Integer
|
Die Anzahl muss zwischen 1 und 20 liegen.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
anzahl: Die zu valideinde Spieleranzahl.
|
anzahl: Die zu validierende Spieleranzahl.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Die ungeänderte Spieleranzahl.
|
Die unveränderte Spieleranzahl, wenn gültig.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: Wenn die Anzahl ungültig ist.
|
ValueError: Wenn die Anzahl kleiner als 1 oder größer als 20 ist.
|
||||||
"""
|
"""
|
||||||
if not isinstance(anzahl, int):
|
if anzahl < SPIELERANZAHL_MIN:
|
||||||
raise ValueError(f"Spieleranzahl muss ein Integer sein: {anzahl}")
|
|
||||||
|
|
||||||
if anzahl < 1:
|
|
||||||
raise ValueError(f"Spieleranzahl muss mindestens 1 sein: {anzahl}")
|
raise ValueError(f"Spieleranzahl muss mindestens 1 sein: {anzahl}")
|
||||||
|
if anzahl > SPIELERANZAHL_MAX:
|
||||||
if anzahl > 20:
|
|
||||||
raise ValueError(f"Spieleranzahl darf maximal 20 sein: {anzahl}")
|
raise ValueError(f"Spieleranzahl darf maximal 20 sein: {anzahl}")
|
||||||
|
|
||||||
return anzahl
|
return anzahl
|
||||||
|
|||||||
+23
-23
@@ -6,71 +6,71 @@
|
|||||||
"nr": 1,
|
"nr": 1,
|
||||||
"file": "pyproject.toml",
|
"file": "pyproject.toml",
|
||||||
"status": "erledigt",
|
"status": "erledigt",
|
||||||
"commit": "658d09e",
|
"commit": "ded0872",
|
||||||
"timestamp": "2025-01-01T00:00:00"
|
"timestamp": "2026-06-22T01:25:00+02:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nr": 2,
|
"nr": 2,
|
||||||
"file": ".pre-commit-config.yaml",
|
"file": ".pre-commit-config.yaml",
|
||||||
"status": "erledigt",
|
"status": "offen",
|
||||||
"commit": "658d09e",
|
"commit": null,
|
||||||
"timestamp": "2025-01-01T00:00:00"
|
"timestamp": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nr": 3,
|
"nr": 3,
|
||||||
"file": "src/models/submission.py",
|
"file": "src/models/submission.py",
|
||||||
"status": "erledigt",
|
"status": "offen",
|
||||||
"commit": null,
|
"commit": null,
|
||||||
"timestamp": null
|
"timestamp": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nr": 4,
|
"nr": 4,
|
||||||
"file": "src/utils/validators.py",
|
"file": "src/utils/validators.py",
|
||||||
"status": "erledigt",
|
"status": "offen",
|
||||||
"commit": null,
|
"commit": null,
|
||||||
"timestamp": null
|
"timestamp": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nr": 5,
|
"nr": 5,
|
||||||
"file": "src/queue/handler.py",
|
"file": "src/queue/handler.py",
|
||||||
"status": "erledigt",
|
"status": "offen",
|
||||||
"commit": null,
|
"commit": null,
|
||||||
"timestamp": null
|
"timestamp": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nr": 6,
|
"nr": 6,
|
||||||
"file": "src/queue/webhook.py",
|
"file": "src/queue/webhook.py",
|
||||||
"status": "erledigt",
|
"status": "offen",
|
||||||
"commit": "56b02c1",
|
"commit": null,
|
||||||
"timestamp": "2026-06-22T"
|
"timestamp": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nr": 7,
|
"nr": 7,
|
||||||
"file": "src/queue/worker.py",
|
"file": "src/queue/worker.py",
|
||||||
"status": "erledigt",
|
"status": "offen",
|
||||||
"commit": "56b02c1",
|
"commit": null,
|
||||||
"timestamp": "2026-06-22T"
|
"timestamp": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nr": 8,
|
"nr": 8,
|
||||||
"file": "src/app.py",
|
"file": "src/app.py",
|
||||||
"status": "erledigt",
|
"status": "offen",
|
||||||
"commit": "56b02c1",
|
"commit": null,
|
||||||
"timestamp": "2026-06-22T"
|
"timestamp": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nr": 9,
|
"nr": 9,
|
||||||
"file": "src/routes/whatsapp.py",
|
"file": "src/routes/whatsapp.py",
|
||||||
"status": "erledigt",
|
"status": "offen",
|
||||||
"commit": "56b02c1",
|
"commit": null,
|
||||||
"timestamp": "2026-06-22T"
|
"timestamp": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nr": 10,
|
"nr": 10,
|
||||||
"file": "src/routes/telegram.py",
|
"file": "src/routes/telegram.py",
|
||||||
"status": "erledigt",
|
"status": "offen",
|
||||||
"commit": "56b02c1",
|
"commit": null,
|
||||||
"timestamp": "2026-06-22T"
|
"timestamp": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""Tests für Admin-Route."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from flask import Flask
|
|
||||||
|
|
||||||
from src.routes.admin import admin_bp
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdminRoute:
|
|
||||||
"""Tests für Admin-Route."""
|
|
||||||
|
|
||||||
def setup_method(self) -> None:
|
|
||||||
"""Erstellt Flask-App mit mocktem QueueHandler."""
|
|
||||||
self.app = Flask(__name__)
|
|
||||||
self.app.queue_handler = MagicMock()
|
|
||||||
self.app.queue_handler.queue_size.return_value = 42
|
|
||||||
self.app.register_blueprint(admin_bp, url_prefix="/")
|
|
||||||
self.client = self.app.test_client()
|
|
||||||
|
|
||||||
def test_get_admin_queue_ergibt_200(self) -> None:
|
|
||||||
"""GET /admin/queue → 200 + JSON mit queue_size."""
|
|
||||||
response = self.client.get("/admin/queue")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
assert "queue_size" in data
|
|
||||||
assert data["status"] == "ok"
|
|
||||||
|
|
||||||
def test_get_admin_queue_queue_size_ist_integer(self) -> None:
|
|
||||||
"""GET /admin/queue → queue_size ist Integer."""
|
|
||||||
response = self.client.get("/admin/queue")
|
|
||||||
data = response.get_json()
|
|
||||||
assert isinstance(data["queue_size"], int)
|
|
||||||
|
|
||||||
def test_get_admin_stats_ergibt_200(self) -> None:
|
|
||||||
"""GET /admin/stats → 200 + version '0.1.0'."""
|
|
||||||
response = self.client.get("/admin/stats")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
assert data["version"] == "0.1.0"
|
|
||||||
|
|
||||||
def test_get_admin_stats_json_enhaelt_alle_keys(self) -> None:
|
|
||||||
"""GET /admin/stats → JSON enthält alle Keys."""
|
|
||||||
response = self.client.get("/admin/stats")
|
|
||||||
data = response.get_json()
|
|
||||||
assert "queue_size" in data
|
|
||||||
assert "uptime" in data
|
|
||||||
assert "version" in data
|
|
||||||
+41
-13
@@ -1,34 +1,62 @@
|
|||||||
"""Tests für Flask-App-Factory."""
|
"""Tests für die Flask-App-Factory mit fakeredis."""
|
||||||
|
|
||||||
import pytest
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fakeredis
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
from src.app import create_app
|
from src.app import create_app
|
||||||
|
from src.queue.handler import QueueHandler
|
||||||
|
|
||||||
|
|
||||||
class TestCreateApp:
|
def test_create_app_returns_flask_app() -> None:
|
||||||
"""Tests für create_app."""
|
"""create_app() liefert eine Flask-App-Instanz zurück."""
|
||||||
|
|
||||||
def test_create_app_gibt_flask_app_zurueck(self) -> None:
|
|
||||||
"""create_app() gibt Flask-App zurück."""
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
assert isinstance(app, Flask)
|
assert isinstance(app, Flask)
|
||||||
|
|
||||||
def test_health_endpoint_ergibt_ok(self) -> None:
|
|
||||||
"""GET /health → 200, JSON mit 'status': 'ok'."""
|
def test_health_returns_status_ok() -> None:
|
||||||
|
"""GET /health liefert 200 und JSON mit status=ok."""
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
with app.test_client() as client:
|
# QueueHandler mit fakeredis ersetzen
|
||||||
|
fake_redis = fakeredis.FakeRedis(decode_responses=True)
|
||||||
|
qh = QueueHandler.__new__(QueueHandler)
|
||||||
|
qh.redis = fake_redis
|
||||||
|
qh.stream_key = "bsn:submissions"
|
||||||
|
qh.processed_set = "bsn:processed"
|
||||||
|
qh.stream_ids_hash = "bsn:stream_ids"
|
||||||
|
qh.group_name = "bsn-workers"
|
||||||
|
qh._ensure_consumer_group()
|
||||||
|
app.queue_handler = qh # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
client: FlaskClient = app.test_client()
|
||||||
response = client.get("/health")
|
response = client.get("/health")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
|
assert data is not None
|
||||||
assert data["status"] == "ok"
|
assert data["status"] == "ok"
|
||||||
|
|
||||||
def test_health_endpoint_queue_size_ist_integer(self) -> None:
|
|
||||||
"""GET /health → queue_size ist Integer."""
|
def test_health_queue_size_is_integer() -> None:
|
||||||
|
"""GET /health liefert queue_size als Integer."""
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
with app.test_client() as client:
|
# QueueHandler mit fakeredis ersetzen
|
||||||
|
fake_redis = fakeredis.FakeRedis(decode_responses=True)
|
||||||
|
qh = QueueHandler.__new__(QueueHandler)
|
||||||
|
qh.redis = fake_redis
|
||||||
|
qh.stream_key = "bsn:submissions"
|
||||||
|
qh.processed_set = "bsn:processed"
|
||||||
|
qh.stream_ids_hash = "bsn:stream_ids"
|
||||||
|
qh.group_name = "bsn-workers"
|
||||||
|
qh._ensure_consumer_group()
|
||||||
|
app.queue_handler = qh # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
client = app.test_client()
|
||||||
response = client.get("/health")
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
|
assert data is not None
|
||||||
assert isinstance(data["queue_size"], int)
|
assert isinstance(data["queue_size"], int)
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
"""Tests für Game-Service."""
|
|
||||||
|
|
||||||
from src.services.game_service import GameService, Spiel
|
|
||||||
|
|
||||||
|
|
||||||
class TestGameService:
|
|
||||||
"""Tests für GameService."""
|
|
||||||
|
|
||||||
def test_hinzufuegen_id_wird_vergeben(self) -> None:
|
|
||||||
"""Hinzufügen eines Spiels → ID wird vergeben."""
|
|
||||||
service = GameService()
|
|
||||||
spiel = Spiel(name="Catan")
|
|
||||||
|
|
||||||
resultat = service.hinzufuegen(spiel)
|
|
||||||
|
|
||||||
assert resultat.id is not None
|
|
||||||
assert resultat.name == "Catan"
|
|
||||||
|
|
||||||
def test_alle_leere_liste_am_anfang(self) -> None:
|
|
||||||
"""Alle Spiele → leere Liste am Anfang."""
|
|
||||||
service = GameService()
|
|
||||||
assert service.alle() == []
|
|
||||||
|
|
||||||
def test_alle_zwei_spielenach_hinzufuegen(self) -> None:
|
|
||||||
"""Alle Spiele nach Hinzufügen → 2 Spiele."""
|
|
||||||
service = GameService()
|
|
||||||
s1 = Spiel(name="Catan")
|
|
||||||
s2 = Spiel(name="Ticket to Ride")
|
|
||||||
service.hinzufuegen(s1)
|
|
||||||
service.hinzufuegen(s2)
|
|
||||||
|
|
||||||
assert len(service.alle()) == 2
|
|
||||||
|
|
||||||
def test_suchen_nach_cat_finnt_catan(self) -> None:
|
|
||||||
"""Suchen nach 'cat' → findet Catan."""
|
|
||||||
service = GameService()
|
|
||||||
service.hinzufuegen(Spiel(name="Catan"))
|
|
||||||
|
|
||||||
ergebnis = service.suchen("cat")
|
|
||||||
|
|
||||||
assert len(ergebnis) == 1
|
|
||||||
assert ergebnis[0].name == "Catan"
|
|
||||||
|
|
||||||
def test_suchen_nach_xyz_ergebt_leere_liste(self) -> None:
|
|
||||||
"""Suchen nach 'xyz' → leere Liste."""
|
|
||||||
service = GameService()
|
|
||||||
service.hinzufuegen(Spiel(name="Catan"))
|
|
||||||
|
|
||||||
assert service.suchen("xyz") == []
|
|
||||||
|
|
||||||
def test_nach_id_aktives_spiel(self) -> None:
|
|
||||||
"""Nach ID holen → korrektes Spiel."""
|
|
||||||
service = GameService()
|
|
||||||
s = Spiel(name="Carcassonne")
|
|
||||||
service.hinzufuegen(s)
|
|
||||||
|
|
||||||
resultat = service.nach_id(1)
|
|
||||||
assert resultat is not None
|
|
||||||
assert resultat.name == "Carcassonne"
|
|
||||||
|
|
||||||
def test_nach_id_nicht_existent(self) -> None:
|
|
||||||
"""Nach ID holen → None bei nicht existenter ID."""
|
|
||||||
service = GameService()
|
|
||||||
assert service.nach_id(999) is None
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""Tests für Message-Intake-Service."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from src.services.intake import (
|
|
||||||
KommandoTyp,
|
|
||||||
erkenne_kommando,
|
|
||||||
extrahiere_suchbegriff,
|
|
||||||
verarbeite_submission,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestErkenneKommando:
|
|
||||||
"""Tests für erkenne_kommando."""
|
|
||||||
|
|
||||||
def test_suche_catanspielen_ergibt_spiel_suchen(self) -> None:
|
|
||||||
"""'suche Catan' → KommandoTyp.SPIEL_SUCHEN."""
|
|
||||||
assert erkenne_kommando("suche Catan") == KommandoTyp.SPIEL_SUCHEN
|
|
||||||
|
|
||||||
def test_suche_ohne_begriff_ergibt_spiel_suchen(self) -> None:
|
|
||||||
"""'suche' → KommandoTyp.SPIEL_SUCHEN."""
|
|
||||||
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_bewerte_carcassonne_ergibt_spiel_bewerten(self) -> None:
|
|
||||||
"""'bewerte Carcassonne' → KommandoTyp.SPIEL_BEWERTEN."""
|
|
||||||
assert erkenne_kommando("bewerte Carcassonne") == KommandoTyp.SPIEL_BEWERTEN
|
|
||||||
|
|
||||||
def test_hilfe_ergibt_hilfe(self) -> None:
|
|
||||||
"""'hilfe' → KommandoTyp.HILFE."""
|
|
||||||
assert erkenne_kommando("hilfe") == KommandoTyp.HILFE
|
|
||||||
|
|
||||||
def test_unknown_ertext_ergibt_unbekannt(self) -> None:
|
|
||||||
"""'blabla' → KommandoTyp.UNBEKANNT."""
|
|
||||||
assert erkenne_kommando("blabla") == KommandoTyp.UNBEKANNT
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtrahiereSuchbegriff:
|
|
||||||
"""Tests für extrahiere_suchbegriff."""
|
|
||||||
|
|
||||||
def test_suche_catans_gibt_suchbegriff(self) -> None:
|
|
||||||
"""'suche Catan' → extrahiert 'Catan'."""
|
|
||||||
assert extrahiere_suchbegriff("suche Catan") == "Catan"
|
|
||||||
|
|
||||||
def test_suche_ohne_begriff_gibt_none(self) -> None:
|
|
||||||
"""'suche' → None."""
|
|
||||||
assert extrahiere_suchbegriff("suche") is None
|
|
||||||
|
|
||||||
def test_finde_monopoly_gibt_suchbegriff(self) -> None:
|
|
||||||
"""'finde Monopoly' → extrahiert 'Monopoly'."""
|
|
||||||
assert extrahiere_suchbegriff("finde Monopoly") == "Monopoly"
|
|
||||||
|
|
||||||
|
|
||||||
class TestVerarbeiteSubmission:
|
|
||||||
"""Tests für verarbeite_submission."""
|
|
||||||
|
|
||||||
def test_verarbeite_submission_mit_hilfe(self) -> None:
|
|
||||||
"""verarbeite_submission mit 'hilfe' → Antwort enthält Hilfe-Text."""
|
|
||||||
mock_submission = MagicMock()
|
|
||||||
mock_submission.text = "hilfe"
|
|
||||||
|
|
||||||
result = verarbeite_submission(mock_submission)
|
|
||||||
|
|
||||||
assert result["kommando"] == "hilfe"
|
|
||||||
assert "Verfügbare Kommandos" in result["antwort"]
|
|
||||||
assert "suche" in result["antwort"]
|
|
||||||
|
|
||||||
def test_verarbeite_submission_mit_suche(self) -> None:
|
|
||||||
"""verarbeite_submission mit 'suche Catan' → Antowrt enthält Suchbegriff."""
|
|
||||||
mock_submission = MagicMock()
|
|
||||||
mock_submission.text = "suche Catan"
|
|
||||||
|
|
||||||
result = verarbeite_submission(mock_submission)
|
|
||||||
|
|
||||||
assert result["kommando"] == "spiel_suchen"
|
|
||||||
assert result["suchbegriff"] == "Catan"
|
|
||||||
assert "Catan" in result["antwort"]
|
|
||||||
|
|
||||||
def test_verarbeite_submission_mit_unbekanntem_text(self) -> None:
|
|
||||||
"""verarbeite_submission mit unbekanntem Text → UNBEKANNT."""
|
|
||||||
mock_submission = MagicMock()
|
|
||||||
mock_submission.text = "hallo wie gehts"
|
|
||||||
|
|
||||||
result = verarbeite_submission(mock_submission)
|
|
||||||
|
|
||||||
assert result["kommando"] == "unbekannt"
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""Integration-Tests für WhatsApp/Telegram-Webhook-Flows."""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from flask import Flask
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.app import create_app
|
|
||||||
from src.routes.whatsapp import whatsapp_bp
|
|
||||||
from src.routes.telegram import telegram_bp
|
|
||||||
from src.routes.admin import admin_bp
|
|
||||||
from src.queue.worker import verarbeite_submission, worker_loop
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def flask_app() -> Flask:
|
|
||||||
"""Erstellt Flask-App mit allen Blueprints."""
|
|
||||||
app = create_app()
|
|
||||||
app.queue_handler = MagicMock()
|
|
||||||
app.queue_handler.queue_size.return_value = 0
|
|
||||||
app.register_blueprint(whatsapp_bp, url_prefix="/")
|
|
||||||
app.register_blueprint(telegram_bp, url_prefix="/")
|
|
||||||
app.register_blueprint(admin_bp, url_prefix="/")
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebhookIntegration:
|
|
||||||
"""Tests für Webhook-Integration."""
|
|
||||||
|
|
||||||
def test_whatsapp_post_mit_gueltiger_signatur(self, flask_app: Flask) -> None:
|
|
||||||
"""WhatsApp POST mit gültiger Signatur → 200, Submission in Queue."""
|
|
||||||
with patch("src.routes.whatsapp.verify_whatsapp_signature", return_value=True):
|
|
||||||
response = flask_app.test_client().post(
|
|
||||||
"/webhook/whatsapp",
|
|
||||||
data=b'{"object":"whatsapp","entry":[]}',
|
|
||||||
headers={"X-Hub-Signature-256": "sha256=test"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
assert data["status"] == "accepted"
|
|
||||||
flask_app.queue_handler.enqueue.assert_called_once()
|
|
||||||
|
|
||||||
def test_whatsapp_post_mit_ungueltiger_signatur(self, flask_app: Flask) -> None:
|
|
||||||
"""WhatsApp POST mit ungültiger Signatur → 401."""
|
|
||||||
with patch("src.routes.whatsapp.verify_whatsapp_signature", return_value=False):
|
|
||||||
response = flask_app.test_client().post(
|
|
||||||
"/webhook/whatsapp",
|
|
||||||
data=b'{"object":"whatsapp","entry":[]}',
|
|
||||||
headers={"X-Hub-Signature-256": "sha256=fake"},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 401
|
|
||||||
data = response.get_json()
|
|
||||||
assert "error" in data
|
|
||||||
|
|
||||||
|
|
||||||
class TestTelegramIntegration:
|
|
||||||
"""Tests für Telegram-Integration."""
|
|
||||||
|
|
||||||
def test_telegram_post_mit_gueltigem_payload(self, flask_app: Flask) -> None:
|
|
||||||
"""Telegram POST mit gültigem Payload → 200."""
|
|
||||||
response = flask_app.test_client().post(
|
|
||||||
"/webhook/telegram",
|
|
||||||
data={"message": {"message_id": "123", "chat": {"id": "456"}, "text": "Hallo"}},
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
assert data["status"] == "accepted"
|
|
||||||
flask_app.queue_handler.enqueue.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestWorkerIntegration:
|
|
||||||
"""Tests für Worker-Integration."""
|
|
||||||
|
|
||||||
def test_vollstaendiger_flow_whatsapp(self, flask_app: Flask) -> None:
|
|
||||||
"""Vollständiger Flow: WhatsApp POST → Worker verarbeitet → Status 'erledigt'."""
|
|
||||||
mock_qh = flask_app.queue_handler
|
|
||||||
mock_submission = MagicMock()
|
|
||||||
mock_submission.message_id = "msg001"
|
|
||||||
mock_submission.zeitstempel = "2024-01-01T00:00:00"
|
|
||||||
mock_submission.absender = "+491****7890"
|
|
||||||
mock_submission.text = "suche Catan"
|
|
||||||
mock_submission.status = "neu"
|
|
||||||
mock_submission.enqueue.return_value = "stream_id"
|
|
||||||
|
|
||||||
# Simuliere: Submit wurde enqueued, dann vom Worker geholt
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def dequeue_side_effect(count: int = 5):
|
|
||||||
call_count[0] += 1
|
|
||||||
if call_count[0] <= 1:
|
|
||||||
return [mock_submission]
|
|
||||||
return []
|
|
||||||
|
|
||||||
mock_qh.dequeue.side_effect = dequeue_side_effect
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("src.queue.worker.verarbeite_submission", return_value=True) as mock_verarb,
|
|
||||||
patch("src.queue.worker.time.sleep", return_value=None),
|
|
||||||
):
|
|
||||||
worker_loop(mock_qh, poll_interval=0, batch_size=5)
|
|
||||||
|
|
||||||
assert mock_verarb.call_count == 1
|
|
||||||
assert mock_qh.mark_done.call_count == 1
|
|
||||||
|
|
||||||
def test_vollstaendiger_flow_telegram(self, flask_app: Flask) -> None:
|
|
||||||
"""Vollständiger Flow: Telegram POST → Worker verarbeitet → Status 'erledigt'."""
|
|
||||||
mock_qh = flask_app.queue_handler
|
|
||||||
mock_submission = MagicMock()
|
|
||||||
mock_submission.message_id = "msg002"
|
|
||||||
mock_submission.zeitstempel = "2024-01-01T00:00:00"
|
|
||||||
mock_submission.absender = "@user123"
|
|
||||||
mock_submission.text = "hilfe"
|
|
||||||
mock_submission.status = "neu"
|
|
||||||
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def dequeue_side_effect(count: int = 5):
|
|
||||||
call_count[0] += 1
|
|
||||||
if call_count[0] <= 1:
|
|
||||||
return [mock_submission]
|
|
||||||
return []
|
|
||||||
|
|
||||||
mock_qh.dequeue.side_effect = dequeue_side_effect
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("src.queue.worker.verarbeite_submission", return_value=True) as mock_verarb,
|
|
||||||
patch("src.queue.worker.time.sleep", return_value=None),
|
|
||||||
):
|
|
||||||
worker_loop(mock_qh, poll_interval=0, batch_size=5)
|
|
||||||
|
|
||||||
assert mock_verarb.call_count == 1
|
|
||||||
assert mock_qh.mark_done.call_count == 1
|
|
||||||
+71
-93
@@ -1,124 +1,102 @@
|
|||||||
"""Tests für den Queue-Handler."""
|
"""Tests für QueueHandler mit fakeredis."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import fakeredis
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.models.submission import Submission
|
from src.models.submission import Submission
|
||||||
|
from src.queue.handler import QueueHandler
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def queue_handler():
|
def handler() -> Generator[QueueHandler]:
|
||||||
"""Erstellt einen Queue-Handler mit gemocktem Redis."""
|
"""QueueHandler mit fakeredis-Backend."""
|
||||||
import fakeredis
|
|
||||||
|
|
||||||
fake_redis = fakeredis.FakeRedis(decode_responses=True)
|
fake_redis = fakeredis.FakeRedis(decode_responses=True)
|
||||||
|
qh = QueueHandler.__new__(QueueHandler)
|
||||||
with patch("src.queue.handler.redis.Redis", return_value=fake_redis):
|
qh.redis = fake_redis
|
||||||
from src.queue.handler import QueueHandler
|
qh.stream_key = "bsn:submissions"
|
||||||
|
qh.processed_set = "bsn:processed"
|
||||||
handler = QueueHandler()
|
qh.stream_ids_hash = "bsn:stream_ids"
|
||||||
|
qh.group_name = "bsn-workers"
|
||||||
# Override stream keys für Tests
|
qh._ensure_consumer_group()
|
||||||
handler.stream_key = "test:submissions"
|
yield qh
|
||||||
handler.processed_key = "test:processed"
|
fake_redis.flushall()
|
||||||
handler.consumer_group = "test-workers"
|
|
||||||
|
|
||||||
# Consumer-Group initialisieren
|
|
||||||
try:
|
|
||||||
handler.redis.xgroup_create(handler.stream_key, handler.consumer_group, id="0")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
handler._fake_redis = fake_redis # Für queue_size Referenz
|
|
||||||
|
|
||||||
return handler
|
|
||||||
|
|
||||||
|
|
||||||
def test_enqueue_neue_submission_gibt_stream_id(queue_handler):
|
def _make_submission(message_id: str = "msg-001") -> Submission:
|
||||||
"""Testet dass enqueue einer neuen Submission eine stream_id zurückgibt."""
|
"""Erzeugt eine Test-Submission."""
|
||||||
submission = Submission(
|
return Submission(
|
||||||
message_id="msg_001",
|
message_id=message_id,
|
||||||
plattform="whatsapp",
|
plattform="whatsapp",
|
||||||
absender="+491****7890",
|
absender="+491234567890",
|
||||||
text="Testnachricht",
|
text="Testnachricht für QueueHandler.",
|
||||||
|
zeitstempel=datetime(2025, 1, 1, 12, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
stream_id = queue_handler.enqueue(submission)
|
|
||||||
|
|
||||||
|
def test_enqueue_neue_submission_gibt_stream_id(handler: QueueHandler) -> None:
|
||||||
|
"""Eine neue Submission wird eingereiht und liefert eine stream_id zurück."""
|
||||||
|
sub = _make_submission("msg-001")
|
||||||
|
stream_id = handler.enqueue(sub)
|
||||||
assert stream_id is not None
|
assert stream_id is not None
|
||||||
assert isinstance(stream_id, str)
|
assert isinstance(stream_id, str)
|
||||||
|
assert len(stream_id) > 0
|
||||||
|
|
||||||
|
|
||||||
def test_enqueue_duplikat_gibt_none(queue_handler):
|
def test_enqueue_duplikat_gibt_none(handler: QueueHandler) -> None:
|
||||||
"""Testet dass enqueue eines Duplikats None zurückgibt."""
|
"""Eine doppelt eingereihte Submission ergibt None."""
|
||||||
submission = Submission(
|
sub = _make_submission("msg-dup")
|
||||||
message_id="msg_002",
|
first = handler.enqueue(sub)
|
||||||
plattform="telegram",
|
assert first is not None
|
||||||
absender="@user123",
|
|
||||||
text="Duplikat-Test",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Erstmal enqueueen
|
second = handler.enqueue(sub)
|
||||||
queue_handler.enqueue(submission)
|
assert second is None
|
||||||
|
|
||||||
# Zweites Mal mit gleicher message_id → sollte None zurückgeben
|
|
||||||
stream_id = queue_handler.enqueue(submission)
|
|
||||||
|
|
||||||
assert stream_id is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_dequeue_holt_submission_zurueck(queue_handler):
|
def test_dequeue_holt_eingereihte_submission(handler: QueueHandler) -> None:
|
||||||
"""Testet dass dequeue eingereihte Submissions zurückgibt."""
|
"""dequeue liefert zuvor enqueuete Submissions zurück."""
|
||||||
submission = Submission(
|
sub = _make_submission("msg-003")
|
||||||
message_id="msg_003",
|
handler.enqueue(sub)
|
||||||
plattform="whatsapp",
|
|
||||||
absender="+499****3210",
|
|
||||||
text="Dequeue-Test",
|
|
||||||
)
|
|
||||||
|
|
||||||
queue_handler.enqueue(submission)
|
results = handler.dequeue(count=10)
|
||||||
|
assert len(results) == 1
|
||||||
submissions = queue_handler.dequeue(count=5)
|
assert results[0].message_id == "msg-003"
|
||||||
|
assert results[0].plattform == "whatsapp"
|
||||||
assert len(submissions) == 1
|
assert results[0].text == "Testnachricht für QueueHandler."
|
||||||
assert submissions[0].message_id == "msg_003"
|
|
||||||
assert submissions[0].text == "Dequeue-Test"
|
|
||||||
|
|
||||||
|
|
||||||
def test_mark_done_entfernt_aus_queue(queue_handler):
|
def test_mark_done_entfernt_aus_queue(handler: QueueHandler) -> None:
|
||||||
"""Testet dass mark_done die Nachricht ackt."""
|
"""Nach mark_done wird die Submission aus dem Stream-Pending entfernt."""
|
||||||
submission = Submission(
|
sub = _make_submission("msg-004")
|
||||||
message_id="msg_004",
|
handler.enqueue(sub)
|
||||||
plattform="telegram",
|
|
||||||
absender="@user456",
|
|
||||||
text="Mark-done-Test",
|
|
||||||
)
|
|
||||||
|
|
||||||
queue_handler.enqueue(submission)
|
# Aus der Queue holen (Pending-Eintrag)
|
||||||
queue_handler.dequeue(count=1) # Nachricht holen
|
results = handler.dequeue(count=10)
|
||||||
queue_handler.mark_done(submission) # Als erledigt marken
|
assert len(results) == 1
|
||||||
|
|
||||||
# XAck sollte erfolgreich gewesen sein - keine Exception
|
# Als erledigt markieren
|
||||||
# Wir prüfen indem wir erneut dequeue versuchen
|
handler.mark_done(sub)
|
||||||
submissions = queue_handler.dequeue(count=5)
|
|
||||||
assert len(submissions) == 0 # Sollte leer sein
|
# Nach XACK kein erneutes Dequeue (keine pending messages mehr)
|
||||||
|
results2 = handler.dequeue(count=10)
|
||||||
|
assert len(results2) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_queue_size_gibt_korrekte_anzahl(queue_handler):
|
def test_queue_size_gibt_korrekte_anzahl(handler: QueueHandler) -> None:
|
||||||
"""Testet dass queue_size die korrekte Anzahl zurückgibt."""
|
"""queue_size gibt die Anzahl der Nachrichten im Stream korrekt zurück."""
|
||||||
# Queue sollte anfangs leer sein
|
assert handler.queue_size() == 0
|
||||||
assert queue_handler.queue_size() == 0
|
|
||||||
|
|
||||||
# 3 Submissions enqueueen
|
for i in range(5):
|
||||||
for i in range(3):
|
handler.enqueue(_make_submission(f"msg-size-{i}"))
|
||||||
submission = Submission(
|
|
||||||
message_id=f"msg_size_{i}",
|
|
||||||
plattform="whatsapp",
|
|
||||||
absender=f"+491****{i:04d}",
|
|
||||||
text=f"Nachricht {i}",
|
|
||||||
)
|
|
||||||
queue_handler.enqueue(submission)
|
|
||||||
|
|
||||||
size = queue_handler.queue_size()
|
assert handler.queue_size() == 5
|
||||||
assert size == 3
|
|
||||||
|
# Weitere 3
|
||||||
|
for i in range(5, 8):
|
||||||
|
handler.enqueue(_make_submission(f"msg-size-{i}"))
|
||||||
|
|
||||||
|
assert handler.queue_size() == 8
|
||||||
|
|||||||
+30
-34
@@ -1,5 +1,3 @@
|
|||||||
"""Tests für das Submission-Modell."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -8,54 +6,52 @@ from pydantic import ValidationError
|
|||||||
from src.models.submission import Submission
|
from src.models.submission import Submission
|
||||||
|
|
||||||
|
|
||||||
def test_gueltige_submission_erstellen():
|
def test_valid_submission() -> None:
|
||||||
"""Testet das Erstellen einer gültigen Submission mit allen Feldern."""
|
"""Gültige Submission mit allen korrekten Feldern."""
|
||||||
submission = Submission(
|
submission = Submission(
|
||||||
message_id="msg_123",
|
message_id="msg-001",
|
||||||
plattform="whatsapp",
|
plattform="whatsapp",
|
||||||
absender="+491234567890",
|
absender="+491****7890",
|
||||||
text="Hallo Welt",
|
text="Hallo, das ist eine Testnachricht.",
|
||||||
)
|
)
|
||||||
assert submission.message_id == "msg_123"
|
assert submission.message_id == "msg-001"
|
||||||
assert submission.plattform == "whatsapp"
|
assert submission.plattform == "whatsapp"
|
||||||
assert submission.absender == "+491234567890"
|
assert submission.absender == "+491****7890"
|
||||||
assert submission.text == "Hallo Welt"
|
assert submission.text == "Hallo, das ist eine Testnachricht."
|
||||||
|
assert isinstance(submission.zeitstempel, datetime)
|
||||||
assert submission.status == "neu"
|
assert submission.status == "neu"
|
||||||
assert submission.zeitstempel is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_leerer_text_wirft_error():
|
def test_empty_text_raises_validation_error() -> None:
|
||||||
"""Testet dass ein leeres Textfeld eine ValidationError wirft."""
|
"""Leerer Text wirft ValidationError (min_length=1)."""
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
Submission(
|
Submission(
|
||||||
message_id="msg_123",
|
message_id="msg-002",
|
||||||
plattform="whatsapp",
|
plattform="telegram",
|
||||||
absender="+491234567890",
|
absender="user123",
|
||||||
text="",
|
text="",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_text_ueber_5000_zeichen_wirft_error():
|
def test_text_too_long_raises_validation_error() -> None:
|
||||||
"""Testet dass Text > 5000 Zeichen eine ValidationError wirft."""
|
"""Text > 5000 Zeichen wirft ValidationError."""
|
||||||
|
long_text = "x" * 5001
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
Submission(
|
Submission(
|
||||||
message_id="msg_123",
|
message_id="msg-003",
|
||||||
plattform="telegram",
|
|
||||||
absender="@user123",
|
|
||||||
text="x" * 5001,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_zeitstempel_wird_automatisch_gesetzt():
|
|
||||||
"""Testet dass zeitstempel automatisch gesetzt wird und nicht None ist."""
|
|
||||||
vor_erstellung = datetime.now()
|
|
||||||
submission = Submission(
|
|
||||||
message_id="msg_456",
|
|
||||||
plattform="whatsapp",
|
plattform="whatsapp",
|
||||||
absender="+499876543210",
|
absender="user456",
|
||||||
text="Testnachricht",
|
text=long_text,
|
||||||
)
|
)
|
||||||
nach_erstellung = datetime.now()
|
|
||||||
|
|
||||||
|
|
||||||
|
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 submission.zeitstempel is not None
|
||||||
assert vor_erstellung <= submission.zeitstempel <= nach_erstellung
|
assert isinstance(submission.zeitstempel, datetime)
|
||||||
|
|||||||
+78
-45
@@ -1,78 +1,111 @@
|
|||||||
"""Tests für Telegram-Webhook-Route."""
|
"""Tests für den Telegram-Webhook Blueprint."""
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fakeredis
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
from src.routes.telegram import telegram_bp
|
from src.app import create_app
|
||||||
|
from src.queue.handler import QueueHandler
|
||||||
|
from src.routes.telegram import telegram
|
||||||
|
|
||||||
|
|
||||||
class TestTelegramWebhook:
|
def _setup_app_with_fakeredis() -> Flask:
|
||||||
"""Tests für Telegram-Webhook-Endpunkt."""
|
"""Erstellt eine Flask-App mit fakeredis-QueueHandler und registriert den Telegram-Blueprint."""
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
def setup_method(self) -> None:
|
fake_redis = fakeredis.FakeRedis(decode_responses=True)
|
||||||
"""Erstellt Flask-App mit mocktem QueueHandler."""
|
qh = QueueHandler.__new__(QueueHandler)
|
||||||
self.app = Flask(__name__)
|
qh.redis = fake_redis
|
||||||
self.app.queue_handler = MagicMock()
|
qh.stream_key = "bsn:submissions"
|
||||||
self.app.register_blueprint(telegram_bp, url_prefix="/webhook")
|
qh.processed_set = "bsn:processed"
|
||||||
self.client = self.app.test_client()
|
qh.stream_ids_hash = "bsn:stream_ids"
|
||||||
|
qh.group_name = "bsn-workers"
|
||||||
|
qh._ensure_consumer_group()
|
||||||
|
app.queue_handler = qh # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
app.register_blueprint(telegram)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_telegram_payload_returns_200() -> None:
|
||||||
|
"""POST /webhook/telegram mit gültigem Payload → 200."""
|
||||||
|
app = _setup_app_with_fakeredis()
|
||||||
|
client: FlaskClient = app.test_client()
|
||||||
|
|
||||||
def test_post_mit_gueltigem_payload(self) -> None:
|
|
||||||
"""POST mit gültigem Payload → 200."""
|
|
||||||
payload = {
|
payload = {
|
||||||
|
"update_id": 123456789,
|
||||||
"message": {
|
"message": {
|
||||||
"message_id": "msg123",
|
"message_id": 100,
|
||||||
"chat": {"id": "-1001234567890"},
|
"from": {
|
||||||
"text": "Hallo, wir spielen heute Brettspiele!",
|
"id": 987654321,
|
||||||
|
"first_name": "TestUser",
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"id": 987654321,
|
||||||
|
"first_name": "TestUser",
|
||||||
|
"type": "private",
|
||||||
|
},
|
||||||
|
"date": 1719000000,
|
||||||
|
"text": "Hallo BSN-Bot!",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
response = self.client.post(
|
response = client.post("/webhook/telegram", json=payload)
|
||||||
"/webhook/telegram",
|
|
||||||
data=payload,
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
assert data["status"] == "accepted"
|
assert data is not None
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
|
||||||
def test_post_ohne_json(self) -> None:
|
|
||||||
"""POST ohne JSON → 400."""
|
def test_missing_json_returns_400() -> None:
|
||||||
response = self.client.post("/webhook/telegram", data="nicht JSON")
|
"""POST /webhook/telegram ohne JSON-Body → 400."""
|
||||||
|
app = _setup_app_with_fakeredis()
|
||||||
|
client: FlaskClient = app.test_client()
|
||||||
|
|
||||||
|
response = client.post("/webhook/telegram", data="not json", content_type="text/plain")
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
|
assert data is not None
|
||||||
assert "error" in data
|
assert "error" in data
|
||||||
|
|
||||||
def test_post_ohne_message_id(self) -> None:
|
|
||||||
"""POST ohne message_id → 400."""
|
def test_missing_message_id_returns_400() -> None:
|
||||||
|
"""POST /webhook/telegram ohne message_id → 400."""
|
||||||
|
app = _setup_app_with_fakeredis()
|
||||||
|
client: FlaskClient = app.test_client()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"message": {
|
"message": {
|
||||||
"chat": {"id": "123"},
|
"chat": {"id": 123},
|
||||||
"text": "Test",
|
"text": "Hallo",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response = self.client.post(
|
|
||||||
"/webhook/telegram",
|
response = client.post("/webhook/telegram", json=payload)
|
||||||
data=payload,
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
assert "error" in data
|
assert data is not None
|
||||||
|
assert "message_id" in data["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_text_returns_400() -> None:
|
||||||
|
"""POST /webhook/telegram mit leerem text → 400 (min_length=1 ValidationError)."""
|
||||||
|
app = _setup_app_with_fakeredis()
|
||||||
|
client: FlaskClient = app.test_client()
|
||||||
|
|
||||||
def test_post_mit_leerem_text(self) -> None:
|
|
||||||
"""POST mit leerem text → 400."""
|
|
||||||
payload = {
|
payload = {
|
||||||
"message": {
|
"message": {
|
||||||
"message_id": "msg456",
|
"message_id": 200,
|
||||||
"chat": {"id": "123"},
|
"chat": {"id": 456},
|
||||||
"text": "",
|
"text": "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response = self.client.post(
|
|
||||||
"/webhook/telegram",
|
response = client.post("/webhook/telegram", json=payload)
|
||||||
data=payload,
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
|
assert data is not None
|
||||||
assert "error" in data
|
assert "error" in data
|
||||||
|
|||||||
+81
-26
@@ -12,42 +12,97 @@ from src.utils.validators import (
|
|||||||
class TestValidiereTelefonnummer:
|
class TestValidiereTelefonnummer:
|
||||||
"""Tests für validiere_telefonnummer."""
|
"""Tests für validiere_telefonnummer."""
|
||||||
|
|
||||||
def test_gueltige_telefonnummer_bereinigt(self):
|
def test_gueltige_telefonnummer_bereinigt(self) -> None:
|
||||||
"""Testet dass eine gültige Telefonnummer bereinigt zurückgegeben wird."""
|
"""Gültige Telefonnummer wird korrekt bereinigt."""
|
||||||
ergebnis = validiere_telefonnummer("+49 123 4567890")
|
# +49 123 4567890 -> +491****7890
|
||||||
assert ergebnis == "+491234567890"
|
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_wirft_error(self):
|
def test_ungueltige_telefonnummer_valueerror(self) -> None:
|
||||||
"""Testet dass eine ungültige Telefonnummer ValueError wirft."""
|
"""Ungültige Telefonnummer wirft ValueError."""
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
validiere_telefonnummer("12345") # Zu wenige Ziffern
|
validiere_telefonnummer(
|
||||||
|
"".join(["4", "9", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]),
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validiere_telefonnummer("+123456")
|
||||||
|
|
||||||
|
|
||||||
class TestValidiereSpielName:
|
class TestValidierespielName:
|
||||||
"""Tests für validiere_spiel_name."""
|
"""Tests für validiere_spiel_name."""
|
||||||
|
|
||||||
def test_gueltiger_spielname_bereinigt(self):
|
def test_gueltiger_spielname_bereinigt(self) -> None:
|
||||||
"""Testet dass ein gültiger Spielname bereinigt zurückgegeben wird."""
|
"""Gültiger Spielname wird korrekt bereinigt."""
|
||||||
ergebnis = validiere_spiel_name(" Monopoly ")
|
assert validiere_spiel_name(" Monopoly ") == "Monopoly"
|
||||||
assert ergebnis == "Monopoly"
|
assert validiere_spiel_name("Schach") == "Schach"
|
||||||
|
|
||||||
def test_html_tags_im_spielnamen_entfernt(self):
|
def test_html_tags_im_spielnamen_entfernt(self) -> None:
|
||||||
"""Testet dass HTML-Tags aus dem Spielnamen entfernt werden."""
|
"""HTML-Tags werden aus dem Spielnamen entfernt."""
|
||||||
ergebnis = validiere_spiel_name("<b>Catan</b>")
|
assert validiere_spiel_name("<b>Monopoly</b>") == "Monopoly"
|
||||||
assert ergebnis == "Catan"
|
assert validiere_spiel_name("<div class='game'>Risiko</div>") == "Risiko"
|
||||||
assert "<b>" not in ergebnis
|
|
||||||
assert "</b>" not in ergebnis
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidiereSpieleranzahl:
|
class TestValidierespieleranzahl:
|
||||||
"""Tests für validiere_spieleranzahl."""
|
"""Tests für validiere_spieleranzahl."""
|
||||||
|
|
||||||
def test_gueltige_spieleranzahl_unveraendert(self):
|
def test_gueltige_spieleranzahl_unveraendert(self) -> None:
|
||||||
"""Testet dass eine gültige Spieleranzahl unverändert zurückgegeben wird."""
|
"""Gültige Spieleranzahl wird unverändert zurückgegeben."""
|
||||||
ergebnis = validiere_spieleranzahl(5)
|
assert validiere_spieleranzahl(1) == 1
|
||||||
assert ergebnis == 5
|
assert validiere_spieleranzahl(10) == 10
|
||||||
|
assert validiere_spieleranzahl(20) == 20
|
||||||
|
|
||||||
def test_ungueltige_spieleranzahl_wirft_error(self):
|
def test_ungueltige_spieleranzahl_valueerror(self) -> None:
|
||||||
"""Testet dass eine ungültige Spieleranzahl ValueError wirft."""
|
"""Ungültige Spieleranzahl wirft ValueError."""
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
validiere_spieleranzahl(25) # Zu viele Spieler
|
validiere_spieleranzahl(0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validiere_spieleranzahl(21)
|
||||||
|
|||||||
+19
-37
@@ -3,62 +3,44 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import os
|
import os
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.queue.webhook import (
|
from src.queue.webhook import (
|
||||||
validate_and_enqueue,
|
|
||||||
verify_telegram_signature,
|
verify_telegram_signature,
|
||||||
verify_whatsapp_signature,
|
verify_whatsapp_signature,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
os.environ["WHATSAPP_APP_SECRET"] = "test" # noqa: S105
|
||||||
|
os.environ["TELEGRAM_BOT_TOKEN"] = "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" # noqa: S105
|
||||||
|
|
||||||
class TestWhatsAppSignature:
|
SECRET = "test" # noqa: S105
|
||||||
"""Tests für WhatsApp-Signatur-Prüfung."""
|
PAYLOAD = b'{"message_id": "123", "absender": "+491234", "text": "Hallo"}'
|
||||||
|
EXPECTED_HMAC = hmac.new(SECRET.encode(), PAYLOAD, hashlib.sha256).hexdigest()
|
||||||
|
VALID_SIGNATURE = f"sha256={EXPECTED_HMAC}"
|
||||||
|
|
||||||
def test_korrekte_whatsapp_signatur_ergibt_true(self) -> None:
|
|
||||||
|
def test_correct_whatsapp_signature_returns_true() -> None:
|
||||||
"""Korrekte WhatsApp-Signatur → True."""
|
"""Korrekte WhatsApp-Signatur → True."""
|
||||||
payload = b'{"object":"whatsapp","entry":[{"id":"123","changes":[{"value":{"messages":[{"from":"491234567890","id":"msg1","text":{"body":"Hallo"}}]}]}]}'
|
assert verify_whatsapp_signature(PAYLOAD, VALID_SIGNATURE) is True
|
||||||
secret = "meinsupergeheim"
|
|
||||||
sig = "sha256=" + hmac.new(
|
|
||||||
secret.encode("utf-8"), payload, hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
# Test mit direktem Secret
|
|
||||||
assert verify_whatsapp_signature(payload, sig, app_secret=secret) is True
|
|
||||||
|
|
||||||
def test_falsche_whatsapp_signatur_ergibt_false(self) -> None:
|
def test_wrong_whatsapp_signature_returns_false() -> None:
|
||||||
"""Falsche WhatsApp-Signatur → False."""
|
"""Falsche WhatsApp-Signatur → False."""
|
||||||
payload = b'{"object":"whatsapp","entry":[]}'
|
assert verify_whatsapp_signature(PAYLOAD, "sha256=wrongsignature") is False
|
||||||
sig = "sha256=falschefakevalue"
|
|
||||||
|
|
||||||
assert verify_whatsapp_signature(
|
|
||||||
payload, sig, app_secret="richtiges_secret"
|
|
||||||
) is False
|
|
||||||
|
|
||||||
def test_fehlender_app_secret_ergibt_false(self) -> None:
|
def test_missing_app_secret_returns_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Fehlender App-Secret → False (sicherer Fail)."""
|
"""Fehlender App-Secret → False (sicherer Fail)."""
|
||||||
# Sichere Fails, wenn weder app_secret noch ENV gesetzt sind
|
monkeypatch.delenv("WHATSAPP_APP_SECRET", raising=False)
|
||||||
alt_secret = os.environ.pop("WHATSAPP_APP_SECRET", None)
|
assert verify_whatsapp_signature(PAYLOAD, VALID_SIGNATURE) is False
|
||||||
|
|
||||||
try:
|
|
||||||
assert verify_whatsapp_signature(b"payload", "sha256=xyz") is False
|
|
||||||
assert verify_whatsapp_signature(b"payload", "sha256=xyz", app_secret="") is False
|
|
||||||
finally:
|
|
||||||
if alt_secret:
|
|
||||||
os.environ["WHATSAPP_APP_SECRET"] = alt_secret
|
|
||||||
|
|
||||||
|
|
||||||
class TestTelegramSignature:
|
def test_correct_telegram_token_returns_true() -> None:
|
||||||
"""Tests für Telegram-Signatur-Prüfung."""
|
"""Korrekter Telegram-Token → True."""
|
||||||
|
assert verify_telegram_signature() is True
|
||||||
|
|
||||||
def test_korrekter_telegram_token_ergibt_true(self) -> None:
|
|
||||||
"""Korrekter Telegram-Token → Token-Länge > 20."""
|
|
||||||
# Ein Telegram-Token hat typischerweise 40+ Zeichen (Bot:Token-Format)
|
|
||||||
assert verify_telegram_signature(b"", "a" * 40) is True
|
|
||||||
|
|
||||||
def test_zu_kurzer_telegram_token_ergibt_false(self) -> None:
|
def test_invalid_telegram_token_too_short_returns_false() -> None:
|
||||||
"""Ungültiger Telegram-Token (zu kurz) → False."""
|
"""Ungültiger Telegram-Token (zu kurz) → False."""
|
||||||
assert verify_telegram_signature(b"", "abc") is False
|
assert verify_telegram_signature(token="short") is False # noqa: S106
|
||||||
assert verify_telegram_signature(b"", "") is False
|
|
||||||
|
|||||||
+59
-42
@@ -1,79 +1,96 @@
|
|||||||
"""Tests für WhatsApp-Webhook-Blueprint."""
|
"""Tests für den WhatsApp-Webhook-Blueprint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
from unittest.mock import patch
|
import os
|
||||||
|
|
||||||
from flask import Flask
|
import fakeredis
|
||||||
|
import pytest
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
from src.app import create_app
|
||||||
|
from src.queue.handler import QueueHandler
|
||||||
from src.routes.whatsapp import whatsapp_bp
|
from src.routes.whatsapp import whatsapp_bp
|
||||||
from src.queue.webhook import verify_whatsapp_signature
|
|
||||||
|
os.environ["WHATSAPP_APP_SECRET"] = "test" # noqa: S105
|
||||||
|
os.environ["WHATSAPP_VERIFY_TOKEN"] = "test-token" # noqa: S105
|
||||||
|
|
||||||
|
SECRET = "test" # noqa: S105
|
||||||
|
PAYLOAD = b'{"message_id": "123", "absender": "+491234", "text": "Hallo"}'
|
||||||
|
EXPECTED_HMAC = hmac.new(SECRET.encode(), PAYLOAD, hashlib.sha256).hexdigest()
|
||||||
|
VALID_SIGNATURE = f"sha256={EXPECTED_HMAC}"
|
||||||
|
|
||||||
|
|
||||||
class TestWhatsAppWebhook:
|
@pytest.fixture
|
||||||
"""Tests für WhatsApp-Webhook-Endpunkt."""
|
def client() -> FlaskClient:
|
||||||
|
"""Test-Client mit fakeredis und registriertem whatsapp-Blueprint."""
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
def setup_method(self) -> None:
|
# QueueHandler mit fakeredis ersetzen
|
||||||
"""Erstellt Flask-App mit registriertem Blueprint."""
|
fake_redis = fakeredis.FakeRedis(decode_responses=True)
|
||||||
self.app = Flask(__name__)
|
qh = QueueHandler.__new__(QueueHandler)
|
||||||
self.app.register_blueprint(whatsapp_bp)
|
qh.redis = fake_redis
|
||||||
self.client = self.app.test_client()
|
qh.stream_key = "bsn:submissions"
|
||||||
|
qh.processed_set = "bsn:processed"
|
||||||
|
qh.stream_ids_hash = "bsn:stream_ids"
|
||||||
|
qh.group_name = "bsn-workers"
|
||||||
|
qh._ensure_consumer_group()
|
||||||
|
app.queue_handler = qh # type: ignore[attr-defined]
|
||||||
|
|
||||||
def test_get_mit_korrektem_verify_token(self) -> None:
|
app.register_blueprint(whatsapp_bp)
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWhatsAppWebhookGET:
|
||||||
|
"""Tests für GET /webhook/whatsapp (Hub-Challenge)."""
|
||||||
|
|
||||||
|
def test_correct_verify_token_returns_challenge(self, client: FlaskClient) -> None:
|
||||||
"""GET mit korrektem verify_token → 200 + Challenge."""
|
"""GET mit korrektem verify_token → 200 + Challenge."""
|
||||||
response = self.client.get(
|
response = client.get(
|
||||||
"/webhook/whatsapp",
|
"/webhook/whatsapp",
|
||||||
query_string={
|
query_string={
|
||||||
"hub.mode": "subscribe",
|
"hub.mode": "subscribe",
|
||||||
"hub.verify_token": "bsn_verify_token",
|
"hub.verify_token": "test-token",
|
||||||
"hub.challenge": "12345",
|
"hub.challenge": "abc123",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data.decode() == "12345"
|
assert response.get_data(as_text=True) == "abc123"
|
||||||
|
|
||||||
def test_get_mit_falschem_verify_token(self) -> None:
|
def test_wrong_verify_token_returns_403(self, client: FlaskClient) -> None:
|
||||||
"""GET mit falschem verify_token → 403."""
|
"""GET mit falschem verify_token → 403."""
|
||||||
response = self.client.get(
|
response = client.get(
|
||||||
"/webhook/whatsapp",
|
"/webhook/whatsapp",
|
||||||
query_string={
|
query_string={
|
||||||
"hub.mode": "subscribe",
|
"hub.mode": "subscribe",
|
||||||
"hub.verify_token": "falscher_token",
|
"hub.verify_token": "wrong-token",
|
||||||
"hub.challenge": "12345",
|
"hub.challenge": "abc123",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
def test_post_mit_korrekter_signatur(self) -> None:
|
|
||||||
"""POST mit korrekter Signatur → 200."""
|
|
||||||
payload = b'{"object":"whatsapp","entry":[]}'
|
|
||||||
secret = "test_geheim"
|
|
||||||
sig = "sha256=" + hmac.new(
|
|
||||||
secret.encode("utf-8"), payload, hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
with patch("src.routes.whatsapp.verify_whatsapp_signature",
|
class TestWhatsAppWebhookPOST:
|
||||||
return_value=True):
|
"""Tests für POST /webhook/whatsapp (Nachrichtenempfang)."""
|
||||||
response = self.client.post(
|
|
||||||
|
def test_correct_signature_returns_200(self, client: FlaskClient) -> None:
|
||||||
|
"""POST mit korrekter Signatur → 200."""
|
||||||
|
response = client.post(
|
||||||
"/webhook/whatsapp",
|
"/webhook/whatsapp",
|
||||||
data=payload,
|
data=PAYLOAD,
|
||||||
headers={"X-Hub-Signature-256": sig},
|
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
|
headers={"X-Hub-Signature-256": VALID_SIGNATURE},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.get_json()
|
|
||||||
assert data["status"] == "accepted"
|
|
||||||
|
|
||||||
def test_post_mit_falscher_signatur(self) -> None:
|
def test_wrong_signature_returns_401(self, client: FlaskClient) -> None:
|
||||||
"""POST mit falscher Signatur → 401."""
|
"""POST mit falscher Signatur → 401."""
|
||||||
payload = b'{"object":"whatsapp","entry":[]}'
|
response = client.post(
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
"/webhook/whatsapp",
|
"/webhook/whatsapp",
|
||||||
data=payload,
|
data=PAYLOAD,
|
||||||
headers={"X-Hub-Signature-256": "sha256=falsch"},
|
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
|
headers={"X-Hub-Signature-256": "sha256=wrongsignature"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
data = response.get_json()
|
|
||||||
assert "error" in data
|
|
||||||
|
|||||||
+80
-65
@@ -1,87 +1,102 @@
|
|||||||
"""Tests für Queue-Worker."""
|
"""Tests für Queue-Worker mit fakeredis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import fakeredis
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.models.submission import Submission
|
from src.models.submission import Submission
|
||||||
|
from src.queue.handler import QueueHandler
|
||||||
from src.queue.worker import verarbeite_submission, worker_loop
|
from src.queue.worker import verarbeite_submission, worker_loop
|
||||||
|
|
||||||
|
|
||||||
class TestVerarbeiteSubmission:
|
class BreakLoopError(Exception):
|
||||||
"""Tests für verarbeite_submission."""
|
"""Exception zum Durchbrechen der worker_loop-Endlosschleife in Tests."""
|
||||||
|
|
||||||
def test_verarbeite_submission_setzt_status_erledigt(self) -> None:
|
|
||||||
"""verarbeite_submission setzt Status auf 'erledigt'."""
|
@pytest.fixture
|
||||||
submission = Submission(
|
def handler() -> Generator[QueueHandler]:
|
||||||
message_id="test1",
|
"""QueueHandler mit fakeredis-Backend."""
|
||||||
|
fake_redis = fakeredis.FakeRedis(decode_responses=True)
|
||||||
|
qh = QueueHandler.__new__(QueueHandler)
|
||||||
|
qh.redis = fake_redis
|
||||||
|
qh.stream_key = "bsn:submissions"
|
||||||
|
qh.processed_set = "bsn:processed"
|
||||||
|
qh.stream_ids_hash = "bsn:stream_ids"
|
||||||
|
qh.group_name = "bsn-workers"
|
||||||
|
qh._ensure_consumer_group()
|
||||||
|
yield qh
|
||||||
|
fake_redis.flushall()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_submission(message_id: str = "msg-001") -> Submission:
|
||||||
|
"""Erzeugt eine Test-Submission."""
|
||||||
|
return Submission(
|
||||||
|
message_id=message_id,
|
||||||
plattform="whatsapp",
|
plattform="whatsapp",
|
||||||
absender="+491234567890",
|
absender="+491****7890",
|
||||||
text="Test Message",
|
text="Testnachricht für Queue-Worker.",
|
||||||
|
zeitstempel=datetime(2025, 1, 1, 12, 0, 0),
|
||||||
)
|
)
|
||||||
assert submission.status == "neu"
|
|
||||||
|
|
||||||
result = verarbeite_submission(submission)
|
|
||||||
|
def test_verarbeite_submission_setzt_status_auf_erledigt() -> None:
|
||||||
|
"""verarbeite_submission setzt den Status auf 'erledigt'."""
|
||||||
|
sub = _make_submission("msg-001")
|
||||||
|
assert sub.status == "neu"
|
||||||
|
|
||||||
|
result = verarbeite_submission(sub)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
assert submission.status == "erledigt"
|
assert sub.status == "erledigt"
|
||||||
|
|
||||||
|
|
||||||
class TestWorkerLoop:
|
def test_worker_loop_verarbeitet_alle_submissions(handler: QueueHandler) -> None:
|
||||||
"""Tests für worker_loop."""
|
|
||||||
|
|
||||||
def test_worker_loop_verarbeitet_queue(self) -> None:
|
|
||||||
"""worker_loop verarbeitet alle Submissions in der Queue."""
|
"""worker_loop verarbeitet alle Submissions in der Queue."""
|
||||||
mock_qh = MagicMock()
|
# Mehrere Submissions in die Queue einreihen
|
||||||
submissions = [
|
subs = [_make_submission(f"msg-w{i}") for i in range(3)]
|
||||||
Submission(
|
for sub in subs:
|
||||||
message_id=f"msg{i}",
|
handler.enqueue(sub)
|
||||||
plattform="whatsapp",
|
|
||||||
absender="+491234567890",
|
|
||||||
text=f"Message {i}",
|
|
||||||
)
|
|
||||||
for i in range(3)
|
|
||||||
]
|
|
||||||
mock_qh.dequeue.return_value = submissions
|
|
||||||
mock_qh.queue_size.return_value = 0
|
|
||||||
|
|
||||||
with patch("src.queue.worker.time.sleep"), \
|
# Verifiziere: Queue enthält 3 Einträge
|
||||||
patch("src.queue.worker.verarbeite_submission") as mock_verarb:
|
assert handler.queue_size() == 3
|
||||||
mock_verarb.return_value = True
|
|
||||||
# worker_loop ist Endlos-Schleife, wir brechen durch sleep-Patch ab
|
|
||||||
# Wir feueln in einem separären Thread oder mit einer Begrenzung
|
|
||||||
pass # Integration wird in systemtests geprüft
|
|
||||||
|
|
||||||
|
# time.sleep so mocken, dass die Schleife nach einer Leer-Poll-Iteration abbricht
|
||||||
|
def _raise_on_sleep(seconds: int) -> None: # noqa: ARG001
|
||||||
|
raise BreakLoopError
|
||||||
|
|
||||||
def test_worker_loop_stopt_nicht_bei_fehler(self) -> None:
|
with patch("src.queue.worker.time.sleep", side_effect=_raise_on_sleep):
|
||||||
"""worker_loop crash nicht bei Fehler in einer Submission."""
|
|
||||||
mock_qh = MagicMock()
|
|
||||||
bad_submission = Submission(
|
|
||||||
message_id="bad1",
|
|
||||||
plattform="whatsapp",
|
|
||||||
absender="+491234567890",
|
|
||||||
text="Test",
|
|
||||||
)
|
|
||||||
|
|
||||||
def bad_dequeue(count: int = 5):
|
|
||||||
return [bad_submission] if not hasattr(bad_dequeue, "called") else []
|
|
||||||
|
|
||||||
bad_dequeue.called = False
|
|
||||||
|
|
||||||
def dequeue_side_effect(count: int = 5):
|
|
||||||
if not bad_dequeue.called:
|
|
||||||
bad_dequeue.called = True
|
|
||||||
return [bad_submission]
|
|
||||||
return []
|
|
||||||
|
|
||||||
mock_qh.dequeue.side_effect = dequeue_side_effect
|
|
||||||
|
|
||||||
with patch("src.queue.worker.verarbeite_submission",
|
|
||||||
side_effect=Exception("Simulierter Fehler")), \
|
|
||||||
patch("src.queue.worker.time.sleep"):
|
|
||||||
# Keine Exception sollte auftreten
|
|
||||||
try:
|
try:
|
||||||
worker_loop(mock_qh, poll_interval=0, batch_size=5)
|
worker_loop(handler, poll_interval=1, batch_size=5)
|
||||||
except StopIteration:
|
except BreakLoopError:
|
||||||
pass # Erwartet - worker_loop läuft in Endlosschleife
|
pass
|
||||||
|
|
||||||
|
# Nach Verarbeitung: dequeue liefert keine neuen Messages mehr
|
||||||
|
# (alle wurden per XACK bestätigt und sind nicht mehr "new")
|
||||||
|
remaining = handler.dequeue(count=10)
|
||||||
|
assert len(remaining) == 0, (
|
||||||
|
f"Erwartet 0 verbleibende Submissions, aber {len(remaining)} gefunden"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_loop_stoppt_bei_leerer_queue(handler: QueueHandler) -> None:
|
||||||
|
"""worker_loop pollt leere Queue ohne Fehler (bricht nach erster sleep-Iteration ab)."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def _raise_after_first_sleep(seconds: int) -> None: # noqa: ARG001
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
raise BreakLoopError
|
||||||
|
|
||||||
|
with patch("src.queue.worker.time.sleep", side_effect=_raise_after_first_sleep):
|
||||||
|
try:
|
||||||
|
worker_loop(handler, poll_interval=1, batch_size=5)
|
||||||
|
except BreakLoopError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Sicherstellen, dass sleep genau einmal aufgerufen wurde (leere Queue → sleep)
|
||||||
|
assert call_count == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user