mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
Replaces the pre-hexagonal scheduled-task implementation with a slice built to the layering spec: a pure domain (two aggregates, two state machines, the policy value object), output ports it declares itself, SQL/launcher/thread adapters implementing them under `app/adapters/`, and a composition root that is the one place any of them is instantiated. The old implementation mixed all of that into `app/scheduler/service.py` and a router that reached straight into repositories, so the rules that matter -- overlap policy, lease handling, which write owns which timestamp -- were only reachable through a live database. They are now unit-assertable on in-memory fakes, with the contract suite running each port against both the fake and real sqlite, and the concurrency invariants pinned by dedicated race tests. Two bugs the old shape hid are fixed on the way: a completion hook that replayed a stale snapshot and rolled back the launch write, and a corrupt stored row surfacing to the client as a 4xx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
600 lines
25 KiB
Python
600 lines
25 KiB
Python
"""Behaviour tests for the scheduled-task HTTP adapter.
|
|
|
|
Driven through the handlers with a **real** `ScheduleService` over in-memory
|
|
port fakes, not a mocked service. The router's whole remaining job is protocol
|
|
translation, and half of that is turning domain errors into status codes -- a
|
|
mocked service would let those assertions pass without a domain error ever
|
|
being raised.
|
|
|
|
`__wrapped__` unwraps `@require_permission` (authorization is covered by its
|
|
own suite) while keeping `_map_domain_errors` in the call path, which is the
|
|
layer under test here.
|
|
|
|
Every scenario the deleted legacy suite (`test_scheduled_task_router_behavior.py`)
|
|
pinned has a counterpart below, plus the response-shape and error-mapping
|
|
cases the old dict-returning router could not express.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from schedule_fakes import (
|
|
FakeRunLauncher,
|
|
FakeThreadLookup,
|
|
InMemoryScheduledRunRepository,
|
|
InMemoryScheduledTaskRepository,
|
|
)
|
|
|
|
from app.gateway.routers.schedule import router as router_module
|
|
from deerflow.domain.schedule.commands import UNSET
|
|
from deerflow.domain.schedule.exceptions import LaunchFailedError, ThreadBusyError
|
|
from deerflow.domain.schedule.model import RunStatus, ScheduledRun, ScheduledTask, SchedulePolicy, ScheduleSpec, TaskStatus, TriggerKind
|
|
from deerflow.domain.schedule.service import ScheduleService
|
|
|
|
USER = "user-1"
|
|
OTHER_USER = "user-2"
|
|
THREAD = "thread-1"
|
|
|
|
|
|
class _User:
|
|
def __init__(self, user_id: str | None) -> None:
|
|
self.id = user_id
|
|
|
|
|
|
@pytest.fixture
|
|
def tasks():
|
|
return InMemoryScheduledTaskRepository()
|
|
|
|
|
|
@pytest.fixture
|
|
def runs():
|
|
return InMemoryScheduledRunRepository()
|
|
|
|
|
|
@pytest.fixture
|
|
def launcher():
|
|
return FakeRunLauncher()
|
|
|
|
|
|
@pytest.fixture
|
|
def service(tasks, runs, launcher):
|
|
return ScheduleService(
|
|
tasks=tasks,
|
|
runs=runs,
|
|
launcher=launcher,
|
|
threads=FakeThreadLookup({THREAD: USER}),
|
|
policy=SchedulePolicy(min_once_delay_seconds=60, max_concurrent_runs=3, lease_seconds=120),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def as_user(monkeypatch):
|
|
"""Authenticate the handlers as a given user id (None = anonymous)."""
|
|
|
|
def _apply(user_id: str | None = USER):
|
|
async def _resolve(_request):
|
|
return None if user_id is None else _User(user_id)
|
|
|
|
monkeypatch.setattr(router_module, "get_optional_user_from_request", _resolve)
|
|
|
|
_apply()
|
|
return _apply
|
|
|
|
|
|
def _call(handler, **kwargs):
|
|
"""Invoke a route handler with authorization unwrapped."""
|
|
return handler.__wrapped__(request=object(), **kwargs)
|
|
|
|
|
|
def _create_body(**overrides):
|
|
defaults = dict(
|
|
thread_id=None,
|
|
context_mode="fresh_thread_per_run",
|
|
title="Daily summary",
|
|
prompt="Summarize the thread",
|
|
schedule_type="cron",
|
|
schedule_spec={"cron": "0 9 * * *"},
|
|
timezone="UTC",
|
|
)
|
|
return router_module.CreateScheduledTaskRequest(**{**defaults, **overrides})
|
|
|
|
|
|
def _update_body(**fields):
|
|
return router_module.UpdateScheduledTaskRequest(**fields)
|
|
|
|
|
|
async def _create(service, **overrides):
|
|
return await _call(router_module.create_scheduled_task, body=_create_body(**overrides), service=service)
|
|
|
|
|
|
class TestCreate:
|
|
@pytest.mark.asyncio
|
|
async def test_a_cron_task_is_created_and_rendered(self, service, as_user):
|
|
created = await _create(service)
|
|
assert created.title == "Daily summary"
|
|
assert created.schedule_type == "cron"
|
|
assert created.schedule_spec == {"cron": "0 9 * * *"}
|
|
assert created.status == "enabled"
|
|
assert created.next_run_at is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_response_carries_no_server_owned_fields(self, service, as_user):
|
|
"""The pre-migration router returned the ORM row, leaking `user_id`,
|
|
`overlap_policy` and `assistant_id` to every client."""
|
|
rendered = (await _create(service)).model_dump()
|
|
assert {"user_id", "assistant_id", "overlap_policy", "lease_owner", "lease_expires_at"} & set(rendered) == set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_fresh_thread_task_needs_no_thread_id(self, service, as_user):
|
|
created = await _create(service, context_mode="fresh_thread_per_run", thread_id=None)
|
|
assert created.thread_id is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reuse_thread_binds_the_thread(self, service, as_user):
|
|
created = await _create(service, context_mode="reuse_thread", thread_id=THREAD)
|
|
assert created.thread_id == THREAD
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reuse_thread_without_a_thread_id_is_422(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _create(service, context_mode="reuse_thread", thread_id=None)
|
|
assert caught.value.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reuse_of_an_unknown_thread_is_404(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _create(service, context_mode="reuse_thread", thread_id="thread-nope")
|
|
assert caught.value.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reuse_of_someone_elses_thread_is_also_404(self, service, as_user):
|
|
"""Indistinguishable from "does not exist" on purpose: telling them
|
|
apart would let a caller probe for threads they cannot see."""
|
|
as_user(OTHER_USER)
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _create(service, context_mode="reuse_thread", thread_id=THREAD)
|
|
assert caught.value.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unknown_schedule_type_is_422(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _create(service, schedule_type="teleport")
|
|
assert caught.value.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_cron_without_its_key_is_422(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _create(service, schedule_type="cron", schedule_spec={})
|
|
assert caught.value.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_malformed_cron_is_422(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _create(service, schedule_type="cron", schedule_spec={"cron": "0 9 * *"})
|
|
assert caught.value.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unknown_timezone_is_422(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _create(service, timezone="Mars/Olympus_Mons")
|
|
assert caught.value.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_once_schedule_too_close_to_now_is_422(self, service, as_user):
|
|
"""`min_once_delay_seconds` is operator policy, and it reaches the
|
|
aggregate through SchedulePolicy rather than being re-checked here."""
|
|
soon = (datetime.now(UTC) + timedelta(seconds=5)).isoformat()
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _create(service, schedule_type="once", schedule_spec={"run_at": soon})
|
|
assert caught.value.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_once_schedule_in_the_past_is_422(self, service, as_user):
|
|
past = (datetime.now(UTC) - timedelta(days=1)).isoformat()
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _create(service, schedule_type="once", schedule_spec={"run_at": past})
|
|
assert caught.value.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unknown_context_mode_is_422(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _create(service, context_mode="telepathy")
|
|
assert caught.value.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_anonymous_caller_is_401(self, service, as_user):
|
|
as_user(None)
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _create(service)
|
|
assert caught.value.status_code == 401
|
|
|
|
|
|
class TestRead:
|
|
@pytest.mark.asyncio
|
|
async def test_a_task_is_fetched_by_id(self, service, as_user):
|
|
created = await _create(service)
|
|
fetched = await _call(router_module.get_scheduled_task, task_id=created.id, service=service)
|
|
assert fetched.id == created.id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unknown_task_is_404(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.get_scheduled_task, task_id="task-nope", service=service)
|
|
assert caught.value.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_another_users_task_is_404(self, service, as_user):
|
|
created = await _create(service)
|
|
as_user(OTHER_USER)
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.get_scheduled_task, task_id=created.id, service=service)
|
|
assert caught.value.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_listing_returns_the_users_tasks(self, service, as_user):
|
|
await _create(service, title="One")
|
|
await _create(service, title="Two")
|
|
listed = await _call(router_module.list_scheduled_tasks, service=service)
|
|
assert {task.title for task in listed} == {"One", "Two"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_anonymous_listing_is_empty_not_401(self, service, as_user):
|
|
"""Unchanged from the pre-migration router: this endpoint has always
|
|
answered an unauthenticated GET with an empty list."""
|
|
as_user(None)
|
|
assert await _call(router_module.list_scheduled_tasks, service=service) == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_thread_listing_filters_by_thread(self, service, as_user):
|
|
await _create(service, context_mode="reuse_thread", thread_id=THREAD, title="Bound")
|
|
await _create(service, title="Unbound")
|
|
listed = await _call(router_module.list_thread_scheduled_tasks, thread_id=THREAD, service=service)
|
|
assert [task.title for task in listed] == ["Bound"]
|
|
|
|
|
|
class TestToCommand:
|
|
"""Transformation ① of the chain: wire shape -> command, owned by the
|
|
request models. Identity is injected as a parameter, never read off the
|
|
wire; the wire's `None` = "not supplied" convention becomes the command's
|
|
unambiguous `UNSET` here and nowhere else."""
|
|
|
|
def test_identity_is_injected_not_read_off_the_wire(self):
|
|
assert "user_id" not in router_module.CreateScheduledTaskRequest.model_fields
|
|
assert "user_id" not in router_module.UpdateScheduledTaskRequest.model_fields
|
|
cmd = _create_body().to_command(USER)
|
|
assert cmd.user_id == USER
|
|
|
|
def test_create_carries_the_parsed_value_object(self):
|
|
cmd = _create_body().to_command(USER)
|
|
assert cmd.schedule.cron == "0 9 * * *"
|
|
assert cmd.title == "Daily summary"
|
|
|
|
def test_update_translates_wire_none_to_unset(self):
|
|
cmd = _update_body(title="Renamed").to_command("task-1", USER, None)
|
|
assert cmd.title == "Renamed"
|
|
assert cmd.prompt is UNSET
|
|
assert cmd.schedule is UNSET
|
|
assert cmd.context is UNSET
|
|
|
|
def test_update_completes_composite_fields_from_the_current_task(self):
|
|
current = ScheduledTask.create(
|
|
user_id=USER,
|
|
title="t",
|
|
prompt="p",
|
|
schedule=ScheduleSpec.cron_schedule("0 9 * * *", "Asia/Shanghai"),
|
|
context_mode="fresh_thread_per_run",
|
|
thread_id=None,
|
|
now=datetime(2026, 7, 27, tzinfo=UTC),
|
|
policy=SchedulePolicy(),
|
|
)
|
|
cmd = _update_body(schedule_spec={"cron": "30 2 * * *"}).to_command(current.task_id, USER, current)
|
|
assert cmd.schedule.cron == "30 2 * * *"
|
|
assert cmd.schedule.timezone == "Asia/Shanghai", "the omitted zone comes from the current value object"
|
|
|
|
cmd = _update_body(thread_id=THREAD).to_command(current.task_id, USER, current)
|
|
assert str(cmd.context.context_mode) == "fresh_thread_per_run", "the omitted mode comes from the current task"
|
|
assert cmd.context.thread_id == THREAD
|
|
|
|
|
|
class TestUpdate:
|
|
@pytest.mark.asyncio
|
|
async def test_a_title_is_updated_in_place(self, service, as_user):
|
|
created = await _create(service)
|
|
updated = await _call(
|
|
router_module.update_scheduled_task,
|
|
task_id=created.id,
|
|
body=_update_body(title="Renamed"),
|
|
service=service,
|
|
)
|
|
assert updated.title == "Renamed"
|
|
assert updated.schedule_spec == created.schedule_spec
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_schedule_spec_change_keeps_the_existing_timezone(self, service, as_user):
|
|
"""Only one of the three schedule parts was supplied; the router reads
|
|
the task to complete the value object rather than the service being
|
|
handed a partial one."""
|
|
created = await _create(service, timezone="Asia/Shanghai")
|
|
updated = await _call(
|
|
router_module.update_scheduled_task,
|
|
task_id=created.id,
|
|
body=_update_body(schedule_spec={"cron": "30 2 * * *"}),
|
|
service=service,
|
|
)
|
|
assert updated.schedule_spec == {"cron": "30 2 * * *"}
|
|
assert updated.timezone == "Asia/Shanghai"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_timezone_change_keeps_the_existing_spec(self, service, as_user):
|
|
created = await _create(service)
|
|
updated = await _call(
|
|
router_module.update_scheduled_task,
|
|
task_id=created.id,
|
|
body=_update_body(timezone="Asia/Shanghai"),
|
|
service=service,
|
|
)
|
|
assert updated.schedule_spec == created.schedule_spec
|
|
assert updated.timezone == "Asia/Shanghai"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_timezone_change_on_a_once_task_keeps_the_same_instant(self, service, as_user):
|
|
"""The `once` half of the fallback above.
|
|
|
|
The omitted `run_at` is read straight off the current value object, and
|
|
the stored instant is already offset-aware, so re-zoning the schedule
|
|
must relabel it without moving it.
|
|
"""
|
|
created = await _create(
|
|
service,
|
|
schedule_type="once",
|
|
schedule_spec={"run_at": "2026-08-01T09:00:00+00:00"},
|
|
timezone="UTC",
|
|
)
|
|
updated = await _call(
|
|
router_module.update_scheduled_task,
|
|
task_id=created.id,
|
|
body=_update_body(timezone="Asia/Shanghai"),
|
|
service=service,
|
|
)
|
|
assert updated.schedule_spec == created.schedule_spec
|
|
assert updated.timezone == "Asia/Shanghai"
|
|
assert updated.next_run_at == created.next_run_at
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_running_task_cannot_be_updated(self, service, tasks, as_user):
|
|
"""Red line: the mutability gate lives in the aggregate now, and the
|
|
router only maps it onto 409."""
|
|
created = await _create(service)
|
|
stored = await tasks.get(created.id, user_id=USER)
|
|
await tasks.save(stored.__class__(**{**stored.__dict__, "status": TaskStatus.RUNNING}))
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(
|
|
router_module.update_scheduled_task,
|
|
task_id=created.id,
|
|
body=_update_body(title="Renamed"),
|
|
service=service,
|
|
)
|
|
assert caught.value.status_code == 409
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_updating_an_unknown_task_is_404(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(
|
|
router_module.update_scheduled_task,
|
|
task_id="task-nope",
|
|
body=_update_body(title="Renamed"),
|
|
service=service,
|
|
)
|
|
assert caught.value.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_malformed_new_schedule_is_422(self, service, as_user):
|
|
created = await _create(service)
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(
|
|
router_module.update_scheduled_task,
|
|
task_id=created.id,
|
|
body=_update_body(schedule_spec={"cron": "0 9 * *"}),
|
|
service=service,
|
|
)
|
|
assert caught.value.status_code == 422
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_terminal_once_task_pushed_into_the_future_is_rearmed(self, service, tasks, as_user):
|
|
"""Red line: without re-arming, the API answers 200 with a
|
|
`next_run_at` that can never fire, because claiming only admits
|
|
`enabled` rows."""
|
|
future = (datetime.now(UTC) + timedelta(days=1)).isoformat()
|
|
created = await _create(service, schedule_type="once", schedule_spec={"run_at": future})
|
|
stored = await tasks.get(created.id, user_id=USER)
|
|
await tasks.save(stored.__class__(**{**stored.__dict__, "status": TaskStatus.FAILED}))
|
|
|
|
later = (datetime.now(UTC) + timedelta(days=2)).isoformat()
|
|
updated = await _call(
|
|
router_module.update_scheduled_task,
|
|
task_id=created.id,
|
|
body=_update_body(schedule_spec={"run_at": later}),
|
|
service=service,
|
|
)
|
|
assert updated.status == "enabled"
|
|
assert updated.next_run_at is not None
|
|
|
|
|
|
class TestPauseResume:
|
|
@pytest.mark.asyncio
|
|
async def test_pause_then_resume(self, service, as_user):
|
|
created = await _create(service)
|
|
paused = await _call(router_module.pause_scheduled_task, task_id=created.id, service=service)
|
|
assert paused.status == "paused"
|
|
resumed = await _call(router_module.resume_scheduled_task, task_id=created.id, service=service)
|
|
assert resumed.status == "enabled"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pausing_a_running_task_is_409(self, service, tasks, as_user):
|
|
created = await _create(service)
|
|
stored = await tasks.get(created.id, user_id=USER)
|
|
await tasks.save(stored.__class__(**{**stored.__dict__, "status": TaskStatus.RUNNING}))
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.pause_scheduled_task, task_id=created.id, service=service)
|
|
assert caught.value.status_code == 409
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pausing_an_unknown_task_is_404(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.pause_scheduled_task, task_id="task-nope", service=service)
|
|
assert caught.value.status_code == 404
|
|
|
|
|
|
class TestTrigger:
|
|
"""Red line: this endpoint answers exactly 409 / 502 / 200, and the success
|
|
body is `{"id": ..., "triggered": true}`."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_launched_trigger_is_200_with_the_documented_body(self, service, as_user):
|
|
created = await _create(service)
|
|
response = await _call(router_module.trigger_scheduled_task, task_id=created.id, service=service)
|
|
assert response.model_dump() == {"id": created.id, "triggered": True}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_busy_thread_is_409(self, service, launcher, as_user):
|
|
created = await _create(service)
|
|
launcher.fail_with = ThreadBusyError("thread already has an active run")
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.trigger_scheduled_task, task_id=created.id, service=service)
|
|
assert caught.value.status_code == 409
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_active_run_for_the_same_task_is_409(self, service, runs, as_user):
|
|
"""The other route to a conflict: the task's single active slot is
|
|
already taken, which the service reports without calling the launcher
|
|
at all."""
|
|
created = await _create(service)
|
|
await runs.add(
|
|
ScheduledRun(
|
|
record_id="task-run-existing",
|
|
task_id=created.id,
|
|
thread_id="thread-x",
|
|
scheduled_for=datetime.now(UTC),
|
|
trigger=TriggerKind.SCHEDULED,
|
|
status=RunStatus.RUNNING,
|
|
)
|
|
)
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.trigger_scheduled_task, task_id=created.id, service=service)
|
|
assert caught.value.status_code == 409
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_launch_failure_is_502(self, service, launcher, as_user):
|
|
"""502, not 500: the failure is downstream of this API and the task
|
|
itself is intact."""
|
|
created = await _create(service)
|
|
launcher.fail_with = LaunchFailedError("run backend unavailable")
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.trigger_scheduled_task, task_id=created.id, service=service)
|
|
assert caught.value.status_code == 502
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_triggering_an_unknown_task_is_404(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.trigger_scheduled_task, task_id="task-nope", service=service)
|
|
assert caught.value.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_paused_task_can_still_be_triggered(self, service, as_user):
|
|
"""Manual dispatch is deliberately allowed while paused, and leaves the
|
|
task paused."""
|
|
created = await _create(service)
|
|
await _call(router_module.pause_scheduled_task, task_id=created.id, service=service)
|
|
response = await _call(router_module.trigger_scheduled_task, task_id=created.id, service=service)
|
|
assert response.triggered is True
|
|
after = await _call(router_module.get_scheduled_task, task_id=created.id, service=service)
|
|
assert after.status == "paused"
|
|
|
|
|
|
class TestDelete:
|
|
@pytest.mark.asyncio
|
|
async def test_a_task_is_deleted(self, service, as_user):
|
|
created = await _create(service)
|
|
response = await _call(router_module.delete_scheduled_task, task_id=created.id, service=service)
|
|
assert response.model_dump() == {"id": created.id, "deleted": True}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deleting_an_unknown_task_is_404(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.delete_scheduled_task, task_id="task-nope", service=service)
|
|
assert caught.value.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deleting_another_users_task_is_404(self, service, as_user):
|
|
created = await _create(service)
|
|
as_user(OTHER_USER)
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.delete_scheduled_task, task_id=created.id, service=service)
|
|
assert caught.value.status_code == 404
|
|
|
|
|
|
class TestRunHistory:
|
|
@pytest.mark.asyncio
|
|
async def test_runs_are_listed_for_an_owned_task(self, service, as_user):
|
|
created = await _create(service)
|
|
await _call(router_module.trigger_scheduled_task, task_id=created.id, service=service)
|
|
listed = await _call(router_module.list_scheduled_task_runs, task_id=created.id, service=service)
|
|
assert len(listed) == 1
|
|
assert listed[0].task_id == created.id
|
|
assert listed[0].trigger == "manual"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_of_an_unknown_task_is_404(self, service, as_user):
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.list_scheduled_task_runs, task_id="task-nope", service=service)
|
|
assert caught.value.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_of_another_users_task_is_404(self, service, as_user):
|
|
"""Ownership is checked on the parent task, so run history cannot be
|
|
read sideways."""
|
|
created = await _create(service)
|
|
as_user(OTHER_USER)
|
|
with pytest.raises(HTTPException) as caught:
|
|
await _call(router_module.list_scheduled_task_runs, task_id=created.id, service=service)
|
|
assert caught.value.status_code == 404
|
|
|
|
|
|
class TestErrorMapping:
|
|
def test_every_mapped_error_is_a_schedule_error(self):
|
|
from deerflow.domain.schedule.exceptions import ScheduleError
|
|
|
|
assert all(issubclass(error, ScheduleError) for error in router_module._STATUS_BY_ERROR)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unclassified_domain_error_is_not_swallowed(self):
|
|
"""A new domain error is a new protocol decision. Letting it through
|
|
surfaces as a 500 that has to be classified, rather than shipping as
|
|
whatever 4xx happened to be nearest."""
|
|
from deerflow.domain.schedule.exceptions import ScheduleError
|
|
|
|
class NewDomainError(ScheduleError):
|
|
pass
|
|
|
|
@router_module._map_domain_errors
|
|
async def handler():
|
|
raise NewDomainError("something new")
|
|
|
|
with pytest.raises(NewDomainError):
|
|
await handler()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_mapped_error_keeps_its_message_as_the_detail(self):
|
|
from deerflow.domain.schedule.exceptions import TaskNotFoundError
|
|
|
|
@router_module._map_domain_errors
|
|
async def handler():
|
|
raise TaskNotFoundError("Scheduled task not found")
|
|
|
|
with pytest.raises(HTTPException) as caught:
|
|
await handler()
|
|
assert caught.value.detail == "Scheduled task not found"
|