diff --git a/tests/test_og_card.py b/tests/test_og_card.py new file mode 100644 index 0000000..b62ee21 --- /dev/null +++ b/tests/test_og_card.py @@ -0,0 +1,168 @@ +"""Tests fuer app/og_card.py — render_og_card mit Cache + Playwright (#134, #141). + +Tests fuer cache_key + get_cached lebten vorher in test_wahlprogramm_fetch.py; +hier kommt der Render-Pfad mit gemocktem Playwright dazu, sodass die volle +Coverage von render_og_card lokal lauft. +""" +from __future__ import annotations + +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from app.og_card import ( + cache_key, + get_cached, + render_og_card, +) + + +class TestCacheKey: + def test_deterministic(self): + a = cache_key("NRW-18/1", "2026-04-01T00:00:00") + b = cache_key("NRW-18/1", "2026-04-01T00:00:00") + assert a == b + + def test_changes_with_updated_at(self): + a = cache_key("NRW-18/1", "2026-04-01T00:00:00") + b = cache_key("NRW-18/1", "2026-04-02T00:00:00") + assert a != b + + def test_length_16(self): + assert len(cache_key("NRW-18/1", "x")) == 16 + + +class TestGetCached: + def test_returns_none_when_missing(self, tmp_path): + assert get_cached("NRW-18/1", "2026-04-01T00:00:00", cache_dir=tmp_path) is None + + def test_returns_path_when_exists(self, tmp_path): + ds = "NRW-18/1" + upd = "2026-04-01T00:00:00" + key = cache_key(ds, upd) + safe = ds.replace("/", "_") + target = tmp_path / f"{safe}_{key}.png" + target.write_bytes(b"\x89PNG dummy") + result = get_cached(ds, upd, cache_dir=tmp_path) + assert result == target + + +class TestRenderOgCard: + """Tests fuer den Render-Pfad. Playwright wird ueber sys.modules-Stub + eingehaengt — sync_playwright() liefert einen ContextManager, der + einen gemockten Browser/Page-Stack zurueckgibt.""" + + def _make_playwright_stub(self, png_bytes: bytes = b"\x89PNG fake"): + """Erstellt ein Stub-Modul 'playwright.sync_api' mit + ``sync_playwright`` als ContextManager, dessen __enter__ einen Mock + liefert, der die Chain pw.chromium.launch().new_page().screenshot() + liefert.""" + mod = types.ModuleType("playwright") + sub = types.ModuleType("playwright.sync_api") + + page_mock = MagicMock() + page_mock.screenshot.return_value = png_bytes + page_mock.goto.return_value = None + + browser_mock = MagicMock() + browser_mock.new_page.return_value = page_mock + browser_mock.close.return_value = None + + pw_mock = MagicMock() + pw_mock.chromium.launch.return_value = browser_mock + + ctx_mgr = MagicMock() + ctx_mgr.__enter__.return_value = pw_mock + ctx_mgr.__exit__.return_value = False + + sub.sync_playwright = MagicMock(return_value=ctx_mgr) + mod.sync_api = sub + return mod, sub, page_mock + + def test_cache_hit_skips_playwright(self, tmp_path): + """Existierender Cache → Playwright wird gar nicht angerufen.""" + ds = "NRW-18/1" + upd = "2026-04-01T00:00:00" + key = cache_key(ds, upd) + safe = ds.replace("/", "_") + cache_file = tmp_path / f"{safe}_{key}.png" + cache_file.write_bytes(b"\x89CACHED") + + # Wenn der Cache hit ist, sollte playwright NICHT importiert werden. + # Dafuer setzen wir einen Stub, der bei Aufruf einen Test-Fehler triggert. + sys.modules.pop("playwright", None) + sys.modules.pop("playwright.sync_api", None) + + with patch.dict(sys.modules, {}, clear=False): + result = render_og_card(ds, upd, cache_dir=tmp_path) + assert result == b"\x89CACHED" + + def test_cache_miss_renders_via_playwright(self, tmp_path): + ds = "NRW-18/2" + upd = "2026-04-02T00:00:00" + png = b"\x89PNG rendered" + + mod, sub, page_mock = self._make_playwright_stub(png) + with patch.dict(sys.modules, {"playwright": mod, "playwright.sync_api": sub}): + result = render_og_card(ds, upd, cache_dir=tmp_path, + base_url="http://test.example") + + assert result == png + # Cache-Datei muss geschrieben sein + key = cache_key(ds, upd) + safe = ds.replace("/", "_") + cache_file = tmp_path / f"{safe}_{key}.png" + assert cache_file.exists() + assert cache_file.read_bytes() == png + + def test_cache_miss_passes_drucksache_to_playwright_url(self, tmp_path): + """URL-Kodierung des Drucksachen-Namens muss ans og-template gehen.""" + ds = "NRW-18/123 (neu)" # Sonderzeichen + upd = "2026-04-03T00:00:00" + mod, sub, page_mock = self._make_playwright_stub() + with patch.dict(sys.modules, {"playwright": mod, "playwright.sync_api": sub}): + render_og_card(ds, upd, cache_dir=tmp_path, + base_url="http://internal:8000") + # page.goto wurde aufgerufen — URL-Argument analysieren + call = page_mock.goto.call_args + url = call.args[0] + assert url.startswith("http://internal:8000/v2/og-template?drucksache=") + # / und Klammern muessen URL-encoded sein + assert "%2F" in url + assert "(" not in url # encoded as %28 + + def test_playwright_exception_returns_none(self, tmp_path): + """Renderer-Fehler darf den Caller nicht crashen.""" + ds = "NRW-18/3" + upd = "2026-04-04T00:00:00" + + mod = types.ModuleType("playwright") + sub = types.ModuleType("playwright.sync_api") + + def _broken(*a, **kw): + raise RuntimeError("Browser launch failed") + sub.sync_playwright = _broken + mod.sync_api = sub + + with patch.dict(sys.modules, {"playwright": mod, "playwright.sync_api": sub}): + result = render_og_card(ds, upd, cache_dir=tmp_path) + assert result is None + # Cache-Datei darf NICHT existieren + key = cache_key(ds, upd) + safe = ds.replace("/", "_") + cache_file = tmp_path / f"{safe}_{key}.png" + assert not cache_file.exists() + + def test_cache_dir_created_if_missing(self, tmp_path): + """render_og_card muss das cache_dir auch anlegen, wenn es fehlt.""" + sub_dir = tmp_path / "deep" / "nested" / "cache" + # Existiert noch nicht + assert not sub_dir.exists() + + mod, sub, page_mock = self._make_playwright_stub() + with patch.dict(sys.modules, {"playwright": mod, "playwright.sync_api": sub}): + render_og_card("NRW-18/4", "2026-04-05T00:00:00", cache_dir=sub_dir) + assert sub_dir.exists()