""" Export and process coverage tests for research_service.py. Covers: - export_report_to_memory: unsupported format raises ValueError - save_research_strategy: update existing, create new, swallowed exception - run_research_process: ResearchTerminatedException, ValueError (LLM config) - _generate_report_path: hash generation """ import hashlib from contextlib import contextmanager from unittest.mock import Mock, MagicMock, patch import pytest from flask import Flask @pytest.fixture(scope="module") def flask_app(): """Minimal Flask app used to provide application context for tests that invoke run_research_process, which is decorated with @log_for_research and therefore accesses flask.g.""" app = Flask(__name__) app.config["TESTING"] = True return app # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_db_session(first_result=None): """Return a mock DB session where .query(...).filter_by(...).first() works.""" mock_query = Mock() mock_query.filter_by.return_value.first.return_value = first_result mock_session = MagicMock() mock_session.query.return_value = mock_query mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) return mock_session @contextmanager def _patched_db_session(first_result=None): """Context manager that patches get_user_db_session.""" mock_session = _make_db_session(first_result=first_result) with patch( "local_deep_research.web.services.research_service.get_user_db_session", return_value=mock_session, ): yield mock_session # --------------------------------------------------------------------------- # export_report_to_memory # --------------------------------------------------------------------------- class TestExportReportUnsupportedFormat: """export_report_to_memory raises ValueError for unknown formats.""" def test_export_report_unsupported_format_raises_value_error(self): from local_deep_research.web.services.research_service import ( export_report_to_memory, ) with pytest.raises(ValueError) as exc_info: export_report_to_memory( "# My Report\n\nContent here.", "totally_unsupported_xyz" ) error_text = str(exc_info.value) assert "Unsupported export format" in error_text assert "totally_unsupported_xyz" in error_text def test_export_report_unsupported_format_lists_available_formats(self): from local_deep_research.web.services.research_service import ( export_report_to_memory, ) with pytest.raises(ValueError) as exc_info: export_report_to_memory("content", "bogus") # The error message should mention available formats assert "Available formats" in str(exc_info.value) def test_export_report_unsupported_format_case_insensitive_normalization( self, ): """Format is lowercased before lookup — BOGUS becomes bogus, still invalid.""" from local_deep_research.web.services.research_service import ( export_report_to_memory, ) with pytest.raises(ValueError): export_report_to_memory("content", "TOTALLY_INVALID_FORMAT") # --------------------------------------------------------------------------- # save_research_strategy # --------------------------------------------------------------------------- class TestSaveResearchStrategyUpdateExisting: """save_research_strategy updates strategy_name when record exists.""" def test_update_existing_strategy(self): from local_deep_research.web.services.research_service import ( save_research_strategy, ) existing = Mock() existing.strategy_name = "old_strategy" mock_session = _make_db_session(first_result=existing) with patch( "local_deep_research.web.services.research_service.get_user_db_session", return_value=mock_session, ): save_research_strategy( research_id=10, strategy_name="new_strategy", username="user1" ) # Strategy name must be updated assert existing.strategy_name == "new_strategy" mock_session.commit.assert_called_once() # add() must NOT have been called — we're updating, not inserting mock_session.add.assert_not_called() class TestSaveResearchStrategyCreateNew: """save_research_strategy inserts a new ResearchStrategy when none exists.""" def test_create_new_strategy_record(self): from local_deep_research.web.services.research_service import ( save_research_strategy, ) from local_deep_research.database.models import ResearchStrategy mock_session = _make_db_session(first_result=None) with patch( "local_deep_research.web.services.research_service.get_user_db_session", return_value=mock_session, ): save_research_strategy( research_id=20, strategy_name="source-based", username="user2" ) # A new record must have been added and committed mock_session.add.assert_called_once() added_obj = mock_session.add.call_args[0][0] assert isinstance(added_obj, ResearchStrategy) assert added_obj.research_id == 20 assert added_obj.strategy_name == "source-based" mock_session.commit.assert_called_once() class TestSaveResearchStrategyException: """save_research_strategy silently swallows exceptions.""" def test_exception_is_swallowed(self): from local_deep_research.web.services.research_service import ( save_research_strategy, ) with patch( "local_deep_research.web.services.research_service.get_user_db_session", side_effect=RuntimeError("DB connection failed"), ): # Must not raise — exception is caught and logged internally save_research_strategy( research_id=99, strategy_name="fallback", username="user3" ) def test_commit_failure_is_swallowed(self): from local_deep_research.web.services.research_service import ( save_research_strategy, ) mock_session = _make_db_session(first_result=None) mock_session.commit.side_effect = Exception("commit error") with patch( "local_deep_research.web.services.research_service.get_user_db_session", return_value=mock_session, ): # Must not raise save_research_strategy( research_id=88, strategy_name="any", username="user4" ) # --------------------------------------------------------------------------- # run_research_process # --------------------------------------------------------------------------- def _base_run_patches(): """Return list of patches required so run_research_process doesn't touch the real system at all. Each patch is a (target, kwargs) pair.""" return [ # Globals ( "local_deep_research.web.services.research_service.get_user_db_session", {"side_effect": _noop_db_session}, ), ( "local_deep_research.web.routes.globals.is_termination_requested", {"return_value": False}, ), ( "local_deep_research.web.routes.globals.is_research_active", {"return_value": True}, ), ( "local_deep_research.web.routes.globals.update_progress_and_check_active", {"return_value": (5, True)}, ), ( "local_deep_research.web.services.research_service.set_search_context", {}, ), ( "local_deep_research.web.services.research_service.SocketIOService", {"return_value": Mock()}, ), ( "local_deep_research.web.services.research_service.cleanup_research_resources", {}, ), ] @contextmanager def _noop_db_session(*a, **kw): session = MagicMock() session.__enter__ = Mock(return_value=session) session.__exit__ = Mock(return_value=False) yield session class TestRunResearchProcessTermination: """run_research_process handles ResearchTerminatedException gracefully. run_research_process is decorated with @log_for_research which writes to flask.g, so all calls must be wrapped in an app context. """ def test_termination_before_start(self, flask_app): """When termination is requested before research begins, function returns early.""" from local_deep_research.web.services.research_service import ( run_research_process, ) with flask_app.app_context(): with ( patch( "local_deep_research.web.routes.globals.is_termination_requested", return_value=True, ), patch( "local_deep_research.web.services.research_service.cleanup_research_resources" ) as mock_cleanup, patch( "local_deep_research.web.services.research_service.get_user_db_session", side_effect=_noop_db_session, ), patch( "local_deep_research.web.services.research_service.set_search_context" ), patch( "local_deep_research.config.thread_settings.set_settings_context" ), patch("local_deep_research.settings.logger.log_settings"), ): # Should return without raising and call cleanup run_research_process( research_id=1, query="test query", mode="quick", username="testuser", ) # Terminated before start → reports SUSPENDED, not "completed". mock_cleanup.assert_called_once_with( 1, "testuser", user_password=None, final_status="suspended" ) def test_termination_raised_during_progress_callback(self, flask_app): """ResearchTerminatedException propagates out and is caught at top level.""" from local_deep_research.web.services.research_service import ( run_research_process, ) from local_deep_research.exceptions import ResearchTerminatedException mock_system = Mock() mock_system.analyze_topic.side_effect = ResearchTerminatedException( "cancelled" ) mock_system.set_progress_callback = Mock() with flask_app.app_context(): 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=(5, True), ), patch( "local_deep_research.web.services.research_service.cleanup_research_resources" ), patch( "local_deep_research.web.services.research_service.get_user_db_session", side_effect=_noop_db_session, ), patch( "local_deep_research.web.services.research_service.AdvancedSearchSystem", return_value=mock_system, ), patch( "local_deep_research.web.services.research_service.set_search_context" ), patch( "local_deep_research.config.thread_settings.set_settings_context" ), patch("local_deep_research.settings.logger.log_settings"), patch( "local_deep_research.web.services.research_service.SocketIOService", return_value=Mock(), ), patch( "local_deep_research.web.queue.processor_v2.queue_processor" ), patch( "local_deep_research.web.services.research_service.handle_termination" ), ): # Must not raise — ResearchTerminatedException is caught at top level run_research_process( research_id=2, query="query that gets cancelled", mode="quick", username="testuser", ) class TestRunResearchProcessLlmConfigError: """run_research_process re-raises ValueError for LLM config errors. All tests wrap calls in flask_app.app_context() because @log_for_research accesses flask.g. """ def test_llm_config_error_is_handled_internally(self, flask_app): """When get_llm raises with config-error keywords, the ValueError is raised inside the LLM setup block, caught by the outer except-Exception handler, and queued as an error update. run_research_process does NOT propagate it — it handles the error and returns normally. This test verifies: - The function completes without raising. - cleanup_research_resources is called (error path always cleans up). - The error update is queued via queue_processor. """ from local_deep_research.web.services.research_service import ( run_research_process, ) def _bad_get_llm(**kwargs): raise RuntimeError("model path not found on llamacpp server") mock_queue_processor = Mock() with flask_app.app_context(): 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=(5, True), ), patch( "local_deep_research.web.services.research_service.get_user_db_session", side_effect=_noop_db_session, ), patch( "local_deep_research.web.services.research_service.get_llm", side_effect=_bad_get_llm, ), patch( "local_deep_research.web.services.research_service.set_search_context" ), patch( "local_deep_research.config.thread_settings.set_settings_context" ), patch("local_deep_research.settings.logger.log_settings"), patch( "local_deep_research.web.services.research_service.SocketIOService", return_value=Mock(), ), patch( "local_deep_research.web.services.research_service.cleanup_research_resources" ) as mock_cleanup, patch( "local_deep_research.web.queue.processor_v2.queue_processor", mock_queue_processor, ), ): # Must NOT raise — error is handled internally run_research_process( research_id=3, query="some query", mode="quick", username="testuser", model="custom-model", model_provider="llamacpp", ) # Cleanup must have been triggered. This is the error path, so it # reports FAILED on the final socket message, not "completed". mock_cleanup.assert_called_once_with( 3, "testuser", user_password=None, final_status="failed" ) def test_missing_username_raises_value_error(self, flask_app): """run_research_process raises ValueError when username is not provided.""" from local_deep_research.web.services.research_service import ( run_research_process, ) with flask_app.app_context(): with ( patch( "local_deep_research.web.services.research_service.get_user_db_session", side_effect=_noop_db_session, ), patch( "local_deep_research.config.thread_settings.set_settings_context" ), patch("local_deep_research.settings.logger.log_settings"), ): with pytest.raises(ValueError) as exc_info: run_research_process( research_id=4, query="no username query", mode="quick", # username deliberately omitted ) assert "Username is required" in str(exc_info.value) # --------------------------------------------------------------------------- # _generate_report_path # --------------------------------------------------------------------------- class TestGenerateReportPathHash: """_generate_report_path embeds a deterministic MD5 hash of the query.""" # DevSkim: ignore DS126858 def test_hash_is_embedded_in_filename(self): from local_deep_research.web.services.research_service import ( _generate_report_path, ) query = "what is the impact of climate change on biodiversity" expected_hash = hashlib.md5( # DevSkim: ignore DS126858 query.encode("utf-8"), usedforsecurity=False ).hexdigest()[:10] result = _generate_report_path(query) assert expected_hash in result.name assert result.suffix == ".md" assert "research_report" in result.name def test_different_queries_produce_different_paths(self): from local_deep_research.web.services.research_service import ( _generate_report_path, ) path_a = _generate_report_path("query alpha") path_b = _generate_report_path("query beta") # Hashes must differ assert path_a.name != path_b.name def test_same_query_produces_same_hash_prefix(self): """The hash portion is deterministic; only the timestamp suffix varies.""" from local_deep_research.web.services.research_service import ( _generate_report_path, ) query = "deterministic hash test" expected_hash = hashlib.md5( # DevSkim: ignore DS126858 query.encode("utf-8"), usedforsecurity=False ).hexdigest()[:10] path1 = _generate_report_path(query) path2 = _generate_report_path(query) assert expected_hash in path1.name assert expected_hash in path2.name def test_path_is_under_output_dir(self): """Result is a child of OUTPUT_DIR.""" from local_deep_research.web.services.research_service import ( _generate_report_path, OUTPUT_DIR, ) result = _generate_report_path("output dir test") assert result.parent == OUTPUT_DIR