Files
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

2374 lines
88 KiB
Python

"""
Comprehensive coverage tests for research_service.py targeting uncovered
code paths in run_research_process, start_research_process,
cleanup_research_resources, handle_termination, cancel_research,
and helper functions.
"""
import threading
from contextlib import contextmanager
from datetime import datetime, UTC
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from loguru import logger
# Register MILESTONE log level used by progress_callback (normally done in log_utils)
try:
logger.level("MILESTONE")
except ValueError:
logger.level("MILESTONE", no=26)
# Module path shorthand
RS = "local_deep_research.web.services.research_service"
# ---------------------------------------------------------------------------
# Helper to build a fake db session context manager
# ---------------------------------------------------------------------------
def _fake_session_ctx(session):
@contextmanager
def ctx(username=None):
yield session
return ctx
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,
)
# Unwrap @log_for_research and @thread_cleanup decorators
return run_research_process.__wrapped__.__wrapped__
# ---------------------------------------------------------------------------
# _parse_research_metadata
# ---------------------------------------------------------------------------
class TestParseResearchMetadata:
def test_dict_input_returns_copy(self):
from local_deep_research.web.services.research_service import (
_parse_research_metadata,
)
original = {"key": "value"}
result = _parse_research_metadata(original)
assert result == original and result is not original
def test_valid_json_string(self):
from local_deep_research.web.services.research_service import (
_parse_research_metadata,
)
assert _parse_research_metadata('{"a":1}') == {"a": 1}
def test_invalid_json_returns_empty(self):
from local_deep_research.web.services.research_service import (
_parse_research_metadata,
)
assert _parse_research_metadata("bad{") == {}
def test_none_returns_empty(self):
from local_deep_research.web.services.research_service import (
_parse_research_metadata,
)
assert _parse_research_metadata(None) == {}
def test_int_returns_empty(self):
from local_deep_research.web.services.research_service import (
_parse_research_metadata,
)
assert _parse_research_metadata(42) == {}
# ---------------------------------------------------------------------------
# _generate_report_path
# ---------------------------------------------------------------------------
class TestGenerateReportPath:
def test_returns_path_with_md_suffix(self):
from local_deep_research.web.services.research_service import (
_generate_report_path,
)
p = _generate_report_path("test")
assert isinstance(p, Path)
assert p.suffix == ".md"
def test_different_queries_different_hashes(self):
from local_deep_research.web.services.research_service import (
_generate_report_path,
)
p1 = _generate_report_path("query one")
p2 = _generate_report_path("query two")
assert p1.stem.split("_")[2] != p2.stem.split("_")[2]
# ---------------------------------------------------------------------------
# get_citation_formatter
# ---------------------------------------------------------------------------
class TestGetCitationFormatter:
@patch(f"{RS}.CitationFormatter")
@patch(f"{RS}.CitationMode")
def test_no_hyperlinks_mode(self, mock_mode, mock_fmt):
with patch(
"local_deep_research.config.search_config.get_setting_from_snapshot",
return_value="no_hyperlinks",
):
from local_deep_research.web.services.research_service import (
get_citation_formatter,
)
get_citation_formatter()
mock_fmt.assert_called_once_with(mode=mock_mode.NO_HYPERLINKS)
@patch(f"{RS}.CitationFormatter")
@patch(f"{RS}.CitationMode")
def test_unknown_format_defaults(self, mock_mode, mock_fmt):
with patch(
"local_deep_research.config.search_config.get_setting_from_snapshot",
return_value="unknown_format",
):
from local_deep_research.web.services.research_service import (
get_citation_formatter,
)
get_citation_formatter()
mock_fmt.assert_called_once_with(mode=mock_mode.NUMBER_HYPERLINKS)
# ---------------------------------------------------------------------------
# export_report_to_memory
# ---------------------------------------------------------------------------
class TestExportReportToMemory:
def test_success(self):
mock_exporter = MagicMock()
mock_result = SimpleNamespace(
content=b"data", filename="r.pdf", mimetype="application/pdf"
)
mock_exporter.export.return_value = mock_result
mock_registry = MagicMock()
mock_registry.get_exporter.return_value = mock_exporter
with (
patch(
"local_deep_research.exporters.ExporterRegistry", mock_registry
),
patch("local_deep_research.exporters.ExportOptions", MagicMock()),
):
from local_deep_research.web.services.research_service import (
export_report_to_memory,
)
content, fname, mime = export_report_to_memory(
"# R", "PDF", title="T"
)
assert content == b"data"
mock_registry.get_exporter.assert_called_once_with("pdf")
def test_unsupported_format(self):
mock_registry = MagicMock()
mock_registry.get_exporter.return_value = None
mock_registry.get_available_formats.return_value = ["pdf"]
with (
patch(
"local_deep_research.exporters.ExporterRegistry", mock_registry
),
patch("local_deep_research.exporters.ExportOptions", MagicMock()),
):
from local_deep_research.web.services.research_service import (
export_report_to_memory,
)
with pytest.raises(ValueError, match="Unsupported"):
export_report_to_memory("# R", "xyz")
# ---------------------------------------------------------------------------
# save_research_strategy / get_research_strategy
# ---------------------------------------------------------------------------
class TestSaveResearchStrategy:
@patch(f"{RS}.get_user_db_session")
@patch(f"{RS}.ResearchStrategy")
def test_create_new(self, mock_cls, mock_sess):
session = MagicMock()
session.query.return_value.filter_by.return_value.first.return_value = (
None
)
mock_sess.side_effect = _fake_session_ctx(session)
from local_deep_research.web.services.research_service import (
save_research_strategy,
)
save_research_strategy(1, "detailed", username="u")
session.add.assert_called_once()
session.commit.assert_called_once()
@patch(f"{RS}.get_user_db_session")
@patch(f"{RS}.ResearchStrategy")
def test_update_existing(self, mock_cls, mock_sess):
session = MagicMock()
existing = MagicMock()
session.query.return_value.filter_by.return_value.first.return_value = (
existing
)
mock_sess.side_effect = _fake_session_ctx(session)
from local_deep_research.web.services.research_service import (
save_research_strategy,
)
save_research_strategy(1, "new_strat", username="u")
assert existing.strategy_name == "new_strat"
session.add.assert_not_called()
@patch(f"{RS}.get_user_db_session")
def test_exception_handled(self, mock_sess):
mock_sess.side_effect = RuntimeError("fail")
from local_deep_research.web.services.research_service import (
save_research_strategy,
)
save_research_strategy(1, "x", username="u") # should not raise
class TestGetResearchStrategy:
@patch(f"{RS}.get_user_db_session")
def test_found(self, mock_sess):
session = MagicMock()
obj = MagicMock()
obj.strategy_name = "deep"
session.query.return_value.filter_by.return_value.first.return_value = (
obj
)
mock_sess.side_effect = _fake_session_ctx(session)
from local_deep_research.web.services.research_service import (
get_research_strategy,
)
assert get_research_strategy(1, username="u") == "deep"
@patch(f"{RS}.get_user_db_session")
def test_not_found(self, mock_sess):
session = MagicMock()
session.query.return_value.filter_by.return_value.first.return_value = (
None
)
mock_sess.side_effect = _fake_session_ctx(session)
from local_deep_research.web.services.research_service import (
get_research_strategy,
)
assert get_research_strategy(1, username="u") is None
@patch(f"{RS}.get_user_db_session")
def test_exception_returns_none(self, mock_sess):
mock_sess.side_effect = RuntimeError("fail")
from local_deep_research.web.services.research_service import (
get_research_strategy,
)
assert get_research_strategy(1, username="u") is None
# ---------------------------------------------------------------------------
# start_research_process
# ---------------------------------------------------------------------------
class TestStartResearchProcess:
@patch(f"{RS}.thread_context", return_value={"ctx": True})
@patch(f"{RS}.thread_with_app_context", side_effect=lambda f: f)
@patch(f"{RS}._global_research_semaphore")
def test_starts_thread_and_registers(
self, mock_sem, mock_app_ctx, mock_tctx
):
with patch(
"local_deep_research.web.routes.globals.check_and_start_research",
return_value=True,
) as mock_check_start:
from local_deep_research.web.services.research_service import (
start_research_process,
)
callback = MagicMock()
thread = start_research_process(
research_id="r1",
query="test",
mode="quick",
run_research_callback=callback,
)
assert isinstance(thread, threading.Thread)
assert thread.daemon is True
mock_check_start.assert_called_once()
args = mock_check_start.call_args
assert args[0][0] == "r1"
info = args[0][1]
assert info["progress"] == 0
assert info["status"] == "in_progress"
# ---------------------------------------------------------------------------
# cleanup_research_resources
# ---------------------------------------------------------------------------
class TestCleanupResearchResources:
@patch(f"{RS}.SocketIOService")
@patch(f"{RS}._last_emit_lock", threading.Lock())
@patch(f"{RS}._last_emit_times", {"r1": 1.0})
@patch("local_deep_research.web.routes.globals.cleanup_research")
@patch(
"local_deep_research.settings.env_registry.is_test_mode",
return_value=False,
)
def test_cleanup_notifies_and_emits(
self, mock_test_mode, mock_cleanup, mock_socket_cls
):
mock_qp = MagicMock()
with patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
):
from local_deep_research.web.services.research_service import (
cleanup_research_resources,
)
cleanup_research_resources("r1", username="alice")
mock_qp.notify_research_completed.assert_called_once_with(
"alice", "r1", user_password=None
)
mock_cleanup.assert_called_once_with("r1")
@patch(f"{RS}.SocketIOService")
@patch(f"{RS}._last_emit_lock", threading.Lock())
@patch(f"{RS}._last_emit_times", {})
@patch("local_deep_research.web.routes.globals.cleanup_research")
@patch(
"local_deep_research.settings.env_registry.is_test_mode",
return_value=False,
)
def test_cleanup_without_username(
self, mock_test_mode, mock_cleanup, mock_socket_cls
):
mock_qp = MagicMock()
with patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
):
from local_deep_research.web.services.research_service import (
cleanup_research_resources,
)
cleanup_research_resources("r2", username=None)
mock_qp.notify_research_completed.assert_not_called()
@patch(f"{RS}.SocketIOService")
@patch(f"{RS}._last_emit_lock", threading.Lock())
@patch(f"{RS}._last_emit_times", {})
@patch("local_deep_research.web.routes.globals.cleanup_research")
@patch(
"local_deep_research.settings.env_registry.is_test_mode",
return_value=False,
)
def test_cleanup_socket_error_handled(
self, mock_test_mode, mock_cleanup, mock_socket_cls
):
"""Socket errors in cleanup should not raise."""
mock_socket_cls.return_value.emit_to_subscribers.side_effect = (
RuntimeError("socket fail")
)
mock_qp = MagicMock()
with patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
):
from local_deep_research.web.services.research_service import (
cleanup_research_resources,
)
# Should not raise
cleanup_research_resources("r3", username="u")
# ---------------------------------------------------------------------------
# handle_termination
# ---------------------------------------------------------------------------
class TestHandleTermination:
@patch(f"{RS}.cleanup_research_resources")
def test_queues_suspension_update(self, mock_cleanup):
mock_qp = MagicMock()
with patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
):
from local_deep_research.web.services.research_service import (
handle_termination,
)
handle_termination("r1", username="alice")
mock_qp.queue_error_update.assert_called_once()
kwargs = mock_qp.queue_error_update.call_args[1]
assert kwargs["username"] == "alice"
assert kwargs["research_id"] == "r1"
assert kwargs["status"] == "suspended"
mock_cleanup.assert_called_once_with(
"r1", "alice", final_status="suspended"
)
@patch(f"{RS}.cleanup_research_resources")
def test_exception_in_queue_handled(self, mock_cleanup):
mock_qp = MagicMock()
mock_qp.queue_error_update.side_effect = RuntimeError("fail")
with patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
):
from local_deep_research.web.services.research_service import (
handle_termination,
)
# Should not raise
handle_termination("r1", username="u")
mock_cleanup.assert_called_once()
# ---------------------------------------------------------------------------
# cancel_research
# ---------------------------------------------------------------------------
class TestCancelResearch:
@patch(f"{RS}.handle_termination")
def test_active_research_cancelled(self, mock_handle):
with (
patch(
"local_deep_research.web.routes.globals.set_termination_flag"
) as mock_flag,
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
):
from local_deep_research.web.services.research_service import (
cancel_research,
)
result = cancel_research("r1", "alice")
assert result is True
mock_flag.assert_called_once_with("r1")
mock_handle.assert_called_once_with("r1", "alice")
@patch(f"{RS}.get_user_db_session")
@patch(f"{RS}.handle_termination")
def test_inactive_completed_research(self, mock_handle, mock_sess):
session = MagicMock()
research = MagicMock()
research.status = "completed"
session.query.return_value.filter_by.return_value.first.return_value = (
research
)
mock_sess.side_effect = _fake_session_ctx(session)
with (
patch(
"local_deep_research.web.routes.globals.set_termination_flag"
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=False,
),
):
from local_deep_research.web.services.research_service import (
cancel_research,
)
result = cancel_research("r1", "alice")
assert result is True
# Already in terminal state, should not call handle_termination
mock_handle.assert_not_called()
@patch(f"{RS}.get_user_db_session")
@patch(f"{RS}.handle_termination")
def test_inactive_not_found(self, mock_handle, mock_sess):
session = MagicMock()
session.query.return_value.filter_by.return_value.first.return_value = (
None
)
mock_sess.side_effect = _fake_session_ctx(session)
with (
patch(
"local_deep_research.web.routes.globals.set_termination_flag"
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=False,
),
):
from local_deep_research.web.services.research_service import (
cancel_research,
)
result = cancel_research("r1", "alice")
assert result is False
@patch(f"{RS}.get_user_db_session")
@patch(f"{RS}.handle_termination")
def test_inactive_in_progress_suspended(self, mock_handle, mock_sess):
"""Research exists in DB in_progress state, not in active dict -> suspend it."""
session = MagicMock()
research = MagicMock()
research.status = "in_progress"
session.query.return_value.filter_by.return_value.first.return_value = (
research
)
mock_sess.side_effect = _fake_session_ctx(session)
with (
patch(
"local_deep_research.web.routes.globals.set_termination_flag"
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=False,
),
):
from local_deep_research.web.services.research_service import (
cancel_research,
)
result = cancel_research("r1", "alice")
assert result is True
assert research.status == "suspended"
session.commit.assert_called_once()
@patch(f"{RS}.get_user_db_session")
@patch(f"{RS}.handle_termination")
def test_db_exception_returns_false(self, mock_handle, mock_sess):
mock_sess.side_effect = RuntimeError("db fail")
with (
patch(
"local_deep_research.web.routes.globals.set_termination_flag"
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=False,
),
):
from local_deep_research.web.services.research_service import (
cancel_research,
)
result = cancel_research("r1", "alice")
assert result is False
def test_outer_exception_returns_false(self):
with patch(
"local_deep_research.web.routes.globals.set_termination_flag",
side_effect=RuntimeError("boom"),
):
from local_deep_research.web.services.research_service import (
cancel_research,
)
result = cancel_research("r1", "alice")
assert result is False
# ---------------------------------------------------------------------------
# run_research_process - the big one
# ---------------------------------------------------------------------------
# Common patches for run_research_process
_RUN_PATCHES = {
"is_termination_requested": False,
"is_research_active": True,
"update_progress_and_check_active": lambda rid, prog: (prog, True),
}
def _make_research_mock(
status="in_progress", research_meta=None, report_content=""
):
r = MagicMock()
r.status = status
r.research_meta = research_meta or {}
r.report_content = report_content
r.created_at = datetime.now(UTC).isoformat()
r.completed_at = None
r.duration_seconds = None
return r
class TestRunResearchProcessNoUsername:
"""run_research_process raises ValueError when username is missing."""
def test_no_username_raises(self):
raw_fn = _get_raw_run_research_process()
with pytest.raises(ValueError, match="Username is required"):
raw_fn("r1", "query", "quick")
class TestRunResearchProcessTerminatedBeforeStart:
"""Research terminated before starting."""
@patch(f"{RS}.cleanup_research_resources")
def test_terminated_early(self, mock_cleanup):
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(0, True),
),
):
raw_fn = _get_raw_run_research_process()
raw_fn("r1", "query", "quick", username="alice")
# Terminated before start → reports SUSPENDED, not "completed".
mock_cleanup.assert_called_once_with(
"r1", "alice", user_password=None, final_status="suspended"
)
class TestRunResearchProcessQuickMode:
"""Quick mode research process end-to-end."""
def _run_quick(
self, results, search_error=None, research_meta=None, **extra_kwargs
):
"""Helper to run quick mode with mocked dependencies."""
mock_session = MagicMock()
research = _make_research_mock(research_meta=research_meta)
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_system = MagicMock()
if search_error:
mock_system.analyze_topic.side_effect = search_error
else:
mock_system.analyze_topic.return_value = results
mock_formatter = MagicMock()
mock_formatter.format_document.return_value = "formatted content"
mock_storage = MagicMock()
mock_storage.save_report.return_value = True
mock_sources_service = MagicMock()
mock_sources_service.save_research_sources.return_value = 3
mock_qp = MagicMock()
kwargs = dict(
username="alice",
settings_snapshot={"search.tool": "searxng"},
**extra_kwargs,
)
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.calculate_duration", return_value=10.0),
patch(
"local_deep_research.storage.get_report_storage",
return_value=mock_storage,
),
patch(
f"{RS}.extract_links_from_search_results",
return_value=[{"url": "http://example.com", "title": "Ex"}],
),
patch(
"local_deep_research.web.services.research_sources_service.ResearchSourcesService",
return_value=mock_sources_service,
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch(
"local_deep_research.settings.logger.log_settings",
),
patch(
"local_deep_research.config.thread_settings.set_settings_context",
),
):
raw_fn = _get_raw_run_research_process()
raw_fn("r1", "test query", "quick", **kwargs)
return research, mock_storage, mock_system
def test_quick_mode_success(self):
results = {
"findings": [
{
"phase": "search",
"content": "found stuff",
"search_results": [{"url": "http://a.com"}],
}
],
"formatted_findings": "# Summary\nGreat results",
"iterations": 3,
}
research, mock_storage, _ = self._run_quick(
results, model="test-model", search_engine="google"
)
assert research.status == "completed"
mock_storage.save_report.assert_called_once()
def test_failed_subscription_run_is_reset_to_due(self):
"""A FAILED subscription-triggered run resets next_refresh to due.
Integration guard for the headline fix #1: the failure handler in
run_research_process must call mark_subscription_due_by_id so a failed
run is retried by the scheduler instead of being hidden a full interval.
"""
with patch(
"local_deep_research.news.subscription_runner.mark_subscription_due_by_id",
return_value=True,
) as mock_reset:
self._run_quick(
results=None,
search_error=RuntimeError("provider unavailable"),
research_meta={
"subscription_id": "sub_x",
"is_news_search": True,
},
)
mock_reset.assert_called_once()
# called as mark_subscription_due_by_id(db_session, subscription_id)
assert mock_reset.call_args.args[1] == "sub_x"
def test_failed_non_subscription_run_does_not_reset(self):
"""A FAILED run that is NOT subscription-triggered never calls the
reset (the guard must require a subscription_id)."""
with patch(
"local_deep_research.news.subscription_runner.mark_subscription_due_by_id",
return_value=True,
) as mock_reset:
self._run_quick(
results=None,
search_error=RuntimeError("boom"),
research_meta={}, # no subscription_id
)
mock_reset.assert_not_called()
def test_quick_mode_no_findings_queues_error(self):
results = {
"findings": [],
"formatted_findings": None,
"iterations": 0,
}
# No findings -> error is caught by outer handler, not raised
# The function handles errors internally and queues error updates
self._run_quick(results)
def test_quick_mode_error_in_findings_token_limit(self):
"""Error in formatted_findings with token limit should trigger fallback."""
results = {
"findings": [
{
"phase": "Final synthesis",
"content": "some synthesized content",
}
],
"formatted_findings": "Error: token limit exceeded context length",
"iterations": 2,
}
research, _, _ = self._run_quick(results)
assert research.status == "completed"
def test_quick_mode_error_in_findings_timeout(self):
results = {
"findings": [],
"formatted_findings": "Error: request timed out",
"iterations": 1,
"current_knowledge": "partial knowledge here",
}
research, _, _ = self._run_quick(results)
assert research.status == "completed"
def test_quick_mode_error_rate_limit(self):
results = {
"findings": [{"phase": "s1", "content": "data"}],
"formatted_findings": "Error: rate limit reached",
"iterations": 1,
}
research, _, _ = self._run_quick(results)
assert research.status == "completed"
def test_quick_mode_error_connection(self):
results = {
"findings": [{"phase": "s1", "content": "data"}],
"formatted_findings": "Error: connection refused network error",
"iterations": 1,
}
research, _, _ = self._run_quick(results)
assert research.status == "completed"
def test_quick_mode_error_llm_error(self):
results = {
"findings": [{"phase": "s1", "content": "data"}],
"formatted_findings": "Error: llm error occurred",
"iterations": 1,
}
research, _, _ = self._run_quick(results)
assert research.status == "completed"
def test_quick_mode_error_unknown(self):
results = {
"findings": [{"phase": "s1", "content": "data"}],
"formatted_findings": "Error: something mysterious happened",
"iterations": 1,
}
research, _, _ = self._run_quick(results)
assert research.status == "completed"
def test_quick_mode_error_no_valid_fallback_all_errors(self):
"""All findings have error content -> emergency fallback."""
results = {
"findings": [
{"phase": "s1", "content": "Error: fail1"},
{"phase": "s2", "content": "Error: fail2"},
],
"formatted_findings": "Error: synthesis failed",
"iterations": 1,
}
research, _, _ = self._run_quick(results)
assert research.status == "completed"
def test_quick_mode_error_valid_findings_combined(self):
"""Some findings are valid, some errors -> combined fallback."""
results = {
"findings": [
{"phase": "s1", "content": "good data here"},
{"phase": "s2", "content": "Error: bad data"},
],
"formatted_findings": "Error: final answer synthesis fail",
"iterations": 1,
}
research, _, _ = self._run_quick(results)
assert research.status == "completed"
def test_quick_mode_news_search_generates_headlines(self):
"""News search metadata should trigger headline/topic generation."""
mock_session = MagicMock()
research = _make_research_mock(
research_meta={"is_news_search": True, "category": "Tech"}
)
research.report_content = "some report"
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_system = MagicMock()
results = {
"findings": [{"phase": "search", "content": "news data"}],
"formatted_findings": "# News Summary",
"iterations": 2,
}
mock_system.analyze_topic.return_value = results
mock_formatter = MagicMock()
mock_formatter.format_document.return_value = "formatted"
mock_storage = MagicMock()
mock_storage.save_report.return_value = True
mock_qp = MagicMock()
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.calculate_duration", return_value=10.0),
patch(
"local_deep_research.storage.get_report_storage",
return_value=mock_storage,
),
patch(
f"{RS}.extract_links_from_search_results",
return_value=[],
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
patch(
"local_deep_research.news.utils.headline_generator.generate_headline",
return_value="Breaking: Test",
),
patch(
"local_deep_research.news.utils.topic_generator.generate_topics",
return_value=["topic1", "topic2"],
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"test",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
assert research.status == "completed"
class TestRunResearchProcessDetailedMode:
"""Detailed/full report mode."""
def test_detailed_mode_success(self):
"""Detailed completion saves the report through the storage
abstraction (get_report_storage), exactly like the quick path.
Guards the H2 fix: the detailed path previously did a raw ORM write
of report_content that bypassed get_report_storage and therefore the
report.enable_file_backup feature. If that regresses, save_report
stops being called and this fails.
"""
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_system = MagicMock()
mock_search_system = MagicMock()
mock_search_system.all_links_of_system = [
{"url": "http://a.com", "title": "A"}
]
results = {
"findings": [{"content": "data"}],
"formatted_findings": "# Report",
"iterations": 5,
"search_system": mock_search_system,
}
mock_system.analyze_topic.return_value = results
mock_formatter = MagicMock()
# Exercise the normal citation path: format_document_split returns the
# answer body plus non-empty sources, so apply_inline_hyperlinks is
# skipped and the over-strip safety check is not triggered for this
# short content. (Without this stub the test passes only via the
# formatter's exception fallback, which is fragile.)
mock_formatter.format_document_split.return_value = (
"# Full Report",
[{"url": "u"}],
)
mock_report_gen = MagicMock()
mock_report_gen.generate_report.return_value = {
"content": "# Full Report",
"metadata": {"sections": 3},
}
mock_storage = MagicMock()
mock_storage.save_report.return_value = True
mock_sources_service = MagicMock()
mock_sources_service.save_research_sources.return_value = 1
mock_qp = MagicMock()
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.calculate_duration", return_value=20.0),
patch(
f"{RS}.IntegratedReportGenerator", return_value=mock_report_gen
),
patch(
"local_deep_research.storage.get_report_storage",
return_value=mock_storage,
),
patch(
f"{RS}.extract_links_from_search_results",
return_value=[],
),
patch(
"local_deep_research.web.services.research_sources_service.ResearchSourcesService",
return_value=mock_sources_service,
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"detailed",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
assert research.status == "completed"
# Detailed routes report-save through the storage abstraction (the
# H2 fix), passing the DETAILED report content.
mock_storage.save_report.assert_called_once()
assert (
mock_storage.save_report.call_args.kwargs["content"]
== "# Full Report"
)
def test_detailed_mode_writes_file_backup_when_enabled(self):
"""Detailed completion writes the on-disk file backup when the
report.enable_file_backup setting is on.
The user-visible half of the H2 fix: because the detailed path now
routes through get_report_storage -> DatabaseWithFileBackupStorage, a
user who enabled file backup gets files for detailed research, not
only quick research. On the old raw ORM write get_report_storage was
never called for detailed, so FileReportStorage was never invoked and
this would fail.
"""
from local_deep_research.storage.database_with_file_backup import (
DatabaseWithFileBackupStorage,
)
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_system = MagicMock()
mock_search_system = MagicMock()
mock_search_system.all_links_of_system = [
{"url": "http://a.com", "title": "A"}
]
results = {
"findings": [{"content": "data"}],
"formatted_findings": "# Report",
"iterations": 5,
"search_system": mock_search_system,
}
mock_system.analyze_topic.return_value = results
mock_report_gen = MagicMock()
mock_report_gen.generate_report.return_value = {
"content": "# Full Report",
"metadata": {"sections": 3},
}
# Exercise the normal citation path (see test_detailed_mode_success)
# rather than the formatter's exception fallback.
mock_formatter = MagicMock()
mock_formatter.format_document_split.return_value = (
"# Full Report",
[{"url": "u"}],
)
mock_file_storage = MagicMock()
mock_file_storage.save_report.return_value = True
mock_sources_service = MagicMock()
mock_sources_service.save_research_sources.return_value = 1
mock_qp = MagicMock()
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.calculate_duration", return_value=20.0),
patch(
f"{RS}.IntegratedReportGenerator", return_value=mock_report_gen
),
# The on-disk writer is mocked so the test touches no filesystem.
patch(
"local_deep_research.storage.database_with_file_backup.FileReportStorage",
return_value=mock_file_storage,
),
# Construct the real backup-aware storage lazily (at call time, so
# the FileReportStorage patch above is active) with backup ENABLED.
patch(
"local_deep_research.storage.get_report_storage",
side_effect=lambda *a, **kw: DatabaseWithFileBackupStorage(
session=mock_session, enable_file_storage=True
),
),
patch(
f"{RS}.extract_links_from_search_results",
return_value=[],
),
patch(
"local_deep_research.web.services.research_sources_service.ResearchSourcesService",
return_value=mock_sources_service,
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"detailed",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
# The file backup was written exactly once, with the detailed report
# content (positional: research_id, content, metadata, username).
mock_file_storage.save_report.assert_called_once()
assert (
mock_file_storage.save_report.call_args.args[1] == "# Full Report"
)
def test_detailed_mode_report_not_found_raises(self):
mock_session = MagicMock()
# First call returns research, second returns None (report not found)
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.side_effect = [
research, # first query in report save
None, # research not found
]
mock_system = MagicMock()
mock_search_system = MagicMock()
mock_search_system.all_links_of_system = []
results = {
"findings": [{"content": "data"}],
"formatted_findings": "# Report",
"iterations": 2,
"search_system": mock_search_system,
}
mock_system.analyze_topic.return_value = results
mock_formatter = MagicMock()
mock_formatter.format_document.return_value = "formatted"
mock_report_gen = MagicMock()
mock_report_gen.generate_report.return_value = {
"content": "# Full Report",
"metadata": {"sections": 1},
}
mock_qp = MagicMock()
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.calculate_duration", return_value=5.0),
patch(
f"{RS}.IntegratedReportGenerator", return_value=mock_report_gen
),
patch(
f"{RS}.extract_links_from_search_results",
return_value=[],
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
# This should handle the error gracefully (error handler catches it)
raw_fn(
"r1",
"query",
"detailed",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
# Error handler should queue error update
mock_qp.queue_error_update.assert_called()
class TestRunResearchProcessSearchErrors:
"""Test search error handling in run_research_process."""
def _run_with_search_error(self, error_msg):
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_system = MagicMock()
mock_system.analyze_topic.side_effect = Exception(error_msg)
mock_qp = MagicMock()
mock_error_gen = MagicMock()
mock_error_gen.generate_error_report.return_value = "# Error Report"
mock_storage = MagicMock()
mock_storage.save_report.return_value = True
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.ErrorReportGenerator", return_value=mock_error_gen),
patch(
"local_deep_research.storage.get_report_storage",
return_value=mock_storage,
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
return mock_qp
def test_503_error(self):
qp = self._run_with_search_error("Something status code: 503 happened")
qp.queue_error_update.assert_called_once()
def test_404_error(self):
qp = self._run_with_search_error("status code: 404 not found")
qp.queue_error_update.assert_called_once()
def test_other_status_code(self):
qp = self._run_with_search_error("status code: 429 rate limited")
qp.queue_error_update.assert_called_once()
def test_connection_error(self):
qp = self._run_with_search_error("Connection refused to host")
qp.queue_error_update.assert_called_once()
def test_generic_error(self):
qp = self._run_with_search_error("something completely unknown")
qp.queue_error_update.assert_called_once()
class TestRunResearchProcessLLMConfigErrors:
"""Test LLM/search configuration error handling."""
def test_llm_config_error_llamacpp(self):
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_qp = MagicMock()
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(
f"{RS}.get_llm",
side_effect=Exception("llamacpp model path is invalid"),
),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.ErrorReportGenerator"),
patch(
"local_deep_research.storage.get_report_storage",
return_value=MagicMock(),
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
model_provider="llamacpp",
)
# Should queue error
mock_qp.queue_error_update.assert_called_once()
def test_search_engine_config_error(self):
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_qp = MagicMock()
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(
f"{RS}.get_search",
side_effect=Exception("searxng instance_url not configured"),
),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.ErrorReportGenerator"),
patch(
"local_deep_research.storage.get_report_storage",
return_value=MagicMock(),
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="searxng",
)
mock_qp.queue_error_update.assert_called_once()
class TestRunResearchProcessErrorHandler:
"""Test the error handler within run_research_process."""
def test_error_with_ollama_unavailable(self):
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_qp = MagicMock()
mock_system = MagicMock()
mock_system.analyze_topic.side_effect = Exception(
"Ollama error (Error type: ollama_unavailable)"
)
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.ErrorReportGenerator"),
patch(
"local_deep_research.storage.get_report_storage",
return_value=MagicMock(),
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
call_kwargs = mock_qp.queue_error_update.call_args[1]
assert "Ollama" in call_kwargs["error_message"]
def test_error_with_model_not_found(self):
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_qp = MagicMock()
mock_system = MagicMock()
mock_system.analyze_topic.side_effect = Exception(
"Error type: model_not_found"
)
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.ErrorReportGenerator"),
patch(
"local_deep_research.storage.get_report_storage",
return_value=MagicMock(),
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
call_kwargs = mock_qp.queue_error_update.call_args[1]
assert (
"model not found" in call_kwargs["error_message"].lower()
or "model" in call_kwargs["error_message"].lower()
)
def test_error_with_connection_error_type(self):
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_qp = MagicMock()
mock_system = MagicMock()
mock_system.analyze_topic.side_effect = Exception(
"Error type: connection_error"
)
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.ErrorReportGenerator"),
patch(
"local_deep_research.storage.get_report_storage",
return_value=MagicMock(),
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
call_kwargs = mock_qp.queue_error_update.call_args[1]
assert (
"connection" in call_kwargs["error_message"].lower()
or "Connection" in call_kwargs["error_message"]
)
def test_error_with_api_error_type(self):
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_qp = MagicMock()
mock_system = MagicMock()
mock_system.analyze_topic.side_effect = Exception(
"Error type: api_error"
)
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.ErrorReportGenerator"),
patch(
"local_deep_research.storage.get_report_storage",
return_value=MagicMock(),
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
mock_qp.queue_error_update.assert_called_once()
def test_error_no_username_cannot_queue(self):
"""Error handler when no username -> cannot queue error update."""
# This tests the branch where username is None in the error handler.
# We need to bypass the initial username check by having it set but then
# making something else fail.
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_qp = MagicMock()
mock_system = MagicMock()
mock_system.analyze_topic.side_effect = Exception("fail")
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.ErrorReportGenerator"),
patch(
"local_deep_research.storage.get_report_storage",
return_value=MagicMock(),
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
# Should still queue with username
mock_qp.queue_error_update.assert_called_once()
def test_error_termination_requested_marks_suspended(self):
"""If termination was requested during error, status should be SUSPENDED."""
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_qp = MagicMock()
mock_system = MagicMock()
mock_system.analyze_topic.side_effect = Exception("fail")
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.ErrorReportGenerator"),
patch(
"local_deep_research.storage.get_report_storage",
return_value=MagicMock(),
),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
# is_termination_requested returns True, so it exits early
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
class TestRunResearchProcessResearchContext:
"""Test research context (follow-up research) handling."""
def test_follow_up_research_context(self):
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_system = MagicMock()
results = {
"findings": [{"phase": "search", "content": "data"}],
"formatted_findings": "# Results",
"iterations": 1,
}
mock_system.analyze_topic.return_value = results
mock_formatter = MagicMock()
mock_formatter.format_document.return_value = "formatted"
mock_storage = MagicMock()
mock_storage.save_report.return_value = True
mock_qp = MagicMock()
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.calculate_duration", return_value=5.0),
patch(
"local_deep_research.storage.get_report_storage",
return_value=mock_storage,
),
patch(f"{RS}.extract_links_from_search_results", return_value=[]),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"follow-up query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
research_context={
"past_findings": "previous research data" * 100
},
)
assert research.status == "completed"
class TestRunResearchProcessSettingsContext:
"""Test SettingsContext inner class behavior."""
def test_settings_context_with_setting_objects(self):
"""Settings snapshot with full setting objects (dict with 'value' key)."""
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_system = MagicMock()
results = {
"findings": [{"phase": "search", "content": "data"}],
"formatted_findings": "# Results",
"iterations": 1,
}
mock_system.analyze_topic.return_value = results
mock_formatter = MagicMock()
mock_formatter.format_document.return_value = "formatted"
mock_storage = MagicMock()
mock_storage.save_report.return_value = True
mock_qp = MagicMock()
# Settings snapshot with both raw values and setting objects.
# Includes search.tool so the run proceeds past the egress build
# (which fails closed on a snapshot with no primary) to the
# SettingsContext extraction under test.
snapshot = {
"report.citation_format": {
"value": "number_hyperlinks",
"type": "str",
},
"search.max_results": 10, # raw value
"search.tool": "searxng",
}
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.calculate_duration", return_value=5.0),
patch(
"local_deep_research.storage.get_report_storage",
return_value=mock_storage,
),
patch(f"{RS}.extract_links_from_search_results", return_value=[]),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
) as mock_set_ctx,
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot=snapshot,
model="m",
search_engine="s",
)
# Verify settings context was set
mock_set_ctx.assert_called_once()
ctx = mock_set_ctx.call_args[0][0]
# The SettingsContext should extract values from setting objects
assert ctx.get_setting("report.citation_format") == "number_hyperlinks"
assert ctx.get_setting("search.max_results") == 10
assert ctx.get_setting("nonexistent", "default") == "default"
class TestRunResearchProcessSubscription:
"""Test subscription update handling in quick mode."""
def test_subscription_update_on_completion(self):
mock_session = MagicMock()
research = _make_research_mock(
research_meta={"subscription_id": "sub_123"}
)
mock_session.query.return_value.filter_by.return_value.first.return_value = research
# On completion the service loads the NewsSubscription via ORM
# (db_session.query(...).filter(...).first()) and advances its refresh
# schedule. The research-row lookups above use filter_by, so the
# subscription lookup (which uses filter) is an independent mock chain.
mock_sub = MagicMock()
mock_sub.id = "sub_123"
mock_sub.refresh_interval_minutes = 60
mock_session.query.return_value.filter.return_value.first.return_value = mock_sub
mock_system = MagicMock()
results = {
"findings": [{"phase": "s", "content": "data"}],
"formatted_findings": "# Results",
"iterations": 1,
}
mock_system.analyze_topic.return_value = results
mock_formatter = MagicMock()
mock_formatter.format_document.return_value = "formatted"
mock_storage = MagicMock()
mock_storage.save_report.return_value = True
mock_qp = MagicMock()
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.calculate_duration", return_value=5.0),
patch(
"local_deep_research.storage.get_report_storage",
return_value=mock_storage,
),
patch(f"{RS}.extract_links_from_search_results", return_value=[]),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
patch(
"local_deep_research.news.subscription_runner.advance_refresh_schedule",
) as mock_advance,
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
mock_advance.assert_called_once()
# Called with the loaded subscription as the first positional arg.
assert mock_advance.call_args.args[0] is mock_sub
class TestRunResearchProcessFinallyBlock:
"""Test the finally block resource cleanup."""
def test_finally_closes_resources(self):
"""Verify use_search, system, use_llm get closed in finally."""
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_llm = MagicMock()
mock_search = MagicMock()
mock_system = MagicMock()
results = {
"findings": [{"phase": "s", "content": "data"}],
"formatted_findings": "# Results",
"iterations": 1,
}
mock_system.analyze_topic.return_value = results
mock_formatter = MagicMock()
mock_formatter.format_document.return_value = "formatted"
mock_storage = MagicMock()
mock_storage.save_report.return_value = True
mock_qp = MagicMock()
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=mock_llm),
patch(f"{RS}.get_search", return_value=mock_search),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.calculate_duration", return_value=5.0),
patch(
"local_deep_research.storage.get_report_storage",
return_value=mock_storage,
),
patch(f"{RS}.extract_links_from_search_results", return_value=[]),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
mock_search.close.assert_called_once()
mock_system.close.assert_called_once()
mock_llm.close.assert_called_once()
def test_finally_close_exceptions_suppressed(self):
"""Exceptions in close() calls should be suppressed."""
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_llm = MagicMock()
mock_llm.close.side_effect = RuntimeError("close fail")
mock_search = MagicMock()
mock_search.close.side_effect = RuntimeError("close fail")
mock_system = MagicMock()
mock_system.close.side_effect = RuntimeError("close fail")
results = {
"findings": [{"phase": "s", "content": "data"}],
"formatted_findings": "# Results",
"iterations": 1,
}
mock_system.analyze_topic.return_value = results
mock_formatter = MagicMock()
mock_formatter.format_document.return_value = "formatted"
mock_storage = MagicMock()
mock_storage.save_report.return_value = True
mock_qp = MagicMock()
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=mock_llm),
patch(f"{RS}.get_search", return_value=mock_search),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.calculate_duration", return_value=5.0),
patch(
"local_deep_research.storage.get_report_storage",
return_value=mock_storage,
),
patch(f"{RS}.extract_links_from_search_results", return_value=[]),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
# Should not raise despite close errors
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
class TestRunResearchProcessSaveReportFailure:
"""Test report save failure in quick mode."""
def test_save_report_failure_raises(self):
mock_session = MagicMock()
research = _make_research_mock()
mock_session.query.return_value.filter_by.return_value.first.return_value = research
mock_system = MagicMock()
results = {
"findings": [{"phase": "s", "content": "data"}],
"formatted_findings": "# Results",
"iterations": 1,
}
mock_system.analyze_topic.return_value = results
mock_formatter = MagicMock()
mock_formatter.format_document.return_value = "formatted"
mock_storage = MagicMock()
mock_storage.save_report.return_value = False # Save fails
mock_qp = MagicMock()
with (
patch(
"local_deep_research.web.routes.globals.is_termination_requested",
return_value=False,
),
patch(
"local_deep_research.web.routes.globals.is_research_active",
return_value=True,
),
patch(
"local_deep_research.web.routes.globals.update_progress_and_check_active",
return_value=(50, True),
),
patch(f"{RS}.get_llm", return_value=MagicMock()),
patch(f"{RS}.get_search", return_value=MagicMock()),
patch(f"{RS}.AdvancedSearchSystem", return_value=mock_system),
patch(f"{RS}.get_citation_formatter", return_value=mock_formatter),
patch(
f"{RS}.get_user_db_session",
side_effect=_fake_session_ctx(mock_session),
),
patch(f"{RS}.cleanup_research_resources"),
patch(f"{RS}.SocketIOService"),
patch(f"{RS}.set_search_context"),
patch(f"{RS}.calculate_duration", return_value=5.0),
patch(
"local_deep_research.storage.get_report_storage",
return_value=mock_storage,
),
patch(f"{RS}.extract_links_from_search_results", return_value=[]),
patch(
"local_deep_research.web.queue.processor_v2.queue_processor",
mock_qp,
),
patch("local_deep_research.settings.logger.log_settings"),
patch(
"local_deep_research.config.thread_settings.set_settings_context"
),
):
raw_fn = _get_raw_run_research_process()
# The error handler should catch this
raw_fn(
"r1",
"query",
"quick",
username="alice",
settings_snapshot={"search.tool": "searxng"},
model="m",
search_engine="s",
)
# Should queue error
mock_qp.queue_error_update.assert_called_once()