Files
learningcircuit--local-deep…/tests/web/services/test_research_service_coverage_gaps.py
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

914 lines
35 KiB
Python

"""
Tests for coverage gaps in research_service.py.
Targets specific uncovered lines:
- run_research_process early termination (307-313)
- SettingsContext inner class (358-380)
- progress_callback termination check (428-434)
- LLM config error handling with ValueError raise (605-630)
- Search engine config error handling with ValueError raise (646-670)
- Search error classification (703-727)
- cleanup_research_resources test-mode delay path (1693-1699)
- handle_termination exception path (1797-1800)
- cancel_research database exception path (1860-1864)
- cancel_research outer exception path (1867-1871)
"""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
from loguru import logger
# Register MILESTONE log level used by progress_callback
try:
logger.level("MILESTONE")
except ValueError:
logger.level("MILESTONE", no=26)
# Module path constants
MODULE = "local_deep_research.web.services.research_service"
GLOBALS_MOD = "local_deep_research.web.routes.globals"
THREAD_SETTINGS_MOD = "local_deep_research.config.thread_settings"
SETTINGS_LOGGER_MOD = "local_deep_research.settings.logger"
QUEUE_PROC_MOD = "local_deep_research.web.queue.processor_v2"
ENV_REGISTRY_MOD = "local_deep_research.settings.env_registry"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fake_session_ctx(session=None):
"""Return a context manager factory that yields a mock session."""
if session is None:
session = MagicMock()
@contextmanager
def ctx(username=None):
yield session
return ctx
def _make_mock_research(status=None, research_meta=None):
"""Build a minimal ResearchHistory mock."""
r = MagicMock()
r.status = status
r.research_meta = research_meta
r.created_at = "2024-01-01T00:00:00"
r.report_content = None
return r
def _get_raw_run_research_process():
"""Get the unwrapped (no decorators) run_research_process function."""
from local_deep_research.web.services.research_service import (
run_research_process,
)
return run_research_process.__wrapped__.__wrapped__
def _base_run_patches(mock_session=None):
"""Return a dict of patches needed for run_research_process tests."""
if mock_session is None:
mock_session = MagicMock()
mock_research = _make_mock_research(research_meta={})
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
return {
f"{MODULE}.get_user_db_session": _fake_session_ctx(mock_session),
f"{MODULE}.handle_termination": MagicMock(),
f"{MODULE}.cleanup_research_resources": MagicMock(),
f"{MODULE}.set_search_context": MagicMock(),
f"{MODULE}.SocketIOService": MagicMock(),
f"{MODULE}.calculate_duration": MagicMock(return_value=5),
f"{MODULE}.ErrorReportGenerator": MagicMock(
return_value=MagicMock(
generate_error_report=MagicMock(return_value="error report")
)
),
f"{GLOBALS_MOD}.is_termination_requested": MagicMock(
return_value=False
),
f"{GLOBALS_MOD}.is_research_active": MagicMock(return_value=False),
f"{GLOBALS_MOD}.update_progress_and_check_active": MagicMock(
return_value=(5, True)
),
f"{SETTINGS_LOGGER_MOD}.log_settings": MagicMock(),
f"{THREAD_SETTINGS_MOD}.set_settings_context": MagicMock(),
f"{QUEUE_PROC_MOD}.queue_processor": MagicMock(),
}
# ---------------------------------------------------------------------------
# 1. run_research_process early termination (lines 307-313)
# ---------------------------------------------------------------------------
class TestRunResearchEarlyTermination:
"""When termination is requested before the research even starts,
the function should clean up and return immediately."""
def test_returns_immediately_when_terminated_before_start(self):
"""If is_termination_requested returns True on entry, the function
calls cleanup_research_resources and returns without running research."""
func = _get_raw_run_research_process()
mock_cleanup = MagicMock()
patches = _base_run_patches()
# Override: termination already requested
patches[f"{GLOBALS_MOD}.is_termination_requested"] = MagicMock(
return_value=True
)
patches[f"{MODULE}.cleanup_research_resources"] = mock_cleanup
# AdvancedSearchSystem should never be instantiated
patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock()
stack = []
for target, mock_obj in patches.items():
p = patch(target, mock_obj)
stack.append(p)
p.start()
try:
func(1, "test query", "quick", username="testuser")
finally:
for p in reversed(stack):
p.stop()
# cleanup_research_resources must have been called. Terminated
# before start → reports SUSPENDED, not a spurious "completed".
mock_cleanup.assert_called_once_with(
1, "testuser", user_password=None, final_status="suspended"
)
# AdvancedSearchSystem should not have been created
patches[f"{MODULE}.AdvancedSearchSystem"].assert_not_called()
# ---------------------------------------------------------------------------
# 2. SettingsContext inner class (lines 358-380)
# ---------------------------------------------------------------------------
class TestSettingsContextInnerClass:
"""The SettingsContext inner class in run_research_process extracts
values from a settings snapshot and provides get_setting()."""
def test_settings_context_extracts_dict_values(self):
"""When snapshot items are full setting objects with 'value' keys,
SettingsContext should extract just the value."""
func = _get_raw_run_research_process()
captured_contexts = []
original_set = MagicMock()
def capture_settings_context(ctx):
captured_contexts.append(ctx)
original_set(ctx)
mock_system = MagicMock()
mock_system.analyze_topic.return_value = {
"findings": "test",
"formatted_findings": "test result",
}
patches = _base_run_patches()
patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock(
return_value=mock_system
)
patches[f"{THREAD_SETTINGS_MOD}.set_settings_context"] = (
capture_settings_context
)
snapshot = {
"llm.model": {"value": "gpt-4", "type": "string"},
"search.max_results": {"value": 10, "type": "int"},
"plain_key": "just_a_value",
}
stack = []
for target, mock_obj in patches.items():
p = patch(target, mock_obj)
stack.append(p)
p.start()
try:
func(
1,
"test",
"quick",
username="user1",
settings_snapshot=snapshot,
)
finally:
for p in reversed(stack):
p.stop()
assert len(captured_contexts) == 1
ctx = captured_contexts[0]
# Full setting objects should have their values extracted
assert ctx.get_setting("llm.model") == "gpt-4"
assert ctx.get_setting("search.max_results") == 10
# Plain values should be kept as-is
assert ctx.get_setting("plain_key") == "just_a_value"
def test_settings_context_returns_default_for_missing_key(self):
"""get_setting returns default when key is not in snapshot."""
func = _get_raw_run_research_process()
captured_contexts = []
def capture(ctx):
captured_contexts.append(ctx)
mock_system = MagicMock()
mock_system.analyze_topic.return_value = {
"findings": "test",
"formatted_findings": "result",
}
patches = _base_run_patches()
patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock(
return_value=mock_system
)
patches[f"{THREAD_SETTINGS_MOD}.set_settings_context"] = capture
stack = []
for target, mock_obj in patches.items():
p = patch(target, mock_obj)
stack.append(p)
p.start()
try:
func(
1,
"test",
"quick",
username="user1",
settings_snapshot={"search.tool": "searxng"},
)
finally:
for p in reversed(stack):
p.stop()
ctx = captured_contexts[0]
assert ctx.get_setting("nonexistent.key", "fallback") == "fallback"
assert ctx.get_setting("nonexistent.key") is None
# ---------------------------------------------------------------------------
# 2b. Worker fails closed when the run has no configured primary engine
# ---------------------------------------------------------------------------
class TestWorkerFailsClosedOnMissingPrimary:
"""The CLI/scheduler/queue path bypasses the API precheck, so the worker is
the only gate. A snapshot with no ``search.tool`` must make
run_research_process refuse the run at the egress build
(resolve_run_primary_engine raises ValueError) — fail closed — rather than
silently running on the public ``searxng`` default. Regression guard for
the security-relevant fail-closed change."""
def test_missing_primary_never_reaches_search_system(self):
func = _get_raw_run_research_process()
mock_system = MagicMock()
mock_system.analyze_topic.return_value = {
"findings": "x",
"formatted_findings": "x",
}
patches = _base_run_patches()
patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock(
return_value=mock_system
)
stack = []
for target, mock_obj in patches.items():
p = patch(target, mock_obj)
stack.append(p)
p.start()
try:
# Empty snapshot => no search.tool => egress build raises =>
# run_research_process handles it as a failed run internally.
func(1, "test", "quick", username="user1", settings_snapshot={})
finally:
for p in reversed(stack):
p.stop()
# Fail closed: the run aborted before the search system ran. (A
# realistic snapshot WITH search.tool does reach analyze_topic — see
# TestSettingsContextInnerClass — so this isolates the egress refusal.)
mock_system.analyze_topic.assert_not_called()
# ---------------------------------------------------------------------------
# 3. progress_callback termination check (lines 428-434)
# ---------------------------------------------------------------------------
class TestProgressCallbackTerminationCheck:
"""progress_callback checks is_termination_requested on every call and
raises ResearchTerminatedException if termination was requested."""
def test_progress_callback_raises_on_termination(self):
"""When termination is requested mid-research, progress_callback
should call handle_termination and raise ResearchTerminatedException."""
from local_deep_research.exceptions import (
ResearchTerminatedException,
)
func = _get_raw_run_research_process()
call_count = [0]
mock_handle = MagicMock()
def termination_check(rid):
call_count[0] += 1
# Return True on second call (first call is the early-exit check,
# second call is from inside progress_callback)
return call_count[0] >= 2
mock_system = MagicMock()
# analyze_topic will trigger progress_callback which triggers termination
mock_system.analyze_topic.side_effect = ResearchTerminatedException(
"terminated"
)
patches = _base_run_patches()
patches[f"{GLOBALS_MOD}.is_termination_requested"] = termination_check
patches[f"{MODULE}.handle_termination"] = mock_handle
patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock(
return_value=mock_system
)
stack = []
for target, mock_obj in patches.items():
p = patch(target, mock_obj)
stack.append(p)
p.start()
try:
# The function catches ResearchTerminatedException internally
func(
1,
"test",
"quick",
username="user1",
settings_snapshot={"search.tool": "searxng"},
)
finally:
for p in reversed(stack):
p.stop()
# The function should not propagate ResearchTerminatedException
# (it is caught in the except ResearchTerminatedException block)
# ---------------------------------------------------------------------------
# 4. LLM config error handling (lines 605-630)
# ---------------------------------------------------------------------------
class TestLLMConfigErrorRaisesValueError:
"""When get_llm raises an error containing config-related keywords,
the code re-raises as ValueError('LLM Configuration Error: ...')."""
def _run_with_llm_error(self, error_msg, model="gpt-4", provider="openai"):
"""Run research with get_llm raising an exception containing error_msg."""
func = _get_raw_run_research_process()
mock_qp = MagicMock()
patches = _base_run_patches()
patches[f"{MODULE}.get_llm"] = MagicMock(
side_effect=Exception(error_msg)
)
patches[f"{QUEUE_PROC_MOD}.queue_processor"] = mock_qp
stack = []
for target, mock_obj in patches.items():
p = patch(target, mock_obj)
stack.append(p)
p.start()
try:
func(
1,
"test",
"quick",
username="user1",
model=model,
model_provider=provider,
settings_snapshot={"search.tool": "searxng"},
)
finally:
for p in reversed(stack):
p.stop()
return mock_qp
def test_llamacpp_keyword_triggers_config_error(self):
"""'llamacpp' is classified as an LLM config problem, but the raw
exception text must not reach the client (CWE-209)."""
mock_qp = self._run_with_llm_error("llamacpp model failed to load")
# Error is caught by outer handler and queued
mock_qp.queue_error_update.assert_called_once()
call_kwargs = str(mock_qp.queue_error_update.call_args)
# Classified as an LLM config problem -> safe generic message...
assert "There was a problem with the LLM configuration" in call_kwargs
# ...with the raw exception detail stripped (it can carry server paths).
assert "llamacpp model failed to load" not in call_kwargs
def test_model_path_keyword_triggers_config_error(self):
"""'model path' -> LLM config category; raw path not leaked."""
mock_qp = self._run_with_llm_error("model path /foo/bar does not exist")
mock_qp.queue_error_update.assert_called_once()
call_kwargs = str(mock_qp.queue_error_update.call_args)
assert "There was a problem with the LLM configuration" in call_kwargs
assert "/foo/bar" not in call_kwargs
def test_gguf_keyword_triggers_config_error(self):
"""'.gguf' -> LLM config category; raw detail not leaked."""
mock_qp = self._run_with_llm_error("please provide a valid .gguf file")
mock_qp.queue_error_update.assert_called_once()
call_kwargs = str(mock_qp.queue_error_update.call_args)
assert "There was a problem with the LLM configuration" in call_kwargs
assert "please provide a valid .gguf file" not in call_kwargs
def test_non_config_llm_error_is_genericized(self):
"""An unrecognized LLM error (no config keyword) reaches the central
handler's `else` branch: the raw exception text must NOT reach the
client (CWE-209) — only the generic 'unexpected error' message."""
mock_qp = self._run_with_llm_error(
"unexpected null pointer in inference at 0xdeadbeef"
)
mock_qp.queue_error_update.assert_called_once()
error_str = str(mock_qp.queue_error_update.call_args)
assert "LLM Configuration Error" not in error_str
# The genuine fix: the raw exception text is genericized.
assert "null pointer in inference" not in error_str
assert "unexpected error" in error_str.lower()
# ---------------------------------------------------------------------------
# 5. Search engine config error handling (lines 646-670)
# ---------------------------------------------------------------------------
class TestSearchEngineConfigError:
"""When get_search raises with config keywords, the code re-raises as
ValueError('Search Engine Configuration Error ...')."""
def _run_with_search_error(self, error_msg, search_engine="searxng"):
func = _get_raw_run_research_process()
mock_qp = MagicMock()
patches = _base_run_patches()
patches[f"{MODULE}.get_search"] = MagicMock(
side_effect=Exception(error_msg)
)
patches[f"{QUEUE_PROC_MOD}.queue_processor"] = mock_qp
stack = []
for target, mock_obj in patches.items():
p = patch(target, mock_obj)
stack.append(p)
p.start()
try:
func(
1,
"test",
"quick",
username="user1",
search_engine=search_engine,
settings_snapshot={"search.tool": "searxng"},
)
finally:
for p in reversed(stack):
p.stop()
return mock_qp
def test_searxng_keyword_triggers_config_error(self):
"""'searxng' -> search config category; raw detail not leaked (CWE-209)."""
mock_qp = self._run_with_search_error("SearXNG instance unreachable")
mock_qp.queue_error_update.assert_called_once()
call_kwargs = str(mock_qp.queue_error_update.call_args)
assert (
"There was a problem with the search engine configuration"
in call_kwargs
)
assert "instance unreachable" not in call_kwargs
def test_api_key_keyword_triggers_config_error(self):
"""'api_key' -> search config category; raw detail not leaked."""
mock_qp = self._run_with_search_error(
"Missing api_key for search provider"
)
mock_qp.queue_error_update.assert_called_once()
call_kwargs = str(mock_qp.queue_error_update.call_args)
assert (
"There was a problem with the search engine configuration"
in call_kwargs
)
assert "Missing api_key for search provider" not in call_kwargs
def test_connection_keyword_triggers_config_error(self):
"""'connection' -> search config category; raw detail not leaked."""
mock_qp = self._run_with_search_error(
"Connection refused by search backend"
)
mock_qp.queue_error_update.assert_called_once()
call_kwargs = str(mock_qp.queue_error_update.call_args)
assert (
"There was a problem with the search engine configuration"
in call_kwargs
)
assert "Connection refused by search backend" not in call_kwargs
def test_non_config_search_error_is_genericized(self):
"""An unrecognized search error reaches the `else` branch: raw text
must NOT reach the client (CWE-209)."""
mock_qp = self._run_with_search_error(
"some random internal crash at /srv/internal"
)
mock_qp.queue_error_update.assert_called_once()
error_str = str(mock_qp.queue_error_update.call_args)
assert "Search Engine Configuration Error" not in error_str
assert "/srv/internal" not in error_str
assert "unexpected error" in error_str.lower()
# ---------------------------------------------------------------------------
# 6. Search error classification (lines 703-727)
# ---------------------------------------------------------------------------
class TestSearchErrorClassification:
"""system.analyze_topic() failures are classified by HTTP status code
or connection error pattern and re-raised with improved messages."""
def _run_with_analyze_error(self, error_msg):
func = _get_raw_run_research_process()
mock_qp = MagicMock()
mock_system = MagicMock()
mock_system.analyze_topic.side_effect = Exception(error_msg)
patches = _base_run_patches()
patches[f"{MODULE}.AdvancedSearchSystem"] = MagicMock(
return_value=mock_system
)
patches[f"{QUEUE_PROC_MOD}.queue_processor"] = mock_qp
stack = []
for target, mock_obj in patches.items():
p = patch(target, mock_obj)
stack.append(p)
p.start()
try:
func(
1,
"test",
"quick",
username="user1",
settings_snapshot={"search.tool": "searxng"},
)
finally:
for p in reversed(stack):
p.stop()
return mock_qp
def test_503_classified_as_ollama_unavailable(self):
"""HTTP 503 is classified as ollama_unavailable by the inner handler
(lines 709-711), then the outer handler (lines 1469-1473) rewrites it
to a user-friendly message with a solution hint."""
mock_qp = self._run_with_analyze_error(
"Request failed with status code: 503"
)
mock_qp.queue_error_update.assert_called_once()
call_kwargs = mock_qp.queue_error_update.call_args[1]
# Outer handler replaces the error type marker with user-friendly text
assert (
"Ollama AI service is unavailable" in call_kwargs["error_message"]
)
assert "solution" in call_kwargs["metadata"]
assert "ollama serve" in call_kwargs["metadata"]["solution"]
def test_404_classified_as_model_not_found(self):
"""HTTP 404 is classified as model_not_found by the inner handler
(lines 712-714), then rewritten by outer handler (lines 1474-1478)."""
mock_qp = self._run_with_analyze_error(
"Request failed with status code: 404"
)
mock_qp.queue_error_update.assert_called_once()
call_kwargs = mock_qp.queue_error_update.call_args[1]
assert "model not found" in call_kwargs["error_message"].lower()
assert "solution" in call_kwargs["metadata"]
assert "ollama pull" in call_kwargs["metadata"]["solution"]
def test_other_status_code_classified_as_api_error(self):
"""Other HTTP status codes are classified as api_error; the client
message is genericized and must not carry the raw status-line tail
(CWE-209), while the solution hint is preserved."""
mock_qp = self._run_with_analyze_error(
"Request failed with status code: 429 LEAKED-INTERNAL-DETAIL"
)
mock_qp.queue_error_update.assert_called_once()
call_kwargs = mock_qp.queue_error_update.call_args[1]
assert (
call_kwargs["error_message"]
== "The language model API rejected the request."
)
assert "LEAKED-INTERNAL-DETAIL" not in str(call_kwargs)
assert "solution" in call_kwargs["metadata"]
assert "API" in call_kwargs["metadata"]["solution"]
def test_connection_error_classified_correctly(self):
"""Connection errors (case-insensitive) are classified as connection_error
by lines 720-722, then rewritten by outer handler (lines 1479-1483)."""
mock_qp = self._run_with_analyze_error(
"Connection refused to localhost:11434"
)
mock_qp.queue_error_update.assert_called_once()
call_kwargs = mock_qp.queue_error_update.call_args[1]
assert "Connection error" in call_kwargs["error_message"]
assert "solution" in call_kwargs["metadata"]
def test_unknown_error_is_genericized(self):
"""Errors without known patterns keep error_type=unknown and match no
rewrite tier, so they reach the central handler's `else` branch: the raw
text must be genericized (CWE-209) and no solution hint is added."""
mock_qp = self._run_with_analyze_error(
"something completely unexpected at /srv/secret happened"
)
mock_qp.queue_error_update.assert_called_once()
call_kwargs = mock_qp.queue_error_update.call_args[1]
# The raw text is not surfaced; a generic message is used instead.
assert "/srv/secret" not in str(call_kwargs)
assert "unexpected error" in call_kwargs["error_message"].lower()
# No solution context is added for unrecognized errors
assert "solution" not in call_kwargs["metadata"]
# ---------------------------------------------------------------------------
# 7. cleanup_research_resources test-mode delay (lines 1693-1699)
# ---------------------------------------------------------------------------
class TestCleanupResearchResourcesTestMode:
"""When is_test_mode() returns True, cleanup adds a 5-second delay."""
@patch(f"{MODULE}.SocketIOService")
@patch(f"{GLOBALS_MOD}.cleanup_research")
@patch(f"{QUEUE_PROC_MOD}.queue_processor")
@patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=True)
def test_test_mode_sleeps_before_cleanup(
self, mock_test_mode, mock_qp, mock_cleanup, mock_socket
):
"""In test mode, time.sleep(5) is called before continuing cleanup."""
from local_deep_research.web.services.research_service import (
cleanup_research_resources,
)
with patch(f"{MODULE}.time.sleep") as mock_sleep:
cleanup_research_resources(42, username="user1")
mock_sleep.assert_called_once_with(5)
mock_test_mode.assert_called_once()
@patch(f"{MODULE}.SocketIOService")
@patch(f"{GLOBALS_MOD}.cleanup_research")
@patch(f"{QUEUE_PROC_MOD}.queue_processor")
@patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False)
def test_non_test_mode_does_not_sleep(
self, mock_test_mode, mock_qp, mock_cleanup, mock_socket
):
"""Outside test mode, no delay is added."""
from local_deep_research.web.services.research_service import (
cleanup_research_resources,
)
with patch(f"{MODULE}.time.sleep") as mock_sleep:
cleanup_research_resources(42, username="user1")
mock_sleep.assert_not_called()
# ---------------------------------------------------------------------------
# 8. handle_termination exception path (lines 1797-1800)
# ---------------------------------------------------------------------------
class TestHandleTerminationExceptionPath:
"""When queue_processor.queue_error_update raises, handle_termination
catches the exception and still proceeds to cleanup."""
@patch(f"{MODULE}.cleanup_research_resources")
def test_queue_error_exception_is_caught_and_cleanup_still_runs(
self, mock_cleanup
):
"""If queue_error_update raises, the exception is caught and
cleanup_research_resources is still called."""
from local_deep_research.web.services.research_service import (
handle_termination,
)
mock_qp = MagicMock()
mock_qp.queue_error_update.side_effect = RuntimeError(
"queue processor down"
)
with patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp):
# Should not raise
handle_termination(99, username="user1")
# cleanup_research_resources must still be called despite the error
mock_cleanup.assert_called_once_with(
99, "user1", final_status="suspended"
)
@patch(f"{MODULE}.cleanup_research_resources")
def test_successful_termination_queues_suspended_status(self, mock_cleanup):
"""On success, handle_termination queues a SUSPENDED status update."""
from local_deep_research.constants import ResearchStatus
from local_deep_research.web.services.research_service import (
handle_termination,
)
mock_qp = MagicMock()
with patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp):
handle_termination(55, username="user1")
mock_qp.queue_error_update.assert_called_once()
call_kwargs = mock_qp.queue_error_update.call_args[1]
assert call_kwargs["status"] == ResearchStatus.SUSPENDED
assert call_kwargs["username"] == "user1"
assert call_kwargs["research_id"] == 55
mock_cleanup.assert_called_once_with(
55, "user1", final_status="suspended"
)
# ---------------------------------------------------------------------------
# 9. cancel_research database exception path (lines 1860-1864)
# ---------------------------------------------------------------------------
class TestCancelResearchDbException:
"""When the database query in the inactive-research branch of
cancel_research raises, the function returns False."""
def test_db_exception_returns_false(self):
"""Database errors in the non-active path return False."""
from local_deep_research.web.services.research_service import (
cancel_research,
)
with patch(f"{GLOBALS_MOD}.set_termination_flag"):
with patch(f"{GLOBALS_MOD}.is_research_active", return_value=False):
with patch(
f"{MODULE}.get_user_db_session",
side_effect=RuntimeError("db connection lost"),
):
result = cancel_research(123, username="user1")
assert result is False
def test_db_not_found_returns_false(self):
"""When research is not found in database, returns False."""
from local_deep_research.web.services.research_service import (
cancel_research,
)
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = None
with patch(f"{GLOBALS_MOD}.set_termination_flag"):
with patch(f"{GLOBALS_MOD}.is_research_active", return_value=False):
with patch(
f"{MODULE}.get_user_db_session",
_fake_session_ctx(mock_session),
):
result = cancel_research(123, username="user1")
assert result is False
# ---------------------------------------------------------------------------
# 10. cancel_research outer exception path (lines 1867-1871)
# ---------------------------------------------------------------------------
class TestCancelResearchOuterException:
"""When an unexpected exception occurs in cancel_research's outer try
block (e.g., set_termination_flag itself raises), the function catches
the exception and returns False."""
def test_set_termination_flag_raises_returns_false(self):
"""If set_termination_flag raises, cancel_research returns False."""
from local_deep_research.web.services.research_service import (
cancel_research,
)
with patch(
f"{GLOBALS_MOD}.set_termination_flag",
side_effect=RuntimeError("unexpected failure"),
):
result = cancel_research(456, username="user1")
assert result is False
def test_is_research_active_raises_returns_false(self):
"""If is_research_active raises, cancel_research returns False."""
from local_deep_research.web.services.research_service import (
cancel_research,
)
with patch(f"{GLOBALS_MOD}.set_termination_flag"):
with patch(
f"{GLOBALS_MOD}.is_research_active",
side_effect=RuntimeError("state corrupted"),
):
result = cancel_research(789, username="user1")
assert result is False
def test_active_research_calls_handle_termination_and_returns_true(self):
"""When research IS active, cancel_research calls handle_termination
and returns True."""
from local_deep_research.web.services.research_service import (
cancel_research,
)
mock_handle = MagicMock()
with patch(f"{GLOBALS_MOD}.set_termination_flag"):
with patch(f"{GLOBALS_MOD}.is_research_active", return_value=True):
with patch(f"{MODULE}.handle_termination", mock_handle):
result = cancel_research(10, username="user1")
assert result is True
mock_handle.assert_called_once_with(10, "user1")
# ---------------------------------------------------------------------------
# CWE-209: the persisted error REPORT must not embed raw exception text
# ---------------------------------------------------------------------------
class TestUnexpectedFailureDoesNotLeakRawException:
"""The enhanced error report is persisted and retrievable via the report
routes, so its error_message must be the sanitized user_friendly_error, not
raw str(e) (CWE-209). This guards the report sink specifically (the
queue/status surface is guarded by the TestLLMConfigError /
TestSearchEngineConfigError ``_is_genericized`` cases)."""
def test_unexpected_exception_text_is_not_in_error_report(self):
# Keyword-free so it is NOT classified as a config/known error and
# reaches the central handler's generic `else` branch.
secret = "ZZSECRETZZ /opt/internal raw traceback frame 0xdeadbeef"
captured = {}
def _capture_report(*args, **kwargs):
captured["error_message"] = kwargs.get("error_message", "")
return "error report"
mock_generator = MagicMock()
mock_generator.generate_error_report.side_effect = _capture_report
patches = _base_run_patches()
patches[f"{MODULE}.ErrorReportGenerator"] = MagicMock(
return_value=mock_generator
)
# search.tool in the snapshot gets the run past the egress precheck so
# the secret-bearing exception actually reaches the failure handler.
patches[f"{MODULE}.get_search"] = MagicMock(
side_effect=Exception(secret)
)
func = _get_raw_run_research_process()
stack = []
for target, mock_obj in patches.items():
p = patch(target, mock_obj)
stack.append(p)
p.start()
try:
func(
1,
"test query",
"quick",
username="testuser",
search_engine="searxng",
settings_snapshot={"search.tool": "searxng"},
)
finally:
for p in reversed(stack):
p.stop()
assert "error_message" in captured, (
"error report was not generated; the except handler was not reached"
)
assert secret not in captured["error_message"]
assert "unexpected error" in captured["error_message"].lower()