All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m57s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 1m27s
171 lines
6.7 KiB
Python
171 lines
6.7 KiB
Python
"""Сохранение расписания в админке уважает такт источника (#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
|