gendesign/tradein-mvp/backend/tests/test_scheduler.py
lekss361 0c53108d7d feat(tradein): in-app scheduler — UI-managed schedule (replaces SSH crontab)
Background scheduler в FastAPI lifespan: each tick (60s) checks scrape_schedules
table, triggers city sweep run если due (next_run_at <= NOW() + enabled + no
running run), recomputes next_run_at для следующих суток в random время в окне.

- 052_scrape_schedules.sql: NEW table (source, enabled, window_start/end_hour UTC,
  default_params jsonb, last_run_id, last_run_at, next_run_at, updated_at).
  Seed: 'avito_city_sweep' enabled, window 2-5 UTC (05:00-08:00 МСК).
- scheduler.py NEW:
  * compute_next_run_at(start, end) — random datetime в окне для +1 day. Cross-midnight OK.
  * scheduler_loop() — tick каждые 60s
  * get_due_schedules / has_running_run / trigger_avito_city_sweep_run
  * reap_zombies() — runs running > 6h без heartbeat → status='zombie'
- main.py: FastAPI lifespan launches scheduler_task; clean shutdown через cancel
- admin.py: 2 endpoints
  * GET  /admin/scrape/schedules — list all (now single row, extensible)
  * PUT  /admin/scrape/schedules/{source} — update config (enabled, window, params)
    + auto-recompute next_run_at
- schemas/trade_in.py: ScheduleConfig + ScheduleConfigUpdate
- tests/test_scheduler.py: 6 offline tests (normal window, cross-midnight, 1h window,
  future check, timezone-aware check)

UI: frontend PR добавит секцию 6 на /scrapers/avito (parallel PR).
Cron-script deploy/avito-city-sweep.sh остаётся как backup, opt-in.
2026-05-23 17:49:00 +03:00

51 lines
1.8 KiB
Python

"""Offline smoke for scheduler logic."""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import datetime, timezone
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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.utc)
next_at = compute_next_run_at(2, 5, now=now)
assert next_at.tzinfo is not None