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
661 lines
22 KiB
Python
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
|