**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>
178 lines
6.8 KiB
Python
178 lines
6.8 KiB
Python
"""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()
|