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,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)