Files
learningcircuit--local-deep…/tests/web/services/test_research_service_extra_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

661 lines
22 KiB
Python

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