deer-flow/backend/tests/test_composition.py
rayhpeng eb37df0779 refactor(schedule): wire the slice through a composition root
Switches production onto the hexagonal path. The legacy modules still
compile and still have tests, but nothing assembles them any more; deleting
them is the next commit, kept separate so it stays reviewable.

Composition root
----------------
`app/composition.py::build_domain_services()` is now the only place an
adapter is instantiated. It was extracted from `deps.py::langgraph_runtime`
rather than added to it: wiring there was tangled with engine startup,
orphan recovery and shutdown, so the one rule that governs it -- no SQL
backend means no service and the routes answer 503 -- could not be tested
without booting the whole application, and was held up by a single comment.
It is a pure function of already-built infrastructure, so that rule is now
an assertion. Feedback moved with it; doing this while adding schedule's
five objects costs one change instead of two.

Primary adapter
---------------
The router is protocol translation only. What is gone is the giveaway: cron
normalisation, `next_run_at` arithmetic, the re-arm rule and hand-written
ownership checks all now live in the aggregate. Domain errors map to status
codes through one table, so a new error surfaces as a 500 to be classified
rather than being swallowed by whichever `except` was nearest.

`spec_mapping` split in two (AWS's own layout puts the wire model under the
entrypoint that owns it, and a primary adapter must not import a secondary
one): `adapters/schedule/spec_column.py` for the JSON column,
`routers/schedule/spec_wire.py` for the HTTP body. The two shapes are equal
only by coincidence, so `test_schedule_spec_parity.py` runs every case
against both and compares their outputs and messages directly. Function
names differ per side so an import from the wrong one is visible.

Explicit responses
------------------
Routes returned the ORM row's `to_dict()`, leaking `user_id`,
`assistant_id`, `overlap_policy` and the two lease columns. The response
models publish exactly the field set the frontend declares -- asserted in
both directions, since an extra field is a leak and a missing one breaks a
client.

One wire detail was nearly changed by accident: Pydantic v2 serializes a UTC
datetime as `...Z`, while the legacy `coerce_iso` path emitted `+00:00`.
`UtcTimestamp` pins `isoformat()` so adopting a model does not silently
alter the wire format for every client parsing these.

Tests
-----
73 new cases: router behaviour driven through a real `ScheduleService` over
in-memory fakes (a mocked service would let the error mapping pass without
a domain error ever being raised), response shape, and the composition root.
Router mappings verified by mutation -- a wrong status code or a dropped
timezone fallback turns 9, 2 and 1 cases red respectively.

Two lifespan tests carried a `SimpleNamespace` config that predates this
change; `langgraph_runtime` now reads `config.scheduler`, so they were given
one. Tolerating the gap with `getattr` was rejected: `AppConfig.scheduler`
always exists, so the fallback would be unreachable in production and exist
purely to excuse an incomplete test double.

Full suite is back to its 24 pre-existing failures.
2026-07-28 19:09:22 +08:00

142 lines
5.6 KiB
Python

