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
1096 lines
39 KiB
Python
1096 lines
39 KiB
Python
"""
|
|
Tests for research_service functions.
|
|
|
|
Tests cover:
|
|
- Citation formatter retrieval
|
|
- Report export functions
|
|
- Research strategy management
|
|
- Research process management
|
|
- Cleanup functions
|
|
"""
|
|
|
|
import hashlib
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
class TestGetCitationFormatter:
|
|
"""Tests for get_citation_formatter function."""
|
|
|
|
@patch("local_deep_research.config.search_config.get_setting_from_snapshot")
|
|
def test_get_citation_formatter_number_hyperlinks(self, mock_get_setting):
|
|
"""Returns formatter with NUMBER_HYPERLINKS mode."""
|
|
from local_deep_research.web.services.research_service import (
|
|
get_citation_formatter,
|
|
)
|
|
from local_deep_research.text_optimization import CitationMode
|
|
|
|
mock_get_setting.return_value = "number_hyperlinks"
|
|
|
|
formatter = get_citation_formatter()
|
|
|
|
assert formatter.mode == CitationMode.NUMBER_HYPERLINKS
|
|
|
|
@patch("local_deep_research.config.search_config.get_setting_from_snapshot")
|
|
def test_get_citation_formatter_domain_hyperlinks(self, mock_get_setting):
|
|
"""Returns formatter with DOMAIN_HYPERLINKS mode."""
|
|
from local_deep_research.web.services.research_service import (
|
|
get_citation_formatter,
|
|
)
|
|
from local_deep_research.text_optimization import CitationMode
|
|
|
|
mock_get_setting.return_value = "domain_hyperlinks"
|
|
|
|
formatter = get_citation_formatter()
|
|
|
|
assert formatter.mode == CitationMode.DOMAIN_HYPERLINKS
|
|
|
|
@patch("local_deep_research.config.search_config.get_setting_from_snapshot")
|
|
def test_get_citation_formatter_no_hyperlinks(self, mock_get_setting):
|
|
"""Returns formatter with NO_HYPERLINKS mode."""
|
|
from local_deep_research.web.services.research_service import (
|
|
get_citation_formatter,
|
|
)
|
|
from local_deep_research.text_optimization import CitationMode
|
|
|
|
mock_get_setting.return_value = "no_hyperlinks"
|
|
|
|
formatter = get_citation_formatter()
|
|
|
|
assert formatter.mode == CitationMode.NO_HYPERLINKS
|
|
|
|
@patch("local_deep_research.config.search_config.get_setting_from_snapshot")
|
|
def test_get_citation_formatter_default(self, mock_get_setting):
|
|
"""Returns formatter with default mode when unknown format."""
|
|
from local_deep_research.web.services.research_service import (
|
|
get_citation_formatter,
|
|
)
|
|
from local_deep_research.text_optimization import CitationMode
|
|
|
|
mock_get_setting.return_value = "unknown_format"
|
|
|
|
formatter = get_citation_formatter()
|
|
|
|
assert formatter.mode == CitationMode.NUMBER_HYPERLINKS
|
|
|
|
|
|
class TestExportReportToMemory:
|
|
"""Tests for export_report_to_memory function."""
|
|
|
|
def test_export_latex_format(self):
|
|
"""export_report_to_memory generates LaTeX content."""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
markdown_content = "# Test Report\n\nThis is test content."
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "latex", title="Test Report"
|
|
)
|
|
|
|
assert filename.endswith(".tex")
|
|
assert mimetype == "text/plain"
|
|
assert isinstance(content, bytes)
|
|
|
|
def test_export_ris_format(self):
|
|
"""export_report_to_memory generates RIS content."""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
markdown_content = "# Test Report\n\nThis is test content."
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "ris", title="Test Report"
|
|
)
|
|
|
|
assert filename.endswith(".ris")
|
|
assert mimetype == "text/plain"
|
|
assert isinstance(content, bytes)
|
|
|
|
def test_export_unsupported_format_raises(self):
|
|
"""export_report_to_memory raises for unsupported format."""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
markdown_content = "# Test Report"
|
|
|
|
try:
|
|
export_report_to_memory(markdown_content, "unsupported")
|
|
assert False, "Should have raised ValueError"
|
|
except ValueError as e:
|
|
assert "Unsupported export format" in str(e)
|
|
|
|
def test_export_quarto_format(self):
|
|
"""export_report_to_memory generates Quarto zip content."""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
markdown_content = "# Test Report\n\nThis is test content."
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "quarto", title="Test Report"
|
|
)
|
|
|
|
assert filename.endswith(".zip")
|
|
assert mimetype == "application/zip"
|
|
assert isinstance(content, bytes)
|
|
|
|
def test_export_pdf_format(self):
|
|
"""export_report_to_memory generates PDF content."""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
markdown_content = "# Test Report\n\nThis is test content."
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "pdf", title="Test Report"
|
|
)
|
|
|
|
assert filename.endswith(".pdf")
|
|
assert mimetype == "application/pdf"
|
|
# PDF files start with %PDF
|
|
assert content.startswith(b"%PDF")
|
|
|
|
|
|
class TestSaveResearchStrategy:
|
|
"""Tests for save_research_strategy function."""
|
|
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.get_user_db_session"
|
|
)
|
|
def test_save_research_strategy_creates_new(self, mock_get_session):
|
|
"""save_research_strategy creates new strategy record."""
|
|
from local_deep_research.web.services.research_service import (
|
|
save_research_strategy,
|
|
)
|
|
|
|
mock_session = MagicMock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value.first.return_value = None
|
|
mock_session.query.return_value = mock_query
|
|
mock_session.__enter__ = Mock(return_value=mock_session)
|
|
mock_session.__exit__ = Mock(return_value=False)
|
|
mock_get_session.return_value = mock_session
|
|
|
|
save_research_strategy(123, "standard", username="testuser")
|
|
|
|
mock_session.add.assert_called_once()
|
|
mock_session.commit.assert_called_once()
|
|
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.get_user_db_session"
|
|
)
|
|
def test_save_research_strategy_updates_existing(self, mock_get_session):
|
|
"""save_research_strategy updates existing strategy."""
|
|
from local_deep_research.web.services.research_service import (
|
|
save_research_strategy,
|
|
)
|
|
|
|
mock_strategy = Mock()
|
|
mock_strategy.strategy_name = "old_strategy"
|
|
|
|
mock_session = MagicMock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value.first.return_value = mock_strategy
|
|
mock_session.query.return_value = mock_query
|
|
mock_session.__enter__ = Mock(return_value=mock_session)
|
|
mock_session.__exit__ = Mock(return_value=False)
|
|
mock_get_session.return_value = mock_session
|
|
|
|
save_research_strategy(123, "new_strategy", username="testuser")
|
|
|
|
assert mock_strategy.strategy_name == "new_strategy"
|
|
mock_session.commit.assert_called_once()
|
|
|
|
def test_username_is_required_keyword_only(self):
|
|
"""Locks the security contract from #4526: ``username`` must be
|
|
supplied, and only as a keyword. This guards the mutation the
|
|
route-level autospec test cannot catch — re-adding a
|
|
``username=None`` default, which would silently reopen the
|
|
``get_user_db_session(None)`` implicit-fallback foot-gun. No DB
|
|
mock: the TypeError is raised at call-binding time, before the
|
|
body runs."""
|
|
from local_deep_research.web.services.research_service import (
|
|
save_research_strategy,
|
|
)
|
|
|
|
# Omitting username entirely -> missing required keyword-only arg.
|
|
with pytest.raises(TypeError, match="username"):
|
|
save_research_strategy(123, "standard")
|
|
|
|
# Passing it positionally must also fail (keyword-only barrier),
|
|
# so a refactor can't silently turn it into a positional arg.
|
|
with pytest.raises(TypeError):
|
|
save_research_strategy(123, "standard", "testuser")
|
|
|
|
|
|
class TestGetResearchStrategy:
|
|
"""Tests for get_research_strategy function."""
|
|
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.get_user_db_session"
|
|
)
|
|
def test_get_research_strategy_found(self, mock_get_session):
|
|
"""get_research_strategy returns strategy name when found."""
|
|
from local_deep_research.web.services.research_service import (
|
|
get_research_strategy,
|
|
)
|
|
|
|
mock_strategy = Mock()
|
|
mock_strategy.strategy_name = "standard"
|
|
|
|
mock_session = MagicMock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value.first.return_value = mock_strategy
|
|
mock_session.query.return_value = mock_query
|
|
mock_session.__enter__ = Mock(return_value=mock_session)
|
|
mock_session.__exit__ = Mock(return_value=False)
|
|
mock_get_session.return_value = mock_session
|
|
|
|
result = get_research_strategy(123, username="testuser")
|
|
|
|
assert result == "standard"
|
|
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.get_user_db_session"
|
|
)
|
|
def test_get_research_strategy_not_found(self, mock_get_session):
|
|
"""get_research_strategy returns None when not found."""
|
|
from local_deep_research.web.services.research_service import (
|
|
get_research_strategy,
|
|
)
|
|
|
|
mock_session = MagicMock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value.first.return_value = None
|
|
mock_session.query.return_value = mock_query
|
|
mock_session.__enter__ = Mock(return_value=mock_session)
|
|
mock_session.__exit__ = Mock(return_value=False)
|
|
mock_get_session.return_value = mock_session
|
|
|
|
result = get_research_strategy(123, username="testuser")
|
|
|
|
assert result is None
|
|
|
|
def test_username_is_required_keyword_only(self):
|
|
"""Locks the security contract from #4526: ``username`` must be
|
|
supplied, and only as a keyword. Without this, re-adding a
|
|
``username=None`` default would pass every other strategy test
|
|
(they all pass ``username=`` explicitly) while silently
|
|
reopening the implicit Flask-session fallback path inside
|
|
``get_user_db_session``. No DB mock: the TypeError is raised at
|
|
call-binding time, before the body runs."""
|
|
from local_deep_research.web.services.research_service import (
|
|
get_research_strategy,
|
|
)
|
|
|
|
# Omitting username entirely -> missing required keyword-only arg.
|
|
with pytest.raises(TypeError, match="username"):
|
|
get_research_strategy(123)
|
|
|
|
# Passing it positionally must also fail (keyword-only barrier).
|
|
with pytest.raises(TypeError):
|
|
get_research_strategy(123, "testuser")
|
|
|
|
|
|
class TestGenerateReportPath:
|
|
"""Tests for _generate_report_path function."""
|
|
|
|
@patch("local_deep_research.web.services.research_service.OUTPUT_DIR")
|
|
def test_generate_report_path_creates_unique_path(self, mock_output_dir):
|
|
"""_generate_report_path creates unique path from query."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_generate_report_path,
|
|
)
|
|
|
|
mock_output_dir.__truediv__ = lambda self, x: Path(f"/test/output/{x}")
|
|
|
|
query = "test research query"
|
|
|
|
result = _generate_report_path(query)
|
|
|
|
# Path should contain hash of query
|
|
query_hash = hashlib.md5( # DevSkim: ignore DS126858
|
|
query.encode("utf-8"), usedforsecurity=False
|
|
).hexdigest()[:10]
|
|
assert query_hash in str(result)
|
|
assert "research_report" in str(result)
|
|
|
|
|
|
class TestStartResearchProcess:
|
|
"""Tests for start_research_process function."""
|
|
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.thread_with_app_context"
|
|
)
|
|
@patch("local_deep_research.web.services.research_service.thread_context")
|
|
@patch("local_deep_research.web.routes.globals.check_and_start_research")
|
|
def test_start_research_process_creates_thread(
|
|
self, mock_check_start, mock_thread_context, mock_thread_with_context
|
|
):
|
|
"""start_research_process creates a thread and atomically registers it."""
|
|
from local_deep_research.web.services.research_service import (
|
|
start_research_process,
|
|
)
|
|
|
|
mock_callback = Mock()
|
|
mock_thread_with_context.return_value = mock_callback
|
|
mock_thread_context.return_value = {}
|
|
mock_check_start.return_value = True # dedup passes
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.research_service.threading.Thread"
|
|
) as mock_thread_class:
|
|
mock_thread = Mock()
|
|
mock_thread_class.return_value = mock_thread
|
|
|
|
start_research_process(
|
|
research_id=123,
|
|
query="test query",
|
|
mode="quick",
|
|
run_research_callback=mock_callback,
|
|
)
|
|
|
|
# The dedup helper owns starting the thread — not the caller.
|
|
mock_thread.start.assert_not_called()
|
|
mock_check_start.assert_called_once()
|
|
call_args = mock_check_start.call_args
|
|
assert call_args[0][0] == 123
|
|
assert call_args[0][1]["status"] == "in_progress"
|
|
assert call_args[0][1]["thread"] is mock_thread
|
|
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.thread_with_app_context"
|
|
)
|
|
@patch("local_deep_research.web.services.research_service.thread_context")
|
|
@patch("local_deep_research.web.routes.globals.check_and_start_research")
|
|
def test_start_research_process_stores_settings(
|
|
self, mock_check_start, mock_thread_context, mock_thread_with_context
|
|
):
|
|
"""start_research_process stores settings in active_research."""
|
|
from local_deep_research.web.services.research_service import (
|
|
start_research_process,
|
|
)
|
|
|
|
mock_callback = Mock()
|
|
mock_thread_with_context.return_value = mock_callback
|
|
mock_thread_context.return_value = {}
|
|
mock_check_start.return_value = True
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.research_service.threading.Thread"
|
|
) as mock_thread_class:
|
|
mock_thread = Mock()
|
|
mock_thread_class.return_value = mock_thread
|
|
|
|
start_research_process(
|
|
research_id=123,
|
|
query="test query",
|
|
mode="detailed",
|
|
run_research_callback=mock_callback,
|
|
model="gpt-4",
|
|
search_engine="google",
|
|
)
|
|
|
|
call_args = mock_check_start.call_args
|
|
settings = call_args[0][1]["settings"]
|
|
assert settings["model"] == "gpt-4"
|
|
assert settings["search_engine"] == "google"
|
|
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.thread_with_app_context"
|
|
)
|
|
@patch("local_deep_research.web.services.research_service.thread_context")
|
|
@patch("local_deep_research.web.routes.globals.check_and_start_research")
|
|
def test_start_research_process_raises_on_duplicate(
|
|
self, mock_check_start, mock_thread_context, mock_thread_with_context
|
|
):
|
|
"""If a live thread already exists for research_id, raises
|
|
DuplicateResearchError without spawning a second thread."""
|
|
import pytest
|
|
from local_deep_research.exceptions import DuplicateResearchError
|
|
from local_deep_research.web.services.research_service import (
|
|
start_research_process,
|
|
)
|
|
|
|
mock_callback = Mock()
|
|
mock_thread_with_context.return_value = mock_callback
|
|
mock_thread_context.return_value = {}
|
|
mock_check_start.return_value = False # dedup refuses
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.research_service.threading.Thread"
|
|
) as mock_thread_class:
|
|
mock_thread = Mock()
|
|
mock_thread_class.return_value = mock_thread
|
|
|
|
with pytest.raises(DuplicateResearchError):
|
|
start_research_process(
|
|
research_id=123,
|
|
query="test query",
|
|
mode="quick",
|
|
run_research_callback=mock_callback,
|
|
)
|
|
|
|
# Thread prepared but never started — check_and_start_research
|
|
# owns the .start() call and refused to make it.
|
|
mock_thread.start.assert_not_called()
|
|
|
|
|
|
class TestCleanupResearchResources:
|
|
"""Tests for cleanup_research_resources function."""
|
|
|
|
@patch("local_deep_research.settings.env_registry.is_test_mode")
|
|
@patch("local_deep_research.web.queue.processor_v2.queue_processor")
|
|
@patch("local_deep_research.web.routes.globals.cleanup_research")
|
|
@patch("local_deep_research.web.services.socket_service.SocketIOService")
|
|
def test_cleanup_calls_cleanup_research(
|
|
self,
|
|
mock_socket,
|
|
mock_cleanup,
|
|
mock_queue,
|
|
mock_test_mode,
|
|
):
|
|
"""cleanup_research_resources calls cleanup_research to remove from dicts."""
|
|
from local_deep_research.web.services.research_service import (
|
|
cleanup_research_resources,
|
|
)
|
|
|
|
mock_test_mode.return_value = False
|
|
|
|
cleanup_research_resources(123, username="testuser")
|
|
|
|
mock_cleanup.assert_called_once_with(123)
|
|
|
|
@patch("local_deep_research.settings.env_registry.is_test_mode")
|
|
@patch("local_deep_research.web.queue.processor_v2.queue_processor")
|
|
@patch("local_deep_research.web.routes.globals.cleanup_research")
|
|
@patch("local_deep_research.web.services.socket_service.SocketIOService")
|
|
def test_cleanup_notifies_queue_processor(
|
|
self,
|
|
mock_socket,
|
|
mock_cleanup,
|
|
mock_queue,
|
|
mock_test_mode,
|
|
):
|
|
"""cleanup_research_resources notifies queue processor."""
|
|
from local_deep_research.web.services.research_service import (
|
|
cleanup_research_resources,
|
|
)
|
|
|
|
mock_test_mode.return_value = False
|
|
|
|
cleanup_research_resources(123, username="testuser")
|
|
|
|
mock_queue.notify_research_completed.assert_called_once_with(
|
|
"testuser", 123, user_password=None
|
|
)
|
|
|
|
|
|
class TestCancelResearch:
|
|
"""Tests for cancel_research function."""
|
|
|
|
@patch(
|
|
"local_deep_research.web.routes.globals.is_research_active",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.web.routes.globals.set_termination_flag")
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.handle_termination"
|
|
)
|
|
def test_cancel_research_sets_termination_flag(
|
|
self, mock_handle_termination, mock_set_flag, mock_is_active
|
|
):
|
|
"""cancel_research sets termination flag."""
|
|
from local_deep_research.web.services.research_service import (
|
|
cancel_research,
|
|
)
|
|
|
|
result = cancel_research(123, username="testuser")
|
|
|
|
assert result is True
|
|
mock_set_flag.assert_called_once_with(123)
|
|
mock_handle_termination.assert_called_once_with(123, "testuser")
|
|
|
|
@patch(
|
|
"local_deep_research.web.routes.globals.is_research_active",
|
|
return_value=False,
|
|
)
|
|
@patch("local_deep_research.web.routes.globals.set_termination_flag")
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.get_user_db_session"
|
|
)
|
|
def test_cancel_research_updates_db_for_inactive(
|
|
self, mock_get_session, mock_set_flag, mock_is_active
|
|
):
|
|
"""cancel_research updates database for inactive research."""
|
|
from local_deep_research.web.services.research_service import (
|
|
cancel_research,
|
|
)
|
|
|
|
mock_research = Mock()
|
|
mock_research.status = "in_progress"
|
|
|
|
mock_session = MagicMock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value.first.return_value = mock_research
|
|
mock_session.query.return_value = mock_query
|
|
mock_session.__enter__ = Mock(return_value=mock_session)
|
|
mock_session.__exit__ = Mock(return_value=False)
|
|
mock_get_session.return_value = mock_session
|
|
|
|
result = cancel_research(123, username="testuser")
|
|
|
|
assert result is True
|
|
assert mock_research.status == "suspended"
|
|
|
|
@patch(
|
|
"local_deep_research.web.routes.globals.is_research_active",
|
|
return_value=False,
|
|
)
|
|
@patch("local_deep_research.web.routes.globals.set_termination_flag")
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.get_user_db_session"
|
|
)
|
|
def test_cancel_research_already_completed(
|
|
self, mock_get_session, mock_set_flag, mock_is_active
|
|
):
|
|
"""cancel_research returns True for already completed research."""
|
|
from local_deep_research.web.services.research_service import (
|
|
cancel_research,
|
|
)
|
|
|
|
mock_research = Mock()
|
|
mock_research.status = "completed"
|
|
|
|
mock_session = MagicMock()
|
|
mock_query = Mock()
|
|
mock_query.filter_by.return_value.first.return_value = mock_research
|
|
mock_session.query.return_value = mock_query
|
|
mock_session.__enter__ = Mock(return_value=mock_session)
|
|
mock_session.__exit__ = Mock(return_value=False)
|
|
mock_get_session.return_value = mock_session
|
|
|
|
result = cancel_research(123, username="testuser")
|
|
|
|
assert result is True
|
|
|
|
|
|
class TestHandleTermination:
|
|
"""Tests for handle_termination function."""
|
|
|
|
@patch("local_deep_research.web.queue.processor_v2.queue_processor")
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.cleanup_research_resources"
|
|
)
|
|
def test_handle_termination_queues_update(self, mock_cleanup, mock_queue):
|
|
"""handle_termination queues suspension update."""
|
|
from local_deep_research.web.services.research_service import (
|
|
handle_termination,
|
|
)
|
|
|
|
handle_termination(123, username="testuser")
|
|
|
|
mock_queue.queue_error_update.assert_called_once()
|
|
call_kwargs = mock_queue.queue_error_update.call_args[1]
|
|
assert call_kwargs["status"] == "suspended"
|
|
assert call_kwargs["research_id"] == 123
|
|
|
|
@patch("local_deep_research.web.queue.processor_v2.queue_processor")
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.cleanup_research_resources"
|
|
)
|
|
def test_handle_termination_calls_cleanup(self, mock_cleanup, mock_queue):
|
|
"""handle_termination calls cleanup function."""
|
|
from local_deep_research.web.services.research_service import (
|
|
handle_termination,
|
|
)
|
|
|
|
handle_termination(123, username="testuser")
|
|
|
|
# Termination must report SUSPENDED to cleanup so the final socket
|
|
# message is not a spurious "completed".
|
|
mock_cleanup.assert_called_once_with(
|
|
123, "testuser", final_status="suspended"
|
|
)
|
|
|
|
|
|
class TestExportQuartoFormat:
|
|
"""Tests for quarto export format."""
|
|
|
|
def test_export_quarto_creates_zip(self):
|
|
"""export_report_to_memory creates zip for quarto format."""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
markdown_content = "# Test Report\n\nThis is test content."
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "quarto", title="Test Report"
|
|
)
|
|
|
|
assert filename.endswith(".zip")
|
|
assert mimetype == "application/zip"
|
|
assert isinstance(content, bytes)
|
|
# Verify it's a valid zip file by checking magic bytes
|
|
assert content[:2] == b"PK"
|
|
|
|
|
|
class TestExportLatexFormat:
|
|
"""Tests for latex export format."""
|
|
|
|
def test_export_latex_format_via_memory(self):
|
|
"""export_report_to_memory handles latex format."""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
markdown_content = "# Test Report\n\nThis is test content."
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "latex", title="Test Report"
|
|
)
|
|
|
|
assert filename.endswith(".tex")
|
|
assert mimetype == "text/plain" # LaTeX uses text/plain mimetype
|
|
assert isinstance(content, bytes)
|
|
|
|
|
|
class TestGenerateReportPathUniqueHash:
|
|
"""Tests for _generate_report_path unique hash generation."""
|
|
|
|
@patch("local_deep_research.web.services.research_service.OUTPUT_DIR")
|
|
def test_different_queries_different_paths(self, mock_output_dir):
|
|
"""Different queries should generate different paths."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_generate_report_path,
|
|
)
|
|
|
|
mock_output_dir.__truediv__ = lambda self, x: Path(f"/test/output/{x}")
|
|
|
|
path1 = _generate_report_path("query one")
|
|
path2 = _generate_report_path("query two")
|
|
|
|
# Paths should be different
|
|
assert str(path1) != str(path2)
|
|
|
|
@patch("local_deep_research.web.services.research_service.OUTPUT_DIR")
|
|
def test_same_query_same_path(self, mock_output_dir):
|
|
"""Same query should generate same path."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_generate_report_path,
|
|
)
|
|
|
|
mock_output_dir.__truediv__ = lambda self, x: Path(f"/test/output/{x}")
|
|
|
|
path1 = _generate_report_path("test query")
|
|
path2 = _generate_report_path("test query")
|
|
|
|
# Paths should be the same
|
|
assert str(path1) == str(path2)
|
|
|
|
|
|
class TestStartResearchProcessWithOptions:
|
|
"""Tests for start_research_process with various options."""
|
|
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.thread_with_app_context"
|
|
)
|
|
@patch("local_deep_research.web.services.research_service.thread_context")
|
|
@patch("local_deep_research.web.routes.globals.check_and_start_research")
|
|
def test_start_research_with_local_collections(
|
|
self, mock_check_start, mock_thread_context, mock_thread_with_context
|
|
):
|
|
"""start_research_process handles local_collections option."""
|
|
from local_deep_research.web.services.research_service import (
|
|
start_research_process,
|
|
)
|
|
|
|
mock_callback = Mock()
|
|
mock_thread_with_context.return_value = mock_callback
|
|
mock_thread_context.return_value = {}
|
|
mock_check_start.return_value = True
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.research_service.threading.Thread"
|
|
) as mock_thread_class:
|
|
mock_thread = Mock()
|
|
mock_thread_class.return_value = mock_thread
|
|
|
|
start_research_process(
|
|
research_id=123,
|
|
query="test query",
|
|
mode="detailed",
|
|
run_research_callback=mock_callback,
|
|
local_collections=["collection1", "collection2"],
|
|
)
|
|
|
|
call_args = mock_check_start.call_args
|
|
settings = call_args[0][1]["settings"]
|
|
assert settings["local_collections"] == [
|
|
"collection1",
|
|
"collection2",
|
|
]
|
|
|
|
@patch(
|
|
"local_deep_research.web.services.research_service.thread_with_app_context"
|
|
)
|
|
@patch("local_deep_research.web.services.research_service.thread_context")
|
|
@patch("local_deep_research.web.routes.globals.check_and_start_research")
|
|
def test_start_research_stores_knowledge_graph_option(
|
|
self, mock_check_start, mock_thread_context, mock_thread_with_context
|
|
):
|
|
"""start_research_process stores knowledge_graph option."""
|
|
from local_deep_research.web.services.research_service import (
|
|
start_research_process,
|
|
)
|
|
|
|
mock_callback = Mock()
|
|
mock_thread_with_context.return_value = mock_callback
|
|
mock_thread_context.return_value = {}
|
|
mock_check_start.return_value = True
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.research_service.threading.Thread"
|
|
) as mock_thread_class:
|
|
mock_thread = Mock()
|
|
mock_thread_class.return_value = mock_thread
|
|
|
|
start_research_process(
|
|
research_id=456,
|
|
query="test query",
|
|
mode="quick",
|
|
run_research_callback=mock_callback,
|
|
enable_knowledge_graph=True,
|
|
)
|
|
|
|
call_args = mock_check_start.call_args
|
|
settings = call_args[0][1]["settings"]
|
|
assert settings["enable_knowledge_graph"] is True
|
|
|
|
|
|
class TestResearchServiceExportFormats:
|
|
"""Tests for export format handling."""
|
|
|
|
def test_export_unsupported_format_returns_error(self):
|
|
"""export_report_to_memory raises for unsupported format."""
|
|
import pytest
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
markdown_content = "# Test Report\n\nThis is test content."
|
|
|
|
# Test unsupported format raises ValueError
|
|
with pytest.raises(ValueError) as exc_info:
|
|
export_report_to_memory(
|
|
markdown_content, "unsupported_format", title="Test"
|
|
)
|
|
|
|
assert "Unsupported export format" in str(exc_info.value)
|
|
|
|
|
|
class TestTitlePrepending:
|
|
"""Test title prepending behavior for different export formats.
|
|
|
|
The export_report_to_memory function should prepend a markdown title
|
|
for formats that render markdown (PDF, ODT), but NOT for formats
|
|
where it would corrupt the output (RIS, LaTeX, Quarto).
|
|
"""
|
|
|
|
def test_pdf_gets_title_prepended(self):
|
|
"""PDF format should get markdown title prepended."""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
# Content without a heading
|
|
markdown_content = "This is content without a title heading."
|
|
title = "My Report Title"
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "pdf", title=title
|
|
)
|
|
|
|
# PDF should be valid and have the title (we can't easily inspect content,
|
|
# but we verify the export succeeded and returned valid PDF)
|
|
assert content.startswith(b"%PDF")
|
|
assert filename.endswith(".pdf")
|
|
|
|
def test_odt_gets_title_prepended(self):
|
|
"""ODT format should get markdown title prepended."""
|
|
# Check if pandoc is available
|
|
try:
|
|
import pypandoc
|
|
|
|
pypandoc.get_pandoc_version()
|
|
except (ImportError, OSError):
|
|
import pytest
|
|
|
|
pytest.skip("Pandoc is not installed (required for ODT export)")
|
|
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
# Content without a heading
|
|
markdown_content = "This is content without a title heading."
|
|
title = "My Report Title"
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "odt", title=title
|
|
)
|
|
|
|
# ODT should be valid ZIP and have the title
|
|
assert content[:2] == b"PK"
|
|
assert filename.endswith(".odt")
|
|
|
|
def test_ris_does_not_get_title_prepended(self):
|
|
"""RIS format should NOT get title prepended (would corrupt output).
|
|
|
|
RIS is a bibliographic format with strict structure. Prepending
|
|
a markdown title would corrupt the output.
|
|
"""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
# Content with sources
|
|
markdown_content = """# Research Report
|
|
|
|
Some content here.
|
|
|
|
## Sources
|
|
|
|
[1] First Source
|
|
URL: https://example.com/1
|
|
"""
|
|
title = "My Report Title"
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "ris", title=title
|
|
)
|
|
|
|
# Decode the RIS content and verify it doesn't start with markdown title
|
|
ris_text = content.decode("utf-8")
|
|
|
|
# RIS should NOT have "# My Report Title" prepended
|
|
assert not ris_text.startswith("# My Report Title")
|
|
assert filename.endswith(".ris")
|
|
|
|
def test_latex_does_not_get_title_prepended(self):
|
|
"""LaTeX format should NOT get title prepended.
|
|
|
|
LaTeX has its own document structure with \\documentclass, etc.
|
|
Prepending a markdown title would not be appropriate.
|
|
"""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
markdown_content = "Some content here."
|
|
title = "My Report Title"
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "latex", title=title
|
|
)
|
|
|
|
# The function should NOT prepend "# My Report Title" to the content
|
|
# before passing to the LaTeX exporter
|
|
# We verify the export succeeded with proper LaTeX output
|
|
assert filename.endswith(".tex")
|
|
assert isinstance(content, bytes)
|
|
# Verify the content is valid (can be decoded)
|
|
assert content.decode("utf-8")
|
|
|
|
def test_quarto_does_not_get_title_prepended(self):
|
|
"""Quarto format should NOT get title prepended.
|
|
|
|
Quarto uses YAML front matter for titles. Prepending a markdown
|
|
title would duplicate the title.
|
|
"""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
markdown_content = "Some content here."
|
|
title = "My Report Title"
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "quarto", title=title
|
|
)
|
|
|
|
# Quarto produces a ZIP file
|
|
assert content[:2] == b"PK"
|
|
assert filename.endswith(".zip")
|
|
|
|
def test_title_not_duplicated_if_already_present(self):
|
|
"""If content already starts with title heading, don't duplicate."""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
# Content that already has the title as heading
|
|
title = "My Report Title"
|
|
markdown_content = f"# {title}\n\nThis content already has the title."
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "pdf", title=title
|
|
)
|
|
|
|
# Should succeed without duplicating
|
|
assert content.startswith(b"%PDF")
|
|
|
|
def test_title_not_prepended_if_content_starts_with_heading(self):
|
|
"""If content starts with any heading, don't prepend title."""
|
|
from local_deep_research.web.services.research_service import (
|
|
export_report_to_memory,
|
|
)
|
|
|
|
# Content that starts with a different heading
|
|
markdown_content = "# Different Heading\n\nSome content here."
|
|
title = "My Report Title"
|
|
|
|
content, filename, mimetype = export_report_to_memory(
|
|
markdown_content, "pdf", title=title
|
|
)
|
|
|
|
# Should succeed - PDF formats should not prepend if content starts with #
|
|
assert content.startswith(b"%PDF")
|
|
|
|
|
|
class TestParseResearchMetadata:
|
|
"""Tests for _parse_research_metadata function."""
|
|
|
|
def test_parse_dict_returns_copy(self):
|
|
"""_parse_research_metadata returns copy when given dict."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_parse_research_metadata,
|
|
)
|
|
|
|
original = {"key": "value", "number": 42}
|
|
result = _parse_research_metadata(original)
|
|
|
|
assert result == original
|
|
# Should be a copy, not the same object
|
|
assert result is not original
|
|
|
|
def test_parse_dict_with_nested_data(self):
|
|
"""_parse_research_metadata handles nested dict data."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_parse_research_metadata,
|
|
)
|
|
|
|
original = {
|
|
"iterations": 5,
|
|
"metadata": {"model": "gpt-4", "search_engine": "google"},
|
|
"sources": ["source1", "source2"],
|
|
}
|
|
result = _parse_research_metadata(original)
|
|
|
|
assert result == original
|
|
assert result["metadata"]["model"] == "gpt-4"
|
|
|
|
def test_parse_valid_json_string(self):
|
|
"""_parse_research_metadata parses valid JSON string."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_parse_research_metadata,
|
|
)
|
|
|
|
json_str = '{"key": "value", "number": 42}'
|
|
result = _parse_research_metadata(json_str)
|
|
|
|
assert result == {"key": "value", "number": 42}
|
|
|
|
def test_parse_complex_json_string(self):
|
|
"""_parse_research_metadata parses complex JSON string."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_parse_research_metadata,
|
|
)
|
|
|
|
json_str = '{"iterations": 5, "metadata": {"model": "gpt-4"}, "sources": ["a", "b"]}'
|
|
result = _parse_research_metadata(json_str)
|
|
|
|
assert result["iterations"] == 5
|
|
assert result["metadata"]["model"] == "gpt-4"
|
|
assert result["sources"] == ["a", "b"]
|
|
|
|
def test_parse_invalid_json_string_returns_empty_dict(self):
|
|
"""_parse_research_metadata returns empty dict for invalid JSON."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_parse_research_metadata,
|
|
)
|
|
|
|
invalid_json = "not valid json {{"
|
|
result = _parse_research_metadata(invalid_json)
|
|
|
|
assert result == {}
|
|
|
|
def test_parse_empty_json_string(self):
|
|
"""_parse_research_metadata handles empty JSON object string."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_parse_research_metadata,
|
|
)
|
|
|
|
result = _parse_research_metadata("{}")
|
|
|
|
assert result == {}
|
|
|
|
def test_parse_none_returns_empty_dict(self):
|
|
"""_parse_research_metadata returns empty dict for None."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_parse_research_metadata,
|
|
)
|
|
|
|
result = _parse_research_metadata(None)
|
|
|
|
assert result == {}
|
|
|
|
def test_parse_empty_string_returns_empty_dict(self):
|
|
"""_parse_research_metadata returns empty dict for empty string."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_parse_research_metadata,
|
|
)
|
|
|
|
result = _parse_research_metadata("")
|
|
|
|
assert result == {}
|
|
|
|
def test_parse_integer_returns_empty_dict(self):
|
|
"""_parse_research_metadata returns empty dict for integer."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_parse_research_metadata,
|
|
)
|
|
|
|
result = _parse_research_metadata(42)
|
|
|
|
assert result == {}
|
|
|
|
def test_parse_list_returns_empty_dict(self):
|
|
"""_parse_research_metadata returns empty dict for list."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_parse_research_metadata,
|
|
)
|
|
|
|
result = _parse_research_metadata([1, 2, 3])
|
|
|
|
assert result == {}
|
|
|
|
def test_parse_empty_dict_returns_empty_dict(self):
|
|
"""_parse_research_metadata handles empty dict."""
|
|
from local_deep_research.web.services.research_service import (
|
|
_parse_research_metadata,
|
|
)
|
|
|
|
result = _parse_research_metadata({})
|
|
|
|
assert result == {}
|