Wichtel-Werkstatt: Secret-Santa-App mit Räumen, Ausschlüssen und Recovery-Links
- FastAPI + SQLite, serverseitig gerenderte Templates, mobil-zuerst - Admin-Passwort, einseitige Ausschlüsse, eingefrorene Auslosung - Teilnehmer sehen Ergebnis per Cookie; Einmal-Recovery-Links bei Verlust - Docker/podman-tauglich (Entrypoint mit Privilegien-Drop, SELinux-:z) - Unit-Tests für Auslosung, E2E-Testskript (30 Checks)
This commit is contained in:
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-End-Test gegen eine laufende Instanz.
|
||||
# Aufruf: BASE=http://localhost:8080 bash tests/e2e.sh
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${BASE:-http://localhost:8080}"
|
||||
JAR_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$JAR_DIR"' EXIT
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
ok() { PASS=$((PASS+1)); echo " ✓ $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " ✗ $1"; }
|
||||
|
||||
check() { # check <beschreibung> <bedingung-wahr?>
|
||||
if [ "${2:-0}" = "1" ]; then ok "$1"; else fail "$1"; fi
|
||||
}
|
||||
|
||||
redirect_of() { # POST mit Formulardaten, liefert Location-Header
|
||||
curl -s -o /dev/null -w '%{redirect_url}' "${@}"
|
||||
}
|
||||
|
||||
echo "== Raum erstellen =="
|
||||
LOC=$(curl -s -c "$JAR_DIR/admin" -o /dev/null -w '%{redirect_url}' \
|
||||
--data-urlencode "room_name=E2E Raum" \
|
||||
--data-urlencode "password=strenggeheim" \
|
||||
"$BASE/rooms")
|
||||
CODE=$(printf '%s' "$LOC" | sed -E 's|.*/r/([A-Z2-9]{6})/admin|\1|')
|
||||
check "Raum angelegt, Code=$CODE" "$([ ${#CODE} -eq 6 ] && echo 1 || echo 0)"
|
||||
|
||||
PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin")
|
||||
echo "$PANEL" | grep -q "Einladung teilen" && ok "Admin-Panel erreichbar" || fail "Admin-Panel erreichbar"
|
||||
|
||||
echo "== Falsches Admin-Passwort =="
|
||||
curl -s -c "$JAR_DIR/fake" -o /dev/null --data-urlencode "password=falsch" "$BASE/r/$CODE/admin/login"
|
||||
FAKE=$(curl -s -b "$JAR_DIR/fake" "$BASE/r/$CODE/admin")
|
||||
echo "$FAKE" | grep -q "Admin-Bereich" && ok "Falsches Passwort bleibt im Login" || fail "Falsches Passwort bleibt im Login"
|
||||
|
||||
echo "== Teilnehmer registrieren =="
|
||||
NAMES=(Anna Ben Clara David)
|
||||
for n in "${NAMES[@]}"; do
|
||||
LOC=$(curl -s -c "$JAR_DIR/$n" -o /dev/null -w '%{redirect_url}' \
|
||||
--data-urlencode "name=$n" "$BASE/r/$CODE/register")
|
||||
case "$LOC" in
|
||||
*/r/"$CODE") ok "Registrierung $n" ;;
|
||||
*) fail "Registrierung $n (Redirect: $LOC)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
LOC=$(curl -s -c "$JAR_DIR/dup" -o /dev/null -w '%{redirect_url}' \
|
||||
--data-urlencode "name=anna" "$BASE/r/$CODE/register")
|
||||
echo "$LOC" | grep -q "error=duplicate" && ok "Doppelter Name wird abgelehnt" || fail "Doppelter Name wird abgelehnt"
|
||||
|
||||
echo "== Admin nimmt selbst teil =="
|
||||
SELF=$(curl -s -b "$JAR_DIR/admin" -c "$JAR_DIR/admin" --data-urlencode "name=Nico" "$BASE/r/$CODE/admin/self-register")
|
||||
echo "$SELF" | grep -q "wichtelst als »Nico«" && ok "Admin als Teilnehmer angemeldet" || fail "Admin als Teilnehmer angemeldet"
|
||||
WAIT=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE")
|
||||
echo "$WAIT" | grep -q "Hi Nico" && ok "Admin sieht eigene Teilnehmeransicht" || fail "Admin sieht eigene Teilnehmeransicht"
|
||||
SELF2=$(curl -s -b "$JAR_DIR/admin" --data-urlencode "name=Nico" "$BASE/r/$CODE/admin/self-register")
|
||||
echo "$SELF2" | grep -q "bereits vergeben" && ok "Admin-Doppelname wird abgelehnt" || fail "Admin-Doppelname wird abgelehnt"
|
||||
NAMES=(Anna Ben Clara David Nico)
|
||||
# Nicos Teilnehmer-Cookie lebt im Admin-Jar – für die Ergebnis-Prüfung kopieren
|
||||
cp "$JAR_DIR/admin" "$JAR_DIR/Nico"
|
||||
|
||||
echo "== Ausschlüsse (Anna darf Ben nicht ziehen, Ben darf Anna nicht ziehen) =="
|
||||
PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin")
|
||||
id_of() { printf '%s' "$PANEL" | grep -oE "value=\"[^\"]+\">$1<" | head -1 | sed -E 's/value="([^"]+)".*/\1/'; }
|
||||
ANNA_ID=$(id_of Anna); BEN_ID=$(id_of Ben)
|
||||
check "Teilnehmer-IDs gefunden" "$([ -n "$ANNA_ID" ] && [ -n "$BEN_ID" ] && echo 1 || echo 0)"
|
||||
|
||||
curl -s -b "$JAR_DIR/admin" -o /dev/null \
|
||||
--data-urlencode "from_id=$ANNA_ID" --data-urlencode "to_id=$BEN_ID" "$BASE/r/$CODE/exclusions/add"
|
||||
curl -s -b "$JAR_DIR/admin" -o /dev/null \
|
||||
--data-urlencode "from_id=$BEN_ID" --data-urlencode "to_id=$ANNA_ID" "$BASE/r/$CODE/exclusions/add"
|
||||
PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin")
|
||||
echo "$PANEL" | grep -q "Anna ➜ darf nicht ➜ Ben" && ok "Ausschluss sichtbar" || fail "Ausschluss sichtbar"
|
||||
|
||||
echo "== Auslosen =="
|
||||
DRAW=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/draw")
|
||||
echo "$DRAW" | grep -q "Auslosung ist abgeschlossen" && ok "Auslosung erfolgreich" || fail "Auslosung erfolgreich"
|
||||
|
||||
echo "== Ergebnisse der Teilnehmer =="
|
||||
declare -A RECEIVER
|
||||
for n in "${NAMES[@]}"; do
|
||||
PAGE=$(curl -s -b "$JAR_DIR/$n" "$BASE/r/$CODE")
|
||||
R=$(printf '%s' "$PAGE" | grep -oE 'result-name">🎁 [^<]+' | sed 's/result-name">🎁 //')
|
||||
RECEIVER[$n]="$R"
|
||||
if [ -n "$R" ] && [ "$R" != "$n" ]; then
|
||||
ok "$n beschenkt $R"
|
||||
else
|
||||
fail "Ergebnis für $n (Wert: '$R')"
|
||||
fi
|
||||
done
|
||||
|
||||
SORTED=$(printf '%s\n' "${RECEIVER[@]}" | sort)
|
||||
UNIQUE=$(printf '%s\n' "${RECEIVER[@]}" | sort -u)
|
||||
check "Jede Person wird genau einmal beschenkt" "$([ "$SORTED" = "$UNIQUE" ] && echo 1 || echo 0)"
|
||||
|
||||
check "Ausschluss eingehalten: Anna ≠> Ben" "$([ "${RECEIVER[Anna]}" != "Ben" ] && echo 1 || echo 0)"
|
||||
check "Ausschluss eingehalten: Ben ≠> Anna" "$([ "${RECEIVER[Ben]}" != "Anna" ] && echo 1 || echo 0)"
|
||||
|
||||
echo "== Admin sieht keine Zuweisungen =="
|
||||
PANEL=$(curl -s -b "$JAR_DIR/admin" "$BASE/r/$CODE/admin")
|
||||
echo "$PANEL" | grep -q "Ausgelost" && ok "Admin sieht Auslosungs-Status" || fail "Admin sieht Auslosungs-Status"
|
||||
LEAK=1
|
||||
echo "$PANEL" | grep -q "Du beschenkst" && LEAK=0
|
||||
for n in "${NAMES[@]}"; do
|
||||
echo "$PANEL" | grep -qE "$n (beschenkt|zieht)" && LEAK=0
|
||||
done
|
||||
check "Keine Ergebnis-Details im Admin-Panel" "$LEAK"
|
||||
|
||||
echo "== Raum ist eingefroren =="
|
||||
DEL=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/participants/$ANNA_ID/delete")
|
||||
echo "$DEL" | grep -q "eingefroren\|können keine" && ok "Teilnehmer löschen nach Auslosung blockiert" || fail "Teilnehmer löschen nach Auslosung blockiert"
|
||||
DRAW2=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/draw")
|
||||
echo "$DRAW2" | grep -q "bereits ausgelost" && ok "Zweite Auslosung blockiert" || fail "Zweite Auslosung blockiert"
|
||||
LATE=$(curl -s -o /dev/null -w '%{redirect_url}' -c "$JAR_DIR/late" --data-urlencode "name=Zuspät" "$BASE/r/$CODE/register")
|
||||
PAGE=$(curl -s -b "$JAR_DIR/late" "$BASE/r/$CODE")
|
||||
echo "$PAGE" | grep -q "nicht mehr möglich" && ok "Keine Registrierung nach Auslosung" || fail "Keine Registrierung nach Auslosung"
|
||||
|
||||
echo "== Recovery-Link (Cookie verloren) =="
|
||||
RECOV=$(curl -s -b "$JAR_DIR/admin" -X POST "$BASE/r/$CODE/recovery/$ANNA_ID")
|
||||
LINK=$(printf '%s' "$RECOV" | grep -oE 'value="[^"]+/recover/[^"]+"' | sed -E 's/^value="//; s/"$//')
|
||||
check "Recovery-Link erzeugt" "$([ -n "$LINK" ] && echo 1 || echo 0)"
|
||||
|
||||
LOC=$(curl -s -c "$JAR_DIR/anna_neu" -o /dev/null -w '%{redirect_url}' "$LINK")
|
||||
case "$LOC" in
|
||||
http://*|https://*) TARGET="$LOC" ;;
|
||||
*) TARGET="$BASE$LOC" ;;
|
||||
esac
|
||||
PAGE=$(curl -s -b "$JAR_DIR/anna_neu" "$TARGET")
|
||||
if echo "$PAGE" | grep -q "Ho ho ho, Anna" && echo "$PAGE" | grep -q "🎁 ${RECEIVER[Anna]}"; then
|
||||
ok "Anna sieht mit neuem Cookie wieder ihr Ergebnis (${RECEIVER[Anna]})"
|
||||
else
|
||||
fail "Anna sieht mit neuem Cookie wieder ihr Ergebnis"
|
||||
fi
|
||||
|
||||
PAGE=$(curl -s -L "$LINK")
|
||||
echo "$PAGE" | grep -q "bereits verwendet" && ok "Recovery-Link ist Einmal-Link" || fail "Recovery-Link ist Einmal-Link"
|
||||
|
||||
echo
|
||||
echo "ERGEBNIS: $PASS ok, $FAIL fehlgeschlagen"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests für die Auslosungslogik.
|
||||
|
||||
Laufen ohne Abhängigkeiten: python tests/test_draw.py
|
||||
(sind aber auch pytest-kompatibel)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.draw import draw_assignment
|
||||
|
||||
|
||||
def check_mapping(mapping, ids, exclusions):
|
||||
assert sorted(mapping.keys()) == sorted(ids), "jeder muss genau einmal geben"
|
||||
assert sorted(mapping.values()) == sorted(ids), "jeder muss genau einmal gezogen werden"
|
||||
for giver, receiver in mapping.items():
|
||||
assert giver != receiver, "niemand darf sich selbst ziehen"
|
||||
assert (giver, receiver) not in exclusions, "Ausschlüsse müssen gelten"
|
||||
|
||||
|
||||
def test_basically():
|
||||
ids = ["a", "b", "c", "d"]
|
||||
mapping = draw_assignment(ids, set())
|
||||
assert mapping is not None
|
||||
check_mapping(mapping, ids, set())
|
||||
|
||||
|
||||
def test_mindestgroesse():
|
||||
assert draw_assignment([], set()) is None
|
||||
assert draw_assignment(["a"], set()) is None
|
||||
|
||||
|
||||
def test_ausschluesse_werden_respektiert():
|
||||
ids = ["anna", "ben", "clara", "david"]
|
||||
# Ehepaar: Anna und Ben dürfen sich nicht gegenseitig ziehen.
|
||||
exclusions = {("anna", "ben"), ("ben", "anna")}
|
||||
rng = random.Random(42)
|
||||
for _ in range(300):
|
||||
mapping = draw_assignment(ids, exclusions, rng=rng)
|
||||
assert mapping is not None
|
||||
check_mapping(mapping, ids, exclusions)
|
||||
|
||||
|
||||
def test_unloesbar_zwei_personen_mit_ausschluss():
|
||||
ids = ["a", "b"]
|
||||
assert draw_assignment(ids, {("a", "b")}) is None
|
||||
|
||||
|
||||
def test_unloesbar_kein_moeglicher_empfaenger():
|
||||
# a darf weder b noch c ziehen → keine gültige Auslosung möglich.
|
||||
ids = ["a", "b", "c"]
|
||||
exclusions = {("a", "b"), ("a", "c")}
|
||||
assert draw_assignment(ids, exclusions) is None
|
||||
|
||||
|
||||
def test_zufall_variiert():
|
||||
ids = [f"p{i}" for i in range(8)]
|
||||
exclusions = {("p0", "p1"), ("p2", "p3")}
|
||||
rng = random.Random(7)
|
||||
results = set()
|
||||
for _ in range(100):
|
||||
mapping = draw_assignment(ids, exclusions, rng=rng)
|
||||
assert mapping is not None
|
||||
check_mapping(mapping, ids, exclusions)
|
||||
results.add(tuple(sorted(mapping.items())))
|
||||
assert len(results) > 1, "Auslosung soll zufällig variieren"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
failures = 0
|
||||
for name, fn in sorted(globals().items()):
|
||||
if name.startswith("test_") and callable(fn):
|
||||
try:
|
||||
fn()
|
||||
print(f" ✓ {name}")
|
||||
except AssertionError as e:
|
||||
failures += 1
|
||||
print(f" ✗ {name}: {e}")
|
||||
print("ALLE TESTS OK" if failures == 0 else f"{failures} TEST(S) FEHLGESCHLAGEN")
|
||||
sys.exit(1 if failures else 0)
|
||||
Reference in New Issue
Block a user