gendesign/tradein-mvp/backend/tests/test_schedule_save_respects_interval_2674.py
bot-backend 27272762ef
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
fix(tradein): такт в сохранении расписания, position_in_serp невыразим (#2674)
2026-08-06 05:49:48 +00:00

171 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сохранение расписания в админке уважает такт источника (#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