"""#2860: обход, не разрешивший ни одного дома, не должен числиться успешным. Четырнадцать прогонов подряд (16.07-17.08.2026) стояли `status='done'` с пустым `error_text` при счётчиках `processed 5, succeeded 0`. Исключения не было — поэтому `mark_done`; но прогон не сделал ничего из заявленного, очередь за это время выросла 351 → 397, а витрина `market.yandex_jk_enrichment` замерла на 34 строках с 15.07. Счётчик при этом был честный. Не хватало вывода из него — ровно тот класс, который эпик #2674 называет самым частым: «механизм исполняется, счётчик честный, вывод из него никто не делает». НЕ подгоняем succeeded: если дома не разрешаются, честный исход — назвать прогон неуспешным, а не дотянуть счётчик до зелёного. """ from __future__ import annotations import os from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest # Settings требует DATABASE_URL на импорте; соседние тесты хендлеров делают так же # (см. test_scrape_skip_visibility.py) — коннекта не будет, нужен только парс. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from app.services.product_handlers import _job_yandex_newbuilding_sweep from app.tasks.yandex_newbuilding_sweep import YandexNewbuildingSweepResult def _ctx() -> SimpleNamespace: return SimpleNamespace(runs=MagicMock()) def _result(processed: int, succeeded: int) -> YandexNewbuildingSweepResult: """Настоящий результат задачи, а не MagicMock. У MagicMock любой не заданный атрибут (`no_proxy_stop` из #3197) — truthy, то есть добавление хендлеру новой ветки по полю результата уводило бы сюда ВСЕ три случая, и тест краснел бы по причине, не имеющей отношения к #2860. """ return YandexNewbuildingSweepResult(processed=processed, succeeded=succeeded, total=425) @pytest.mark.asyncio async def test_zero_resolved_marks_run_failed() -> None: """processed > 0, succeeded == 0 → прогон неуспешен, причина названа.""" ctx = _ctx() with patch( "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", AsyncMock(return_value=_result(5, 0)), ): await _job_yandex_newbuilding_sweep(MagicMock(), 4156, {}, ctx) ctx.runs.mark_done.assert_not_called() ctx.runs.mark_failed.assert_called_once() reason = ctx.runs.mark_failed.call_args[0][2] assert "разрешено 0" in reason, reason @pytest.mark.asyncio async def test_partial_success_still_done() -> None: """Контроль: хотя бы один разрешённый дом — прогон по-прежнему успешен. Зелёный с обеих сторон правки. Без него правка могла бы объявить неуспешным любой неполный прогон — а `succeeded: 1 из 5` был нормой в июле, когда механизм работал. """ ctx = _ctx() with patch( "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", AsyncMock(return_value=_result(5, 1)), ): await _job_yandex_newbuilding_sweep(MagicMock(), 1489, {}, ctx) ctx.runs.mark_failed.assert_not_called() ctx.runs.mark_done.assert_called_once() @pytest.mark.asyncio async def test_empty_queue_is_not_a_failure() -> None: """Контроль: обрабатывать было нечего (processed == 0) — это не отказ. Иначе гейт краснел бы каждый раз, когда очередь разобрана до конца, то есть ровно в успешном состоянии. """ ctx = _ctx() with patch( "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", AsyncMock(return_value=_result(0, 0)), ): await _job_yandex_newbuilding_sweep(MagicMock(), 9999, {}, ctx) ctx.runs.mark_failed.assert_not_called() ctx.runs.mark_done.assert_called_once()