deer-flow/backend/tests/test_schedule_spec_mapping.py
rayhpeng 0bae77ffc0 refactor(schedule): move the secondary adapters to app/adapters
Adopts the layout feedback landed in cb49dd67: secondary adapters live
under `app/adapters/<context>/`, one file per port, the file named after
the port in snake_case with the technology carried by the class name.
`app/infra/` is now gone entirely.

The rename also separates two meanings of "run" that shared one filename
space: `run_sql.py` held `ScheduledRun` (an execution record), while the
`run_launcher.py` still to come deals in Gateway runs.

  task_sql.py     -> scheduled_task_repository.py
  run_sql.py      -> scheduled_run_repository.py
  spec_mapping.py -> spec_mapping.py  (implements no port, so no rename)

Both SQL adapters now carry the `Secondary adapter (owned persistence)`
docstring marker -- this context owns both tables and writes its own
queries. `spec_mapping` says instead that it is a boundary mapping and
names its two callers. The package `__init__.py` is empty, so imports go
through the full path and a class's home file stays unambiguous.

Pure move: every top-level symbol was compared against its pre-move
original by AST dump (docstrings excluded), plus a separate per-class
method-name comparison, since a whole-class dump reports a docstring edit
and a renamed method the same way.

Also repoints three docstrings and one diagram that still named the
deleted `app/infra/` path.
2026-07-28 18:14:46 +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.adapters.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