"""Extra coverage tests for research_service.py targeting cancel/termination/cleanup. Targets functions NOT covered by test_research_service_deep_coverage.py: - cancel_research: active, not found, terminal state, non-active non-terminal, db exception, top-level - handle_termination: success, queue processor exception - cleanup_research_resources: completed status, no username, socket emit exception - _generate_report_path: normal query, unicode/special chars, different queries produce different paths - _parse_research_metadata: dict, JSON string, invalid JSON, non-string - get_citation_formatter: various citation modes - export_report_to_memory: success, unsupported format - save_research_strategy: create new, update existing, exception - get_research_strategy: found, not found, exception - start_research_process: starts thread """ 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" GLOBALS_MOD = "local_deep_research.web.routes.globals" QUEUE_PROC_MOD = "local_deep_research.web.queue.processor_v2" ENV_REGISTRY_MOD = "local_deep_research.settings.env_registry" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _fake_session_ctx(session=None): if session is None: session = MagicMock() @contextmanager def ctx(username=None): yield session return ctx def _make_mock_research(status=None): r = MagicMock() r.status = status return r # --------------------------------------------------------------------------- # cancel_research # --------------------------------------------------------------------------- class TestCancelResearch: def test_active_research_terminates(self): """Active research → sets termination flag, calls handle_termination, returns True.""" from local_deep_research.web.services.research_service import ( cancel_research, ) with ( patch(f"{GLOBALS_MOD}.set_termination_flag") as mock_flag, patch(f"{GLOBALS_MOD}.is_research_active", return_value=True), patch(f"{MODULE}.handle_termination") as mock_term, ): result = cancel_research("res-1", "testuser") assert result is True mock_flag.assert_called_once_with("res-1") mock_term.assert_called_once_with("res-1", "testuser") def test_not_found_returns_false(self): """Research not in active dict and not in DB → returns False.""" from local_deep_research.web.services.research_service import ( cancel_research, ) ms = MagicMock() ms.query.return_value.filter_by.return_value.first.return_value = None with ( patch(f"{GLOBALS_MOD}.set_termination_flag"), patch(f"{GLOBALS_MOD}.is_research_active", return_value=False), patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)), ): result = cancel_research("no-such", "testuser") assert result is False def test_terminal_state_returns_true(self): """Research already completed → returns True (already stopped).""" from local_deep_research.web.services.research_service import ( cancel_research, ) research = _make_mock_research(status="completed") ms = MagicMock() ms.query.return_value.filter_by.return_value.first.return_value = ( research ) with ( patch(f"{GLOBALS_MOD}.set_termination_flag"), patch(f"{GLOBALS_MOD}.is_research_active", return_value=False), patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)), ): result = cancel_research("res-1", "testuser") assert result is True def test_non_active_non_terminal_suspends(self): """Research in DB but not active/terminal → suspended, returns True.""" from local_deep_research.web.services.research_service import ( cancel_research, ) research = _make_mock_research(status="in_progress") ms = MagicMock() ms.query.return_value.filter_by.return_value.first.return_value = ( research ) with ( patch(f"{GLOBALS_MOD}.set_termination_flag"), patch(f"{GLOBALS_MOD}.is_research_active", return_value=False), patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)), ): result = cancel_research("res-1", "testuser") assert result is True assert research.status == "suspended" ms.commit.assert_called_once() def test_db_exception_returns_false(self): """DB error during non-active lookup → returns False.""" from local_deep_research.web.services.research_service import ( cancel_research, ) with ( patch(f"{GLOBALS_MOD}.set_termination_flag"), patch(f"{GLOBALS_MOD}.is_research_active", return_value=False), patch( f"{MODULE}.get_user_db_session", side_effect=Exception("db error"), ), ): result = cancel_research("res-1", "testuser") assert result is False def test_unexpected_top_level_exception(self): """Top-level exception → returns False.""" from local_deep_research.web.services.research_service import ( cancel_research, ) with patch( f"{GLOBALS_MOD}.set_termination_flag", side_effect=RuntimeError("boom"), ): result = cancel_research("res-1", "testuser") assert result is False # --------------------------------------------------------------------------- # handle_termination # --------------------------------------------------------------------------- class TestHandleTermination: def test_success_queues_error_update(self): """Queues suspension update and calls cleanup.""" from local_deep_research.web.services.research_service import ( handle_termination, ) mock_qp = MagicMock() with ( patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp), patch(f"{MODULE}.cleanup_research_resources") as mock_cleanup, ): handle_termination("res-1", "testuser") mock_qp.queue_error_update.assert_called_once() call_kwargs = mock_qp.queue_error_update.call_args assert call_kwargs.kwargs["research_id"] == "res-1" assert call_kwargs.kwargs["status"] == "suspended" # Cleanup must be told this ended via termination so the final # socket message reports SUSPENDED, not a spurious "completed". mock_cleanup.assert_called_once_with( "res-1", "testuser", final_status="suspended" ) def test_queue_processor_exception_still_cleans_up(self): """Queue processor error → swallowed, cleanup still runs.""" from local_deep_research.web.services.research_service import ( handle_termination, ) mock_qp = MagicMock() mock_qp.queue_error_update.side_effect = RuntimeError("queue down") with ( patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp), patch(f"{MODULE}.cleanup_research_resources") as mock_cleanup, ): handle_termination("res-1", "testuser") mock_cleanup.assert_called_once_with( "res-1", "testuser", final_status="suspended" ) # --------------------------------------------------------------------------- # cleanup_research_resources # --------------------------------------------------------------------------- class TestCleanupResearchResources: def _run_cleanup(self, mock_qp=None, mock_sio=None): from local_deep_research.web.services.research_service import ( cleanup_research_resources, ) if mock_qp is None: mock_qp = MagicMock() if mock_sio is None: mock_sio = MagicMock() with ( patch(f"{GLOBALS_MOD}.cleanup_research"), patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp), patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False), patch(f"{MODULE}.SocketIOService", return_value=mock_sio), ): cleanup_research_resources("res-1", "testuser") return mock_qp, mock_sio def test_completed_status_emits_completion(self): """Default path emits completed final message.""" mock_qp, mock_sio = self._run_cleanup() mock_qp.notify_research_completed.assert_called_once_with( "testuser", "res-1", user_password=None ) mock_sio.emit_to_subscribers.assert_called_once() call_args = mock_sio.emit_to_subscribers.call_args event_data = call_args[0][2] assert event_data["progress"] == 100 def test_suspended_final_status_does_not_emit_completed(self): """Regression: on the termination path the caller passes final_status=SUSPENDED, so the final socket message must report SUSPENDED (progress 0) — NOT a hard-coded 'completed' (progress 100). The previous hard-coded COMPLETED made the chat client render an answer over a stopped state and flipped the standard progress page to 100%/Completed on user stop.""" from local_deep_research.constants import ResearchStatus from local_deep_research.web.services.research_service import ( cleanup_research_resources, ) mock_qp = MagicMock() mock_sio = MagicMock() with ( patch(f"{GLOBALS_MOD}.cleanup_research"), patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp), patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False), patch(f"{MODULE}.SocketIOService", return_value=mock_sio), ): cleanup_research_resources( "res-1", "testuser", final_status=ResearchStatus.SUSPENDED, ) mock_sio.emit_to_subscribers.assert_called_once() event_data = mock_sio.emit_to_subscribers.call_args[0][2] assert event_data["status"] == ResearchStatus.SUSPENDED assert event_data["status"] != ResearchStatus.COMPLETED # Suspended research shows 0%, not a misleading 100%. assert event_data["progress"] == 0 def test_no_username_skips_notify(self): """No username → skips queue processor notify.""" from local_deep_research.web.services.research_service import ( cleanup_research_resources, ) mock_qp = MagicMock() with ( patch(f"{GLOBALS_MOD}.cleanup_research"), patch(f"{QUEUE_PROC_MOD}.queue_processor", mock_qp), patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False), patch(f"{MODULE}.SocketIOService", return_value=MagicMock()), ): cleanup_research_resources("res-1", username=None) mock_qp.notify_research_completed.assert_not_called() def test_socket_emit_exception_swallowed(self): """Socket emit error → swallowed gracefully.""" from local_deep_research.web.services.research_service import ( cleanup_research_resources, ) mock_sio = MagicMock() mock_sio.emit_to_subscribers.side_effect = RuntimeError("socket down") with ( patch(f"{GLOBALS_MOD}.cleanup_research"), patch(f"{QUEUE_PROC_MOD}.queue_processor", MagicMock()), patch(f"{ENV_REGISTRY_MOD}.is_test_mode", return_value=False), patch(f"{MODULE}.SocketIOService", return_value=mock_sio), ): # Should not raise cleanup_research_resources("res-1", "testuser") # --------------------------------------------------------------------------- # _generate_report_path # --------------------------------------------------------------------------- class TestGenerateReportPath: def test_normal_query(self): from local_deep_research.web.services.research_service import ( _generate_report_path, ) path = _generate_report_path("What is machine learning?") assert path.suffix == ".md" assert "research_report_" in path.name def test_unicode_special_chars(self): from local_deep_research.web.services.research_service import ( _generate_report_path, ) path = _generate_report_path("日本語クエリ $pecial Ch@rs!") assert path.suffix == ".md" assert "research_report_" in path.name def test_different_queries_different_hashes(self): from local_deep_research.web.services.research_service import ( _generate_report_path, ) path1 = _generate_report_path("query one") path2 = _generate_report_path("query two") # Hash portion should differ assert path1.name != path2.name # --------------------------------------------------------------------------- # _parse_research_metadata # --------------------------------------------------------------------------- class TestParseResearchMetadata: def test_dict_input(self): from local_deep_research.web.services.research_service import ( _parse_research_metadata, ) result = _parse_research_metadata({"key": "val"}) assert result == {"key": "val"} def test_json_string_input(self): from local_deep_research.web.services.research_service import ( _parse_research_metadata, ) result = _parse_research_metadata('{"key": "val"}') assert result == {"key": "val"} def test_invalid_json_string(self): from local_deep_research.web.services.research_service import ( _parse_research_metadata, ) result = _parse_research_metadata("not json") assert result == {} def test_none_input(self): from local_deep_research.web.services.research_service import ( _parse_research_metadata, ) result = _parse_research_metadata(None) assert result == {} def test_int_input(self): from local_deep_research.web.services.research_service import ( _parse_research_metadata, ) result = _parse_research_metadata(42) assert result == {} # --------------------------------------------------------------------------- # get_citation_formatter # --------------------------------------------------------------------------- class TestGetCitationFormatter: def test_default_mode(self): from local_deep_research.web.services.research_service import ( get_citation_formatter, ) with patch( "local_deep_research.config.search_config.get_setting_from_snapshot", return_value="number_hyperlinks", ): formatter = get_citation_formatter() assert formatter is not None def test_domain_hyperlinks_mode(self): from local_deep_research.web.services.research_service import ( get_citation_formatter, ) with patch( "local_deep_research.config.search_config.get_setting_from_snapshot", return_value="domain_hyperlinks", ): formatter = get_citation_formatter() assert formatter is not None def test_no_hyperlinks_mode(self): from local_deep_research.web.services.research_service import ( get_citation_formatter, ) with patch( "local_deep_research.config.search_config.get_setting_from_snapshot", return_value="no_hyperlinks", ): formatter = get_citation_formatter() assert formatter is not None def test_unknown_mode_falls_back(self): from local_deep_research.web.services.research_service import ( get_citation_formatter, ) with patch( "local_deep_research.config.search_config.get_setting_from_snapshot", return_value="nonexistent_mode", ): formatter = get_citation_formatter() assert formatter is not None # --------------------------------------------------------------------------- # export_report_to_memory # --------------------------------------------------------------------------- class TestExportReportToMemory: def test_success(self): from local_deep_research.web.services.research_service import ( export_report_to_memory, ) mock_exporter = MagicMock() mock_result = MagicMock() mock_result.content = b"PDF bytes" mock_result.filename = "report.pdf" mock_result.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 ): content, filename, mimetype = export_report_to_memory( "# Report", "pdf", title="Test" ) assert content == b"PDF bytes" assert filename == "report.pdf" def test_unsupported_format_raises(self): """Unsupported format raises ValueError.""" from local_deep_research.exporters import ExporterRegistry original = ExporterRegistry.get_exporter try: ExporterRegistry.get_exporter = staticmethod(lambda fmt: None) ExporterRegistry.get_available_formats = staticmethod( lambda: ["pdf", "latex"] ) from local_deep_research.web.services.research_service import ( export_report_to_memory, ) with pytest.raises(ValueError, match="Unsupported"): export_report_to_memory("# Report", "docx") finally: ExporterRegistry.get_exporter = original # --------------------------------------------------------------------------- # save_research_strategy / get_research_strategy # --------------------------------------------------------------------------- class TestSaveResearchStrategy: def test_create_new_strategy(self): from local_deep_research.web.services.research_service import ( save_research_strategy, ) ms = MagicMock() ms.query.return_value.filter_by.return_value.first.return_value = None with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)): save_research_strategy("res-1", "source-based", username="user") ms.add.assert_called_once() ms.commit.assert_called_once() def test_update_existing_strategy(self): from local_deep_research.web.services.research_service import ( save_research_strategy, ) existing = MagicMock() ms = MagicMock() ms.query.return_value.filter_by.return_value.first.return_value = ( existing ) with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)): save_research_strategy("res-1", "langgraph-agent", username="user") assert existing.strategy_name == "langgraph-agent" ms.commit.assert_called_once() def test_exception_swallowed(self): from local_deep_research.web.services.research_service import ( save_research_strategy, ) with patch( f"{MODULE}.get_user_db_session", side_effect=Exception("db fail"), ): # Should not raise save_research_strategy("res-1", "source-based", username="user") class TestGetResearchStrategy: def test_found(self): from local_deep_research.web.services.research_service import ( get_research_strategy, ) strategy = MagicMock() strategy.strategy_name = "source-based" ms = MagicMock() ms.query.return_value.filter_by.return_value.first.return_value = ( strategy ) with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)): result = get_research_strategy("res-1", username="user") assert result == "source-based" def test_not_found(self): from local_deep_research.web.services.research_service import ( get_research_strategy, ) ms = MagicMock() ms.query.return_value.filter_by.return_value.first.return_value = None with patch(f"{MODULE}.get_user_db_session", _fake_session_ctx(ms)): result = get_research_strategy("res-1", username="user") assert result is None def test_exception_returns_none(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 fail"), ): result = get_research_strategy("res-1", username="user") assert result is None # --------------------------------------------------------------------------- # start_research_process # --------------------------------------------------------------------------- class TestStartResearchProcess: def test_starts_thread(self): from local_deep_research.web.services.research_service import ( start_research_process, ) callback = MagicMock() 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, ), patch(f"{MODULE}.thread_context", return_value={}), patch( "local_deep_research.web.routes.globals.check_and_start_research", side_effect=fake_check_and_start, ), patch("threading.Thread") as mock_thread_cls, ): mock_thread = MagicMock() mock_thread.ident = 12345 mock_thread_cls.return_value = mock_thread result = start_research_process( "res-1", "test query", "quick", callback, username="user" ) mock_thread.start.assert_called_once() assert result is mock_thread