deer-flow/backend/tests/test_scheduler_capability_access.py
Totoro 35023bb908
feat(scheduler): create conversation schedules and support agent self-stop (#6229)
* feat(scheduler): create conversation schedules and support agent self-stop

* fix(scheduler): validate goals and trial requests before dispatch

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-04 06:46:13 +08:00

530 lines
27 KiB
Python

"""Gateway schedule grants retain the owner's permissions and origin scope."""
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from _router_auth_helpers import call_unwrapped
from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED, AUTH_SOURCE_INTERNAL, AUTH_SOURCE_PAT, AUTH_SOURCE_SESSION
from app.gateway.authz import AuthContext
from app.gateway.routers import scheduled_tasks
from deerflow.agents.interaction_policy import RunInteractionMode, RunInteractionPolicy
from deerflow.config.app_config import AppConfig
from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict
_PERMISSIONS = ("threads:read", "threads:write", "runs:create")
def _user(*, user_id="alice", token_version=0, role="user"):
return SimpleNamespace(id=user_id, token_version=token_version, system_role=role, needs_setup=False)
def _task(*, user_id="alice", origin="origin", **updates):
return {"id": "task-1", "user_id": user_id, "origin_thread_id": origin, "status": "enabled", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "goal_objective": None, "standing_notes": [], **updates}
def _setup(monkeypatch, *, source=AUTH_SOURCE_SESSION, permissions=_PERMISSIONS, original_text="Run this task now", mode="interactive", task=None, enabled=True, tool_enabled=True):
from app.gateway import scheduled_task_access as access
user = _user()
repo = SimpleNamespace(
get=AsyncMock(return_value=task or _task()),
list_by_origin_thread=AsyncMock(return_value=[task or _task()]),
create=AsyncMock(side_effect=lambda **values: {"id": values["task_id"], "status": "enabled", **values}),
pause_with_queue_cancellation=AsyncMock(return_value="paused"),
delete_with_queue_cancellation=AsyncMock(return_value="deleted"),
append_standing_note=AsyncMock(return_value=task or _task(standing_notes=["use develop"])),
)
occurrences = SimpleNamespace(request_stop=AsyncMock(return_value=True))
service = SimpleNamespace(dispatch_task=AsyncMock(return_value={"outcome": "launched", "run_id": "trial-run"}))
threads = SimpleNamespace(get=AsyncMock(return_value={"user_id": "alice"}), check_access=AsyncMock(return_value=True))
provider = SimpleNamespace(get_user=AsyncMock(return_value=user))
config = AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, "scheduler": {"enabled": enabled, "tool_enabled": tool_enabled}})
request = SimpleNamespace(state=SimpleNamespace(auth_source=source, user=user, auth=AuthContext(user, list(permissions))), app=SimpleNamespace(state=SimpleNamespace(scheduled_task_service=service)))
monkeypatch.setattr(access, "get_config", lambda: config)
monkeypatch.setattr(access, "get_scheduled_task_repo", lambda _request: repo)
monkeypatch.setattr(access, "get_scheduled_task_run_repo", lambda _request: occurrences)
monkeypatch.setattr(access, "get_scheduled_task_service", lambda _request: service)
monkeypatch.setattr(access, "get_thread_store", lambda _request: threads)
monkeypatch.setattr(access, "get_local_provider", lambda: provider)
resolve = AsyncMock(return_value=list(permissions))
monkeypatch.setattr(access, "resolve_route_permissions", resolve)
async def prepare(**updates):
return await access.prepare_scheduler_capability(
request,
user=user,
thread_id="origin",
run_id="run-1",
assistant_id="lead_agent",
original_user_text=original_text,
interaction_policy=RunInteractionPolicy(RunInteractionMode(mode)),
**updates,
)
return prepare, repo, occurrences, service, threads, provider, resolve, request
@pytest.mark.asyncio
@pytest.mark.parametrize("source", [None, AUTH_SOURCE_PAT, "unknown"])
async def test_untrusted_auth_sources_cannot_receive_a_schedule_grant(monkeypatch, source):
prepare, *_ = _setup(monkeypatch, source=source)
assert await prepare() is None
@pytest.mark.asyncio
@pytest.mark.parametrize(("enabled", "tool_enabled"), [(False, True), (True, False)])
async def test_operator_flags_withhold_the_gateway_grant(monkeypatch, enabled, tool_enabled):
prepare, *_ = _setup(monkeypatch, enabled=enabled, tool_enabled=tool_enabled)
assert await prepare() is None
@pytest.mark.asyncio
async def test_capability_requires_the_running_scheduler_service(monkeypatch):
from fastapi import HTTPException
from app.gateway import scheduled_task_access as access
prepare, *_ = _setup(monkeypatch)
monkeypatch.setattr(access, "get_scheduled_task_service", lambda _request: (_ for _ in ()).throw(HTTPException(503)))
assert await prepare() is None
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["scheduled", "autonomous", "webhook"])
async def test_noninteractive_runs_without_private_occurrence_authority_get_no_grant(monkeypatch, mode):
prepare, *_ = _setup(monkeypatch, mode=mode)
assert await prepare() is None
@pytest.mark.asyncio
async def test_session_principal_and_permission_ceiling_cannot_be_widened(monkeypatch):
prepare, repo, _, _, _, _, resolve, request = _setup(monkeypatch, permissions=("threads:read",))
capability = await prepare()
resolve.return_value = list(_PERMISSIONS)
result = await capability.manage(action="create", request={})
assert result["status_code"] == 403
repo.create.assert_not_awaited()
request.state.auth.user = _user(user_id="bob")
assert await prepare() is None
@pytest.mark.asyncio
@pytest.mark.parametrize("owner", [None, _user(user_id="bob"), _user(token_version=1)])
async def test_deleted_changed_or_revoked_owner_cannot_use_an_existing_grant(monkeypatch, owner):
prepare, repo, _, _, _, provider, _, _ = _setup(monkeypatch)
capability = await prepare()
provider.get_user.return_value = owner
result = await capability.manage(action="list", request={})
assert result["status_code"] == 401
repo.list_by_origin_thread.assert_not_awaited()
@pytest.mark.asyncio
async def test_live_permission_revocation_is_honored_each_action(monkeypatch):
prepare, repo, _, _, _, _, resolve, _ = _setup(monkeypatch)
capability = await prepare()
resolve.return_value = []
result = await capability.manage(action="list", request={})
assert result["status_code"] == 403
repo.list_by_origin_thread.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(("action", "permission"), [("list", "threads:read"), ("create", "runs:create"), ("pause", "threads:write"), ("delete", "threads:write"), ("note", "threads:write"), ("trial", "runs:create")])
async def test_each_action_checks_its_route_permissions(monkeypatch, action, permission):
prepare, repo, _, service, *_ = _setup(monkeypatch, permissions=tuple(value for value in _PERMISSIONS if value != permission))
capability = await prepare()
result = await capability.manage(action=action, request={"task_id": "task-1"})
assert result["status_code"] == 403
repo.get.assert_not_awaited()
repo.create.assert_not_awaited()
service.dispatch_task.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("source_owner", [None, "bob"])
async def test_deleted_shared_or_foreign_origin_is_not_a_management_grant(monkeypatch, source_owner):
prepare, _, _, _, threads, *_ = _setup(monkeypatch)
threads.get.return_value = None if source_owner is None else {"user_id": source_owner}
assert await prepare() is None
@pytest.mark.asyncio
@pytest.mark.parametrize("task", [_task(user_id="bob"), _task(origin="other-origin"), _task(origin=None)])
@pytest.mark.parametrize("action", ["pause", "delete", "note", "trial"])
async def test_management_targets_require_both_owner_and_origin(monkeypatch, task, action):
prepare, repo, _, service, *_ = _setup(monkeypatch, task=task, original_text="use develop")
capability = await prepare()
payload = {"task_id": "task-1", **({"note": "use develop"} if action == "note" else {})}
result = await capability.manage(action=action, request=payload)
assert result["status_code"] == 404
repo.pause_with_queue_cancellation.assert_not_awaited()
repo.delete_with_queue_cancellation.assert_not_awaited()
repo.append_standing_note.assert_not_awaited()
service.dispatch_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_list_projects_only_safe_fields_from_the_bound_origin(monkeypatch):
prepare, repo, *_ = _setup(monkeypatch, task=_task(secret="must-not-leak", lease_owner="private-worker", standing_notes=["use develop"]))
capability = await prepare()
result = await capability.manage(action="list", request={})
repo.list_by_origin_thread.assert_awaited_once_with("alice", "origin")
assert result["tasks"][0]["standing_notes"] == ["use develop"]
assert "secret" not in result["tasks"][0]
assert "lease_owner" not in result["tasks"][0]
@pytest.mark.asyncio
async def test_create_uses_bound_origin_and_echoes_goal_and_stop_details(monkeypatch):
prepare, repo, *_ = _setup(monkeypatch)
capability = await prepare()
result = await capability.manage(
action="create",
request={"title": "Report", "prompt": "Write the report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC", "goal_objective": "Write a report from the source documents", "max_runs": 3},
)
values = repo.create.await_args.kwargs
assert values["user_id"] == "alice"
assert values["origin_thread_id"] == "origin"
assert values["thread_id"] is None
assert values["assistant_id"] == "lead_agent"
assert result["goal_objective"] == "Write a report from the source documents"
assert result["max_runs"] == 3
assert result["next_run_at"] is not None
assert "stop_instructions" in result
@pytest.mark.asyncio
@pytest.mark.parametrize("objective", ["", " \t\n ", "g" * 4001])
async def test_create_rejects_invalid_goal_before_persisting_schedule(monkeypatch, objective):
prepare, repo, *_ = _setup(monkeypatch)
capability = await prepare()
result = await capability.manage(action="create", request={"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC", "goal_objective": objective})
assert result["status_code"] == 422
repo.create.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("objective", [None, " " + "g" * 4000 + "\t\n"])
async def test_create_preserves_optional_and_normalized_boundary_goal(monkeypatch, objective):
prepare, repo, *_ = _setup(monkeypatch)
capability = await prepare()
result = await capability.manage(action="create", request={"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC", "goal_objective": objective})
assert "error" not in result
assert result["goal_objective"] == objective
assert repo.create.await_args.kwargs["goal_objective"] == objective
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [{"user_id": "bob"}, {"thread_id": "other-origin"}, {"assistant_id": "other-agent"}, {"origin_thread_id": "other-origin"}])
async def test_model_identity_fields_are_rejected_even_if_directly_called(monkeypatch, payload):
prepare, repo, *_ = _setup(monkeypatch)
capability = await prepare()
result = await capability.manage(action="create", request=payload)
assert result["status_code"] == 422
repo.create.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("schedule_type,schedule_spec", [("interval", {"every_seconds": 60}), ("cron", {"cron": "*/5 * * * *"}), ("cron", {"cron": "0,30 * * * *"})])
async def test_frequent_conversation_schedules_require_a_user_end_condition(monkeypatch, schedule_type, schedule_spec):
prepare, repo, *_ = _setup(monkeypatch)
capability = await prepare()
result = await capability.manage(action="create", request={"title": "Check", "prompt": "Check sources", "schedule_type": schedule_type, "schedule_spec": schedule_spec, "timezone": "UTC"})
assert result["status_code"] == 422
repo.create.assert_not_awaited()
@pytest.mark.asyncio
async def test_reused_thread_goal_is_rejected(monkeypatch):
prepare, repo, *_ = _setup(monkeypatch)
capability = await prepare()
result = await capability.manage(
action="create", request={"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC", "context_mode": "reuse_thread", "goal_objective": "Write report"}
)
assert result["status_code"] == 422
repo.create.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("bounds", [{"goal_objective": " "}, {"max_runs": True}, {"end_at": "2025-01-01T00:00:00+00:00"}, {"end_at": "2027-01-01T00:00:00"}, {"end_at": "2027-01-01T00:00:00+00:00"}])
async def test_create_rejects_unusable_goal_and_end_bounds(monkeypatch, bounds):
prepare, repo, *_ = _setup(monkeypatch)
capability = await prepare()
definition = {"title": "Report", "prompt": "Write report", "schedule_type": "once", "schedule_spec": {"run_at": "2027-01-01T00:00:00+00:00"}, "timezone": "UTC", **bounds}
result = await capability.manage(action="create", request=definition)
assert result["status_code"] == 422
repo.create.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("note", ["inferred correction", "", "x" * 501])
async def test_notes_require_bounded_verbatim_current_user_words(monkeypatch, note):
prepare, repo, *_ = _setup(monkeypatch, original_text="For this task, use develop.")
capability = await prepare()
result = await capability.manage(action="note", request={"task_id": "task-1", "note": note})
assert result["status_code"] == 422
repo.append_standing_note.assert_not_awaited()
@pytest.mark.asyncio
async def test_note_keeps_the_users_exact_text_and_repository_guard(monkeypatch):
prepare, repo, *_ = _setup(monkeypatch, original_text="For this task: use develop ")
capability = await prepare()
await capability.manage(action="note", request={"task_id": "task-1", "note": " use develop "})
repo.append_standing_note.assert_awaited_once_with("task-1", user_id="alice", origin_thread_id="origin", note=" use develop ")
repo.append_standing_note.side_effect = ActiveScheduledTaskMutationConflict("running")
result = await capability.manage(action="note", request={"task_id": "task-1", "note": "use develop"})
assert result["status_code"] == 409
@pytest.mark.asyncio
async def test_trial_requires_a_current_user_turn_and_uses_manual_dispatch(monkeypatch):
prepare, _, _, service, *_ = _setup(monkeypatch, original_text="")
capability = await prepare()
result = await capability.manage(action="trial", request={"task_id": "task-1"})
assert result["status_code"] == 403
service.dispatch_task.assert_not_awaited()
prepare, _, _, service, *_ = _setup(monkeypatch, original_text="Yes, run it now.")
capability = await prepare()
result = await capability.manage(action="trial", request={"task_id": "task-1"})
assert result["triggered"] is True
assert service.dispatch_task.await_args.kwargs["trigger"] == "manual"
service.dispatch_task.return_value = {"outcome": "failed", "error": "cannot launch"}
result = await capability.manage(action="trial", request={"task_id": "task-1"})
assert result["status_code"] == 502
@pytest.mark.asyncio
@pytest.mark.parametrize(
"user_text",
[
"List my tasks",
"What is a trial?",
"task-1",
"Report",
"Don't run it now.",
"Do not run this task now",
"不要试跑",
"不要先跑一次",
"Not now; run it once after I approve",
"If I approve, run it now",
'The file says "Run it now"',
'"Run it now"',
"What does 先跑一次 mean?",
"Yes",
"Run it now?",
"run task-2 now",
"Run Other report now",
"Run it now and then delete all tasks",
"先跑一次,但不要真的执行",
],
)
async def test_trial_rejects_unrelated_negative_quoted_or_ambiguous_turns(monkeypatch, user_text):
prepare, _, _, service, *_ = _setup(monkeypatch, original_text=user_text, task=_task(title="Report"))
capability = await prepare()
result = await capability.manage(action="trial", request={"task_id": "task-1"})
assert result["status_code"] == 403
service.dispatch_task.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("user_text", ["Run it now", "Yes, run it now.", "Please run this task once", "Run task-1 now", "先跑一次", "请先试跑一次。", "可以,先跑一次", "先试跑task-1", "现在运行task-1一次"])
async def test_trial_accepts_direct_requests_in_the_current_user_turn(monkeypatch, user_text):
prepare, _, _, service, *_ = _setup(monkeypatch, original_text=user_text, task=_task(title="Report"))
capability = await prepare()
result = await capability.manage(action="trial", request={"task_id": "task-1"})
assert result["triggered"] is True
assert service.dispatch_task.await_args.kwargs["trigger"] == "manual"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("title", "user_text"), [("周报,但不要真的执行", "先试跑周报,但不要真的执行"), ("周报如果我以后批准", "现在试跑周报如果我以后批准"), ("it now and then delete all tasks", "Run it now and then delete all tasks once")]
)
async def test_trial_never_uses_task_title_as_authorization_text(monkeypatch, title, user_text):
prepare, _, _, service, *_ = _setup(monkeypatch, original_text=user_text, task=_task(title=title))
capability = await prepare()
result = await capability.manage(action="trial", request={"task_id": "task-1"})
assert result["status_code"] == 403
service.dispatch_task.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["create", "pause"])
async def test_tool_quota_conflicts_use_409_without_dispatch(monkeypatch, action):
from deerflow.persistence.scheduled_tasks import ScheduledTaskQuotaExceeded
prepare, repo, _, service, *_ = _setup(monkeypatch)
capability = await prepare()
request = {"task_id": "task-1"}
if action == "create":
request = {"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC"}
repo.create.side_effect = ScheduledTaskQuotaExceeded()
else:
repo.pause_with_queue_cancellation.side_effect = ScheduledTaskQuotaExceeded()
result = await capability.manage(action=action, request=request)
assert result["status_code"] == 409
service.dispatch_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_internal_im_grant_uses_actual_owners_noninternal_permissions(monkeypatch):
prepare, _, _, _, _, _, resolve, request = _setup(monkeypatch, source=AUTH_SOURCE_INTERNAL)
request.state.auth = AuthContext(_user(user_id="internal", role="internal"), ["threads:read"])
capability = await prepare()
assert capability is not None
assert all(call.kwargs["is_internal"] is False for call in resolve.await_args_list)
@pytest.mark.asyncio
async def test_synthetic_internal_principal_cannot_become_the_schedule_owner(monkeypatch):
prepare, _, _, _, _, provider, _, request = _setup(monkeypatch, source=AUTH_SOURCE_INTERNAL)
request.state.user.system_role = "internal"
provider.get_user.return_value.system_role = "internal"
assert await prepare() is None
@pytest.mark.asyncio
async def test_scheduled_grant_only_stops_the_privately_bound_occurrence(monkeypatch):
prepare, _, occurrences, *_ = _setup(monkeypatch, source=AUTH_SOURCE_INTERNAL, mode="scheduled")
capability = await prepare(scheduled_task_runtime={"user_id": "alice", "task_id": "task-1", "occurrence_id": "occurrence-1"})
assert capability.mode == "scheduled"
result = await capability.stop_current_schedule()
assert result["stop_requested"] is True
occurrences.request_stop.assert_awaited_once_with("occurrence-1", task_id="task-1", run_id="run-1", user_id="alice")
result = await capability.manage(action="list", request={})
assert result["status_code"] == 403
@pytest.mark.asyncio
@pytest.mark.parametrize("runtime", [{"user_id": "bob", "task_id": "task-1", "occurrence_id": "occurrence-1"}, {"user_id": "alice", "task_id": "task-1"}])
async def test_scheduled_runtime_must_bind_the_actual_owner_and_occurrence(monkeypatch, runtime):
prepare, *_ = _setup(monkeypatch, source=AUTH_SOURCE_INTERNAL, mode="scheduled")
assert await prepare(scheduled_task_runtime=runtime) is None
@pytest.mark.asyncio
async def test_self_stop_rechecks_permissions_and_durable_occurrence_binding(monkeypatch):
prepare, _, occurrences, _, _, _, resolve, _ = _setup(monkeypatch, source=AUTH_SOURCE_INTERNAL, mode="scheduled")
capability = await prepare(scheduled_task_runtime={"user_id": "alice", "task_id": "task-1", "occurrence_id": "occurrence-1"})
occurrences.request_stop.return_value = False
assert (await capability.stop_current_schedule())["status_code"] == 409
resolve.return_value = ["threads:read"]
assert (await capability.stop_current_schedule())["status_code"] == 403
assert occurrences.request_stop.await_count == 1
@pytest.mark.asyncio
async def test_auth_disabled_default_owner_needs_the_explicit_host_mode(monkeypatch):
from app.gateway import scheduled_task_access as access
from app.gateway.auth_disabled import get_auth_disabled_user
prepare, _, _, _, threads, provider, _, request = _setup(monkeypatch, source=AUTH_SOURCE_AUTH_DISABLED)
user = get_auth_disabled_user()
request.state.user = user
request.state.auth = AuthContext(user, list(_PERMISSIONS))
threads.get.return_value = {"user_id": str(user.id)}
provider.get_user.return_value = None
monkeypatch.setattr(access, "is_auth_disabled", lambda: True)
capability = await access.prepare_scheduler_capability(request, user=user, thread_id="origin", run_id="run-1", assistant_id="lead_agent", original_user_text="List tasks", interaction_policy=RunInteractionPolicy.interactive())
assert capability is not None
result = await capability.manage(action="list", request={})
assert "error" not in result
monkeypatch.setattr(access, "is_auth_disabled", lambda: False)
result = await capability.manage(action="list", request={})
assert result["status_code"] == 401
@pytest.mark.asyncio
@pytest.mark.parametrize(
"updates",
[
{"timezone": "missing/zone"},
{"schedule_type": "unsupported"},
{"schedule_spec": {"every_seconds": 1}},
{"schedule_type": "cron", "schedule_spec": {"cron": "not cron"}},
{"context_mode": "wrong"},
{"schedule_type": "once", "schedule_spec": {"run_at": (datetime.now(UTC) - timedelta(hours=1)).isoformat()}},
],
)
async def test_rest_and_tool_share_creation_validation_status_and_reason(monkeypatch, updates):
prepare, repo, _, _, threads, _, _, request = _setup(monkeypatch)
capability = await prepare()
body = {"title": "Task", "prompt": "Work", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC", **updates}
tool_result = await capability.manage(action="create", request=body)
monkeypatch.setattr(scheduled_tasks, "get_scheduled_task_repo", lambda _request: repo)
monkeypatch.setattr(scheduled_tasks, "get_thread_store", lambda _request: threads)
monkeypatch.setattr(scheduled_tasks, "get_config", lambda: AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}))
monkeypatch.setattr(scheduled_tasks, "get_optional_user_from_request", AsyncMock(return_value=_user()))
from fastapi import HTTPException
with pytest.raises(HTTPException) as error:
await call_unwrapped(scheduled_tasks.create_scheduled_task, request=request, body=scheduled_tasks.ScheduledTaskCreateRequest(**body))
assert tool_result["status_code"] == error.value.status_code
assert tool_result["error"] == error.value.detail
@pytest.mark.asyncio
async def test_capability_maps_real_repository_note_capacity_and_live_task_limit(monkeypatch, tmp_path):
from app.gateway import scheduled_task_access as access
from deerflow.config.database_config import DatabaseConfig
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
try:
prepare, *_ = _setup(monkeypatch, original_text=" ".join(f"note-{index}" for index in range(11)))
repo = ScheduledTaskRepository(get_session_factory())
monkeypatch.setattr(access, "get_scheduled_task_repo", lambda _request: repo)
capability = await prepare()
definition = {"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC"}
created = await capability.manage(action="create", request=definition)
for index in range(10):
result = await capability.manage(action="note", request={"task_id": created["id"], "note": f"note-{index}"})
assert "error" not in result
result = await capability.manage(action="note", request={"task_id": created["id"], "note": "note-10"})
assert result["status_code"] == 422
stored = await repo.get(created["id"], user_id="alice")
assert len(stored["standing_notes"]) == 10
for _ in range(19):
assert "error" not in await capability.manage(action="create", request=definition)
result = await capability.manage(action="create", request=definition)
assert result["status_code"] == 409
assert "20 live" in result["error"]
assert len(await repo.list_by_origin_thread("alice", "origin")) == 20
finally:
await close_engine()
@pytest.mark.asyncio
async def test_capability_and_shared_validation_offload_configuration_and_timezone_io(monkeypatch, tmp_path):
from support.detectors.blocking_io_runtime import detect_blocking_io_strict
from app.gateway import scheduled_task_access as access
from app.gateway import scheduled_task_validation as validation
prepare, *_ = _setup(monkeypatch)
config = access.get_config()
marker = tmp_path / "configuration-and-timezone-fixture.txt"
marker.write_text("fixture", encoding="utf-8")
original_validate_timezone = validation.validate_timezone
def read_config():
marker.read_text(encoding="utf-8")
return config
def read_timezone(timezone):
marker.read_text(encoding="utf-8")
return original_validate_timezone(timezone)
monkeypatch.setattr(access, "get_config", read_config)
monkeypatch.setattr(validation, "validate_timezone", read_timezone)
with detect_blocking_io_strict():
capability = await prepare()
result = await capability.manage(action="create", request={"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC"})
assert "error" not in result