mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(scheduler): reject nonexistent local times at admission * fix(scheduler): label run_at errors and clarify cron DST behavior
157 lines
8.0 KiB
Python
157 lines
8.0 KiB
Python
"""HTTP and conversation admission must not silently move nonexistent local times."""
|
|
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import AsyncMock
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.gateway.routers.scheduled_tasks import ScheduledTaskCreateRequest, ScheduledTaskUpdateRequest
|
|
from app.gateway.scheduled_task_validation import (
|
|
prepare_resume_updates,
|
|
resolve_end_at,
|
|
validate_scheduled_task_create,
|
|
validate_scheduled_task_update,
|
|
)
|
|
from deerflow.config.scheduler_config import SchedulerConfig
|
|
from deerflow.scheduler.schedules import next_run_at
|
|
|
|
NOW = datetime(2026, 10, 22, tzinfo=UTC)
|
|
GAPS = [
|
|
("America/New_York", "2027-03-14T02:30:00"),
|
|
("Europe/Berlin", "2027-03-28T02:30:00"),
|
|
("Australia/Sydney", "2027-10-03T02:30:00"),
|
|
("Australia/Lord_Howe", "2027-10-03T02:15:00"),
|
|
]
|
|
|
|
|
|
def _existing(timezone, **updates):
|
|
return {
|
|
"schedule_type": "once",
|
|
"schedule_spec": {"run_at": "2027-01-01T09:00:00"},
|
|
"timezone": timezone,
|
|
"status": "enabled",
|
|
"next_run_at": datetime(2027, 1, 1, tzinfo=UTC),
|
|
"context_mode": "fresh_thread_per_run",
|
|
"thread_id": None,
|
|
"goal_objective": None,
|
|
"max_runs": None,
|
|
"end_at": None,
|
|
**updates,
|
|
}
|
|
|
|
|
|
def _time_fields(field, value):
|
|
return {"schedule_spec": {"run_at": value}} if field == "run_at" else {"end_at": value}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(("timezone", "wall_time"), GAPS)
|
|
@pytest.mark.parametrize("field", ["run_at", "end_at"])
|
|
@pytest.mark.parametrize("operation", ["create", "update"])
|
|
async def test_shared_admission_rejects_nonexistent_wall_time(timezone, wall_time, field, operation):
|
|
fields = _time_fields(field, wall_time)
|
|
with pytest.raises(HTTPException) as caught:
|
|
if operation == "create":
|
|
body = ScheduledTaskCreateRequest(
|
|
title="Report",
|
|
prompt="Write the report",
|
|
schedule_type="once",
|
|
timezone=timezone,
|
|
**({"schedule_spec": {"run_at": "2027-01-01T09:00:00"}} | fields),
|
|
)
|
|
await validate_scheduled_task_create(body, user_id="owner", thread_store=None, scheduler_config=SchedulerConfig(), assistant_resolver=AsyncMock(return_value="lead_agent"), now=NOW)
|
|
else:
|
|
body = ScheduledTaskUpdateRequest(**fields)
|
|
await validate_scheduled_task_update(_existing(timezone), body.model_dump(exclude_unset=True), user_id="owner", thread_store=None, scheduler_config=SchedulerConfig(), assistant_resolver=AsyncMock(), used=0, now=NOW)
|
|
assert caught.value.status_code == 422
|
|
assert caught.value.detail["code"] == ("invalid_schedule" if field == "run_at" else "invalid_request")
|
|
assert caught.value.detail["message"].startswith(f"{field}: ")
|
|
assert "does not exist" in caught.value.detail["message"]
|
|
assert timezone in caught.value.detail["message"]
|
|
|
|
|
|
@pytest.mark.parametrize(("timezone", "wall_time"), GAPS)
|
|
def test_resume_rejects_nonexistent_end_at(timezone, wall_time):
|
|
with pytest.raises(HTTPException) as caught:
|
|
prepare_resume_updates(_existing(timezone, status="paused"), {"end_at": wall_time}, used=0, now=NOW)
|
|
assert caught.value.status_code == 422
|
|
assert caught.value.detail["code"] == "invalid_request"
|
|
assert "does not exist" in caught.value.detail["message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("timezone", "wall_time", "expected"),
|
|
[
|
|
("America/New_York", "2027-03-14T01:30:00", "2027-03-14T06:30:00+00:00"),
|
|
("America/New_York", "2027-03-14T03:30:00", "2027-03-14T07:30:00+00:00"),
|
|
("Europe/Berlin", "2027-03-28T01:30:00", "2027-03-28T00:30:00+00:00"),
|
|
("Europe/Berlin", "2027-03-28T03:30:00", "2027-03-28T01:30:00+00:00"),
|
|
("Australia/Sydney", "2027-10-03T01:30:00", "2027-10-02T15:30:00+00:00"),
|
|
("Australia/Sydney", "2027-10-03T03:30:00", "2027-10-02T16:30:00+00:00"),
|
|
("Australia/Lord_Howe", "2027-10-03T01:45:00", "2027-10-02T15:15:00+00:00"),
|
|
("Australia/Lord_Howe", "2027-10-03T02:45:00", "2027-10-02T15:45:00+00:00"),
|
|
("America/New_York", "2027-11-07T01:30:00", "2027-11-07T05:30:00+00:00"),
|
|
("America/New_York", "2027-11-07T01:30:00-05:00", "2027-11-07T06:30:00+00:00"),
|
|
("America/New_York", "2027-03-14T02:30:00-05:00", "2027-03-14T07:30:00+00:00"),
|
|
("Asia/Shanghai", "2027-03-14T02:30:00", "2027-03-13T18:30:00+00:00"),
|
|
],
|
|
)
|
|
async def test_valid_wall_times_and_explicit_instants_keep_their_meaning(timezone, wall_time, expected):
|
|
body = ScheduledTaskCreateRequest(title="Report", prompt="Write the report", schedule_type="once", schedule_spec={"run_at": wall_time}, timezone=timezone)
|
|
definition = await validate_scheduled_task_create(body, user_id="owner", thread_store=None, scheduler_config=SchedulerConfig(), assistant_resolver=AsyncMock(return_value="lead_agent"), now=NOW)
|
|
assert definition["next_run_at"] == datetime.fromisoformat(expected)
|
|
assert resolve_end_at(wall_time, timezone) == datetime.fromisoformat(expected)
|
|
|
|
|
|
def test_end_at_preserves_an_aware_second_fold():
|
|
end_at = datetime(2027, 11, 7, 1, 30, tzinfo=ZoneInfo("America/New_York"), fold=1)
|
|
assert resolve_end_at(end_at, "America/New_York") == datetime(2027, 11, 7, 6, 30, tzinfo=UTC)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("status", ["enabled", "paused"])
|
|
async def test_unchanged_legacy_gap_schedule_keeps_its_already_accepted_instant(status):
|
|
timezone, wall_time = GAPS[0]
|
|
spec = {"run_at": wall_time}
|
|
accepted = next_run_at("once", spec, timezone, now=NOW)
|
|
existing = _existing(timezone, status=status, schedule_spec=spec, next_run_at=accepted)
|
|
updates = await validate_scheduled_task_update(
|
|
existing, {"schedule_spec": spec, "timezone": timezone, "title": "Renamed"}, user_id="owner", thread_store=None, scheduler_config=SchedulerConfig(), assistant_resolver=AsyncMock(), used=0, now=NOW
|
|
)
|
|
assert updates["next_run_at"] == accepted
|
|
assert next_run_at("once", spec, timezone, now=accepted) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("change", ["timezone", "terminal"])
|
|
async def test_gap_schedule_is_revalidated_when_reinterpreted_or_rearmed(change):
|
|
timezone, wall_time = GAPS[0]
|
|
spec = {"run_at": wall_time}
|
|
existing = _existing("UTC" if change == "timezone" else timezone, schedule_spec=spec, status="completed" if change == "terminal" else "enabled")
|
|
fields = {"timezone": timezone} if change == "timezone" else {"schedule_spec": spec}
|
|
with pytest.raises(HTTPException) as caught:
|
|
await validate_scheduled_task_update(existing, fields, user_id="owner", thread_store=None, scheduler_config=SchedulerConfig(), assistant_resolver=AsyncMock(), used=0, now=NOW)
|
|
assert caught.value.status_code == 422
|
|
assert caught.value.detail["code"] == "invalid_schedule"
|
|
assert caught.value.detail["message"].startswith("run_at: ")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ["create", "update", "reactivate"])
|
|
@pytest.mark.parametrize("run_at", ["not-a-date", "2027-02-30T09:00:00"])
|
|
async def test_invalid_once_date_names_run_at_in_the_admission_error(operation, run_at):
|
|
spec = {"run_at": run_at}
|
|
with pytest.raises(HTTPException) as caught:
|
|
if operation == "create":
|
|
body = ScheduledTaskCreateRequest(title="Report", prompt="Write the report", schedule_type="once", schedule_spec=spec, timezone="UTC")
|
|
await validate_scheduled_task_create(body, user_id="owner", thread_store=None, scheduler_config=SchedulerConfig(), assistant_resolver=AsyncMock(), now=NOW)
|
|
else:
|
|
existing = _existing("UTC", status="completed" if operation == "reactivate" else "enabled")
|
|
await validate_scheduled_task_update(existing, {"schedule_spec": spec}, user_id="owner", thread_store=None, scheduler_config=SchedulerConfig(), assistant_resolver=AsyncMock(), used=0, now=NOW)
|
|
assert caught.value.status_code == 422
|
|
assert caught.value.detail["code"] == "invalid_schedule"
|
|
assert caught.value.detail["message"].startswith("run_at: ")
|