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
624 lines
24 KiB
Python
624 lines
24 KiB
Python
"""
|
|
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()
|