""" 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()