mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-18 18:46:17 +00:00
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.
142 lines
5.6 KiB
Python
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
|