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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"&" in avatar.content
|
||||
assert b"<title>Meier & 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
|
||||
Reference in New Issue
Block a user