feat: 统一自动化任务调度配置并增强聊天流恢复

This commit is contained in:
qzl
2026-03-24 18:19:33 +08:00
parent 23359c2d01
commit 389f5248fc
30 changed files with 1144 additions and 888 deletions
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime, time, timezone
from datetime import datetime, timezone
from uuid import UUID, uuid4
from fastapi.testclient import TestClient
@@ -29,13 +29,24 @@ def _make_job_response(
id=job_id or uuid4(),
owner_id=owner_id or uuid4(),
title=overrides.get("title", "Test Job"),
schedule_type=overrides.get("schedule_type", "daily"),
run_at=overrides.get("run_at", time(9, 0, 0)),
timezone=overrides.get("timezone", "Asia/Shanghai"),
status=overrides.get("status", "active"),
is_system=overrides.get("is_system", False),
config=overrides.get(
"config", {"input_template": "Hello", "enabled_tools": [], "context": {}}
"config",
{
"input_template": "Hello",
"enabled_tools": [],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
},
),
next_run_at=overrides.get("next_run_at", now),
created_at=overrides.get("created_at", now),
@@ -104,13 +115,19 @@ def test_create_automation_job_requires_auth() -> None:
"/api/v1/automation-jobs",
json={
"title": "New Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai",
"config": {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
},
},
)
@@ -140,14 +157,20 @@ def test_create_automation_job_succeeds() -> None:
"/api/v1/automation-jobs",
json={
"title": "New Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai",
"status": "active",
"config": {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
},
},
)
@@ -178,14 +201,20 @@ def test_create_automation_job_respects_limit() -> None:
"/api/v1/automation-jobs",
json={
"title": "New Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai",
"status": "active",
"config": {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
},
},
)
@@ -204,7 +233,7 @@ def test_get_automation_job_requires_auth() -> None:
def test_get_automation_job_returns_job() -> None:
user_id = uuid4()
job_id = uuid4()
job = _make_job_response(id=job_id, owner_id=user_id)
job = _make_job_response(job_id=job_id, owner_id=user_id)
captured_job_id = job_id
captured_owner_id = user_id
@@ -266,7 +295,11 @@ def test_update_automation_job_requires_auth() -> None:
def test_update_automation_job_succeeds() -> None:
user_id = uuid4()
job_id = uuid4()
updated_job = _make_job_response(id=job_id, owner_id=user_id, title="Updated Title")
updated_job = _make_job_response(
job_id=job_id,
owner_id=user_id,
title="Updated Title",
)
class FakeService:
async def update(
@@ -114,6 +114,28 @@ def test_build_router_messages_skips_injection_when_context_last_is_user() -> No
assert msg.content == existing_context[i].content
def test_build_router_messages_appends_user_input_to_context_tail() -> None:
runner = AgentScopeRunner()
run_input = _run_input()
from agentscope.message import Msg
existing_context = [
Msg(name="assistant", role="assistant", content="上一轮回复"),
Msg(name="tool", role="assistant", content="工具结果"),
]
messages = runner._build_router_messages(
context_messages=existing_context,
run_input=run_input,
)
assert len(messages) == len(existing_context) + 1
assert messages[-1].role == "user"
assert messages[-1].content == "hello"
assert messages[0].content == "上一轮回复"
def test_build_model_omits_none_generate_kwargs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -8,6 +8,8 @@ import pytest
from models.automation_jobs import AutomationJob as OrmAutomationJob, ScheduleType
from schemas.automation import (
RuntimeConfig,
ScheduleConfig,
ScheduleRunAt,
)
from v1.automation_jobs.service import AutomationJobsService, _compute_next_run_at
@@ -50,7 +52,6 @@ def _make_orm_job(
*,
job_id: UUID | None = None,
owner_id: UUID | None = None,
schedule_type: ScheduleType = ScheduleType.DAILY,
next_run_at: datetime | None = None,
) -> OrmAutomationJob:
now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc)
@@ -66,9 +67,14 @@ def _make_orm_job(
"window_count": 2,
},
"input_template": "auto input: {date}",
"schedule": {
"type": "daily",
"run_at": {
"hour": 8,
"minute": 0,
},
},
},
schedule_type=schedule_type,
run_at=now - timedelta(hours=1),
next_run_at=next_run_at or now - timedelta(minutes=1),
timezone="UTC",
last_run_at=None,
@@ -108,12 +114,14 @@ async def test_scan_and_dispatch_calls_dispatch_fn_with_runtime_config() -> None
def test_compute_next_run_at_daily() -> None:
now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc)
current = datetime(2026, 3, 19, 11, 0, tzinfo=timezone.utc)
computed = _compute_next_run_at(
current_next_run_at=current,
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=11, minute=0),
),
timezone_str="UTC",
now_utc=now,
schedule_type=ScheduleType.DAILY,
)
assert computed == datetime(2026, 3, 20, 11, 0, tzinfo=timezone.utc)
@@ -121,12 +129,15 @@ def test_compute_next_run_at_daily() -> None:
def test_compute_next_run_at_weekly() -> None:
now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc)
current = datetime(2026, 3, 10, 11, 0, tzinfo=timezone.utc)
computed = _compute_next_run_at(
current_next_run_at=current,
schedule=ScheduleConfig(
type=ScheduleType.WEEKLY,
run_at=ScheduleRunAt(hour=11, minute=0),
weekdays=[2],
),
timezone_str="UTC",
now_utc=now,
schedule_type=ScheduleType.WEEKLY,
)
assert computed == datetime(2026, 3, 24, 11, 0, tzinfo=timezone.utc)
@@ -13,7 +13,7 @@ def test_memory_automation_static_config_contract() -> None:
"memory.forget",
]
assert config.input_template is not None
assert "提取" in config.input_template
assert "回顾" in config.input_template
assert "遗忘" in config.input_template
assert config.schedule is not None
assert config.schedule.type.value == "daily"
@@ -8,38 +8,39 @@ import pytest
from models.automation_jobs import ScheduleType
from v1.auth.registration_bootstrap import (
compute_next_local_time_utc,
compute_first_run_at_utc,
)
from schemas.automation import ScheduleConfig, ScheduleRunAt
def test_compute_next_local_time_utc_from_asia_shanghai() -> None:
def test_compute_first_run_at_utc_from_asia_shanghai() -> None:
now_utc = datetime(2026, 3, 23, 0, 30, tzinfo=timezone.utc)
run_at, next_run_at = compute_next_local_time_utc(
first_run_at = compute_first_run_at_utc(
now_utc=now_utc,
timezone_name="Asia/Shanghai",
local_hour=8,
local_minute=0,
schedule_type=ScheduleType.DAILY,
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=8, minute=0),
),
)
assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
assert next_run_at == datetime(2026, 3, 25, 0, 0, tzinfo=timezone.utc)
assert first_run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
def test_compute_next_local_time_utc_rolls_to_next_day_when_passed() -> None:
def test_compute_first_run_at_utc_rolls_to_next_day_when_passed() -> None:
now_utc = datetime(2026, 3, 23, 2, 30, tzinfo=timezone.utc)
run_at, next_run_at = compute_next_local_time_utc(
first_run_at = compute_first_run_at_utc(
now_utc=now_utc,
timezone_name="Asia/Shanghai",
local_hour=8,
local_minute=0,
schedule_type=ScheduleType.DAILY,
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=8, minute=0),
),
)
assert run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
assert next_run_at == datetime(2026, 3, 25, 0, 0, tzinfo=timezone.utc)
assert first_run_at == datetime(2026, 3, 24, 0, 0, tzinfo=timezone.utc)
@pytest.mark.asyncio
@@ -1,21 +1,22 @@
from datetime import datetime, time, timezone
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from models.automation_jobs import AutomationJobStatus, ScheduleType
from v1.automation_jobs.repository import AutomationJobsRepository
from v1.automation_jobs.schemas import (
AutomationJobCreateRequest,
AutomationJobUpdateRequest,
)
from schemas.automation import (
AgentTool,
AutomationJobConfig,
ContextSource,
ContextWindowMode,
MessageContextConfig,
ScheduleConfig,
ScheduleRunAt,
)
from v1.automation_jobs.repository import AutomationJobsRepository
from v1.automation_jobs.schemas import (
AutomationJobCreateRequest,
AutomationJobUpdateRequest,
)
@@ -28,14 +29,16 @@ def _make_config() -> AutomationJobConfig:
window_mode=ContextWindowMode.DAY,
window_count=2,
),
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=9, minute=0),
),
)
def _make_create_request() -> AutomationJobCreateRequest:
return AutomationJobCreateRequest(
title="Test Job",
schedule_type=ScheduleType.DAILY,
run_at=time(9, 0, 0),
timezone="Asia/Shanghai",
status=AutomationJobStatus.ACTIVE,
config=_make_config(),
@@ -57,9 +60,6 @@ async def test_list_by_owner_returns_jobs() -> None:
assert result == [job_one, job_two]
session.execute.assert_awaited_once()
call_args = session.execute.call_args
stmt = call_args[0][0]
assert "owner_id" in str(stmt)
@pytest.mark.asyncio
@@ -74,16 +74,10 @@ async def test_count_user_jobs_counts_non_bootstrap_jobs() -> None:
result = await repository.count_user_jobs(owner_id)
assert result == 3
session.execute.assert_awaited_once()
call_args = session.execute.call_args
stmt = call_args[0][0]
stmt_str = str(stmt)
assert "bootstrap_key" in stmt_str
assert "IS NULL" in stmt_str or "is_(None)" in stmt_str.lower()
@pytest.mark.asyncio
async def test_create_sets_bootstrap_key_to_none() -> None:
async def test_create_sets_fields_and_next_run_at() -> None:
session = AsyncMock()
session.add = MagicMock()
repository = AutomationJobsRepository(session)
@@ -93,67 +87,13 @@ async def test_create_sets_bootstrap_key_to_none() -> None:
await repository.create(owner_id, data)
session.add.assert_called_once()
call_args = session.add.call_args[0][0]
assert call_args.bootstrap_key is None
session.flush.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_sets_correct_fields() -> None:
session = AsyncMock()
session.add = MagicMock()
repository = AutomationJobsRepository(session)
owner_id = uuid4()
data = _make_create_request()
await repository.create(owner_id, data)
call_args = session.add.call_args[0][0]
assert call_args.owner_id == owner_id
assert call_args.bootstrap_key is None
assert call_args.title == data.title
assert call_args.schedule_type == data.schedule_type
assert call_args.timezone == data.timezone
assert call_args.status == data.status
@pytest.mark.asyncio
async def test_update_returns_updated_job() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.schedule_type = ScheduleType.DAILY
existing_job.config = {"input_template": "Old"}
updated_job = MagicMock()
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = updated_job
session.execute.return_value = execute_result
data = AutomationJobUpdateRequest(title="Updated Title")
result = await repository.update(job_id, data)
assert result is updated_job
session.flush.assert_awaited()
@pytest.mark.asyncio
async def test_update_merges_config() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.schedule_type = ScheduleType.DAILY
existing_job.config = {"input_template": "Old", "enabled_tools": []}
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = existing_job
session.execute.return_value = execute_result
data = AutomationJobUpdateRequest(
config={"input_template": "New", "context": {"source": "latest_chat"}}
)
await repository.update(job_id, data)
session.flush.assert_awaited()
assert call_args.next_run_at is not None
@pytest.mark.asyncio
@@ -161,9 +101,8 @@ async def test_update_returns_none_when_job_not_found() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = None
session.execute.return_value = execute_result
repository.get_by_id = AsyncMock(return_value=None)
data = AutomationJobUpdateRequest(title="Updated Title")
result = await repository.update(job_id, data)
@@ -171,6 +110,50 @@ async def test_update_returns_none_when_job_not_found() -> None:
assert result is None
@pytest.mark.asyncio
async def test_update_merges_config_and_recomputes_next_run() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.timezone = "UTC"
existing_job.config = {
"input_template": "Old",
"enabled_tools": ["memory.write"],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 8, "minute": 0},
},
}
repository.get_by_id = AsyncMock(return_value=existing_job)
repository.update_by_id = AsyncMock(return_value=existing_job)
data = AutomationJobUpdateRequest(
config=AutomationJobConfig(
enabled_tools=[AgentTool.MEMORY_WRITE, AgentTool.MEMORY_FORGET],
schedule=ScheduleConfig(
type=ScheduleType.WEEKLY,
run_at=ScheduleRunAt(hour=10, minute=30),
weekdays=[2, 5],
),
),
)
result = await repository.update(job_id, data)
assert result is not None
update_values = repository.update_by_id.call_args[0][1]
assert "config" in update_values
assert "next_run_at" in update_values
enabled_tools = update_values["config"]["enabled_tools"]
assert isinstance(enabled_tools[0], str)
@pytest.mark.asyncio
async def test_soft_delete_calls_soft_delete_by_id() -> None:
session = AsyncMock()
@@ -197,91 +180,3 @@ async def test_list_due_jobs_filters_by_active_status() -> None:
await repository.list_due_jobs(now_utc=MagicMock(), limit=10)
session.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_stores_run_at_as_timezone_aware() -> None:
session = AsyncMock()
session.add = MagicMock()
repository = AutomationJobsRepository(session)
owner_id = uuid4()
data = _make_create_request()
await repository.create(owner_id, data)
call_args = session.add.call_args[0][0]
assert call_args.run_at.tzinfo is not None, "run_at should be timezone-aware"
@pytest.mark.asyncio
async def test_update_run_at_with_timezone_none_uses_existing_timezone() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.schedule_type = ScheduleType.DAILY
existing_job.timezone = "America/New_York"
existing_job.config = {}
existing_job.run_at = None
execute_result = MagicMock()
execute_result.scalar_one_or_none.return_value = existing_job
session.execute.return_value = execute_result
repository.update_by_id = AsyncMock(return_value=existing_job)
data = AutomationJobUpdateRequest(run_at=time(14, 30, 0))
result = await repository.update(job_id, data)
assert result is not None
update_values = repository.update_by_id.call_args[0][1]
assert "run_at" in update_values
assert "next_run_at" in update_values
@pytest.mark.asyncio
async def test_update_schedule_type_recomputes_next_run_at() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.schedule_type = ScheduleType.DAILY
existing_job.timezone = "UTC"
existing_job.run_at = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc)
existing_job.config = {}
repository.get_by_id = AsyncMock(return_value=existing_job)
repository.update_by_id = AsyncMock(return_value=existing_job)
data = AutomationJobUpdateRequest(schedule_type=ScheduleType.WEEKLY)
result = await repository.update(job_id, data)
assert result is not None
update_values = repository.update_by_id.call_args[0][1]
assert update_values["schedule_type"] == ScheduleType.WEEKLY
assert "run_at" in update_values
assert "next_run_at" in update_values
@pytest.mark.asyncio
async def test_update_config_serializes_enum_values_to_json() -> None:
session = AsyncMock()
repository = AutomationJobsRepository(session)
job_id = uuid4()
existing_job = MagicMock()
existing_job.schedule_type = ScheduleType.DAILY
existing_job.timezone = "UTC"
existing_job.run_at = datetime(2026, 1, 1, 8, 0, 0, tzinfo=timezone.utc)
existing_job.config = {"input_template": "Old"}
repository.get_by_id = AsyncMock(return_value=existing_job)
repository.update_by_id = AsyncMock(return_value=existing_job)
data = AutomationJobUpdateRequest(
config={"enabled_tools": [AgentTool.MEMORY_WRITE]},
)
result = await repository.update(job_id, data)
assert result is not None
update_values = repository.update_by_id.call_args[0][1]
enabled_tools = update_values["config"]["enabled_tools"]
assert isinstance(enabled_tools[0], str)
@@ -1,246 +1,107 @@
import pytest
from datetime import datetime
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from pydantic import ValidationError
from schemas.automation import AgentTool, AutomationJobConfig
from v1.automation_jobs.schemas import (
AutomationJobCreateRequest,
AutomationJobUpdateRequest,
AutomationJobResponse,
AutomationJobUpdateRequest,
)
from schemas.automation import AgentTool, AutomationJobConfig
class TestIsSystemProperty:
def test_is_system_true_when_bootstrap_key_present(self):
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = "memory_extraction"
mock_orm_job.title = "Test Job"
mock_orm_job.schedule_type = "daily"
mock_orm_job.run_at = datetime.now()
mock_orm_job.config = {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
}
mock_orm_job.schedule_type = "daily"
mock_orm_job.status = "active"
mock_orm_job.timezone = "Asia/Shanghai"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
mock_orm_job.deleted_at = None
resp = AutomationJobResponse.from_orm(mock_orm_job)
assert resp.is_system is True
def test_is_system_false_when_bootstrap_key_none(self):
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = None
mock_orm_job.title = "Test Job"
mock_orm_job.schedule_type = "daily"
mock_orm_job.run_at = datetime.now()
mock_orm_job.config = {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
}
mock_orm_job.schedule_type = "daily"
mock_orm_job.status = "active"
mock_orm_job.timezone = "Asia/Shanghai"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
mock_orm_job.deleted_at = None
resp = AutomationJobResponse.from_orm(mock_orm_job)
assert resp.is_system is False
def _mock_orm_job() -> MagicMock:
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = "memory_extraction"
mock_orm_job.title = "Test Job"
mock_orm_job.config = {
"input_template": "Hello",
"enabled_tools": ["memory.write", "memory.forget"],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 8, "minute": 0},
},
}
mock_orm_job.status = "active"
mock_orm_job.timezone = "Asia/Shanghai"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
return mock_orm_job
class TestFromOrm:
def test_run_at_converted_from_datetime_to_time(self):
run_at_datetime = datetime(2024, 6, 15, 14, 30, 0)
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = None
mock_orm_job.title = "Test Job"
mock_orm_job.schedule_type = "daily"
mock_orm_job.run_at = run_at_datetime
mock_orm_job.config = {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
}
mock_orm_job.schedule_type = "daily"
mock_orm_job.status = "active"
mock_orm_job.timezone = "Asia/Shanghai"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
mock_orm_job.deleted_at = None
resp = AutomationJobResponse.from_orm(mock_orm_job)
assert resp.run_at == run_at_datetime.time()
def test_config_deserialized(self):
config = {
"input_template": "Test template",
"enabled_tools": [AgentTool.MEMORY_WRITE],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 5,
},
}
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = None
mock_orm_job.title = "Test Job"
mock_orm_job.schedule_type = "daily"
mock_orm_job.run_at = datetime.now()
mock_orm_job.config = config
mock_orm_job.schedule_type = "daily"
mock_orm_job.status = "active"
mock_orm_job.timezone = "Asia/Shanghai"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
mock_orm_job.deleted_at = None
resp = AutomationJobResponse.from_orm(mock_orm_job)
assert resp.config.input_template == "Test template"
assert resp.config.enabled_tools == [AgentTool.MEMORY_WRITE]
assert resp.config.context.window_count == 5
def test_is_system_derived_from_bootstrap_key(self):
mock_orm_job = MagicMock()
mock_orm_job.id = uuid4()
mock_orm_job.owner_id = uuid4()
mock_orm_job.bootstrap_key = "system_bootstrap"
mock_orm_job.title = "Test Job"
mock_orm_job.schedule_type = "daily"
mock_orm_job.run_at = datetime.now()
mock_orm_job.config = {
"input_template": "Hello",
"enabled_tools": [],
"context": {},
}
mock_orm_job.schedule_type = "daily"
mock_orm_job.status = "active"
mock_orm_job.timezone = "UTC"
mock_orm_job.next_run_at = datetime.now()
mock_orm_job.last_run_at = None
mock_orm_job.created_at = datetime.now()
mock_orm_job.updated_at = datetime.now()
mock_orm_job.deleted_at = None
resp = AutomationJobResponse.from_orm(mock_orm_job)
assert resp.is_system is True
assert resp.bootstrap_key == "system_bootstrap"
def test_response_is_system_true_when_bootstrap_key_present() -> None:
resp = AutomationJobResponse.from_orm(_mock_orm_job())
assert resp.is_system is True
class TestTimezoneValidation:
def test_valid_timezone(self):
request = AutomationJobCreateRequest.model_validate(
def test_response_parses_schedule_from_config() -> None:
resp = AutomationJobResponse.from_orm(_mock_orm_job())
assert resp.config.schedule is not None
assert resp.config.schedule.type.value == "daily"
assert resp.config.schedule.run_at.hour == 8
def test_create_request_requires_config_schedule() -> None:
with pytest.raises(ValidationError):
AutomationJobCreateRequest.model_validate(
{
"title": "Test Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Asia/Shanghai",
"config": {
"input_template": "Hello",
"enabled_tools": [],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
},
}
)
assert request.timezone == "Asia/Shanghai"
def test_invalid_timezone(self):
with pytest.raises(ValidationError) as exc_info:
AutomationJobCreateRequest.model_validate(
{
"title": "Test Job",
"schedule_type": "daily",
"run_at": "09:00:00",
"timezone": "Invalid/Timezone",
"config": {
"input_template": "Hello",
"enabled_tools": [],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
},
}
)
assert "timezone must be a valid IANA timezone" in str(exc_info.value)
def test_update_valid_timezone(self):
request = AutomationJobUpdateRequest.model_validate(
{
"timezone": "America/New_York",
}
)
assert request.timezone == "America/New_York"
def test_update_invalid_timezone(self):
with pytest.raises(ValidationError) as exc_info:
AutomationJobUpdateRequest.model_validate(
{
"timezone": "Invalid/Timezone",
}
)
assert "timezone must be a valid IANA timezone" in str(exc_info.value)
def test_update_none_timezone_allowed(self):
request = AutomationJobUpdateRequest.model_validate(
{
"timezone": None,
}
)
assert request.timezone is None
class TestAutomationJobConfigPatch:
def test_all_fields_optional(self):
patch = AutomationJobConfig.model_validate({})
assert patch.input_template is None
assert patch.enabled_tools is None
assert patch.context is None
def test_create_request_valid_timezone() -> None:
request = AutomationJobCreateRequest.model_validate(
{
"title": "Test Job",
"timezone": "Asia/Shanghai",
"config": {
"input_template": "Hello",
"enabled_tools": ["memory.write"],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
},
}
)
assert request.timezone == "Asia/Shanghai"
def test_partial_input_template(self):
patch = AutomationJobConfig.model_validate(
{
"input_template": "Updated template",
}
)
assert patch.input_template == "Updated template"
assert patch.enabled_tools is None
assert patch.context is None
def test_extra_fields_forbidden(self):
with pytest.raises(ValidationError):
AutomationJobConfig.model_validate(
{
"input_template": "Test",
"unknown_field": "value",
}
)
def test_update_timezone_validation() -> None:
request = AutomationJobUpdateRequest.model_validate(
{"timezone": "America/New_York"}
)
assert request.timezone == "America/New_York"
with pytest.raises(ValidationError):
AutomationJobUpdateRequest.model_validate({"timezone": "Invalid/Timezone"})
def test_config_patch_still_allows_partial_payload() -> None:
patch = AutomationJobConfig.model_validate(
{"enabled_tools": [AgentTool.MEMORY_WRITE]}
)
assert patch.input_template is None
assert patch.enabled_tools == [AgentTool.MEMORY_WRITE]
@@ -1,6 +1,6 @@
from datetime import datetime, time, timezone
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
@@ -23,6 +23,8 @@ from schemas.automation import (
ContextSource,
ContextWindowMode,
MessageContextConfig,
ScheduleConfig,
ScheduleRunAt,
)
@@ -35,14 +37,16 @@ def _make_config() -> AutomationJobConfig:
window_mode=ContextWindowMode.DAY,
window_count=2,
),
schedule=ScheduleConfig(
type=ScheduleType.DAILY,
run_at=ScheduleRunAt(hour=9, minute=0),
),
)
def _make_create_request() -> AutomationJobCreateRequest:
return AutomationJobCreateRequest(
title="Test Job",
schedule_type=ScheduleType.DAILY,
run_at=time(9, 0, 0),
timezone="Asia/Shanghai",
status=AutomationJobStatus.ACTIVE,
config=_make_config(),
@@ -50,18 +54,28 @@ def _make_create_request() -> AutomationJobCreateRequest:
def _make_job(
owner_id: MagicMock | None = None, bootstrap_key: str | None = None
owner_id: UUID | None = None, bootstrap_key: str | None = None
) -> MagicMock:
job = MagicMock()
job.id = uuid4()
job.owner_id = owner_id or uuid4()
job.bootstrap_key = bootstrap_key
job.title = "Test Job"
job.schedule_type = ScheduleType.DAILY
job.run_at = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc)
job.timezone = "Asia/Shanghai"
job.status = AutomationJobStatus.ACTIVE
job.config = {"input_template": "Hello"}
job.config = {
"input_template": "Hello",
"enabled_tools": ["memory.write"],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"schedule": {
"type": "daily",
"run_at": {"hour": 9, "minute": 0},
},
}
job.next_run_at = datetime(2024, 1, 2, 9, 0, 0, tzinfo=timezone.utc)
job.last_run_at = None
job.created_at = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc)
@@ -210,7 +224,9 @@ class TestUpdate:
with pytest.raises(AutomationJobNotFound):
await service.update(
job_id, owner_id, AutomationJobUpdateRequest(title="New")
job_id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
)
@pytest.mark.asyncio
@@ -225,7 +241,9 @@ class TestUpdate:
with pytest.raises(AutomationJobNotFound):
await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="New")
job.id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
)
@pytest.mark.asyncio
@@ -239,7 +257,9 @@ class TestUpdate:
with pytest.raises(SystemJobModificationForbidden):
await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="New")
job.id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
)
repository.update.assert_not_called()
@@ -257,12 +277,15 @@ class TestUpdate:
repository.update.return_value = updated_job
result = await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="Updated Title")
job.id,
owner_id,
AutomationJobUpdateRequest(title="Updated Title", timezone="UTC"),
)
assert result.title == "Updated Title"
repository.update.assert_awaited_once_with(
job.id, AutomationJobUpdateRequest(title="Updated Title")
job.id,
AutomationJobUpdateRequest(title="Updated Title", timezone="UTC"),
)
session.commit.assert_awaited_once()
@@ -278,7 +301,9 @@ class TestUpdate:
with pytest.raises(AutomationJobNotFound):
await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="New")
job.id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
)
@pytest.mark.asyncio
@@ -293,7 +318,9 @@ class TestUpdate:
with pytest.raises(HTTPException) as exc:
await service.update(
job.id, owner_id, AutomationJobUpdateRequest(title="New")
job.id,
owner_id,
AutomationJobUpdateRequest(title="New", timezone="UTC"),
)
assert exc.value.status_code == 503