feat(logos): Provider-Kette, lokaler Cache und Markenfarben

- simple-icons als kompakter Index im Repository (3.459 Marken, 2 MB gzip),
  erzeugt von scripts/vendor_simple_icons.py bzw. `make vendor-icons`
- logo.dev und Brandfetch als optionale Adapter, ohne Schlüssel übersprungen
- Favicon-Fallback und generierter Buchstaben-Avatar als Garantie
- Bei eindeutigem Offline-Treffer unterbleiben Anfragen nach außen komplett
- Cache im Dateisystem nach SHA-256, Auslieferung nur über /api/logos/{id}
  mit immutable-Header und ETag
- Markenfarbe aus SVG-Fills bzw. per k-Means (k=4) über 64x64 Pixel, dazu eine
  aufgehellte Variante mit mindestens 4,5:1 Kontrast auf dunklem Grund
- Kandidatensuche mit Vorauswahl, Auswahl, Upload und Zurücksetzen
- Bildtyp wird nur noch am Inhalt bestimmt, nicht an der gemeldeten Kopfzeile
- 60 neue Tests, insgesamt 208 grün

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 13:54:21 +02:00
co-authored by Claude Opus 5
parent b586d27b77
commit 8edb69fe6a
16 changed files with 2039 additions and 7 deletions
+4
View File
@@ -1,6 +1,7 @@
"""Gemeinsame Test-Fixtures. Nutzt eine separate Testdatenbank."""
import os
import tempfile
# Muss vor dem ersten Import der Anwendung gesetzt sein, da die Settings gecacht werden.
os.environ.setdefault(
@@ -12,6 +13,9 @@ os.environ.setdefault(
)
os.environ.setdefault("SECRET_KEY", "test-secret-key-mindestens-32-zeichen-lang")
os.environ.setdefault("ENVIRONMENT", "test")
# Die Hintergrundsuche liefe außerhalb der Testtransaktion sie wird gezielt aufgerufen.
os.environ.setdefault("LOGO_AUTO_RESOLVE", "false")
os.environ.setdefault("LOGO_STORAGE_DIR", tempfile.mkdtemp(prefix="moneyfy-logos-"))
from collections.abc import AsyncGenerator
from datetime import date
+158
View File
@@ -0,0 +1,158 @@
"""Tests der Markenfarben-Ermittlung."""
import io
import pytest
from PIL import Image
from app.services.colors import (
DARK_BACKGROUND,
MIN_CONTRAST,
brand_colors,
color_from_raster,
color_from_svg,
contrast_ratio,
deterministic_color,
hex_to_rgb,
is_grayscale,
lighten_for_dark_background,
normalize_hex,
)
def png(pixels: list[tuple[int, int, int, int]], size: tuple[int, int] = (8, 8)) -> bytes:
"""Baut ein kleines PNG aus einer Pixelliste."""
image = Image.new("RGBA", size)
image.putdata([pixels[index % len(pixels)] for index in range(size[0] * size[1])])
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
# --- Umrechnungen --------------------------------------------------------------
@pytest.mark.parametrize(
("eingabe", "erwartet"),
[
("#E50914", "#e50914"),
("E50914", "#e50914"),
("#f00", "#ff0000"),
("#E50914FF", "#e50914"),
("kein-hex", None),
("#12345", None),
],
)
def test_hexwerte_werden_normalisiert(eingabe: str, erwartet: str | None) -> None:
assert normalize_hex(eingabe) == erwartet
def test_kontrastverhaeltnis_nach_wcag() -> None:
# Schwarz auf Weiß ist das Maximum von 21:1.
assert round(contrast_ratio("#000000", "#ffffff"), 1) == 21.0
assert round(contrast_ratio("#ffffff", "#ffffff"), 1) == 1.0
def test_graustufen_werden_erkannt() -> None:
assert is_grayscale(hex_to_rgb("#808080")) is True
assert is_grayscale(hex_to_rgb("#ffffff")) is True
assert is_grayscale(hex_to_rgb("#e50914")) is False
# --- Aufhellung ----------------------------------------------------------------
@pytest.mark.parametrize(
"farbe",
["#e50914", "#1d4ed8", "#166534", "#7c2d12", "#0058a3", "#e20074", "#312e81"],
)
def test_aufgehellte_farbe_erreicht_den_mindestkontrast(farbe: str) -> None:
aufgehellt = lighten_for_dark_background(farbe)
assert contrast_ratio(aufgehellt, DARK_BACKGROUND) >= MIN_CONTRAST
def test_bereits_helle_farbe_bleibt_unveraendert() -> None:
# Ein helles Grün erfüllt den Kontrast bereits.
assert lighten_for_dark_background("#1ed760") == "#1ed760"
def test_farbpaar_faellt_auf_den_namenshash_zurueck() -> None:
ohne = brand_colors(None, fallback_name="Knuspertronic")
nochmal = brand_colors(None, fallback_name="Knuspertronic")
assert ohne.color == nochmal.color # deterministisch
assert ohne.color == deterministic_color("Knuspertronic")
assert contrast_ratio(ohne.color_dark, DARK_BACKGROUND) >= MIN_CONTRAST
def test_namensfarbe_ignoriert_gross_schreibung_und_leerzeichen() -> None:
assert deterministic_color(" Netflix ") == deterministic_color("netflix")
# --- SVG -----------------------------------------------------------------------
def test_svg_haeufigste_nicht_graustufe_gewinnt() -> None:
svg = (
b'<svg><path fill="#ffffff" d="a"/><path fill="#e50914" d="b"/>'
b'<path fill="#e50914" d="c"/><path fill="#111111" d="d"/></svg>'
)
assert color_from_svg(svg) == "#e50914"
def test_svg_versteht_style_und_rgb_schreibweise() -> None:
svg = b'<svg><path style="fill:rgb(29, 185, 84)" d="a"/></svg>'
assert color_from_svg(svg) == "#1db954"
def test_svg_beruecksichtigt_verlaufsstopps() -> None:
svg = b'<svg><linearGradient><stop stop-color="#833ab4"/></linearGradient></svg>'
assert color_from_svg(svg) == "#833ab4"
def test_svg_ohne_farbe_liefert_nichts() -> None:
assert color_from_svg(b'<svg><path d="M0 0"/></svg>') is None
assert color_from_svg(b'<svg><path fill="#333333" d="M0 0"/></svg>') is None
# --- Rastergrafiken ------------------------------------------------------------
def test_raster_findet_die_dominante_farbe() -> None:
# Überwiegend Rot mit weißem Rand.
bild = png([(229, 9, 20, 255)] * 12 + [(255, 255, 255, 255)] * 4)
ergebnis = color_from_raster(bild)
assert ergebnis is not None
rot, gruen, blau = hex_to_rgb(ergebnis)
assert rot > 200 and gruen < 60 and blau < 60
def test_raster_ignoriert_durchsichtige_pixel() -> None:
"""Ein grüner Bildinhalt auf vollständig durchsichtigem Blau bleibt grün."""
bild = png([(30, 215, 96, 255)] * 8 + [(0, 0, 255, 0)] * 8)
ergebnis = color_from_raster(bild)
assert ergebnis is not None
_, gruen, blau = hex_to_rgb(ergebnis)
assert gruen > 150 and blau < 150
def test_reines_graustufenbild_liefert_keine_markenfarbe() -> None:
bild = png([(20, 20, 20, 255)] * 8 + [(240, 240, 240, 255)] * 8)
assert color_from_raster(bild) is None
def test_vollstaendig_durchsichtiges_bild_liefert_nichts() -> None:
assert color_from_raster(png([(255, 0, 0, 0)])) is None
def test_kaputte_datei_wirft_nicht() -> None:
assert color_from_raster(b"kein bild") is None
+424
View File
@@ -0,0 +1,424 @@
"""Tests der Provider-Kette, des Caches und der Logo-Endpunkte."""
import io
from pathlib import Path
import httpx
import pytest
from httpx import AsyncClient
from PIL import Image
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models import LogoAsset, Merchant
from app.models.enums import LogoSource, LogoStatus
from app.services.logos import (
detect_mime,
extract_domain,
generate_avatar,
initials,
resolve_merchant_logo,
search_candidates,
simple_icon_index,
store_candidate,
)
@pytest.fixture
def kein_netz(monkeypatch: pytest.MonkeyPatch) -> None:
"""Jeder ausgehende Request lässt den Test scheitern."""
class Verboten(httpx.AsyncClient):
def __init__(self, *args, **kwargs):
raise AssertionError("Es wurde ein ausgehender Request versucht.")
monkeypatch.setattr(httpx, "AsyncClient", Verboten)
@pytest.fixture
def netz_ohne_treffer(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Ersetzt ausgehende Requests durch 404 und protokolliert die URLs."""
aufgerufen: list[str] = []
class Leer:
def __init__(self, *args, **kwargs):
pass
async def get(self, url, **kwargs):
aufgerufen.append(str(url))
return httpx.Response(404, request=httpx.Request("GET", url))
async def aclose(self) -> None:
return None
monkeypatch.setattr(httpx, "AsyncClient", Leer)
return aufgerufen
def png_bytes(farbe: tuple[int, int, int] = (229, 9, 20), size: int = 32) -> bytes:
image = Image.new("RGBA", (size, size), (*farbe, 255))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
async def firma(session: AsyncSession, name: str, domain: str | None = None) -> Merchant:
from app.services.merchants import normalize_name
merchant = Merchant(name=name, normalized_name=normalize_name(name), domain=domain)
session.add(merchant)
await session.flush()
return merchant
# --- simple-icons --------------------------------------------------------------
def test_index_ist_vollstaendig_gevendort() -> None:
index = simple_icon_index()
assert index.version, "Der Index muss eine Version melden."
assert len(index.icons) > 3000
assert index.lookup("netflix") is not None
assert index.lookup("netflix").hex_color.lower() == "e50914"
async def test_netflix_ohne_schluessel_und_ohne_netz(kein_netz: None) -> None:
"""Akzeptanzkriterium: bekannte Marke ohne API-Keys und ohne Netzzugriff."""
kandidaten = await search_candidates("Netflix", stop_on_exact=True)
bester = kandidaten[0]
assert bester.source is LogoSource.SIMPLE_ICONS
assert bester.mime == "image/svg+xml"
assert bester.brand_color == "#e50914"
assert bester.score == 1.0
assert b"<svg" in bester.content
async def test_treffer_ueber_alias_und_domain(kein_netz: None) -> None:
ueber_domain = await search_candidates("Irgendwas", "spotify.com", stop_on_exact=True)
assert ueber_domain[0].title == "Spotify"
# Rechtsform im Namen darf den Treffer nicht verhindern.
mit_rechtsform = await search_candidates("Deutsche Telekom AG", stop_on_exact=True)
assert mit_rechtsform[0].title == "Deutsche Telekom"
async def test_teiltreffer_wird_niedriger_bewertet(netz_ohne_treffer: list[str]) -> None:
"""'Netflix Standard' ist kein exakter Name, soll aber Netflix finden."""
kandidaten = await search_candidates("Netflix Standard")
treffer = [k for k in kandidaten if k.source is LogoSource.SIMPLE_ICONS]
assert treffer[0].title == "Netflix"
assert 0.5 <= treffer[0].score < 0.95
# --- Generierter Avatar --------------------------------------------------------
@pytest.mark.parametrize(
("name", "erwartet"),
[
("Netflix", "NE"),
("Deutsche Telekom", "DT"),
("1&1 Telecom", "1T"),
("X", "X"),
("!!!", "?"),
],
)
def test_initialen(name: str, erwartet: str) -> None:
assert initials(name) == erwartet
def test_avatar_ist_deterministisch() -> None:
erster = generate_avatar("Knuspertronic GmbH")
zweiter = generate_avatar("Knuspertronic GmbH")
assert erster.sha256 == zweiter.sha256
assert erster.source is LogoSource.GENERATED
assert b"KG" in erster.content
def test_avatar_maskiert_sonderzeichen() -> None:
avatar = generate_avatar("Meier & Söhne")
assert b"&amp;" in avatar.content
assert b"<title>Meier &amp; S\xc3\xb6hne</title>" in avatar.content
async def test_fantasiefirma_endet_beim_avatar(netz_ohne_treffer: list[str]) -> None:
"""Akzeptanzkriterium: unbekannte Firma endet fehlerfrei beim Avatar."""
kandidaten = await search_candidates("Knuspertronic GmbH", stop_on_exact=True)
assert len(kandidaten) == 1
assert kandidaten[0].source is LogoSource.GENERATED
assert kandidaten[0].brand_color is not None
async def test_providerfehler_werden_nicht_durchgereicht(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ein abstürzender Provider darf die Kette nicht beenden."""
class Kaputt:
def __init__(self, *args, **kwargs):
pass
async def get(self, url, **kwargs):
raise httpx.ConnectError("Netz weg")
async def aclose(self) -> None:
return None
monkeypatch.setattr(httpx, "AsyncClient", Kaputt)
kandidaten = await search_candidates("Voellig Unbekannt AG")
assert kandidaten[-1].source is LogoSource.GENERATED
# --- Hilfsfunktionen -----------------------------------------------------------
@pytest.mark.parametrize(
("eingabe", "erwartet"),
[
("netflix.com", "netflix.com"),
("https://www.netflix.com/de/", "netflix.com"),
("http://sub.example.co.uk/pfad", "sub.example.co.uk"),
("keine domain", None),
(None, None),
],
)
def test_domain_wird_herausgeloest(eingabe: str | None, erwartet: str | None) -> None:
assert extract_domain(eingabe) == erwartet
def test_dateityp_wird_an_der_signatur_erkannt() -> None:
assert detect_mime(png_bytes()) == "image/png"
assert detect_mime(b'<svg xmlns="http://www.w3.org/2000/svg"></svg>') == "image/svg+xml"
assert detect_mime(b"\xff\xd8\xff\xe0irgendwas") == "image/jpeg"
assert detect_mime(b'<?xml version="1.0"?><svg viewBox="0 0 1 1"/>') == "image/svg+xml"
# Eine behauptete Kopfzeile darf keinen Bildtyp vortäuschen.
assert detect_mime(b"harmloser text", "image/png") is None
assert detect_mime(b"%PDF-1.7", "image/svg+xml") is None
# --- Cache ---------------------------------------------------------------------
async def test_gleicher_inhalt_wird_nur_einmal_gespeichert(session: AsyncSession) -> None:
kandidat = generate_avatar("Knuspertronic")
erster = await store_candidate(session, kandidat)
zweiter = await store_candidate(session, kandidat)
assert erster.id == zweiter.id
anzahl = (await session.execute(select(func.count()).select_from(LogoAsset))).scalar_one()
assert anzahl == 1
assert (settings.logo_storage_dir / erster.file_path).exists()
assert Path(erster.file_path).stem == kandidat.sha256
async def test_zuordnung_setzt_farbe_und_status(session: AsyncSession, kein_netz: None) -> None:
merchant = await firma(session, "Netflix")
asset = await resolve_merchant_logo(session, merchant)
assert asset is not None
assert merchant.logo_asset_id == asset.id
assert merchant.logo_source is LogoSource.SIMPLE_ICONS
assert merchant.logo_status is LogoStatus.RESOLVED
assert merchant.brand_color == "#e50914"
# Die aufgehellte Variante ist für den dunklen Hintergrund gedacht.
assert merchant.brand_color_dark != merchant.brand_color
async def test_zweite_zuordnung_erzeugt_kein_neues_asset(
session: AsyncSession, kein_netz: None
) -> None:
"""Akzeptanzkriterium: der zweite Aufruf löst keinen ausgehenden Request aus."""
merchant = await firma(session, "Netflix")
erster = await resolve_merchant_logo(session, merchant)
zweiter = await resolve_merchant_logo(session, merchant)
assert erster.id == zweiter.id
anzahl = (await session.execute(select(func.count()).select_from(LogoAsset))).scalar_one()
assert anzahl == 1
async def test_manuelles_logo_wird_nicht_ueberschrieben(
session: AsyncSession, kein_netz: None
) -> None:
merchant = await firma(session, "Netflix")
merchant.logo_status = LogoStatus.MANUAL
merchant.logo_asset_id = None
ergebnis = await resolve_merchant_logo(session, merchant)
assert ergebnis is None
assert merchant.logo_asset_id is None
# Mit force wird sie doch ersetzt.
erzwungen = await resolve_merchant_logo(session, merchant, force=True)
assert erzwungen is not None
# --- Endpunkte -----------------------------------------------------------------
async def test_logo_wird_lokal_mit_cache_header_ausgeliefert(
auth_client: AsyncClient, session: AsyncSession, kein_netz: None
) -> None:
merchant = await firma(session, "Netflix")
asset = await resolve_merchant_logo(session, merchant)
antwort = await auth_client.get(f"/api/logos/{asset.id}")
assert antwort.status_code == 200
assert antwort.headers["content-type"].startswith("image/svg+xml")
assert antwort.headers["cache-control"] == "public, max-age=31536000, immutable"
assert antwort.headers["etag"] == f'"{asset.sha256}"'
assert b"<svg" in antwort.content
# Mit passendem ETag antwortet der Server ohne Nutzlast.
erneut = await auth_client.get(
f"/api/logos/{asset.id}", headers={"If-None-Match": antwort.headers["etag"]}
)
assert erneut.status_code == 304
async def test_unbekanntes_logo_liefert_404(auth_client: AsyncClient) -> None:
antwort = await auth_client.get("/api/logos/999999")
assert antwort.status_code == 404
assert antwort.json()["code"] == "not_found"
async def test_suche_liefert_kandidaten_mit_vorauswahl(
auth_client: AsyncClient, netz_ohne_treffer: list[str]
) -> None:
angelegt = (await auth_client.post("/api/merchants", json={"name": "Netflix"})).json()
ergebnis = (await auth_client.post(f"/api/merchants/{angelegt['id']}/logo/search")).json()
kandidaten = ergebnis["candidates"]
assert 1 <= len(kandidaten) <= 5
assert kandidaten[0]["source"] == "simple-icons"
assert kandidaten[0]["is_preselected"] is True
assert sum(1 for k in kandidaten if k["is_preselected"]) == 1
assert kandidaten[-1]["source"] == "generated"
# Jeder Kandidat liegt bereits im Cache und ist sofort abrufbar.
for kandidat in kandidaten:
bild = await auth_client.get(f"/api/logos/{kandidat['candidate_id']}")
assert bild.status_code == 200
# Die Auswahl der Firma wurde durch die Suche noch nicht verändert.
firma_danach = (await auth_client.get(f"/api/merchants/{angelegt['id']}")).json()
assert firma_danach["logo_asset_id"] is None
assert firma_danach["logo_status"] == "pending"
async def test_kandidat_auswaehlen_setzt_manuell(
auth_client: AsyncClient, netz_ohne_treffer: list[str]
) -> None:
angelegt = (await auth_client.post("/api/merchants", json={"name": "Netflix"})).json()
kandidaten = (await auth_client.post(f"/api/merchants/{angelegt['id']}/logo/search")).json()[
"candidates"
]
gewaehlt = (
await auth_client.post(
f"/api/merchants/{angelegt['id']}/logo/select",
json={"candidate_id": kandidaten[0]["candidate_id"]},
)
).json()
assert gewaehlt["logo_asset_id"] == kandidaten[0]["candidate_id"]
assert gewaehlt["logo_status"] == "manual"
assert gewaehlt["brand_color"] == "#e50914"
async def test_auswahl_eines_unbekannten_kandidaten(auth_client: AsyncClient) -> None:
angelegt = (await auth_client.post("/api/merchants", json={"name": "Netflix"})).json()
antwort = await auth_client.post(
f"/api/merchants/{angelegt['id']}/logo/select", json={"candidate_id": 999999}
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "candidate_not_found"
async def test_suche_mit_eigener_domain(
auth_client: AsyncClient, netz_ohne_treffer: list[str]
) -> None:
angelegt = (await auth_client.post("/api/merchants", json={"name": "Streamdienst"})).json()
await auth_client.post(
f"/api/merchants/{angelegt['id']}/logo/search", params={"domain": "spotify.com"}
)
# Die Domain wird übernommen und führt zum Treffer über den Domainnamen.
danach = (await auth_client.get(f"/api/merchants/{angelegt['id']}")).json()
assert danach["domain"] == "spotify.com"
async def test_upload_setzt_manuell_und_farbe(auth_client: AsyncClient) -> None:
angelegt = (await auth_client.post("/api/merchants", json={"name": "Hausverwaltung"})).json()
antwort = await auth_client.post(
f"/api/merchants/{angelegt['id']}/logo/upload",
files={"file": ("logo.png", png_bytes((30, 215, 96)), "image/png")},
)
assert antwort.status_code == 200
body = antwort.json()
assert body["logo_status"] == "manual"
assert body["logo_source"] == "upload"
assert body["logo_asset_id"] is not None
assert body["brand_color"] is not None
bild = await auth_client.get(f"/api/logos/{body['logo_asset_id']}")
assert bild.headers["content-type"].startswith("image/png")
async def test_upload_lehnt_fremde_dateitypen_ab(auth_client: AsyncClient) -> None:
angelegt = (await auth_client.post("/api/merchants", json={"name": "Testfirma"})).json()
antwort = await auth_client.post(
f"/api/merchants/{angelegt['id']}/logo/upload",
files={"file": ("schaedlich.pdf", b"%PDF-1.7 kein Bild", "application/pdf")},
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "unsupported_media_type"
async def test_upload_lehnt_zu_grosse_dateien_ab(auth_client: AsyncClient) -> None:
angelegt = (await auth_client.post("/api/merchants", json={"name": "Grossfirma"})).json()
zu_gross = b"<svg>" + b"x" * (settings.logo_max_upload_bytes + 1) + b"</svg>"
antwort = await auth_client.post(
f"/api/merchants/{angelegt['id']}/logo/upload",
files={"file": ("gross.svg", zu_gross, "image/svg+xml")},
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "logo_too_large"
async def test_logo_entfernen_setzt_zurueck(
auth_client: AsyncClient, netz_ohne_treffer: list[str]
) -> None:
angelegt = (await auth_client.post("/api/merchants", json={"name": "Netflix"})).json()
await auth_client.post(f"/api/merchants/{angelegt['id']}/logo/resolve")
entfernt = (await auth_client.delete(f"/api/merchants/{angelegt['id']}/logo")).json()
assert entfernt["logo_asset_id"] is None
assert entfernt["logo_status"] == "pending"
assert entfernt["brand_color"] is None