"""Tests для DomClick detail-backfill orchestrator (issue #2000 Layer B). Зеркалит конвенции test_avito_detail_backfill.py: module-level patch-target строки, _mock_db(snapshot) helper, runs = MagicMock() assertions. DomClick-специфика: единственный BrowserFetcher (нет curl-fallback), cookies из domclick_session_svc (мокается целиком как модуль), эксепшн-триада DomClickBlockedError/DomClickParseError вместо Avito's Blocked/RateLimited/ListingGone. """ from __future__ import annotations import os import sys from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) import pytest # noqa: E402 from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError # noqa: E402 from app.core import shutdown as _sd # noqa: E402 from app.tasks.domclick_detail_backfill import ( # noqa: E402 DomClickDetailBackfillResult, run_domclick_detail_backfill, ) @pytest.fixture(autouse=True) def _reset_shutdown() -> None: """shutdown — module-global Event: чистим вокруг каждого теста (изоляция #1182).""" _sd.reset_shutdown() yield _sd.reset_shutdown() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_snapshot(n: int) -> list[dict]: return [ { "id": i + 1, "source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}", } for i in range(n) ] def _mock_db(snapshot: list[dict]) -> MagicMock: """Fake Session: first execute() returns snapshot via .mappings().all().""" db = MagicMock() sel = MagicMock() sel.mappings.return_value.all.return_value = snapshot db.execute.return_value = sel return db def _mock_browser_fetcher_cls() -> MagicMock: """MagicMock class whose instance is a working async context manager.""" instance = AsyncMock() instance.__aenter__ = AsyncMock(return_value=instance) instance.__aexit__ = AsyncMock(return_value=False) return MagicMock(return_value=instance) _FETCH = "app.tasks.domclick_detail_backfill.fetch_detail" _SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment" _RUNS = "app.tasks.domclick_detail_backfill.runs_mod" _SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep" _SETTINGS = "app.tasks.domclick_detail_backfill.settings" _SHUTDOWN = "app.tasks.domclick_detail_backfill.shutdown_requested" _BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher" _SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc" # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_backfill_empty_snapshot_marks_done() -> None: """Empty snapshot -> mark_done immediately, no fetch calls, no BrowserFetcher open.""" db = _mock_db([]) runs = MagicMock() fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") mock_svc = MagicMock() mock_svc.load_session.return_value = {"CAS_ID": "123"} mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), patch(_SESSION_SVC, mock_svc), patch(_RUNS, runs), patch(_BROWSER_FETCHER, mock_bf_cls), patch(_FETCH) as mock_fetch, ): result = await run_domclick_detail_backfill( db, run_id=1, params={"batch_size": 10, "budget_sec": 60} ) assert isinstance(result, DomClickDetailBackfillResult) assert result.attempted == 0 assert result.enriched == 0 mock_fetch.assert_not_called() mock_bf_cls.assert_not_called() runs.mark_done.assert_called_once() runs.mark_failed.assert_not_called() @pytest.mark.asyncio async def test_backfill_processes_snapshot_with_cookies_threaded() -> None: """3 listings -> all fetched+enriched, cookies from load_session() threaded into every fetch_detail() call kwargs, mark_done called.""" snapshot = _make_snapshot(3) db = _mock_db(snapshot) runs = MagicMock() mock_enrichment = MagicMock() mock_fetch = AsyncMock(return_value=mock_enrichment) mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") fake_cookies = {"CAS_ID": "999", "qrator_jsid2": "abc"} mock_svc = MagicMock() mock_svc.load_session.return_value = fake_cookies mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), patch(_SESSION_SVC, mock_svc), patch(_RUNS, runs), patch(_BROWSER_FETCHER, mock_bf_cls), patch(_FETCH, mock_fetch), patch(_SAVE, mock_save), patch(_SLEEP, new_callable=AsyncMock), ): result = await run_domclick_detail_backfill( db, run_id=2, params={"batch_size": 10, "budget_sec": 3600} ) assert result.attempted == 3 assert result.enriched == 3 assert result.blocked == 0 assert result.failed == 0 assert mock_fetch.call_count == 3 mock_bf_cls.assert_called_once_with( source="domclick", endpoint=fake_settings.browser_http_endpoint ) for call in mock_fetch.call_args_list: _, kwargs = call assert kwargs.get("cookies") == fake_cookies assert kwargs.get("browser_fetcher") is not None runs.mark_done.assert_called_once() runs.mark_failed.assert_not_called() @pytest.mark.asyncio async def test_backfill_cookies_none_still_proceeds_with_warning(caplog) -> None: """No valid session (load_session()->None) -> run still proceeds (fetch_detail called with cookies=None), but a warning is logged so operators refresh the session. """ snapshot = _make_snapshot(1) db = _mock_db(snapshot) runs = MagicMock() mock_enrichment = MagicMock() mock_fetch = AsyncMock(return_value=mock_enrichment) mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") mock_svc = MagicMock() mock_svc.load_session.return_value = None mock_bf_cls = _mock_browser_fetcher_cls() with ( caplog.at_level("WARNING"), patch(_SETTINGS, fake_settings), patch(_SESSION_SVC, mock_svc), patch(_RUNS, runs), patch(_BROWSER_FETCHER, mock_bf_cls), patch(_FETCH, mock_fetch), patch(_SAVE, mock_save), patch(_SLEEP, new_callable=AsyncMock), ): result = await run_domclick_detail_backfill( db, run_id=3, params={"batch_size": 10, "budget_sec": 3600} ) assert result.enriched == 1 _, kwargs = mock_fetch.call_args assert kwargs.get("cookies") is None assert "no valid DomClick session cookies" in caplog.text runs.mark_done.assert_called_once() runs.mark_failed.assert_not_called() @pytest.mark.asyncio async def test_backfill_blocked_abort_after_max_consecutive() -> None: """3 consecutive DomClickBlockedError -> abort, mark_done (NOT mark_failed). No IP-rotation recovery step exists for DomClick (single dedicated proxy) -- abort happens on the SAME iteration the threshold is hit, no extra recovery calls. """ snapshot = _make_snapshot(10) db = _mock_db(snapshot) runs = MagicMock() blocked_exc = DomClickBlockedError("QRATOR challenge page detected") mock_fetch = AsyncMock(side_effect=blocked_exc) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") mock_svc = MagicMock() mock_svc.load_session.return_value = {"CAS_ID": "123"} mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), patch(_SESSION_SVC, mock_svc), patch(_RUNS, runs), patch(_BROWSER_FETCHER, mock_bf_cls), patch(_FETCH, mock_fetch), patch(_SLEEP, new_callable=AsyncMock), ): result = await run_domclick_detail_backfill( db, run_id=4, params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3}, ) assert result.blocked == 3 assert result.attempted == 3 assert result.enriched == 0 runs.mark_done.assert_called_once() runs.mark_failed.assert_not_called() @pytest.mark.asyncio async def test_backfill_parse_error_counts_failed_no_abort() -> None: """DomClickParseError (schema drift, not a block) -> failed++, consecutive-block breaker NOT touched, run does NOT abort, continues to next listing. """ snapshot = _make_snapshot(2) db = _mock_db(snapshot) runs = MagicMock() mock_enrichment = MagicMock() mock_fetch = AsyncMock( side_effect=[DomClickParseError("__SSR_STATE__ not found"), mock_enrichment] ) mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") mock_svc = MagicMock() mock_svc.load_session.return_value = {"CAS_ID": "123"} mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), patch(_SESSION_SVC, mock_svc), patch(_RUNS, runs), patch(_BROWSER_FETCHER, mock_bf_cls), patch(_FETCH, mock_fetch), patch(_SAVE, mock_save), patch(_SLEEP, new_callable=AsyncMock), ): result = await run_domclick_detail_backfill( db, run_id=5, params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3}, ) assert result.failed == 1 assert result.enriched == 1 assert result.attempted == 2 assert result.blocked == 0 runs.mark_done.assert_called_once() runs.mark_failed.assert_not_called() @pytest.mark.asyncio async def test_backfill_sigterm_drain_breaks_and_marks_done_partial() -> None: """#1182 Phase 2: shutdown_requested() True -> loop выходит на границе карточки, mark_done вызывается с ЧАСТИЧНЫМИ счётчиками (не mark_failed). """ snapshot = _make_snapshot(3) db = _mock_db(snapshot) runs = MagicMock() mock_enrichment = MagicMock() mock_fetch = AsyncMock(return_value=mock_enrichment) mock_save = MagicMock(return_value=True) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") mock_svc = MagicMock() mock_svc.load_session.return_value = {"CAS_ID": "123"} mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), patch(_SESSION_SVC, mock_svc), patch(_RUNS, runs), patch(_BROWSER_FETCHER, mock_bf_cls), patch(_FETCH, mock_fetch), patch(_SAVE, mock_save), patch(_SLEEP, new_callable=AsyncMock), patch(_SHUTDOWN, side_effect=[False, True]), ): result = await run_domclick_detail_backfill( db, run_id=6, params={"batch_size": 10, "budget_sec": 3600} ) assert result.attempted == 1 assert result.enriched == 1 assert mock_fetch.call_count == 1 runs.mark_done.assert_called_once() runs.mark_failed.assert_not_called() runs.mark_cancelled.assert_not_called() done_counters = runs.mark_done.call_args.args[2] assert done_counters["attempted"] == 1 @pytest.mark.asyncio async def test_backfill_budget_guard_stops_loop() -> None: """Budget expired before first listing -> fetch_detail not called.""" snapshot = _make_snapshot(5) db = _mock_db(snapshot) runs = MagicMock() mono_values = iter([0.0, 999.0, 999.0]) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") mock_svc = MagicMock() mock_svc.load_session.return_value = {"CAS_ID": "123"} mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), patch(_SESSION_SVC, mock_svc), patch(_RUNS, runs), patch(_BROWSER_FETCHER, mock_bf_cls), patch("app.tasks.domclick_detail_backfill.time.monotonic", side_effect=mono_values), patch(_FETCH) as mock_fetch, ): await run_domclick_detail_backfill(db, run_id=7, params={"batch_size": 5, "budget_sec": 1}) mock_fetch.assert_not_called() runs.mark_done.assert_called_once() @pytest.mark.asyncio async def test_backfill_top_level_exception_marks_failed() -> None: """db.execute raises -> mark_failed called, exception re-raised.""" db = MagicMock() db.execute.side_effect = RuntimeError("DB connection lost") runs = MagicMock() fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") mock_svc = MagicMock() mock_svc.load_session.return_value = {"CAS_ID": "123"} mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), patch(_SESSION_SVC, mock_svc), patch(_RUNS, runs), patch(_BROWSER_FETCHER, mock_bf_cls), ): with pytest.raises(RuntimeError, match="DB connection lost"): await run_domclick_detail_backfill( db, run_id=8, params={"batch_size": 5, "budget_sec": 60} ) runs.mark_failed.assert_called_once() runs.mark_done.assert_not_called() @pytest.mark.asyncio async def test_backfill_generic_exception_continues_and_rolls_back() -> None: """Unexpected exception on one listing -> failed++, db.rollback(), loop continues.""" snapshot = _make_snapshot(2) db = _mock_db(snapshot) runs = MagicMock() mock_enrichment = MagicMock() mock_fetch = AsyncMock(side_effect=[RuntimeError("unexpected"), mock_enrichment]) fake_settings = MagicMock(browser_http_endpoint="http://browser:9000") mock_svc = MagicMock() mock_svc.load_session.return_value = {"CAS_ID": "123"} mock_bf_cls = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), patch(_SESSION_SVC, mock_svc), patch(_RUNS, runs), patch(_BROWSER_FETCHER, mock_bf_cls), patch(_FETCH, mock_fetch), patch(_SAVE, return_value=True), patch(_SLEEP, new_callable=AsyncMock), ): result = await run_domclick_detail_backfill( db, run_id=9, params={"batch_size": 10, "budget_sec": 3600} ) assert result.failed == 1 assert result.enriched == 1 assert result.attempted == 2 db.rollback.assert_called() runs.mark_done.assert_called_once()