"""Tests for the composition root.
The point of extracting `build_domain_services` from the lifespan is that the
rules below become assertions. Before, "a memory backend means no service,
and the routes answer 503" was a comment inside a 180-line startup function
that no test could reach without booting the whole application.
"""
from __future__ import annotations
import pytest
from app.composition import DomainServices, build_domain_services, build_schedule_policy
from deerflow.config.scheduler_config import SchedulerConfig
from deerflow.domain.schedule.model import SchedulePolicy
class _StubRunStore:
async def get(self, run_id: str):
return None
class _StubThreadStore:
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
return True
async def _launch_run(**kwargs):
return {"run_id": "run-1", "thread_id": kwargs.get("thread_id", "thread-1")}
def _build(session_factory, scheduler_config=None) -> DomainServices:
return build_domain_services(
session_factory=session_factory,
run_store=_StubRunStore(),
thread_store=_StubThreadStore(),
launch_run=_launch_run,
scheduler_config=scheduler_config or SchedulerConfig(),
)
class TestMemoryBackend:
"""`session_factory is None` is how `database.backend: memory` presents."""
def test_no_session_factory_yields_no_services(self):
services = _build(None)
assert services.feedback is None
assert services.schedule is None
def test_it_does_not_degrade_to_an_in_memory_implementation(self):
"""Refusing is the intended behaviour: a scheduled task that silently
vanishes on restart is worse than one the API declines to accept."""
assert _build(None).schedule is None
class TestSqlBackend:
def test_a_session_factory_yields_both_services(self):
"""A fake sessionmaker is enough -- wiring must not touch the database,
which is what makes this assertable without a live engine."""
services = _build(object())
assert services.feedback is not None
assert services.schedule is not None
def test_the_two_contexts_are_wired_independently(self):
services = _build(object())
assert services.feedback is not services.schedule
class TestSchedulePolicy:
def test_every_threshold_comes_from_the_operator_config(self):
policy = build_schedule_policy(
SchedulerConfig(
min_once_delay_seconds=30,
max_concurrent_runs=7,
lease_seconds=90,
)
)
assert policy == SchedulePolicy(
min_once_delay_seconds=30,
max_concurrent_runs=7,
lease_seconds=90,
)
def test_the_domain_defaults_are_not_what_production_gets(self):
"""The domain's defaults are permissive so that "nobody configured a
policy" invents no business constraint. Production must not inherit
them by accident -- the config's own defaults are the real values."""
from_config = build_schedule_policy(SchedulerConfig())
assert from_config != SchedulePolicy()
assert from_config.min_once_delay_seconds == SchedulerConfig().min_once_delay_seconds
@pytest.mark.parametrize(
("field", "value"),
[
("min_once_delay_seconds", 45),
("max_concurrent_runs", 5),
("lease_seconds", 300),
],
)
def test_each_field_is_mapped_from_its_own_config_key(self, field, value):
"""Guards against two thresholds being wired from one key -- a
transposition the type checker cannot see, since all three are ints."""
policy = build_schedule_policy(SchedulerConfig(**{field: value}))
assert getattr(policy, field) == value
def test_poll_interval_is_not_part_of_the_policy(self):
"""How often to look is the poller's business, not a rule any task is
subject to."""
assert not hasattr(SchedulePolicy(), "poll_interval_seconds")
class TestEveryPortGetsTheRightAdapter:
"""Deliberately white-box: verifying which adapter landed in which slot is
the entire job of a composition root, and all four are keyword arguments
of compatible shape, so a transposition type-checks cleanly and would only
surface as a production error.
"""
def test_each_schedule_port_is_filled_with_its_own_adapter(self):
from app.adapters.schedule.run_launcher import GatewayRunLauncher
from app.adapters.schedule.scheduled_run_repository import SqlScheduledRunRepository
from app.adapters.schedule.scheduled_task_repository import SqlScheduledTaskRepository
from app.adapters.schedule.thread_lookup import ThreadStoreThreadLookup
service = _build(object()).schedule
assert isinstance(service._tasks, SqlScheduledTaskRepository)
assert isinstance(service._runs, SqlScheduledRunRepository)
assert isinstance(service._launcher, GatewayRunLauncher)
assert isinstance(service._threads, ThreadStoreThreadLookup)
def test_each_feedback_port_is_filled_with_its_own_adapter(self):
from app.adapters.feedback.feedback_repository import SqlFeedbackRepository
from app.adapters.feedback.run_lookup import RunStoreRunLookup
service = _build(object()).feedback
assert isinstance(service._repository, SqlFeedbackRepository)
assert isinstance(service._runs, RunStoreRunLookup)
def test_the_policy_reaches_the_service(self):
service = _build(object(), SchedulerConfig(max_concurrent_runs=9)).schedule
assert service._policy.max_concurrent_runs == 9