deer-flow/backend/tests/test_scheduled_task_cron_preview.py
tiammomo 444bfb72ce
feat(scheduled-tasks): preview upcoming cron occurrences (#5381)
Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>
2026-09-12 14:59:04 +08:00

179 lines
7.5 KiB
Python

from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import Mock
from zoneinfo import ZoneInfo
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI
from app.gateway.authz import AuthContext
from app.gateway.routers import scheduled_tasks
from deerflow.scheduler.schedules import next_run_at
URL = "/api/scheduled-tasks/preview-cron"
PAYLOAD = {"cron": "0 9 * * 1-5", "timezone": "Asia/Shanghai", "count": 3, "start_at": "2026-09-12T00:00:00Z"}
@pytest_asyncio.fixture
async def client(monkeypatch):
app = FastAPI()
app.include_router(scheduled_tasks.router)
@app.middleware("http")
async def authenticate(request, call_next):
user = None if request.headers.get("x-test-auth") == "anonymous" else SimpleNamespace(id="preview-user")
permissions = [] if request.headers.get("x-test-auth") == "denied" else ["threads:read"]
request.state.auth = AuthContext(user=user, permissions=permissions)
return await call_next(request)
async def user_from_request(request):
return request.state.auth.user
monkeypatch.setattr(scheduled_tasks, "get_optional_user_from_request", user_from_request)
# A preview must remain usable without a task database, thread store or worker.
for name in ("get_scheduled_task_repo", "get_scheduled_task_run_repo", "get_thread_store", "get_scheduled_task_service", "get_config"):
monkeypatch.setattr(scheduled_tasks, name, Mock(side_effect=AssertionError(f"Preview accessed {name}")))
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as session:
yield session
@pytest.mark.asyncio
async def test_preview_returns_exact_utc_and_local_times_without_persistence(client):
response = await client.post(URL, json={**PAYLOAD, "cron": " 0\t9 * * 1-5 "})
assert response.status_code == 200, response.text
assert response.json() == {
"cron": "0 9 * * 1-5",
"timezone": "Asia/Shanghai",
"start_at": "2026-09-12T00:00:00Z",
"occurrences": [
{"run_at": "2026-09-14T01:00:00Z", "local_time": "2026-09-14T09:00:00+08:00"},
{"run_at": "2026-09-15T01:00:00Z", "local_time": "2026-09-15T09:00:00+08:00"},
{"run_at": "2026-09-16T01:00:00Z", "local_time": "2026-09-16T09:00:00+08:00"},
],
}
@pytest.mark.asyncio
@pytest.mark.parametrize(("auth", "status"), [("anonymous", 401), ("denied", 403)])
async def test_preview_enforces_authentication_and_read_permission(client, auth, status):
response = await client.post(URL, json=PAYLOAD, headers={"x-test-auth": auth})
assert response.status_code == status
@pytest.mark.asyncio
@pytest.mark.parametrize(
"override",
[
{"count": 0},
{"count": 11},
{"count": True},
{"count": "3"},
{"cron": ""},
{"cron": " " * 257},
{"cron": "0 0 * * * *"},
{"cron": "61 * * * *"},
{"cron": "0 0 31 2 *"},
{"timezone": "Not/A_Zone"},
{"timezone": "../UTC"},
{"timezone": "/UTC"},
{"timezone": ""},
{"timezone": "x" * 129},
{"start_at": "2026-09-12T00:00:00"},
{"start_at": "invalid"},
{"start_at": "9999-12-31T23:59:59Z"},
],
)
async def test_preview_rejects_invalid_or_unfulfillable_inputs(client, override):
response = await client.post(URL, json={**PAYLOAD, **override})
assert response.status_code == 422, response.text
@pytest.mark.asyncio
async def test_preview_default_reference_is_captured_once_and_count_defaults_to_five(client, monkeypatch):
class FixedDatetime(datetime):
calls = 0
@classmethod
def now(cls, tz=None):
cls.calls += 1
return datetime(2026, 9, 12, 0, 0, tzinfo=UTC)
monkeypatch.setattr(scheduled_tasks, "datetime", FixedDatetime)
response = await client.post(URL, json={"cron": "0 * * * *", "timezone": "UTC"})
assert response.status_code == 200, response.text
data = response.json()
assert FixedDatetime.calls == 1
assert data["start_at"] == "2026-09-12T00:00:00Z"
assert [row["run_at"] for row in data["occurrences"]] == [f"2026-09-12T0{hour}:00:00Z" for hour in range(1, 6)]
@pytest.mark.asyncio
async def test_preview_normalizes_offset_reference_and_excludes_reference_instant(client):
response = await client.post(URL, json={**PAYLOAD, "cron": "0 * * * *", "count": 1, "start_at": "2026-09-12T08:00:00+08:00"})
assert response.status_code == 200, response.text
assert response.json()["start_at"] == "2026-09-12T00:00:00Z"
assert response.json()["occurrences"][0]["run_at"] == "2026-09-12T01:00:00Z"
@pytest.mark.asyncio
@pytest.mark.parametrize("start_at", ["2026-03-07T12:00:00Z", "2026-10-31T12:00:00Z"])
async def test_preview_preserves_scheduler_dst_semantics(client, start_at):
cron = "30 2 * * *"
zone = "America/New_York"
cursor = datetime.fromisoformat(start_at)
expected = []
for _ in range(10):
cursor = next_run_at("cron", {"cron": cron}, zone, now=cursor)
expected.append(cursor)
response = await client.post(URL, json={"cron": cron, "timezone": zone, "count": 10, "start_at": start_at})
assert response.status_code == 200, response.text
rows = response.json()["occurrences"]
assert [datetime.fromisoformat(row["run_at"]) for row in rows] == expected
assert [row["local_time"] for row in rows] == [instant.astimezone(ZoneInfo(zone)).isoformat() for instant in expected]
@pytest.mark.asyncio
async def test_preview_calculates_off_the_request_thread(client, monkeypatch):
import threading
request_thread = threading.get_ident()
observed = []
original = scheduled_tasks.compute_next_run_at
def calculate(*args, **kwargs):
observed.append(threading.get_ident())
return original(*args, **kwargs)
monkeypatch.setattr(scheduled_tasks, "compute_next_run_at", calculate)
response = await client.post(URL, json=PAYLOAD)
assert response.status_code == 200, response.text
assert len(observed) == 3
assert all(ident != request_thread for ident in observed)
@pytest.mark.asyncio
async def test_preview_first_occurrence_matches_task_creation_at_same_clock(client, monkeypatch):
from unittest.mock import AsyncMock
from _router_auth_helpers import call_unwrapped
class FixedDatetime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 9, 12, 0, 0, tzinfo=UTC)
monkeypatch.setattr(scheduled_tasks, "datetime", FixedDatetime)
response = await client.post(URL, json={"cron": PAYLOAD["cron"], "timezone": PAYLOAD["timezone"], "count": 1})
assert response.status_code == 200, response.text
# Only the separate creation below is allowed to acquire persistence dependencies.
repo = SimpleNamespace(create=AsyncMock())
monkeypatch.setattr(scheduled_tasks, "get_scheduled_task_repo", lambda request: repo)
monkeypatch.setattr(scheduled_tasks, "get_thread_store", lambda request: None)
monkeypatch.setattr(scheduled_tasks, "get_config", lambda: SimpleNamespace())
request = SimpleNamespace(state=SimpleNamespace(auth=AuthContext(user=SimpleNamespace(id="preview-user"))))
body = scheduled_tasks.ScheduledTaskCreateRequest(title="Example", prompt="Example", schedule_type="cron", schedule_spec={"cron": PAYLOAD["cron"]}, timezone=PAYLOAD["timezone"])
await call_unwrapped(scheduled_tasks.create_scheduled_task, request=request, body=body)
assert repo.create.await_args.kwargs["next_run_at"] == datetime.fromisoformat(response.json()["occurrences"][0]["run_at"])