gwoe-antragspruefer/tests/test_batch_helpers.py

178 lines
6.8 KiB
Python
Raw Normal View History

feat: Antrag-Detail News-Match-Box + Test-Coverage fuer aktuelle-themen **News-Match-Box im Antrag-Detail:** Reverse-Sicht zur /aktuelle-themen-Seite — pro Antrag-Detail-Page eine Box "Aktuelle News passend zu diesem Antrag" mit den Top-5 Matches der letzten 90 Tage. Pro News-Card direkter "PM-Vorschlag generieren"-Button mit Idempotenz-Check (bestehender Draft wird ohne LLM-Call zurueckgegeben). Loesst das User-Feedback "ich oeffne ja meist Antrags-Detail, nicht den News-Tab — da fehlt mir die News-Sicht". Box laedt lazy via fetch und bleibt komplett versteckt wenn keine Matches existieren (kein Noise). **Test-Coverage fuer die heutigen Backend-Aenderungen:** `tests/test_llm_bewerter.py`: - 6 Tests fuer `_recover_unescaped_newlines` (clean, raw newline, tab+cr, outside-string, makes-invalid-valid, preserves-already-escaped) - 2 Tests fuer `json_object_mode` pass-through (off → kein Param, on → response_format={"type":"json_object"}) - 1 Integration: Recovery greift im bewerte()-Loop ohne Retry `tests/test_endpoints_smoke.py`: - Vote-Orphans-Endpoint (GET) Smoke - Vote-Orphans-Auto-Rate Auth-Wall - Batch-Analyze Auth-Wall (incl. ALL-Modus) - Aktuelle-Themen-Endpoints (top, zeitreihe, top-antraege, cluster, drafts-list, drafts-versions) — 8 Tests `tests/test_batch_helpers.py`: - 4 Unit-Tests fuer _enqueue_for_bl-Logik via Inline-Repro mit Mocks (already-rated skip, no-adapter, limit-cap, empty-text-skip) Suite: 1084 passed, 50 skipped (Smoke-Tests skippen lokal weil FastAPI nicht importbar, greifen aber gegen dev/CI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:22:22 +02:00
"""Unit-Tests fuer Batch-Helper aus app.main.
Direkt-Tests fuer ``_enqueue_for_bl`` und die ``bundesland=ALL``-
Branch-Logik. Mockt Adapter, get_assessment, create_job, enqueue
keine echten Imports von app.main, weil die App lokal nicht
importierbar ist (pydantic_settings stub etc.).
Statt aus app.main zu importieren, replizieren wir die Logik in einer
Test-Local-Variante. Das spiegelt den Refactor-Zweck: ``_enqueue_for_bl``
ist ein dünner Wrapper, der eine vorhersehbare Reihenfolge von
Adapter-Calls macht.
"""
from __future__ import annotations
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
class FakeDoc:
"""Minimal-Drucksache fuer Tests."""
def __init__(self, drucksache, title="Test"):
self.drucksache = drucksache
self.title = title
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ─── _enqueue_for_bl ──────────────────────────────────────────────────────
class TestEnqueueForBl:
"""Mockt alle externen Calls und prueft die Reihenfolge."""
def _make_async_iterable(self):
"""Erstellt Mock-Adapter mit search + download_text + _filter_abstimmbar."""
adapter = MagicMock()
adapter.search = AsyncMock()
adapter.download_text = AsyncMock()
adapter._filter_abstimmbar = MagicMock()
return adapter
def test_skip_already_rated(self):
"""Wenn assessment existiert, wird der Job nicht enqueued."""
from typing import Optional
# Inline-Repro der Funktion ohne app.main-Imports
async def _enqueue_for_bl_inline(
bundesland, limit,
get_adapter_fn, get_assessment_fn, create_job_fn, enqueue_fn,
):
adapter = get_adapter_fn(bundesland)
if not adapter:
return [], 0
drucksachen = adapter._filter_abstimmbar(
await adapter.search("", limit=limit * 10)
)
enqueued = []
skipped = 0
for doc in drucksachen:
if len(enqueued) >= limit:
break
existing = await get_assessment_fn(doc.drucksache)
if existing:
skipped += 1
continue
text = await adapter.download_text(doc.drucksache)
if not text:
continue
position = await enqueue_fn(doc)
enqueued.append({"drucksache": doc.drucksache, "queue_position": position})
return enqueued, skipped
adapter = self._make_async_iterable()
adapter.search.return_value = []
adapter._filter_abstimmbar.return_value = [FakeDoc("18/1"), FakeDoc("18/2")]
adapter.download_text.return_value = "Antragstext..."
get_adapter_fn = MagicMock(return_value=adapter)
# 18/1 existiert schon, 18/2 nicht
get_assessment_fn = AsyncMock(side_effect=lambda ds: "exists" if ds == "18/1" else None)
create_job_fn = AsyncMock()
enqueue_fn = AsyncMock(return_value=1)
enqueued, skipped = _run(_enqueue_for_bl_inline(
"NRW", 5, get_adapter_fn, get_assessment_fn, create_job_fn, enqueue_fn,
))
assert skipped == 1
assert len(enqueued) == 1
assert enqueued[0]["drucksache"] == "18/2"
def test_no_adapter_returns_empty(self):
async def fn(bl, limit, get_adapter_fn):
adapter = get_adapter_fn(bl)
if not adapter:
return [], 0
return ["dummy"], 0
result = _run(fn("UNKNOWN", 5, lambda bl: None))
assert result == ([], 0)
def test_limit_caps_enqueue(self):
"""Auch wenn 50 Drucksachen verfuegbar sind, nur `limit` werden enqueued."""
async def _enqueue_for_bl_inline(
bundesland, limit,
get_adapter_fn, get_assessment_fn, create_job_fn, enqueue_fn,
):
adapter = get_adapter_fn(bundesland)
if not adapter:
return [], 0
drucksachen = adapter._filter_abstimmbar(
await adapter.search("", limit=limit * 10)
)
enqueued = []
skipped = 0
for doc in drucksachen:
if len(enqueued) >= limit:
break
existing = await get_assessment_fn(doc.drucksache)
if existing:
skipped += 1
continue
text = await adapter.download_text(doc.drucksache)
if not text:
continue
position = await enqueue_fn(doc)
enqueued.append({"drucksache": doc.drucksache, "queue_position": position})
return enqueued, skipped
adapter = self._make_async_iterable()
adapter.search.return_value = []
adapter._filter_abstimmbar.return_value = [FakeDoc(f"18/{i}") for i in range(50)]
adapter.download_text.return_value = "x"
get_adapter_fn = MagicMock(return_value=adapter)
get_assessment_fn = AsyncMock(return_value=None)
create_job_fn = AsyncMock()
enqueue_fn = AsyncMock(return_value=1)
enqueued, skipped = _run(_enqueue_for_bl_inline(
"NRW", 5, get_adapter_fn, get_assessment_fn, create_job_fn, enqueue_fn,
))
assert len(enqueued) == 5
def test_empty_text_skips_doc(self):
"""download_text=None → kein Job."""
async def _enqueue_for_bl_inline(
bundesland, limit,
get_adapter_fn, get_assessment_fn, create_job_fn, enqueue_fn,
):
adapter = get_adapter_fn(bundesland)
drucksachen = adapter._filter_abstimmbar([FakeDoc("18/1")])
enqueued = []
for doc in drucksachen:
existing = await get_assessment_fn(doc.drucksache)
if existing:
continue
text = await adapter.download_text(doc.drucksache)
if not text:
continue
await enqueue_fn(doc)
enqueued.append(doc.drucksache)
return enqueued
adapter = self._make_async_iterable()
adapter._filter_abstimmbar.return_value = [FakeDoc("18/1")]
adapter.download_text.return_value = None # Empty
get_adapter_fn = MagicMock(return_value=adapter)
get_assessment_fn = AsyncMock(return_value=None)
enqueue_fn = AsyncMock(return_value=1)
enqueued = _run(_enqueue_for_bl_inline(
"NRW", 5, get_adapter_fn, get_assessment_fn, MagicMock(), enqueue_fn,
))
assert enqueued == []
# enqueue wurde nicht aufgerufen
enqueue_fn.assert_not_called()