"""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()