deer-flow/backend/tests/test_schedule_corrupt_rows.py
rayhpeng c38d291505 refactor(schedule): standardize the module on the hexagonal architecture
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>
2026-07-31 11:16:46 +08:00

127 lines
5.0 KiB
Python

"""A corrupt stored schedule is an operator problem, not a client error.
``SqlScheduledTaskRepository._to_domain`` rebuilds the aggregate on every
read, so a row whose stored schedule no longer parses surfaces at read time.
It used to surface as ``InvalidScheduleError`` -- the same error the aggregate
raises for a *client-submitted* schedule, which the router maps to 422. That
double duty told the client "your request is wrong" about a request that was
perfectly fine, and made the row unrepairable over HTTP: PATCH reads the task
before writing, so the fix path 422'd too.
``CorruptStoredScheduleError`` splits the vocabulary: it is raised only by the
persistence adapter, and it is deliberately absent from the router's status
table so it falls through to the unclassified-500 branch -- a server-side
fault reported as one.
SQL-only: the in-memory double stores whole aggregates and cannot hold a
corrupt row, which is exactly why this file exists beside the contract suite
rather than inside it.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from datetime import UTC, datetime
import pytest
import pytest_asyncio
from app.adapters.schedule.scheduled_task_repository import SqlScheduledTaskRepository
from app.gateway.routers.schedule.router import _STATUS_BY_ERROR
from deerflow.config.database_config import DatabaseConfig
from deerflow.domain.schedule.exceptions import CorruptStoredScheduleError, InvalidScheduleError
from deerflow.domain.schedule.model import ScheduledTask, SchedulePolicy, ScheduleSpec
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow
pytestmark = pytest.mark.asyncio
NOW = datetime(2026, 7, 27, 12, 0, tzinfo=UTC)
@pytest_asyncio.fixture
async def repo(tmp_path) -> AsyncIterator[SqlScheduledTaskRepository]:
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
try:
sf = get_session_factory()
assert sf is not None
yield SqlScheduledTaskRepository(sf)
finally:
await close_engine()
async def _add_task(repo: SqlScheduledTaskRepository, *, title: str = "healthy") -> ScheduledTask:
return await repo.add(
ScheduledTask.create(
user_id="user-1",
title=title,
prompt="p",
schedule=ScheduleSpec.cron_schedule("0 9 * * *", "UTC"),
context_mode="fresh_thread_per_run",
thread_id=None,
now=NOW,
policy=SchedulePolicy(),
)
)
async def _corrupt(task_id: str, **columns) -> None:
"""Damage a stored row directly -- the exact shape a bug or a manual edit
would leave behind, unreachable through the port."""
sf = get_session_factory()
assert sf is not None
async with sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
assert row is not None
for name, value in columns.items():
setattr(row, name, value)
await session.commit()
class TestSingleRowReads:
async def test_a_corrupt_schedule_raises_the_dedicated_error(self, repo):
task = await _add_task(repo)
await _corrupt(task.task_id, schedule_spec={})
with pytest.raises(CorruptStoredScheduleError):
await repo.get(task.task_id, user_id="user-1")
async def test_the_dedicated_error_is_not_the_client_facing_one(self, repo):
"""The router maps InvalidScheduleError to 422; a corrupt row must not
ride that mapping."""
task = await _add_task(repo)
await _corrupt(task.task_id, schedule_spec={})
with pytest.raises(CorruptStoredScheduleError) as exc_info:
await repo.get(task.task_id, user_id="user-1")
assert not isinstance(exc_info.value, InvalidScheduleError)
async def test_a_corrupt_context_mode_is_reported_the_same_way(self, repo):
"""Enum rebuild failures are the same fault as spec parse failures: a
raw ValueError out of the adapter would be a technical exception
crossing the boundary untranslated."""
task = await _add_task(repo)
await _corrupt(task.task_id, context_mode="not-a-mode")
with pytest.raises(CorruptStoredScheduleError):
await repo.get(task.task_id, user_id="user-1")
class TestListReads:
async def test_a_corrupt_row_does_not_take_down_the_listing(self, repo):
healthy = await _add_task(repo, title="healthy")
broken = await _add_task(repo, title="broken")
await _corrupt(broken.task_id, schedule_spec={})
listed = await repo.list_by_user("user-1")
assert [t.task_id for t in listed] == [healthy.task_id]
class TestRouterMapping:
def test_the_corrupt_row_error_is_unclassified_on_purpose(self):
"""Absent from the status table means the router's fallthrough turns
it into a 500 -- a new protocol decision would have to add it here
deliberately."""
assert CorruptStoredScheduleError not in _STATUS_BY_ERROR