"""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" 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"Meier & S\xc3\xb6hne" 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'') == "image/svg+xml" assert detect_mime(b"\xff\xd8\xff\xe0irgendwas") == "image/jpeg" assert detect_mime(b'') == "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" 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"" + b"x" * (settings.logo_max_upload_bytes + 1) + b"" 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