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
2374 lines
88 KiB
Python
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()
|