Feature: QR-Kontingente für Partner — Bestellung, Übergabe, Rückverfolgung
Partner verteilen gedruckte QR-Codes (Sticker/Flyer); jeder physische Code
ist einzeln rückverfolgbar von Scan bis Registrierung.
Backend:
- partner_qr_batches + partner_qr_codes (Token 8-stellig, ohne 0/O/1/l/I),
users.referred_qr, partner_codes.owner_user_id (+Backfill über referred_by)
- /q/{token}: Scan zählen (scans, first/last_scan_at) → Redirect
/?ref=CODE&qr=TOKEN — dockt am bestehenden Referral-Flow an
- Registrierung: qr_token wird nur zugeordnet, wenn er zum eingelösten
Partner-Code gehört (Manipulationsschutz)
- Admin: Kontingent bestellen (max 500), Liste mit Scans/Registrierungen,
Löschen (Zweiklick), druckfertiges A4-PDF (segno+fpdf2, 3×4 Grid mit
Kurz-URL + laufender Nummer), Code-Besitzer zuordnen
- Partner-Self-Service: /partner/my-qr (+PDF) für Code-Besitzer
Frontend:
- Admin-Partner-Tab: Karte 'QR-Kontingente' (Bestellung, Stats, PDF, Besitzer)
- Partner-Profil: 'Meine QR-Codes' mit Scans/Registrierungen + PDF-Download
- boot.js/app.js speichern ?qr=, Registrierung schickt qr_token mit
Neu: segno==1.6.6 (pure-python QR). Tests: 5 neue (PDF, Scan-Zählung,
Attribution, Fremd-Token-Schutz, Self-Service). Suite: 51 passed.
This commit is contained in:
parent
cadfb24a8d
commit
f604ab7c4f
16 changed files with 621 additions and 23 deletions
129
tests/test_partner_qr.py
Normal file
129
tests/test_partner_qr.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""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
|
||||
assert r.headers["location"] == f"/?ref={code['code']}&qr={token}"
|
||||
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 -> users.referred_qr gesetzt, Kontingent-Stats zaehlen."""
|
||||
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
|
||||
|
||||
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["registrations"] == 1
|
||||
|
||||
|
||||
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_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"]]
|
||||
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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue