- 业务 ORM 数据支持 MySQL 后端(asyncmy),新增 PortableJSON/
BeijingDateTime 可移植列类型、迁移脚本与切库脚本
- 修复 GET /api/scheduled-tasks/runs/{id} 的 _load_run 无限递归
(误调用自身,应委托 service.get_run)导致的 RecursionError 500
- 定时任务名按用户唯一:create/update/admin_update 主动校验,
冲突抛 DuplicateTaskNameError,路由转 409 而非裸 IntegrityError 500
- 补充回归测试 test_scheduled_task_run_lookup / _name_uniqueness
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
"""Tests for per-user scheduled-task name uniqueness.
|
|
|
|
Task names are unique per user (``uq_scheduled_tasks_user_name``). The service
|
|
proactively rejects collisions with :class:`DuplicateTaskNameError` so the API
|
|
returns a 409 instead of letting a raw DB ``IntegrityError`` surface as a 500.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import pytest
|
|
|
|
from deerflow.persistence.scheduled_tasks import MemoryScheduledTaskStore
|
|
from deerflow.runtime.scheduler import DuplicateTaskNameError
|
|
from deerflow.runtime.scheduler.service import ScheduledTaskService
|
|
|
|
|
|
@pytest.fixture
|
|
def store() -> MemoryScheduledTaskStore:
|
|
return MemoryScheduledTaskStore()
|
|
|
|
|
|
def _build_service(store: MemoryScheduledTaskStore) -> ScheduledTaskService:
|
|
service = ScheduledTaskService(app=None, store=store, poll_interval_seconds=60.0)
|
|
service._element_client = None # noqa: SLF001
|
|
return service
|
|
|
|
|
|
def _seed_task(store: MemoryScheduledTaskStore, *, task_id: str, user_id: str, name: str) -> dict:
|
|
task = {
|
|
"task_id": task_id,
|
|
"user_id": user_id,
|
|
"scheduler_thread_id": f"thread-{user_id}",
|
|
"name": name,
|
|
"prompt": "hello",
|
|
"schedule_text": "0 9 * * *",
|
|
"cron_expr": "0 9 * * *",
|
|
"timezone": "UTC",
|
|
"enabled": True,
|
|
"next_run_at": datetime.now(UTC),
|
|
"execution_context": {},
|
|
}
|
|
store._tasks[task_id] = task # noqa: SLF001
|
|
return task
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_task_rejects_duplicate_name(store):
|
|
"""A second task with the same (user_id, name) is refused before any insert."""
|
|
_seed_task(store, task_id="t1", user_id="alice", name="日报")
|
|
service = _build_service(store)
|
|
|
|
with pytest.raises(DuplicateTaskNameError):
|
|
await service.create_task(user_id="alice", name="日报", prompt="p", schedule="0 9 * * *")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_task_treats_whitespace_padded_name_as_duplicate(store):
|
|
"""The name is trimmed before the uniqueness check, matching what is stored."""
|
|
_seed_task(store, task_id="t1", user_id="alice", name="日报")
|
|
service = _build_service(store)
|
|
|
|
with pytest.raises(DuplicateTaskNameError):
|
|
await service.create_task(user_id="alice", name=" 日报 ", prompt="p", schedule="0 9 * * *")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_task_allows_same_name_for_different_user(store):
|
|
"""The constraint is per user — another user may reuse the name.
|
|
|
|
The create proceeds past the name check and only fails later because the
|
|
test service has no app wired for scheduler-thread setup; the point here is
|
|
that no DuplicateTaskNameError is raised.
|
|
"""
|
|
_seed_task(store, task_id="t1", user_id="alice", name="日报")
|
|
service = _build_service(store)
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
await service.create_task(user_id="bob", name="日报", prompt="p", schedule="0 9 * * *")
|
|
assert not isinstance(exc_info.value, DuplicateTaskNameError)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_task_rejects_rename_to_existing_name(store):
|
|
"""Renaming a task onto a sibling's name is refused."""
|
|
_seed_task(store, task_id="t1", user_id="alice", name="日报")
|
|
_seed_task(store, task_id="t2", user_id="alice", name="周报")
|
|
service = _build_service(store)
|
|
|
|
with pytest.raises(DuplicateTaskNameError):
|
|
await service.update_task("alice", "t2", {"name": "日报"})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_task_allows_keeping_own_name(store):
|
|
"""Re-saving a task without changing its name is not a self-collision."""
|
|
_seed_task(store, task_id="t1", user_id="alice", name="日报")
|
|
service = _build_service(store)
|
|
|
|
updated = await service.update_task("alice", "t1", {"name": "日报", "prompt": "new"})
|
|
|
|
assert updated is not None
|
|
assert updated["name"] == "日报"
|
|
assert updated["prompt"] == "new"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_update_task_rejects_rename_to_existing_name(store):
|
|
"""Admin cross-user rename still honours the owner's name uniqueness."""
|
|
_seed_task(store, task_id="t1", user_id="alice", name="日报")
|
|
_seed_task(store, task_id="t2", user_id="alice", name="周报")
|
|
service = _build_service(store)
|
|
|
|
with pytest.raises(DuplicateTaskNameError):
|
|
await service.admin_update_task("t2", {"name": "日报"})
|