mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
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.
79 lines
3.7 KiB
Python
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
|