feat: Ein Markenlogo je Vertrag

Am Logo findet man den Vertrag schneller als am Namen: Netflix erkennt man am
roten N, nicht am Wort – und erst recht nicht an einem Zahnrad aus der
Symbolauswahl. Die bleibt als Rueckfall fuer alles, wozu es kein Logo gibt.

Hochgeladen wird, was das Telefon hergibt; gespeichert ein PNG von hoechstens
128 Pixeln Kantenlaenge. Aus drei Megabyte werden ein paar Kilobyte, und das
Seitenverhaeltnis bleibt – ein verzerrtes Logo sieht falscher aus als ein
kleines.

Immer PNG, egal was hereinkam. Das erspart der Anzeige jede
Fallunterscheidung und schliesst SVG aus: Eine SVG-Datei kann Skript
enthalten, und ein Bild, das der Nutzer hochlaedt und der Browser ausfuehrt,
ist genau die Art Falle, die man sich nicht in ein Finanzprogramm legt.

Eigene Tabelle, nicht eine Spalte an den Vertraegen: Sonst zoege jede
Listenabfrage saemtliche Bilder mit heraus, obwohl die Liste sie einzeln
nachlaedt.

ZWEI FEHLER IM SICHERN, die dabei aufgefallen sind:

Binaeres liess sich gar nicht sichern – die Ausgabe scheiterte an den Bytes,
und zwar erst beim Schreiben, also nachdem der Nutzer glaubt, ein Backup zu
haben. Jetzt Base64.

Und "Ergaenzen" legte Zeilen von Tabellen mit natuerlichem Schluessel ein
zweites Mal an, statt sie wiederzuerkennen. Beim Logo kippte damit die ganze
Wiederherstellung. Betraf bisher keine Tabelle, weil noch keine solche einen
Vergleichsschluessel hatte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 19:35:37 +02:00
co-authored by Claude Opus 5
parent 0a790fd45a
commit dd2c33725f
12 changed files with 484 additions and 12 deletions
+120
View File
@@ -5,6 +5,7 @@ Monat kosten. Alles andere haengt daran, dass sie stimmt: auch ueber
Waehrungen und ueber verschiedene Intervalle hinweg.
"""
import json
import secrets
from datetime import date, timedelta
@@ -178,3 +179,122 @@ def test_angebotene_waehrungen_sind_eine_einstellung(admin):
assert abo["currency"] == "USD"
finally:
admin.put("/api/settings/active_currencies", params={"value": "EUR,CHF,USD"})
# --- Markenlogo --------------------------------------------------------------
def _bild(groesse=(600, 400), farbe=(200, 30, 30, 255)) -> bytes:
"""Ein echtes PNG, absichtlich zu gross – so kommt es aus dem Alltag."""
import io as _io
from PIL import Image
puffer = _io.BytesIO()
Image.new("RGBA", groesse, farbe).save(puffer, format="PNG")
return puffer.getvalue()
def _hochladen(admin, sub_id, daten=None, name="logo.png", typ="image/png"):
return admin.put(f"/api/subscriptions/{sub_id}/logo",
files={"file": (name, daten if daten is not None else _bild(), typ)})
def test_logo_wird_klein_gerechnet_und_ausgeliefert(admin):
"""Hochgeladen wird, was das Telefon hergibt – gespeichert ein Bildchen."""
from app.services import logo
abo = _abo(admin)
assert abo["has_logo"] is False
r = _hochladen(admin, abo["id"])
assert r.status_code == 200, r.text
assert r.json()["has_logo"] is True
assert r.json()["logo_version"] is not None
bild = admin.get(f"/api/subscriptions/{abo['id']}/logo")
assert bild.status_code == 200
assert bild.headers["content-type"] == "image/png"
assert len(bild.content) < 20_000, "Logo nicht verkleinert"
from PIL import Image
import io as _io
gespeichert = Image.open(_io.BytesIO(bild.content))
assert max(gespeichert.size) <= logo.MAX_KANTE
# Seitenverhaeltnis bleibt: ein verzerrtes Logo sieht falscher aus als ein
# kleines. 600x400 ist 3:2.
assert gespeichert.size == (128, 85), gespeichert.size
def test_ohne_logo_gibt_es_kein_bild(admin):
abo = _abo(admin)
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
def test_logo_laesst_sich_austauschen_und_entfernen(admin):
abo = _abo(admin)
_hochladen(admin, abo["id"], _bild((300, 300), (10, 200, 10, 255)))
erst = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
_hochladen(admin, abo["id"], _bild((300, 300), (10, 10, 200, 255)))
zweit = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
assert zweit != erst, "das alte Logo blieb stehen"
r = admin.delete(f"/api/subscriptions/{abo['id']}/logo")
assert r.status_code == 200, r.text
assert r.json()["has_logo"] is False
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
def test_was_kein_bild_ist_wird_verstaendlich_abgewiesen(admin):
abo = _abo(admin)
r = _hochladen(admin, abo["id"], b"kein bild, nur text", "notiz.txt", "text/plain")
assert r.status_code == 400, r.text
assert "Bild" in r.json()["detail"]
def test_logo_eines_fremden_vertrags_bleibt_verschlossen(admin):
"""Sonst laege es hinter einer geratenen Nummer offen."""
abo = _abo(admin)
_hochladen(admin, abo["id"])
username, password = new_user(admin)
anderer, _ = login_ready(username, password)
assert anderer.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
def test_geloeschter_vertrag_nimmt_sein_logo_mit(admin):
from app.database import SessionLocal
from app.models import SubscriptionLogo
abo = _abo(admin)
_hochladen(admin, abo["id"])
assert admin.delete(f"/api/subscriptions/{abo['id']}").status_code == 204
with SessionLocal() as s:
assert s.get(SubscriptionLogo, abo["id"]) is None, "Logo blieb verwaist zurueck"
def test_logo_uebersteht_sichern_und_einspielen(admin):
"""Ein Bild ist binaer – ohne Base64 scheitert schon das Schreiben der
Sicherung, und zwar erst, wenn der Nutzer sie zu haben glaubt."""
abo = _abo(admin, name="Logo-Backup " + secrets.token_hex(3))
_hochladen(admin, abo["id"], _bild((200, 200), (7, 77, 177, 255)))
vorher = admin.get(f"/api/subscriptions/{abo['id']}/logo").content
sicherung = admin.get("/api/export/backup.json")
assert sicherung.status_code == 200, sicherung.text
roh = sicherung.content
daten = json.loads(roh)
haufen = daten.get("data", daten)
logos = haufen.get("subscription_logos")
assert logos, f"Logos fehlen in der Sicherung: {sorted(haufen)[:25]}"
admin.delete(f"/api/subscriptions/{abo['id']}/logo")
assert admin.get(f"/api/subscriptions/{abo['id']}/logo").status_code == 404
r = admin.post("/api/backup/restore",
files={"file": ("backup.json", roh, "application/json")},
data={"mode": "merge"})
assert r.status_code == 200, r.text
nachher = admin.get(f"/api/subscriptions/{abo['id']}/logo")
assert nachher.status_code == 200, "Logo kam nicht zurueck"
assert nachher.content == vorher, "Logo kam veraendert zurueck"