deer-flow/backend/tests/conftest.py
rayhpeng 551865abcf fix(feedback): address review on dialog state, errors, and test seams
Four findings from @willem-bd on #4401:

- FeedbackDialog stays mounted across messages, so selected tags and the
  comment survived an ESC/click-outside dismiss and pre-filled the next
  thumbs-down. Reset on every close path via a wrapped onOpenChange.
- Neither the dialog's handleSubmit nor handleDialogSubmit caught a failed
  enrichment PUT, so a rejection went unhandled and the user got no signal.
  Catch in the dialog (where the rejection lands), toast, and keep the input
  for a retry.
- rate_run awaited the RunLookup port before Feedback.create validated the
  rating, contradicting its own "before any I/O happens" docstring: an
  invalid rating on an unknown run surfaced as RunNotFoundError. Validate
  first, restoring the legacy router's 400-before-404 ordering. The service
  test now uses an unknown run id so it actually pins that order.
- InMemoryFeedbackRepository moved out of test_feedback.py into
  tests/feedback_fakes.py (with FakeRunLookup) so two test modules share it
  without one importing the other; conftest states the tests-dir sys.path
  dependency explicitly, which also makes it work under
  --import-mode=importlib.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:58:45 +08:00

168 lines
5.9 KiB
Python

"""Test configuration for the backend test suite.
Sets up sys.path and pre-mocks modules that would cause circular import
issues when unit-testing lightweight config/registry code in isolation.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
# Make 'app' and 'deerflow' importable from any working directory
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
# Shared test helpers (e.g. feedback_fakes) import as plain modules. pytest's
# default prepend import mode already puts this directory on the path, but not
# under --import-mode=importlib, so state the dependency explicitly.
sys.path.insert(0, str(Path(__file__).parent))
# Break the circular import chain that exists in production code:
# deerflow.subagents.__init__
# -> .executor (SubagentExecutor, SubagentResult)
# -> deerflow.agents.thread_state
# -> deerflow.agents.__init__
# -> lead_agent.agent
# -> subagent_limit_middleware
# -> deerflow.subagents.executor <-- circular!
#
# By injecting a mock for deerflow.subagents.executor *before* any test module
# triggers the import, __init__.py's "from .executor import ..." succeeds
# immediately without running the real executor module.
_executor_mock = MagicMock()
_executor_mock.SubagentExecutor = MagicMock
_executor_mock.SubagentResult = MagicMock
_executor_mock.SubagentStatus = MagicMock
_executor_mock.MAX_CONCURRENT_SUBAGENTS = 3
_executor_mock.get_background_task_result = MagicMock()
sys.modules["deerflow.subagents.executor"] = _executor_mock
@pytest.fixture()
def provisioner_module():
"""Load docker/provisioner/app.py as an importable test module.
Shared by test_provisioner_kubeconfig and test_provisioner_pvc_volumes so
that any change to the provisioner entry-point path or module name only
needs to be updated in one place.
"""
repo_root = Path(__file__).resolve().parents[2]
module_path = repo_root / "docker" / "provisioner" / "app.py"
spec = importlib.util.spec_from_file_location("provisioner_app_test", module_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
previous_module = sys.modules.get(spec.name)
sys.modules[spec.name] = module
try:
spec.loader.exec_module(module)
yield module
finally:
if previous_module is None:
sys.modules.pop(spec.name, None)
else:
sys.modules[spec.name] = previous_module
# ---------------------------------------------------------------------------
# Auto-set user context for every test unless marked no_auto_user
# ---------------------------------------------------------------------------
#
# Repository methods read ``user_id`` from a contextvar by default
# (see ``deerflow.runtime.user_context``). Without this fixture, every
# pre-existing persistence test would raise RuntimeError because the
# contextvar is unset. The fixture sets a default test user on every
# test; tests that explicitly want to verify behaviour *without* a user
# context should mark themselves ``@pytest.mark.no_auto_user``.
@pytest.fixture(autouse=True)
def _reset_skill_storage_singleton():
"""Reset the SkillStorage singleton between tests to prevent cross-test contamination."""
try:
from deerflow.skills.storage import reset_skill_storage
except ImportError:
yield
return
reset_skill_storage()
try:
yield
finally:
reset_skill_storage()
@pytest.fixture(autouse=True)
def _reset_frozen_checkpoint_channel_mode(monkeypatch):
"""Reset the process-global frozen checkpoint channel mode between tests.
Production treats ``checkpoint_channel_mode`` as restart-required: the
first client/app freezes it for the process. The test suite builds many
clients and apps with different modes in one process, so the freeze must
not leak across tests. Mirrors the per-test ``monkeypatch.setattr``
resets already used in test_client.py / test_lead_agent_model_resolution.py.
"""
from deerflow.runtime import checkpoint_mode
monkeypatch.setattr(checkpoint_mode, "_frozen_checkpoint_channel_mode", None)
yield
@pytest.fixture(autouse=True)
def _restore_title_config_singleton():
"""Reset ``_title_config`` to its pristine default after every test.
``AppConfig.from_file()`` writes the on-disk ``title`` block into the
module-level singleton (``config/app_config.py`` calls
``load_title_config_from_dict``). Any test that loads the real
``config.yaml`` therefore leaves the singleton in a state that
``test_title_middleware_core_logic.py`` does not expect; that suite
relies on the pristine ``TitleConfig()`` default (``enabled=True``).
We restore the default after every test so test files stay
independent regardless of order.
"""
try:
from deerflow.config.title_config import reset_title_config
except ImportError:
yield
return
try:
yield
finally:
reset_title_config()
@pytest.fixture(autouse=True)
def _auto_user_context(request):
"""Inject a default ``test-user-autouse`` into the contextvar.
Opt-out via ``@pytest.mark.no_auto_user``. Uses lazy import so that
tests which don't touch the persistence layer never pay the cost
of importing runtime.user_context.
"""
if request.node.get_closest_marker("no_auto_user"):
yield
return
try:
from deerflow.runtime.user_context import (
reset_current_user,
set_current_user,
)
except ImportError:
yield
return
user = SimpleNamespace(id="test-user-autouse", email="test@local")
token = set_current_user(user)
try:
yield
finally:
reset_current_user(token)