banyaro/tests/test_partner_qr.py
rene ed7c469c6a Züchter-Bereich (Hub) + Settings-Partner-Karte raus + Admin: alle Code-Einlösungen mit Kanal
- Neue Seite #breeder-dashboard (Welten-Chip 'Züchter' role:breeder in HUND,
  ersetzt die Einzel-Chips Zuchtkartei + Wurfverw.; beide FABs wandern an den
  neuen Chip; Läufigkeit bleibt eigener Chip in HUND, Rene-Vorgabe):
  Zwinger-Karte (Name, verifiziert-Badge, Profil-Editor), Wurfverwaltung mit
  Wurf-Anzahl, Zuchtkartei mit Hunde-Anzahl. Einzelseiten bleiben erreichbar.
- Settings: Partner-Karte entfernt — der 🤝-Welten-Chip ist der Einstieg.
- Admin 'Aktive Codes': 👥 zeigt jetzt ALLE Einlösungen eines Codes mit
  Kanal-Badge (QR #seq aus Kontingent vs. Link/manuell), Datum und
  Bestätigt-Status — Endpoint /admin/partner/codes/{id}/registrations.
Suite: 55 passed.
2026-06-07 19:55:51 +02:00

298 lines
13 KiB
Python

"""Smoke-Tests fuer Partner-QR-Kontingente (Bestellung, Scan, Registrierungs-Rueckverfolgung)."""
import secrets
def _create_code(client, admin, code=None):
code = code or f"QRTEST{secrets.token_hex(3).upper()}"
r = client.post("/api/admin/partner/codes", headers=admin["headers"], json={
"code": code, "label": f"Testpartner {code}", "grants_founder": 0,
})
assert r.status_code == 201, r.text
return r.json()
def _create_batch(client, admin, code_id, quantity=5):
r = client.post(f"/api/admin/partner/codes/{code_id}/qr-batches",
headers=admin["headers"],
json={"label": "Sticker Testlauf", "quantity": quantity})
assert r.status_code == 201, r.text
return r.json()
def _batch_tokens(batch_id):
from database import db
with db() as conn:
return [r["token"] for r in conn.execute(
"SELECT token FROM partner_qr_codes WHERE batch_id=? ORDER BY seq", (batch_id,)
).fetchall()]
def test_batch_create_and_pdf(client, admin):
"""Kontingent anlegen -> N eindeutige Tokens + druckfertiges PDF."""
code = _create_code(client, admin)
batch = _create_batch(client, admin, code["id"], quantity=7)
assert batch["quantity"] == 7 and batch["codes"] == 7
tokens = _batch_tokens(batch["id"])
assert len(set(tokens)) == 7
r = client.get(f"/api/admin/partner/qr-batches/{batch['id']}/pdf", headers=admin["headers"])
assert r.status_code == 200
assert r.headers["content-type"] == "application/pdf"
assert r.content[:4] == b"%PDF"
def test_scan_redirects_and_counts(client, admin):
"""/q/{token} -> 302 mit ref+qr, Scan-Zaehler steigt; unbekannter Token -> /."""
code = _create_code(client, admin)
batch = _create_batch(client, admin, code["id"], quantity=1)
token = _batch_tokens(batch["id"])[0]
r = client.get(f"/q/{token}", follow_redirects=False)
assert r.status_code == 302
# Bewusst KEIN Klartext-Code in der URL — sonst liest jeder Scanner den Code ab
assert r.headers["location"] == f"/?qr={token}"
assert code["code"] not in r.headers["location"]
client.get(f"/q/{token}", follow_redirects=False)
r = client.get("/api/admin/partner/qr-batches", headers=admin["headers"])
mine = [b for b in r.json() if b["id"] == batch["id"]][0]
assert mine["scans"] == 2
r = client.get("/q/gibtsnich", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/"
def test_registration_attributed_to_qr(client, admin):
"""Registrierung mit ref+qr -> referred_qr gesetzt; unbestaetigt=Versuch, bestaetigt=Registrierung."""
code = _create_code(client, admin)
batch = _create_batch(client, admin, code["id"], quantity=2)
token = _batch_tokens(batch["id"])[0]
email = f"qr-{secrets.token_hex(4)}@example.com"
r = client.post("/api/auth/register", json={
"email": email, "password": "QrTest1234!", "name": f"qru{secrets.token_hex(3)}",
"ref_code": code["code"], "qr_token": token,
})
assert r.status_code == 200, r.text
from database import db
with db() as conn:
row = conn.execute("SELECT referred_by, referred_qr FROM users WHERE email=?", (email,)).fetchone()
assert row["referred_by"] == -code["id"]
assert row["referred_qr"] == token
# Frisch registriert = E-Mail unbestaetigt -> zaehlt als Versuch
def _batch():
r = client.get("/api/admin/partner/qr-batches", headers=admin["headers"])
return [b for b in r.json() if b["id"] == batch["id"]][0]
assert _batch()["attempts"] == 1 and _batch()["registrations"] == 0
# Nach E-Mail-Bestaetigung -> echte Registrierung
with db() as conn:
conn.execute("UPDATE users SET email_verified=1 WHERE email=?", (email,))
assert _batch()["registrations"] == 1 and _batch()["attempts"] == 0
# Admin-Detail-Liste: Account mit Datum, Status und Sticker-Nr
r = client.get(f"/api/admin/partner/qr-batches/{batch['id']}/registrations",
headers=admin["headers"])
assert r.status_code == 200
regs = r.json()
assert len(regs) == 1
assert regs[0]["email"] == email
assert regs[0]["email_verified"] == 1
assert regs[0]["seq"] == 1
assert regs[0]["created_at"]
def test_registration_with_qr_only(client, admin):
"""Registrierung NUR mit qr_token (ohne ref_code) -> Code wird server-seitig aufgeloest."""
code = _create_code(client, admin)
batch = _create_batch(client, admin, code["id"], quantity=1)
token = _batch_tokens(batch["id"])[0]
email = f"qro-{secrets.token_hex(4)}@example.com"
r = client.post("/api/auth/register", json={
"email": email, "password": "QrTest1234!", "name": f"qro{secrets.token_hex(3)}",
"qr_token": token,
})
assert r.status_code == 200, r.text
from database import db
with db() as conn:
row = conn.execute("SELECT referred_by, referred_qr FROM users WHERE email=?", (email,)).fetchone()
assert row["referred_by"] == -code["id"]
assert row["referred_qr"] == token
def test_code_registrations_with_channel(client, admin):
"""Admin-Liste aller Code-Einloesungen unterscheidet QR-Sticker und Link/manuell."""
code = _create_code(client, admin)
batch = _create_batch(client, admin, code["id"], quantity=1)
token = _batch_tokens(batch["id"])[0]
# 1x via QR, 1x via Code direkt
client.post("/api/auth/register", json={
"email": f"ch1-{secrets.token_hex(4)}@example.com", "password": "QrTest1234!",
"name": f"ch1{secrets.token_hex(3)}", "ref_code": code["code"], "qr_token": token,
})
client.post("/api/auth/register", json={
"email": f"ch2-{secrets.token_hex(4)}@example.com", "password": "QrTest1234!",
"name": f"ch2{secrets.token_hex(3)}", "ref_code": code["code"],
})
r = client.get(f"/api/admin/partner/codes/{code['id']}/registrations", headers=admin["headers"])
assert r.status_code == 200
regs = r.json()
assert len(regs) == 2
channels = {(x["qr_seq"] or 0) for x in regs}
assert channels == {0, 1} # einer ohne QR (None), einer über Sticker #1
def test_paused_code_not_redeemable(client, admin):
"""Pausierter Code (Notbremse) -> keine Einloesung, Info-Endpoint 404; reaktivierbar."""
code = _create_code(client, admin)
r = client.post(f"/api/admin/partner/codes/{code['id']}/toggle", headers=admin["headers"])
assert r.status_code == 200 and r.json()["active"] == 0
# Info-Endpoint: wie nicht existent
assert client.get(f"/api/partner/codes/{code['code']}/info").status_code == 404
# Registrierung mit pausiertem Code -> keine Zuordnung
email = f"qrp-{secrets.token_hex(4)}@example.com"
r = client.post("/api/auth/register", json={
"email": email, "password": "QrTest1234!", "name": f"qrp{secrets.token_hex(3)}",
"ref_code": code["code"],
})
assert r.status_code == 200, r.text
from database import db
with db() as conn:
row = conn.execute("SELECT referred_by FROM users WHERE email=?", (email,)).fetchone()
assert row["referred_by"] is None
# Reaktivieren funktioniert
r = client.post(f"/api/admin/partner/codes/{code['id']}/toggle", headers=admin["headers"])
assert r.json()["active"] == 1
assert client.get(f"/api/partner/codes/{code['code']}/info").status_code == 200
def test_qr_token_must_match_code(client, admin):
"""QR-Token eines FREMDEN Codes wird nicht zugeordnet (Manipulationsschutz)."""
code_a = _create_code(client, admin)
code_b = _create_code(client, admin)
batch_b = _create_batch(client, admin, code_b["id"], quantity=1)
token_b = _batch_tokens(batch_b["id"])[0]
email = f"qrx-{secrets.token_hex(4)}@example.com"
r = client.post("/api/auth/register", json={
"email": email, "password": "QrTest1234!", "name": f"qrx{secrets.token_hex(3)}",
"ref_code": code_a["code"], "qr_token": token_b,
})
assert r.status_code == 200, r.text
from database import db
with db() as conn:
row = conn.execute("SELECT referred_qr FROM users WHERE email=?", (email,)).fetchone()
assert row["referred_qr"] is None
def test_partner_thank_you_mail(client, admin, user, monkeypatch):
"""E-Mail-Bestaetigung eines Geworbenen -> Dank-Mail mit Statistik an den Code-Besitzer."""
from database import db
with db() as conn:
conn.execute("UPDATE users SET is_partner=1 WHERE email=?", (user["email"],))
uid = conn.execute("SELECT id FROM users WHERE email=?", (user["email"],)).fetchone()["id"]
code = _create_code(client, admin)
batch = _create_batch(client, admin, code["id"], quantity=1)
token = _batch_tokens(batch["id"])[0]
client.post(f"/api/admin/partner/codes/{code['id']}/owner",
headers=admin["headers"], json={"user_id": uid})
sent = []
import routes.outreach as outreach
monkeypatch.setattr(outreach, "_send_smtp",
lambda to, subject, body, account="partner", html=None:
sent.append({"to": to, "subject": subject, "body": body}))
email = f"qrm-{secrets.token_hex(4)}@example.com"
r = client.post("/api/auth/register", json={
"email": email, "password": "QrTest1234!", "name": f"qrm{secrets.token_hex(3)}",
"ref_code": code["code"], "qr_token": token,
})
assert r.status_code == 200, r.text
with db() as conn:
vtoken = conn.execute(
"SELECT verification_token FROM users WHERE email=?", (email,)
).fetchone()["verification_token"]
sent.clear() # Verifikations-Mail an den Neuen ignorieren
r = client.get(f"/api/auth/verify-email/{vtoken}", follow_redirects=False)
assert r.status_code == 302
thank = [m for m in sent if m["to"] == user["email"]]
assert len(thank) == 1, f"Dank-Mail fehlt: {sent}"
assert "Danke" in thank[0]["subject"]
assert "1 bestätigte Registrierung" in thank[0]["body"] # Statistik
assert "#1" in thank[0]["body"] # QR-Sticker-Herkunft
# Doppelt verifizieren -> keine zweite Mail
sent.clear()
client.get(f"/api/auth/verify-email/{vtoken}", follow_redirects=False)
assert not [m for m in sent if m["to"] == user["email"]]
def test_partner_self_service_qr(client, admin, user):
"""Code-Besitzer sieht eigene Kontingente + kann PDF laden; Fremde nicht."""
from database import db
with db() as conn:
conn.execute("UPDATE users SET is_partner=1 WHERE email=?", (user["email"],))
uid = conn.execute("SELECT id FROM users WHERE email=?", (user["email"],)).fetchone()["id"]
code = _create_code(client, admin)
batch = _create_batch(client, admin, code["id"], quantity=3)
# Ohne Besitzer: leere Liste
r = client.get("/api/partner/my-qr", headers=user["headers"])
assert r.status_code == 200 and r.json() == []
# Besitzer zuordnen -> sichtbar + PDF
r = client.post(f"/api/admin/partner/codes/{code['id']}/owner",
headers=admin["headers"], json={"user_id": uid})
assert r.status_code == 200
r = client.get("/api/partner/my-qr", headers=user["headers"])
assert [b["id"] for b in r.json()] == [batch["id"]]
assert r.json()[0]["codes_used"] == 0
r = client.get(f"/api/partner/my-qr/{batch['id']}/pdf", headers=user["headers"])
assert r.status_code == 200 and r.content[:4] == b"%PDF"
# Einzel-Code-Status: alle frei, dann einer verbraucht
r = client.get(f"/api/partner/my-qr/{batch['id']}/codes", headers=user["headers"])
codes_list = r.json()
assert len(codes_list) == 3
assert all(c["registrations"] == 0 and c["scans"] == 0 for c in codes_list)
token = codes_list[0]["token"]
client.get(f"/q/{token}", follow_redirects=False)
email = f"qrc-{secrets.token_hex(4)}@example.com"
client.post("/api/auth/register", json={
"email": email, "password": "QrTest1234!", "name": f"qrc{secrets.token_hex(3)}",
"ref_code": code["code"], "qr_token": token,
})
with db() as conn:
conn.execute("UPDATE users SET email_verified=1 WHERE email=?", (email,))
r = client.get(f"/api/partner/my-qr/{batch['id']}/codes", headers=user["headers"])
first = [c for c in r.json() if c["seq"] == 1][0]
assert first["scans"] == 1 and first["registrations"] == 1
assert first["first_registration_at"]
r = client.get("/api/partner/my-qr", headers=user["headers"])
assert r.json()[0]["codes_used"] == 1
# Dashboard-Stats: eigener Code mit Zahlen + Profil-Status
r = client.get("/api/partner/my-stats", headers=user["headers"])
assert r.status_code == 200
d = r.json()
mycode = [c for c in d["codes"] if c["id"] == code["id"]][0]
assert mycode["registrations"] == 1
assert mycode["registrations_month"] == 1
assert "approved" in d["profile"]