Compare commits

..

10 Commits

Author SHA1 Message Date
Hermes Agent a6d3dc2189 feat: WhatsApp-Webhook mit Signatur-Prüfung und Queue-Enqueue 2026-06-22 06:37:34 +02:00
Hermes Agent 5a72f9ec80 feat: Telegram-Webhook mit JSON-Validierung und Queue-Enqueue 2026-06-22 06:37:31 +02:00
Hermes Agent e382201878 feat: Queue-Worker mit Batch-Verarbeitung und Fehlertoleranz 2026-06-22 06:34:40 +02:00
Hermes Agent 3e360911f5 feat: Flask-App-Factory mit Health-Check und QueueHandler 2026-06-22 06:34:09 +02:00
Hermes Agent c2b3ee551f feat: QueueHandler mit Redis-Streams, Idempotenz, Dedup 2026-06-22 06:32:12 +02:00
Hermes Agent 204da375c5 feat: Submission-Pydantic-Modell mit Validierung 2026-06-22 06:29:43 +02:00
Hermes Agent ac7aa0197b chore: pre-commit hooks mit Ruff Lint + Format 2026-06-22 06:27:00 +02:00
Hermes Agent bf67483e53 chore: Aufgabe 1 erledigt (ded0872) 2026-06-22 00:51:39 +02:00
Hermes Agent ded087292a chore: pyproject.toml mit Ruff, mypy, pytest-Konfiguration 2026-06-22 00:51:25 +02:00
Hermes Agent eebac265af chore: Aufgabe 1 gestartet 2026-06-22 00:50:43 +02:00
34 changed files with 750 additions and 2186 deletions
-46
View File
@@ -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
-86
View File
@@ -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!)
-270
View File
@@ -1,270 +0,0 @@
# Alicia — Aufgaben 610: BSN-Livesystem
> **Repo:** `git@git.datenhimmel.work:danielkrause/BSN-Livesystem.git`
> **Branch:** `main`
> **Deine Basis:** Aufgaben 15 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`
-336
View File
@@ -1,336 +0,0 @@
# Alicia — Aufgaben 1115: BSN-Livesystem Phase 2
> **Repo:** `git@git.datenhimmel.work:danielkrause/BSN-Livesystem.git`
> **Branch:** `main`
> **Stand:** Aufgaben 110 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`
-56
View File
@@ -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
View File
@@ -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
View File
@@ -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
-40
View File
@@ -1,40 +0,0 @@
"""Logging-Konfiguration mit structlog für strukturierte Logs."""
from __future__ import annotations
from typing import cast
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 cast(structlog.stdlib.BoundLogger, structlog.get_logger(name))
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-5
View File
@@ -1,5 +0,0 @@
"""Flask-Blueprint für WhatsApp-Webhook-Endpunkt."""
from flask import Blueprint, request, jsonify
whatsapp_bp = Blueprint("whatsapp", __name__)
-21
View File
@@ -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
View File
@@ -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
View File
@@ -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
-84
View File
@@ -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
-103
View File
@@ -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
View File
@@ -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
View File
@@ -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
} }
] ]
} }
View File
-48
View File
@@ -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
View File
@@ -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)
-64
View File
@@ -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
-89
View File
@@ -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"
-137
View File
@@ -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
-25
View File
@@ -1,25 +0,0 @@
"""Tests für Logging-Konfiguration."""
import structlog
from src.logging_config import get_logger, setup_logging
class TestLoggingConfig:
"""Tests für logging_config."""
def test_setup_logging_laeuft_ohne_fehler_durch(self) -> None:
"""setup_logging() läuft ohne Fehler."""
setup_logging() # Sollte keine Exception werfen
def test_get_logger_gibt_bound_logger_zurueck(self) -> None:
"""get_logger(__name__) gibt Logger-ähnliches Objekt zurück."""
logger = get_logger("test_logger")
# Structlog gibt BoundLoggerLazyProxy zurück (lazy evaluation)
assert hasattr(logger, "info")
assert hasattr(logger, "warning")
def test_logger_info_produziert_keinen_fehler(self) -> None:
"""Logger.info() produziert keinen Fehler."""
logger = get_logger("test_logger")
logger.info("test message") # Sollte keine Exception werfen
+71 -93
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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