mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
179 lines
7.5 KiB
Python
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"])
|