deer-flow/backend/tests/test_config_frozen.py
greatmengqi 84dccef230 refactor(config): Phase 2 — eliminate AppConfig.current() ambient lookup
Finish Phase 2 of the config refactor: production code no longer calls
AppConfig.current() anywhere. AppConfig now flows as an explicit parameter
down every consumer lane.

Call-site migrations
--------------------
- Memory subsystem (queue/updater/storage): MemoryConfig captured at
  enqueue time so the Timer closure survives the ContextVar boundary.
- Sandbox layer: tools.py, security.py, sandbox_provider.py, local_sandbox_provider,
  aio_sandbox_provider all take app_config explicitly. Module-level
  caching in tools.py's path helpers is removed — pure parameter flow.
- Skills layer: manager.py + loader.py + lead_agent.prompt cache refresh
  all thread app_config; cache worker closes over it.
- Community tools (tavily, jina, firecrawl, exa, ddg, image_search,
  infoquest, aio_sandbox): read runtime.context.app_config.
- Subagents registry: get_subagent_config / list_subagents /
  get_available_subagent_names require app_config.
- Runtime worker: requires RunContext.app_config; no fallback.
- Gateway routers (uploads, skills): add Depends(get_config).
- Channels feishu: uses AppConfig.from_file() (pure) at its sync boundary.
- LangGraph Server bootstrap (make_lead_agent): falls back to
  AppConfig.from_file() — pure load, not ambient lookup.

Context resolution
------------------
- resolve_context(runtime) now raises on non-DeerFlowContext runtime.context.
  Every entry point attaches typed context; dict/None shapes are rejected
  loudly instead of being papered over with an ambient AppConfig lookup.

AppConfig lifecycle
-------------------
- AppConfig.current() kept as a deprecated slot that raises RuntimeError,
  purely so legacy tests that still run `patch.object(AppConfig, "current")`
  don't trip AttributeError at teardown. Production never calls it.
- conftest autouse fixture no longer monkey-patches `current` — it only
  stubs `from_file()` so tests don't need a real config.yaml.

Design refs
-----------
- docs/plans/2026-04-12-config-refactor-plan.md (Phase 2: P2-6..P2-10)
- docs/plans/2026-04-12-config-refactor-design.md §8

All 2338 non-e2e tests pass. Zero AppConfig.current() call sites remain
in backend/packages or backend/app (docstrings in deps.py excepted).
2026-04-17 11:14:13 +08:00

96 lines
3.6 KiB
Python

"""Verify that all sub-config Pydantic models are frozen (immutable).
Frozen models reject attribute assignment after construction, raising
pydantic.ValidationError. This test collects every BaseModel subclass
defined in the deerflow.config package and asserts that mutation is
blocked.
"""
import inspect
import pkgutil
import pytest
from pydantic import BaseModel, ValidationError
import deerflow.config as config_pkg
def _collect_config_models() -> list[type[BaseModel]]:
"""Walk deerflow.config.* and return all concrete BaseModel subclasses."""
import importlib
models: list[type[BaseModel]] = []
package_path = config_pkg.__path__
package_prefix = config_pkg.__name__ + "."
for _importer, modname, _ispkg in pkgutil.walk_packages(package_path, prefix=package_prefix):
try:
mod = importlib.import_module(modname)
except Exception:
continue
for _name, obj in inspect.getmembers(mod, inspect.isclass):
if (
issubclass(obj, BaseModel)
and obj is not BaseModel
and obj.__module__ == mod.__name__
):
models.append(obj)
return models
_EXCLUDED: set[str] = set()
_ALL_MODELS = [m for m in _collect_config_models() if m.__name__ not in _EXCLUDED]
# Sanity: make sure we actually collected a meaningful set.
assert len(_ALL_MODELS) >= 15, f"Expected at least 15 config models, found {len(_ALL_MODELS)}: {[m.__name__ for m in _ALL_MODELS]}"
@pytest.mark.parametrize("model_cls", _ALL_MODELS, ids=lambda cls: cls.__name__)
def test_config_model_is_frozen(model_cls: type[BaseModel]):
"""Every sub-config model must have frozen=True in its model_config."""
cfg = model_cls.model_config
assert cfg.get("frozen") is True, (
f"{model_cls.__name__} is not frozen. "
f"Add `model_config = ConfigDict(frozen=True)` or add `frozen=True` to the existing ConfigDict."
)
@pytest.mark.parametrize("model_cls", _ALL_MODELS, ids=lambda cls: cls.__name__)
def test_config_model_rejects_mutation(model_cls: type[BaseModel]):
"""Constructing then mutating any field must raise ValidationError."""
# Build a minimal instance -- use model_construct to skip validation for
# required fields, then pick the first field to try mutating.
fields = list(model_cls.model_fields.keys())
if not fields:
pytest.skip(f"{model_cls.__name__} has no fields")
instance = model_cls.model_construct()
first_field = fields[0]
with pytest.raises(ValidationError):
setattr(instance, first_field, "MUTATED")
def test_extensions_nested_dict_mutation_is_not_blocked_by_pydantic():
"""Regression guard: Pydantic `frozen=True` does NOT deep-freeze container fields.
This test documents the trap — callers MUST compose a new dict and persist
it + reload AppConfig instead of reaching into `extensions.skills[x]`.
If you need the dict to be truly immutable, wrap with Mapping/frozendict.
"""
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig
ext = ExtensionsConfig(mcp_servers={}, skills={"a": SkillStateConfig(enabled=True)})
# This is the pre-refactor anti-pattern: Pydantic lets it through because
# the outer model is frozen but the inner dict is a plain builtin. No error.
ext.skills["a"] = SkillStateConfig(enabled=False)
ext.skills["b"] = SkillStateConfig(enabled=True)
# The test asserts the leak exists so a future "add deep-freeze" change
# flips this expectation and forces call-site review.
assert ext.skills["a"].enabled is False
assert "b" in ext.skills