"""Тесты для scheduler_main.py — standalone scraper-контейнер (#1182). (a) модуль импортируется без реального DB/сети. (b) _should_run() возвращает False при SCHEDULER_ENABLE=false. (c) _run() отменяется чисто без unhandled exceptions. (d) #1182 Phase 2: SIGTERM → request_shutdown() (не task.cancel); задача кооперативно drain'ится; safety-net wait_for-timeout добивает зависшую. """ from __future__ import annotations import asyncio import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import pytest import app.scheduler_main as sm from app.core import shutdown as sd @pytest.fixture(autouse=True) def _reset_shutdown() -> None: """shutdown — module-global Event: сбрасываем вокруг каждого теста для изоляции.""" sd.reset_shutdown() yield sd.reset_shutdown() # (a) Импорт модуля def test_scheduler_main_importable() -> None: """scheduler_main импортируется без ошибок.""" assert sm is not None assert hasattr(sm, "_should_run") assert hasattr(sm, "_run") # (b) Kill-switch SCHEDULER_ENABLE=false def test_should_run_false_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: """_should_run() == False когда SCHEDULER_ENABLE отключён.""" monkeypatch.setattr(sm.settings, "scheduler_enable", False) assert sm._should_run() is False def test_should_run_true_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None: """_should_run() == True при включённом scheduler.""" monkeypatch.setattr(sm.settings, "scheduler_enable", True) assert sm._should_run() is True # (c) _run() отменяется чисто @pytest.mark.asyncio async def test_run_cancels_cleanly(monkeypatch: pytest.MonkeyPatch) -> None: """_run() завершается без unhandled exceptions при отмене задачи.""" cancelled = asyncio.Event() async def _stub_loop() -> None: """Короткая заглушка: ждёт отмены, имитируя бесконечный kit scheduler_loop.""" cancelled.set() await asyncio.sleep(999) # #2397 Part C: _run() безусловно зовёт _run_kit_scheduler() (legacy fallback удалён) — # патчим саму функцию на модуле scheduler_main. monkeypatch.setattr(sm, "_run_kit_scheduler", _stub_loop) task = asyncio.create_task(sm._run()) # Ждём пока _stub_loop запустится (флаг) await asyncio.wait_for(cancelled.wait(), timeout=2.0) task.cancel() # Не должно бросить никакого исключения try: await task except asyncio.CancelledError: pass # CancelledError от самого task — допустимо # Тест прошёл если не было unhandled exception assert task.done() # (d) #1182 Phase 2 — кооперативный SIGTERM-drain @pytest.mark.asyncio async def test_await_scheduler_drains_on_shutdown() -> None: """_await_scheduler: кооперативная задача видит флаг и выходит сама — без cancel.""" drained = False async def _coop_loop() -> None: nonlocal drained while not sd.shutdown_requested(): await asyncio.sleep(0.01) drained = True # докоммитил unit и вышел task = asyncio.create_task(_coop_loop()) runner = asyncio.create_task(sm._await_scheduler(task)) await asyncio.sleep(0.03) assert not runner.done(), "без shutdown _await_scheduler не завершается" sd.request_shutdown() await asyncio.wait_for(runner, timeout=2.0) assert drained is True assert task.done() assert not task.cancelled(), "кооперативный выход — НЕ hard-cancel" @pytest.mark.asyncio async def test_await_scheduler_timeout_hard_cancels(monkeypatch: pytest.MonkeyPatch) -> None: """_await_scheduler: некооперативная задача (игнорит флаг) добивается cancel'ом по истечении safety-net grace (_DRAIN_TIMEOUT_S).""" monkeypatch.setattr(sm, "_DRAIN_TIMEOUT_S", 0.05) async def _stuck_loop() -> None: # Игнорирует shutdown_requested() — висит вечно. await asyncio.Event().wait() task = asyncio.create_task(_stuck_loop()) sd.request_shutdown() # drain уже запрошен await asyncio.wait_for(sm._await_scheduler(task), timeout=2.0) assert task.done() assert task.cancelled(), "превысила grace → hard-cancel" @pytest.mark.asyncio async def test_run_uses_request_shutdown_not_cancel(monkeypatch: pytest.MonkeyPatch) -> None: """_run(): когда выставлен shutdown, scheduler_loop сам drain'ится и _run выходит, БЕЗ обращения к task.cancel() (кооперативный путь, не hard-cancel).""" started = asyncio.Event() async def _coop_loop() -> None: started.set() while not sd.shutdown_requested(): await asyncio.sleep(0.01) # #2397 Part C: _run() безусловно зовёт _run_kit_scheduler() (legacy fallback удалён). monkeypatch.setattr(sm, "_run_kit_scheduler", _coop_loop) run_task = asyncio.create_task(sm._run()) await asyncio.wait_for(started.wait(), timeout=2.0) # Имитируем то, что делает SIGTERM-хендлер (на Windows add_signal_handler нет). sm.request_shutdown() await asyncio.wait_for(run_task, timeout=2.0) assert run_task.done() assert run_task.exception() is None