Files
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

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 == {}