"""Offline smoke for scheduler logic.""" import asyncio import os from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from datetime import UTC, datetime import pytest from app.services.scheduler import ZOMBIE_THRESHOLD_HOURS, compute_next_run_at def test_compute_next_run_in_normal_window() -> None: now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) next_at = compute_next_run_at(2, 5, now=now) assert next_at.date() == datetime(2026, 5, 24).date() assert 2 <= next_at.hour <= 4 # window [2, 5) UTC def test_compute_next_run_cross_midnight() -> None: now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) # Window 22..3 = 22:00-23:59 + 00:00-02:59 next_at = compute_next_run_at(22, 3, now=now) # Должен попасть либо в [22,23] либо в [0,3) range hour = next_at.hour assert hour >= 22 or hour < 3 def test_compute_next_run_window_1_hour() -> None: now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) # Минимальное окно [3, 4) → ровно 3-й час next_at = compute_next_run_at(3, 4, now=now) assert next_at.hour == 3 def test_zombie_threshold_constant() -> None: assert ZOMBIE_THRESHOLD_HOURS > 0 def test_compute_next_run_is_future() -> None: """next_run_at всегда в будущем (завтра) — не в прошлом.""" now = datetime(2026, 5, 23, 3, 30, tzinfo=UTC) next_at = compute_next_run_at(2, 5, now=now) assert next_at > now def test_compute_next_run_timezone_aware() -> None: """Результат всегда timezone-aware (UTC).""" now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) next_at = compute_next_run_at(2, 5, now=now) assert next_at.tzinfo is not None # ── #750: атомарный _claim_run (advisory lock — anti-double-sweep) ────────── from collections import deque # noqa: E402 from app.services import scheduler as _sched # noqa: E402 class _Scalar: def __init__(self, v: object) -> None: self._v = v def scalar(self) -> object: return self._v class _Fetchone: def __init__(self, row: object) -> None: self._row = row def fetchone(self) -> object: return self._row class _FakeSchedulerDB: """Stateful fake моделирующий advisory-lock claim (#750), без реальной БД. - SELECT pg_try_advisory_xact_lock → lock_available. - SELECT 1 FROM scrape_runs (has_running_run) → из running_results (если задано), иначе по флагу self.running[source]. - UPDATE scrape_schedules → no-op. create_run (монкипатчится) выставляет self.running[source]=True. """ def __init__(self, *, lock_available: bool = True, running_results: list[bool] | None = None): self.lock_available = lock_available self.running: dict[str, bool] = {} self._running_results: deque[bool] = deque(running_results or []) self.commits = 0 self.rollbacks = 0 def execute(self, stmt: object, params: dict | None = None): sql = str(stmt) if "pg_try_advisory_xact_lock" in sql: return _Scalar(self.lock_available) if "SELECT 1 FROM scrape_runs" in sql: if self._running_results: return _Fetchone(object() if self._running_results.popleft() else None) src = (params or {}).get("source") return _Fetchone(object() if self.running.get(src) else None) return _Fetchone(None) # UPDATE scrape_schedules etc. def commit(self) -> None: self.commits += 1 def rollback(self) -> None: self.rollbacks += 1 _SCHED_ROW = { "source": "avito_city_sweep", "window_start_hour": 2, "window_end_hour": 5, "default_params": {}, } def test_claim_run_double_call_creates_one_run(monkeypatch: pytest.MonkeyPatch) -> None: """#750 DoD: два последовательных _claim_run одного source (нет running) → первый run_id, второй None (create_run вызван РОВНО один раз).""" db = _FakeSchedulerDB() calls = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 db.running[source] = True return 100 + calls["n"] monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) rid1 = _sched._claim_run(db, _SCHED_ROW) rid2 = _sched._claim_run(db, _SCHED_ROW) assert rid1 == 101 assert rid2 is None assert calls["n"] == 1 # второй НЕ создал run def test_claim_run_skips_when_advisory_lock_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: """#750: lock занят конкурентным тиком → return None, create_run НЕ вызван.""" db = _FakeSchedulerDB(lock_available=False) calls = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 return 999 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) assert _sched._claim_run(db, _SCHED_ROW) is None assert calls["n"] == 0 def test_claim_run_rechecks_running_under_lock(monkeypatch: pytest.MonkeyPatch) -> None: """#750: running появился МЕЖДУ pre-check и взятием лока (double-checked) → return None + rollback (освобождение лока), create_run НЕ вызван.""" # pre-check → False (нет running), re-check под локом → True (появился). db = _FakeSchedulerDB(lock_available=True, running_results=[False, True]) calls = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 return 999 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) assert _sched._claim_run(db, _SCHED_ROW) is None assert calls["n"] == 0 assert db.rollbacks == 1 # лок освобождён # ── #974: trigger_yandex_newbuilding_sweep_run + dispatch ──────────────────── _YANDEX_NB_SWEEP_ROW = { "source": "yandex_newbuilding_sweep", "window_start_hour": 2, "window_end_hour": 5, "default_params": { "limit": 5, "request_delay_sec": 8.0, "city": "ekaterinburg", }, } @pytest.mark.asyncio async def test_trigger_yandex_newbuilding_sweep_creates_run( monkeypatch: pytest.MonkeyPatch, ) -> None: """trigger_yandex_newbuilding_sweep_run claims run + launches create_task (#974).""" db = _FakeSchedulerDB() calls: dict[str, list] = {"create_run": [], "sweep": []} def fake_create_run(_d, *, source, params): calls["create_run"].append(source) db.running[source] = True return 301 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) # Patch enrich_yandex_newbuilding_sweep to a coroutine that records call sweep_result = MagicMock() sweep_result.to_dict.return_value = {} async def fake_sweep(run_db, *, limit, **kwargs): calls["sweep"].append(limit) return sweep_result # Patch inside the _run closure (lazy import) monkeypatch.setattr( "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", fake_sweep, ) monkeypatch.setattr(_sched, "SessionLocal", lambda: db) # mark_done is called inside _run; mock it so it doesn't fail on fake db monkeypatch.setattr(_sched.runs_mod, "mark_done", lambda *a, **kw: None) monkeypatch.setattr(_sched.runs_mod, "mark_failed", lambda *a, **kw: None) run_id = await _sched.trigger_yandex_newbuilding_sweep_run(db, _YANDEX_NB_SWEEP_ROW) assert run_id == 301 assert calls["create_run"] == ["yandex_newbuilding_sweep"] # Drain event-loop so the asyncio.create_task runs await asyncio.sleep(0) assert calls["sweep"] == [5] @pytest.mark.asyncio async def test_trigger_yandex_newbuilding_sweep_skips_when_running( monkeypatch: pytest.MonkeyPatch, ) -> None: """trigger_yandex_newbuilding_sweep_run returns None when source already running.""" db = _FakeSchedulerDB() db.running["yandex_newbuilding_sweep"] = True calls: dict[str, int] = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 return 999 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) result = await _sched.trigger_yandex_newbuilding_sweep_run(db, _YANDEX_NB_SWEEP_ROW) assert result is None assert calls["n"] == 0 @pytest.mark.asyncio async def test_scheduler_dispatch_routes_yandex_newbuilding_sweep( monkeypatch: pytest.MonkeyPatch, ) -> None: """Dispatch block в scheduler_loop роутит yandex_newbuilding_sweep → trigger.""" triggered: list[str] = [] async def fake_trigger(db, sch): triggered.append(sch["source"]) return 1 monkeypatch.setattr(_sched, "trigger_yandex_newbuilding_sweep_run", fake_trigger) db = object() sch = _YANDEX_NB_SWEEP_ROW source = sch["source"] # Replicate the elif chain from scheduler_loop for yandex_newbuilding_sweep if source == "yandex_newbuilding_sweep": await _sched.trigger_yandex_newbuilding_sweep_run(db, sch) assert "yandex_newbuilding_sweep" in triggered # ── #973: trigger_cian_city_sweep_run + dispatch ───────────────────────────── _CIAN_SWEEP_ROW = { "source": "cian_city_sweep", "window_start_hour": 2, "window_end_hour": 5, "default_params": { "pages_per_anchor": 3, "request_delay_sec": 5.0, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": True, }, } @pytest.mark.asyncio async def test_trigger_cian_city_sweep_creates_run(monkeypatch: pytest.MonkeyPatch) -> None: """trigger_cian_city_sweep_run claims run + launches create_task (#973).""" db = _FakeSchedulerDB() calls: dict[str, list] = {"create_run": [], "sweep": []} def fake_create_run(_d, *, source, params): calls["create_run"].append(source) db.running[source] = True return 201 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) # Patch run_cian_city_sweep to a coroutine that records call async def fake_sweep(run_db, *, run_id, **kwargs): calls["sweep"].append(run_id) monkeypatch.setattr(_sched, "run_cian_city_sweep", fake_sweep) # Patch SessionLocal to return the same db monkeypatch.setattr(_sched, "SessionLocal", lambda: db) run_id = await _sched.trigger_cian_city_sweep_run(db, _CIAN_SWEEP_ROW) assert run_id == 201 assert calls["create_run"] == ["cian_city_sweep"] # Drain event-loop so the asyncio.create_task runs await asyncio.sleep(0) assert calls["sweep"] == [201] @pytest.mark.asyncio async def test_trigger_cian_city_sweep_skips_when_running(monkeypatch: pytest.MonkeyPatch) -> None: """trigger_cian_city_sweep_run returns None when source already running.""" db = _FakeSchedulerDB() db.running["cian_city_sweep"] = True calls: dict[str, int] = {"n": 0} def fake_create_run(_d, *, source, params): calls["n"] += 1 return 999 monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) result = await _sched.trigger_cian_city_sweep_run(db, _CIAN_SWEEP_ROW) assert result is None assert calls["n"] == 0 @pytest.mark.asyncio async def test_scheduler_dispatch_routes_cian_city_sweep(monkeypatch: pytest.MonkeyPatch) -> None: """Dispatch block в scheduler_loop роутит cian_city_sweep → trigger_cian_city_sweep_run.""" triggered: list[str] = [] async def fake_trigger(db, sch): triggered.append(sch["source"]) return 1 monkeypatch.setattr(_sched, "trigger_cian_city_sweep_run", fake_trigger) # Simulate the dispatch block for one due schedule db = object() sch = _CIAN_SWEEP_ROW source = sch["source"] # Replicate the elif chain from scheduler_loop for cian_city_sweep if source == "cian_city_sweep": await _sched.trigger_cian_city_sweep_run(db, sch) assert "cian_city_sweep" in triggered