deer-flow/backend/tests/test_schedule_spec_mapping.py
rayhpeng d4c24e3f6f feat(schedule): implement the persistence ports in SQL
Two secondary adapters plus the spec mapping they share, and the first
real payoff of the port boundary: test_schedule_fakes.py becomes a
contract suite that runs all 31 cases against both the in-memory doubles
and the SQL adapters on a real sqlite file. A rule stated in a port
docstring now has to hold for both, and a divergence is a failure rather
than a surprise in production.

The queries come over unchanged. The claim statement's FOR UPDATE SKIP
LOCKED and both protect_terminal conditional writes are this module's
concurrency contract, not style, and the IntegrityError translation in
add() is what lets the service collapse a lost active-slot race into the
same outcome as its own non-atomic fast path.

Two things the adapters own that the domain deliberately does not. The
claiming process's identity is generated here, because who claimed a
task is an identity rather than a rule and nothing reads it back. And
`Unsupported schedule_type` is raised by the mapping, because
ScheduleSpec only accepts the enum -- structural checks belong to the
boundary, value rules to __post_init__, and both surface as the same
domain error so the router maps one family.

_to_domain introduces a failure mode the legacy repository did not have:
a row whose stored schedule no longer parses. Single-row reads let it
propagate; list reads skip and log, so one corrupt row cannot 500 an
entire listing.

The contract suite reaches past the port in exactly one place -- seeding
a task that already carries a claim, a shape claim_due would never
produce -- and says so where it does.
2026-07-28 14:46:13 +08:00

79 lines
3.7 KiB
Python

"""Boundary tests for the schedule_spec mapping.
This is where a `Mapping[str, Any]` is allowed to exist. The split it enforces
is the point: **structural** problems (key missing, wrong type, unknown
schedule type) are caught here, **value** problems (5-field cron, resolvable
timezone) are left to `ScheduleSpec.__post_init__` -- and both surface as the
same domain error, so the router maps one family.
"""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from app.infra.schedule.spec_mapping import spec_to_domain, spec_to_wire
from deerflow.domain.schedule.model import InvalidScheduleError, ScheduleSpec, ScheduleType
class TestStructuralChecks:
def test_unknown_schedule_type_is_rejected(self):
"""The one rule the domain cannot state: ScheduleSpec only accepts the
enum, so a bad string has to be caught at the boundary."""
with pytest.raises(InvalidScheduleError, match="Unsupported schedule_type"):
spec_to_domain("teleport", {"cron": "0 9 * * *"}, "UTC")
@pytest.mark.parametrize("spec", [{}, None, {"cron": 5}, {"run_at": "..."}])
def test_cron_without_a_string_cron_is_rejected(self, spec):
with pytest.raises(InvalidScheduleError, match="requires schedule_spec"):
spec_to_domain("cron", spec, "UTC")
@pytest.mark.parametrize("spec", [{}, None, {"run_at": 5}, {"cron": "0 9 * * *"}])
def test_once_without_a_string_run_at_is_rejected(self, spec):
with pytest.raises(InvalidScheduleError, match="requires run_at"):
spec_to_domain("once", spec, "UTC")
def test_an_unparseable_run_at_is_rejected(self):
with pytest.raises(InvalidScheduleError, match="unparseable run_at"):
spec_to_domain("once", {"run_at": "next tuesday"}, "UTC")
class TestValueChecksStayInTheDomain:
"""These are not re-implemented here -- they arrive from __post_init__,
which is why the boundary can stay thin."""
def test_a_bad_cron_expression_still_raises(self):
with pytest.raises(InvalidScheduleError, match="exactly 5 fields"):
spec_to_domain("cron", {"cron": "0 9 * *"}, "UTC")
def test_a_bad_timezone_still_raises(self):
with pytest.raises(InvalidScheduleError, match="Unknown timezone"):
spec_to_domain("cron", {"cron": "0 9 * * *"}, "Mars/Olympus_Mons")
class TestRoundTrip:
def test_cron_round_trips_normalized(self):
spec = spec_to_domain("cron", {"cron": " 0 9 * * * "}, "Asia/Shanghai")
assert spec.schedule_type is ScheduleType.CRON
assert spec_to_wire(spec) == {"cron": "0 9 * * *"}
assert spec_to_domain("cron", spec_to_wire(spec), "Asia/Shanghai") == spec
def test_once_round_trips_to_the_same_instant(self):
spec = spec_to_domain("once", {"run_at": "2026-08-01T09:00:00"}, "Asia/Shanghai")
assert spec.run_at == datetime(2026, 8, 1, 1, 0, tzinfo=UTC)
assert spec_to_domain("once", spec_to_wire(spec), "Asia/Shanghai") == spec
def test_a_trailing_z_input_parses_and_re_emits_as_an_offset(self):
"""The shape the frontend actually submits (`zonedLocalToUtcIso`).
Both spellings parse on either side, so normalizing is deliberate."""
spec = spec_to_domain("once", {"run_at": "2026-08-01T01:00:00Z"}, "Asia/Shanghai")
emitted = spec_to_wire(spec)["run_at"]
assert emitted.endswith("+00:00")
assert spec_to_domain("once", {"run_at": emitted}, "Asia/Shanghai") == spec
def test_wire_output_is_idempotent(self):
spec = ScheduleSpec.once_at(datetime(2026, 8, 1, 9, 0, tzinfo=UTC), "UTC")
once = spec_to_wire(spec)
assert spec_to_wire(spec_to_domain("once", once, "UTC")) == once