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

624 lines
24 KiB
Python

"""
Deep coverage tests for research_service.py targeting ~97 missing statements.
Focuses on:
- _parse_research_metadata edge cases
- get_citation_formatter all modes
- export_report_to_memory error paths
- save_research_strategy create vs update paths
- get_research_strategy error path
- start_research_process rate-limited callback
- progress_callback branches (throttling, metadata fields, phase adjustments)
- run_research_process: LLM config errors, search engine config errors,
quick mode error types in formatted_findings, detailed mode, error handler
- cleanup_research_resources and handle_termination
"""
import threading
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from loguru import logger
# Register MILESTONE log level used by progress_callback
try:
logger.level("MILESTONE")
except ValueError:
logger.level("MILESTONE", no=26)
MODULE = "local_deep_research.web.services.research_service"
# Correct patch targets for symbols that are lazily imported inside functions
GLOBALS_MOD = "local_deep_research.web.routes.globals"
THREAD_SETTINGS_MOD = "local_deep_research.config.thread_settings"
SETTINGS_LOGGER_MOD = "local_deep_research.settings.logger"
QUEUE_PROC_MOD = "local_deep_research.web.queue.processor_v2"
ENV_REGISTRY_MOD = "local_deep_research.settings.env_registry"
RESOURCE_UTILS_MOD = "local_deep_research.utilities.resource_utils"
STORAGE_MOD = "local_deep_research.storage"
SOURCES_SERVICE_MOD = (
"local_deep_research.web.services.research_sources_service"
)
# get_setting_from_snapshot is imported into search_config from thread_settings
SEARCH_CONFIG_MOD = "local_deep_research.config.search_config"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fake_session_ctx(session=None):
"""Return a context manager factory that yields a mock session."""
if session is None:
session = MagicMock()
@contextmanager
def ctx(username=None):
yield session
return ctx
def _make_mock_research(
status=None, research_meta=None, created_at=None, report_content=None
):
"""Build a minimal ResearchHistory mock."""
r = MagicMock()
r.status = status
r.research_meta = research_meta
r.created_at = created_at or "2024-01-01T00:00:00"
r.report_content = report_content
return r
def _base_run_patches(mock_session=None):
"""
Return a dict of patches needed for run_research_process tests.
The key challenge: run_research_process uses lazy imports inside the function body.
These must be patched at their actual source module, not at MODULE.xyz.
"""
if mock_session is None:
mock_session = MagicMock()
mock_research = _make_mock_research(research_meta={})
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
return {
# Module-level imports (can be patched at MODULE)
f"{MODULE}.get_user_db_session": _fake_session_ctx(mock_session),
f"{MODULE}.handle_termination": MagicMock(),
f"{MODULE}.cleanup_research_resources": MagicMock(),
f"{MODULE}.set_search_context": MagicMock(),
f"{MODULE}.SocketIOService": MagicMock(),
f"{MODULE}.calculate_duration": MagicMock(return_value=5),
# Lazy imports inside function: patch at their actual source module
f"{GLOBALS_MOD}.is_termination_requested": MagicMock(
return_value=False
),
f"{GLOBALS_MOD}.is_research_active": MagicMock(return_value=False),
f"{GLOBALS_MOD}.update_progress_and_check_active": MagicMock(
return_value=(5, True)
),
f"{SETTINGS_LOGGER_MOD}.log_settings": MagicMock(),
f"{THREAD_SETTINGS_MOD}.set_settings_context": MagicMock(),
}
# ---------------------------------------------------------------------------
# _parse_research_metadata
# ---------------------------------------------------------------------------
class TestParseResearchMetadataDeep:
def _call(self, value):
from local_deep_research.web.services.research_service import (
_parse_research_metadata,
)
return _parse_research_metadata(value)
def test_dict_returns_copy(self):
original = {"a": 1}
result = self._call(original)
assert result == {"a": 1}
assert result is not original
def test_valid_json_string(self):
result = self._call('{"key": "value"}')
assert result == {"key": "value"}
def test_invalid_json_string_returns_empty(self):
result = self._call("{not valid json!!")
assert result == {}
def test_none_returns_empty(self):
result = self._call(None)
assert result == {}
def test_integer_returns_empty(self):
result = self._call(42)
assert result == {}
def test_empty_string_returns_empty(self):
result = self._call("")
assert result == {}
# ---------------------------------------------------------------------------
# get_citation_formatter
# ---------------------------------------------------------------------------
class TestGetCitationFormatterDeep:
"""
get_citation_formatter lazily imports get_setting_from_snapshot from
local_deep_research.config.search_config, which re-exports it from
local_deep_research.config.thread_settings.
Patch at the search_config module where it is looked up.
"""
def test_domain_hyperlinks_mode(self):
from local_deep_research.text_optimization import CitationMode
with patch(
f"{SEARCH_CONFIG_MOD}.get_setting_from_snapshot",
return_value="domain_hyperlinks",
):
from local_deep_research.web.services.research_service import (
get_citation_formatter,
)
formatter = get_citation_formatter()
assert formatter.mode == CitationMode.DOMAIN_HYPERLINKS
def test_no_hyperlinks_mode(self):
from local_deep_research.text_optimization import CitationMode
with patch(
f"{SEARCH_CONFIG_MOD}.get_setting_from_snapshot",
return_value="no_hyperlinks",
):
from local_deep_research.web.services.research_service import (
get_citation_formatter,
)
formatter = get_citation_formatter()
assert formatter.mode == CitationMode.NO_HYPERLINKS
def test_unknown_mode_defaults_to_number_hyperlinks(self):
from local_deep_research.text_optimization import CitationMode
with patch(
f"{SEARCH_CONFIG_MOD}.get_setting_from_snapshot",
return_value="nonexistent_mode",
):
from local_deep_research.web.services.research_service import (
get_citation_formatter,
)
formatter = get_citation_formatter()
assert formatter.mode == CitationMode.NUMBER_HYPERLINKS
def test_domain_id_hyperlinks_mode(self):
from local_deep_research.text_optimization import CitationMode
with patch(
f"{SEARCH_CONFIG_MOD}.get_setting_from_snapshot",
return_value="domain_id_hyperlinks",
):
from local_deep_research.web.services.research_service import (
get_citation_formatter,
)
formatter = get_citation_formatter()
assert formatter.mode == CitationMode.DOMAIN_ID_HYPERLINKS
def test_domain_id_always_hyperlinks_mode(self):
from local_deep_research.text_optimization import CitationMode
with patch(
f"{SEARCH_CONFIG_MOD}.get_setting_from_snapshot",
return_value="domain_id_always_hyperlinks",
):
from local_deep_research.web.services.research_service import (
get_citation_formatter,
)
formatter = get_citation_formatter()
assert formatter.mode == CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS
# ---------------------------------------------------------------------------
# export_report_to_memory
# ---------------------------------------------------------------------------
class TestExportReportToMemoryDeep:
"""
export_report_to_memory lazily imports ExporterRegistry and ExportOptions
from local_deep_research.exporters.
Patch them at their actual module locations.
"""
def test_unsupported_format_raises_value_error(self):
from local_deep_research.web.services.research_service import (
export_report_to_memory,
)
mock_registry = MagicMock()
mock_registry.get_exporter.return_value = None
mock_registry.get_available_formats.return_value = ["pdf", "odt"]
with patch(
"local_deep_research.exporters.registry.ExporterRegistry",
mock_registry,
):
with pytest.raises((ValueError, AttributeError)):
export_report_to_memory("# content", "xyz", title="T")
def test_successful_export_returns_tuple(self):
from local_deep_research.web.services.research_service import (
export_report_to_memory,
)
mock_result = MagicMock()
mock_result.content = b"data"
mock_result.filename = "report.pdf"
mock_result.mimetype = "application/pdf"
mock_exporter = MagicMock()
mock_exporter.export.return_value = mock_result
# Patch the ExporterRegistry where it is imported in the exporters package
with patch(
"local_deep_research.exporters.ExporterRegistry"
) as mock_registry_cls:
mock_registry_cls.get_exporter.return_value = mock_exporter
with patch("local_deep_research.exporters.ExportOptions"):
content, filename, mimetype = export_report_to_memory(
"# hello", "PDF", title="My Title"
)
assert content == b"data"
assert filename == "report.pdf"
assert mimetype == "application/pdf"
# ---------------------------------------------------------------------------
# save_research_strategy
# ---------------------------------------------------------------------------
class TestSaveResearchStrategyDeep:
def test_creates_new_strategy_when_none_exists(self):
from local_deep_research.web.services.research_service import (
save_research_strategy,
)
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = None
with patch(
f"{MODULE}.get_user_db_session", _fake_session_ctx(mock_session)
):
with patch(f"{MODULE}.ResearchStrategy") as mock_strategy_cls:
save_research_strategy(42, "source-based", username="user1")
mock_strategy_cls.assert_called_once_with(
research_id=42, strategy_name="source-based"
)
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
def test_updates_existing_strategy(self):
from local_deep_research.web.services.research_service import (
save_research_strategy,
)
existing = MagicMock()
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = existing
with patch(
f"{MODULE}.get_user_db_session", _fake_session_ctx(mock_session)
):
save_research_strategy(42, "react", username="user1")
assert existing.strategy_name == "react"
mock_session.commit.assert_called_once()
def test_exception_is_swallowed(self):
from local_deep_research.web.services.research_service import (
save_research_strategy,
)
with patch(
f"{MODULE}.get_user_db_session", side_effect=RuntimeError("db down")
):
# Should not raise
save_research_strategy(99, "strategy", username="user1")
# ---------------------------------------------------------------------------
# get_research_strategy
# ---------------------------------------------------------------------------
class TestGetResearchStrategyDeep:
def test_returns_none_on_exception(self):
from local_deep_research.web.services.research_service import (
get_research_strategy,
)
with patch(
f"{MODULE}.get_user_db_session", side_effect=Exception("db err")
):
result = get_research_strategy(1, username="user1")
assert result is None
def test_returns_none_when_no_strategy_found(self):
from local_deep_research.web.services.research_service import (
get_research_strategy,
)
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = None
with patch(
f"{MODULE}.get_user_db_session", _fake_session_ctx(mock_session)
):
result = get_research_strategy(99, username="user1")
assert result is None
def test_returns_strategy_name(self):
"""``get_research_strategy`` returns the stored strategy name
verbatim — including the removed ``mcp`` value (#4548). It is a
display-only read that never feeds the factory, so a saved 'mcp'
research keeps loading after the strategy is gone."""
from local_deep_research.web.services.research_service import (
get_research_strategy,
)
mock_strategy = MagicMock()
mock_strategy.strategy_name = "mcp"
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_strategy
with patch(
f"{MODULE}.get_user_db_session", _fake_session_ctx(mock_session)
):
result = get_research_strategy(7, username="user1")
assert result == "mcp"
# ---------------------------------------------------------------------------
# start_research_process - rate-limited callback
# ---------------------------------------------------------------------------
class TestStartResearchProcessDeep:
def test_rate_limited_callback_acquires_and_releases_semaphore(self):
"""The rate-limited wrapper must acquire and release the global semaphore."""
from local_deep_research.web.services.research_service import (
start_research_process,
)
real_sem = threading.Semaphore(5)
def fake_callback(ctx, rid, q, mode, **kw):
pass
mock_thread = MagicMock()
mock_thread_cls = MagicMock(return_value=mock_thread)
# Dedup stub: starts the thread and returns True.
def fake_check_and_start(rid, data):
data["thread"].start()
return True
with patch(
f"{MODULE}.thread_with_app_context", side_effect=lambda f: f
):
with patch(f"{MODULE}.threading.Thread", mock_thread_cls):
with patch(f"{MODULE}.thread_context", return_value={}):
with patch(
f"{MODULE}._global_research_semaphore", real_sem
):
with patch(
f"{GLOBALS_MOD}.check_and_start_research",
side_effect=fake_check_and_start,
):
start_research_process(
research_id=1,
query="test",
mode="quick",
run_research_callback=fake_callback,
)
# Thread was started (by the dedup helper stub)
mock_thread.start.assert_called_once()
def test_check_and_start_research_called_with_correct_keys(self):
from local_deep_research.web.services.research_service import (
start_research_process,
)
from local_deep_research.constants import ResearchStatus
captured = []
def capture(rid, data):
captured.append((rid, data))
return True
with patch(
f"{MODULE}.thread_with_app_context", side_effect=lambda f: f
):
with patch(f"{MODULE}.threading.Thread") as mock_thread_cls:
mock_thread_cls.return_value.start = MagicMock()
with patch(f"{MODULE}.thread_context", return_value={}):
with patch(
f"{GLOBALS_MOD}.check_and_start_research",
side_effect=capture,
):
start_research_process(
research_id=123,
query="q",
mode="quick",
run_research_callback=MagicMock(),
)
assert len(captured) == 1
rid, data = captured[0]
assert rid == 123
assert data["status"] == ResearchStatus.IN_PROGRESS
assert data["progress"] == 0
assert "thread" in data
# ---------------------------------------------------------------------------
# LLM config error paths (internal handling)
# ---------------------------------------------------------------------------
class TestLLMConfigErrorPaths:
"""
run_research_process handles LLM config errors internally via the except handler.
The ValueError raised for config errors is caught by the outer exception handler
and processed via queue_processor.queue_error_update. The function does NOT
re-raise the exception to the caller.
"""
def _run_with_llm_error(
self, error_message, model="gpt-4", model_provider="openai"
):
"""Run research with a failing get_llm and return the queue_error_update mock."""
mock_session = MagicMock()
mock_research = _make_mock_research(research_meta={})
mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research
mock_qp = MagicMock()
from local_deep_research.web.services.research_service import (
run_research_process,
)
func = run_research_process.__wrapped__.__wrapped__
with patch(
f"{MODULE}.get_user_db_session", _fake_session_ctx(mock_session)
):
with patch(f"{MODULE}.handle_termination"):
with patch(f"{MODULE}.cleanup_research_resources"):
with patch(f"{MODULE}.set_search_context"):
with patch(f"{MODULE}.SocketIOService"):
with patch(
f"{MODULE}.ErrorReportGenerator",
return_value=MagicMock(
generate_error_report=MagicMock(
return_value="error report"
)
),
):
with patch(
f"{MODULE}.get_llm",
side_effect=Exception(error_message),
):
with patch(
f"{GLOBALS_MOD}.is_termination_requested",
return_value=False,
):
with patch(
f"{GLOBALS_MOD}.is_research_active",
return_value=False,
):
with patch(
f"{GLOBALS_MOD}.update_progress_and_check_active",
return_value=(5, True),
):
with patch(
f"{SETTINGS_LOGGER_MOD}.log_settings"
):
with patch(
f"{THREAD_SETTINGS_MOD}.set_settings_context"
):
with patch(
f"{QUEUE_PROC_MOD}.queue_processor",
mock_qp,
):
# Should NOT raise - errors are handled internally
func(
1,
"query",
"quick",
username="user1",
model=model,
model_provider=model_provider,
# Realistic primary so the run reaches the LLM-error
# path under test (not the egress fail-closed raise).
settings_snapshot={
"search.tool": "searxng"
},
)
return mock_qp
def test_llamacpp_error_queues_error_update(self):
mock_qp = self._run_with_llm_error("llamacpp model path not found")
mock_qp.queue_error_update.assert_called_once()
def test_model_path_error_queues_error_update(self):
mock_qp = self._run_with_llm_error(
"model path /nonexistent does not exist"
)
mock_qp.queue_error_update.assert_called_once()
def test_gguf_error_queues_error_update(self):
mock_qp = self._run_with_llm_error("requires a .gguf file")
mock_qp.queue_error_update.assert_called_once()
def test_generic_llm_error_queues_error_update(self):
mock_qp = self._run_with_llm_error("some other random error")
mock_qp.queue_error_update.assert_called_once()
# ---------------------------------------------------------------------------
# cleanup_research_resources
# ---------------------------------------------------------------------------
class TestCleanupResearchResourcesDeep:
def test_notify_called_with_username(self):
from local_deep_research.web.services.research_service import (
cleanup_research_resources,
)
mock_qp = MagicMock()
with patch(f"{GLOBALS_MOD}.cleanup_research"):
with patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp):
with patch(
f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False
):
cleanup_research_resources(99, username="user1")
mock_qp.notify_research_completed.assert_called_once_with(
"user1", 99, user_password=None
)
def test_no_username_skips_notify(self):
from local_deep_research.web.services.research_service import (
cleanup_research_resources,
)
mock_qp = MagicMock()
with patch(f"{GLOBALS_MOD}.cleanup_research"):
with patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp):
with patch(
f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False
):
cleanup_research_resources(99, username=None)
mock_qp.notify_research_completed.assert_not_called()