"""Сохранение расписания в админке уважает такт источника (#2674). Баг: PUT /admin/scrape/schedules/{source} звал compute_next_run_at БЕЗ interval_days, получал default=1 и ставил next_run_at на завтра — какой бы такт ни стоял в default_params. Недельный avito_full_load после правки соседнего поля побежал бы через сутки. На суточных источниках дефект невидим: для них «завтра» и есть верный ответ, поэтому баг прожил до разбора #2674. Планировщик (scraper_kit.orchestration.scheduler._claim_run/_defer_next_run_at) такт читал правильно — расходились именно два входа в одну и ту же формулу. Без сети, без БД: endpoint вызывается напрямую с mock-сессией, проверяются bind-params. """ from __future__ import annotations import os from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from app.api.v1.admin import update_schedule from app.schemas.trade_in import ScheduleConfigUpdate def _mock_db() -> MagicMock: db = MagicMock() row = { "id": 1, "source": "avito_full_load", "enabled": True, "window_start_hour": 13, "window_end_hour": 15, "default_params": {}, "last_run_id": None, "last_run_at": None, "next_run_at": None, "updated_at": None, } db.execute.return_value.mappings.return_value.fetchone.return_value = row return db def _saved_params(db: MagicMock) -> dict[str, Any]: """Bind-params единственного execute в update_schedule.""" return db.execute.call_args_list[0].args[1] def _saved_sql(db: MagicMock) -> str: return str(db.execute.call_args_list[0].args[0]) def test_weekly_source_gets_next_run_in_a_week_not_tomorrow() -> None: """Такт 7 → сохранение → next_run_at примерно через 7 суток. Падает на старом коде.""" db = _mock_db() before = datetime.now(tz=UTC) update_schedule( "avito_full_load", ScheduleConfigUpdate( enabled=True, window_start_hour=13, window_end_hour=15, default_params={"interval_days": 7, "concurrency": 1}, ), db, ) next_at = _saved_params(db)["next_at"] delta_days = (next_at - before).total_seconds() / 86400 # Окно 13:00-15:00 внутри целевых суток → разброс ±1 сутки вокруг ровно 7. assert 6.0 < delta_days < 8.0, f"ожидали ~7 суток, получили {delta_days:.2f}" # Старое поведение (interval_days не передан → default=1) дало бы «завтра». assert delta_days > 2.0, "next_run_at уехал на завтра — такт снова потерян" def test_daily_source_still_runs_tomorrow() -> None: """Такт 1 (и его отсутствие) — прежнее поведение, back-compat.""" for params in ({}, {"interval_days": 1}): db = _mock_db() before = datetime.now(tz=UTC) update_schedule( "cian_city_sweep", ScheduleConfigUpdate(window_start_hour=2, window_end_hour=5, default_params=params), db, ) delta_days = (_saved_params(db)["next_at"] - before).total_seconds() / 86400 assert 0.0 < delta_days < 2.0, f"params={params}: ожидали «завтра», got {delta_days:.2f}" def test_null_interval_days_is_none_safe() -> None: """`"interval_days": null` в jsonb → такт 1, а не TypeError (как в scheduler).""" db = _mock_db() update_schedule( "cian_city_sweep", ScheduleConfigUpdate( window_start_hour=2, window_end_hour=5, default_params={"interval_days": None} ), db, ) assert _saved_params(db)["next_at"] is not None def test_explicit_next_run_at_is_honored() -> None: """Оператор явно задал момент — уважаем как есть, ничего не пересчитываем.""" db = _mock_db() wanted = datetime(2026, 9, 1, 3, 30, tzinfo=UTC) update_schedule( "avito_full_load", ScheduleConfigUpdate( window_start_hour=13, window_end_hour=15, default_params={"interval_days": 7}, next_run_at=wanted, ), db, ) params = _saved_params(db) assert params["next_at"] == wanted assert params["explicit"] is True def test_explicit_past_next_run_at_means_run_now() -> None: """«Запустить сейчас» = момент в прошлом/now: планировщик берёт next_run_at <= NOW().""" db = _mock_db() now = datetime.now(tz=UTC) - timedelta(minutes=1) update_schedule( "avito_full_load", ScheduleConfigUpdate( window_start_hour=13, window_end_hour=15, default_params={}, next_run_at=now ), db, ) params = _saved_params(db) assert params["next_at"] == now, "прошедший момент не должен подменяться пересчётом" assert params["explicit"] is True def test_no_explicit_next_run_at_flags_recompute() -> None: """Без явного момента флаг explicit=false → SQL решает, двигать ли существующий.""" db = _mock_db() update_schedule( "cian_city_sweep", ScheduleConfigUpdate(window_start_hour=2, window_end_hour=5, default_params={}), db, ) assert _saved_params(db)["explicit"] is False def test_upsert_preserves_future_run_when_window_and_interval_unchanged() -> None: """Shape-check: ON CONFLICT не перезаписывает будущий next_run_at без причины. Само ветвление живёт в SQL (CASE), проверить его исполнение офлайн нечем — здесь сторожим, что ветка не исчезла из запроса при следующей правке. """ db = _mock_db() update_schedule( "cian_city_sweep", ScheduleConfigUpdate(window_start_hour=2, window_end_hour=5, default_params={}), db, ) sql = _saved_sql(db) assert "scrape_schedules.next_run_at > NOW()" in sql assert "THEN scrape_schedules.next_run_at" in sql assert "CAST(:explicit AS boolean)" in sql # psycopg v3: CAST, не :explicit::boolean