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

782 lines
28 KiB
Python

"""
Tests for the MCP server tools.
These tests use mocked research functions to verify the MCP tool implementations
without actually running research (which would take minutes).
"""
from unittest.mock import patch
import pytest
# Skip all tests if MCP is not available
try:
import mcp # noqa: F401
MCP_AVAILABLE = True
except ImportError:
MCP_AVAILABLE = False
pytestmark = pytest.mark.skipif(
not MCP_AVAILABLE, reason="MCP package not installed"
)
class TestQuickResearch:
"""Tests for the quick_research tool."""
def test_quick_research_success(self, mock_quick_summary):
"""Test successful quick research."""
from local_deep_research.mcp.server import quick_research
result = quick_research(query="What is quantum computing?")
assert result["status"] == "success"
assert "summary" in result
assert (
result["summary"]
== "This is a test summary about quantum computing."
)
assert "findings" in result
assert "sources" in result
assert result["iterations"] == 1
mock_quick_summary.assert_called_once()
def test_quick_research_with_overrides(self, mock_quick_summary):
"""Test quick research with parameter overrides."""
from local_deep_research.mcp.server import quick_research
result = quick_research(
query="Test query",
search_engine="wikipedia",
strategy="source-based",
iterations=2,
)
assert result["status"] == "success"
mock_quick_summary.assert_called_once()
# Verify settings_snapshot was passed
call_kwargs = mock_quick_summary.call_args[1]
assert "settings_snapshot" in call_kwargs
def test_quick_research_error_handling(self):
"""Test error handling in quick research."""
from local_deep_research.mcp.server import quick_research
with patch(
"local_deep_research.mcp.server.ldr_quick_summary",
side_effect=Exception("API key invalid"),
):
result = quick_research(query="Test query")
assert result["status"] == "error"
assert "error" in result
assert result["error_type"] == "auth_error"
class TestDetailedResearch:
"""Tests for the detailed_research tool."""
def test_detailed_research_success(self, mock_detailed_research):
"""Test successful detailed research."""
from local_deep_research.mcp.server import detailed_research
result = detailed_research(query="quantum computing applications")
assert result["status"] == "success"
assert result["query"] == "quantum computing applications"
assert result["research_id"] == "test-research-123"
assert "summary" in result
assert "findings" in result
assert len(result["findings"]) == 2
assert "metadata" in result
def test_detailed_research_error_handling(self):
"""Test error handling in detailed research."""
from local_deep_research.mcp.server import detailed_research
with patch(
"local_deep_research.mcp.server.ldr_detailed_research",
side_effect=Exception("Service unavailable: 503"),
):
result = detailed_research(query="Test query")
assert result["status"] == "error"
assert result["error_type"] == "service_unavailable"
class TestGenerateReport:
"""Tests for the generate_report tool."""
def test_generate_report_success(self, mock_generate_report):
"""Test successful report generation."""
from local_deep_research.mcp.server import generate_report
result = generate_report(query="quantum computing")
assert result["status"] == "success"
assert "content" in result
assert result["content"].startswith("# Research Report")
assert "metadata" in result
def test_generate_report_with_options(self, mock_generate_report):
"""Test report generation with options."""
from local_deep_research.mcp.server import generate_report
result = generate_report(
query="Test query",
search_engine="arxiv",
searches_per_section=3,
)
assert result["status"] == "success"
call_kwargs = mock_generate_report.call_args[1]
assert call_kwargs["searches_per_section"] == 3
def test_generate_report_preserves_assembled_sources_block(self):
"""MCP surfaces the generator's in-memory assembled content verbatim,
including the ``## Sources`` block — it never DB-reads or strips it.
Pins the MCP-keeps-assembled-shape invariant the #3665 audit
confirmed (in-memory generator output, never a DB read).
"""
from local_deep_research.mcp.server import generate_report
assembled = (
"# Research Report\n\n## Introduction\n\nBody.\n\n"
"## Sources\n\n[1] https://src.example\n\n"
"## Research Metrics\n\nSearch Iterations: 2\n"
)
with patch(
"local_deep_research.mcp.server.ldr_generate_report",
return_value={"content": assembled, "metadata": {"query": "q"}},
):
result = generate_report(query="q")
assert result["status"] == "success"
# Verbatim passthrough: the assembled content (incl. ## Sources and the
# trailing ## Research Metrics) survives unmodified. Full equality —
# substring presence alone wouldn't catch truncation after ## Sources.
assert result["content"] == assembled
assert result["metadata"] == {"query": "q"}
class TestAnalyzeDocuments:
"""Tests for the analyze_documents tool."""
def test_analyze_documents_success(self, mock_analyze_documents):
"""Test successful document analysis."""
from local_deep_research.mcp.server import analyze_documents
result = analyze_documents(
query="test query",
collection_name="test_collection",
)
assert result["status"] == "success"
assert result["collection"] == "test_collection"
assert result["document_count"] == 1
assert "summary" in result
assert "documents" in result
def test_analyze_documents_error_handling(self):
"""Test error handling in document analysis."""
from local_deep_research.mcp.server import analyze_documents
with patch(
"local_deep_research.mcp.server.ldr_analyze_documents",
side_effect=Exception("Collection not found"),
):
result = analyze_documents(
query="test query",
collection_name="nonexistent",
)
assert result["status"] == "error"
assert result["error_type"] == "model_not_found"
class TestDiscoveryTools:
"""Tests for discovery tools (list_search_engines, list_strategies, get_configuration)."""
def test_list_strategies(self):
"""Test listing available strategies."""
from local_deep_research.mcp.server import list_strategies
result = list_strategies()
assert result["status"] == "success"
assert "strategies" in result
assert len(result["strategies"]) > 0
# Check that each strategy has required fields
for strategy in result["strategies"]:
assert "name" in strategy
assert "description" in strategy
def test_list_search_engines(self):
"""Test listing available search engines."""
from local_deep_research.mcp.server import list_search_engines
# Mock the search_config function at the right location
mock_engines = {
"wikipedia": {
"description": "Wikipedia search",
"strengths": ["Free", "Reliable"],
"weaknesses": ["Limited depth"],
"requires_api_key": False,
},
"arxiv": {
"description": "arXiv academic papers",
"strengths": ["Academic sources"],
"weaknesses": ["Science only"],
"requires_api_key": False,
},
}
with patch(
"local_deep_research.web_search_engines.search_engines_config.search_config",
return_value=mock_engines,
):
result = list_search_engines()
assert result["status"] == "success"
assert "engines" in result
assert len(result["engines"]) == 2
def test_get_configuration(self, mock_settings_snapshot):
"""Test getting server configuration."""
from local_deep_research.mcp.server import get_configuration
result = get_configuration()
assert result["status"] == "success"
assert "config" in result
config = result["config"]
assert "llm" in config
assert "search" in config
class TestSearch:
"""Tests for the search tool."""
def test_search_success(self):
"""Test successful raw search."""
from local_deep_research.mcp.server import search
mock_engine = type(
"MockEngine",
(),
{
"run": lambda self, q: [
{
"title": "Result 1",
"link": "https://example.com/1",
"snippet": "Snippet 1",
},
{
"title": "Result 2",
"link": "https://example.com/2",
"snippet": "Snippet 2",
},
]
},
)()
mock_engines_config = {
"wikipedia": {
"description": "Wikipedia",
"requires_api_key": False,
},
}
with (
patch(
"local_deep_research.mcp.server.create_settings_snapshot",
return_value={},
),
patch(
"local_deep_research.web_search_engines.search_engines_config.search_config",
return_value=mock_engines_config,
),
patch(
"local_deep_research.web_search_engines.search_engine_factory.create_search_engine",
return_value=mock_engine,
),
):
result = search(query="quantum computing", engine="wikipedia")
assert result["status"] == "success"
assert result["query"] == "quantum computing"
assert result["engine"] == "wikipedia"
assert result["result_count"] == 2
assert len(result["results"]) == 2
assert result["results"][0]["title"] == "Result 1"
def test_search_with_max_results(self):
"""Test that max_results is passed to factory."""
from local_deep_research.mcp.server import search
mock_engine = type("MockEngine", (), {"run": lambda self, q: []})()
mock_engines_config = {
"arxiv": {"description": "arXiv", "requires_api_key": False},
}
with (
patch(
"local_deep_research.mcp.server.create_settings_snapshot",
return_value={},
),
patch(
"local_deep_research.web_search_engines.search_engines_config.search_config",
return_value=mock_engines_config,
),
patch(
"local_deep_research.web_search_engines.search_engine_factory.create_search_engine",
return_value=mock_engine,
) as mock_factory,
):
result = search(query="test", engine="arxiv", max_results=25)
assert result["status"] == "success"
mock_factory.assert_called_once()
call_kwargs = mock_factory.call_args[1]
assert call_kwargs["max_results"] == 25
def test_search_empty_query_error(self):
"""Test that empty query returns validation error."""
from local_deep_research.mcp.server import search
result = search(query="", engine="wikipedia")
assert result["status"] == "error"
assert result["error_type"] == "validation_error"
assert "empty" in result["error"].lower()
def test_search_invalid_engine_error(self):
"""Test that unknown engine name returns validation error with available engines."""
from local_deep_research.mcp.server import search
mock_engines_config = {
"wikipedia": {"description": "Wikipedia"},
"arxiv": {"description": "arXiv"},
}
with (
patch(
"local_deep_research.mcp.server.create_settings_snapshot",
return_value={},
),
patch(
"local_deep_research.web_search_engines.search_engines_config.search_config",
return_value=mock_engines_config,
),
):
result = search(query="test", engine="nonexistent")
assert result["status"] == "error"
assert result["error_type"] == "validation_error"
assert "nonexistent" in result["error"]
assert "arxiv" in result["error"]
assert "wikipedia" in result["error"]
def test_search_engine_creation_failure(self):
"""Test error when factory returns None."""
from local_deep_research.mcp.server import search
mock_engines_config = {
"searxng": {"description": "SearXNG", "requires_api_key": False},
}
with (
patch(
"local_deep_research.mcp.server.create_settings_snapshot",
return_value={},
),
patch(
"local_deep_research.web_search_engines.search_engines_config.search_config",
return_value=mock_engines_config,
),
patch(
"local_deep_research.web_search_engines.search_engine_factory.create_search_engine",
return_value=None,
),
):
result = search(query="test", engine="searxng")
assert result["status"] == "error"
assert "configuration_error" in result["error_type"]
def test_search_empty_results(self):
"""Test success with empty results list."""
from local_deep_research.mcp.server import search
mock_engine = type("MockEngine", (), {"run": lambda self, q: []})()
mock_engines_config = {
"arxiv": {"description": "arXiv", "requires_api_key": False},
}
with (
patch(
"local_deep_research.mcp.server.create_settings_snapshot",
return_value={},
),
patch(
"local_deep_research.web_search_engines.search_engines_config.search_config",
return_value=mock_engines_config,
),
patch(
"local_deep_research.web_search_engines.search_engine_factory.create_search_engine",
return_value=mock_engine,
),
):
result = search(query="obscure topic", engine="arxiv")
assert result["status"] == "success"
assert result["result_count"] == 0
assert result["results"] == []
def test_search_missing_api_key_error(self):
"""Test error when engine requires API key but none is configured."""
from local_deep_research.mcp.server import search
mock_engines_config = {
"brave": {"description": "Brave Search", "requires_api_key": True},
}
with (
patch(
"local_deep_research.mcp.server.create_settings_snapshot",
return_value={},
),
patch(
"local_deep_research.web_search_engines.search_engines_config.search_config",
return_value=mock_engines_config,
),
):
result = search(query="test", engine="brave")
assert result["status"] == "error"
assert result["error_type"] == "validation_error"
assert "api key" in result["error"].lower()
assert "brave" in result["error"].lower()
def test_search_requires_llm_engine_without_llm(self):
"""Test that engines with requires_llm=True work without LLM (degraded mode)."""
from local_deep_research.mcp.server import search
mock_engine = type(
"MockEngine",
(),
{
"run": lambda self, q: [
{
"title": "PubMed Result",
"link": "https://pubmed.ncbi.nlm.nih.gov/123",
"snippet": "CRISPR gene therapy results",
},
]
},
)()
mock_engines_config = {
"pubmed": {
"description": "PubMed biomedical literature",
"requires_api_key": False,
"requires_llm": True,
},
}
with (
patch(
"local_deep_research.mcp.server.create_settings_snapshot",
return_value={},
),
patch(
"local_deep_research.web_search_engines.search_engines_config.search_config",
return_value=mock_engines_config,
),
patch(
"local_deep_research.web_search_engines.search_engine_factory.create_search_engine",
return_value=mock_engine,
) as mock_factory,
):
result = search(query="CRISPR gene therapy", engine="pubmed")
assert result["status"] == "success"
assert result["engine"] == "pubmed"
assert result["result_count"] == 1
assert result["results"][0]["title"] == "PubMed Result"
# Verify factory was called with llm=None (search tool doesn't use LLM)
call_kwargs = mock_factory.call_args[1]
assert call_kwargs["llm"] is None
def test_search_normalizes_body_to_snippet(self):
"""Test that results with 'body' key get 'snippet' alias."""
from local_deep_research.mcp.server import search
mock_engine = type(
"MockEngine",
(),
{
"run": lambda self, q: [
{
"title": "Result",
"link": "https://example.com",
"body": "Body text",
},
]
},
)()
mock_engines_config = {
"wikipedia": {
"description": "Wikipedia",
"requires_api_key": False,
},
}
with (
patch(
"local_deep_research.mcp.server.create_settings_snapshot",
return_value={},
),
patch(
"local_deep_research.web_search_engines.search_engines_config.search_config",
return_value=mock_engines_config,
),
patch(
"local_deep_research.web_search_engines.search_engine_factory.create_search_engine",
return_value=mock_engine,
),
):
result = search(query="test", engine="wikipedia")
assert result["status"] == "success"
assert result["results"][0]["snippet"] == "Body text"
class TestErrorClassification:
"""Tests for error classification logic."""
def test_classify_service_unavailable(self):
"""Test classification of service unavailable errors."""
from local_deep_research.mcp.server import _classify_error
assert (
_classify_error("Error 503: Service unavailable")
== "service_unavailable"
)
assert (
_classify_error("The service is unavailable")
== "service_unavailable"
)
def test_classify_model_not_found(self):
"""Test classification of model not found errors."""
from local_deep_research.mcp.server import _classify_error
assert (
_classify_error("Error 404: Model not found") == "model_not_found"
)
assert _classify_error("Model llama3 not found") == "model_not_found"
def test_classify_auth_error(self):
"""Test classification of authentication errors."""
from local_deep_research.mcp.server import _classify_error
assert _classify_error("Invalid API key") == "auth_error"
assert _classify_error("Authentication failed") == "auth_error"
assert _classify_error("Unauthorized access") == "auth_error"
def test_classify_timeout(self):
"""Test classification of timeout errors."""
from local_deep_research.mcp.server import _classify_error
assert _classify_error("Request timeout") == "timeout"
assert _classify_error("Connection timeout after 30s") == "timeout"
def test_classify_rate_limit(self):
"""Test classification of rate limit errors."""
from local_deep_research.mcp.server import _classify_error
assert _classify_error("Rate limit exceeded") == "rate_limit"
def test_classify_unknown(self):
"""Test classification of unknown errors."""
from local_deep_research.mcp.server import _classify_error
assert _classify_error("Something weird happened") == "unknown"
class TestSettingsOverrides:
"""Tests for settings override building."""
def test_build_settings_overrides_empty(self):
"""Test building overrides with no parameters."""
from local_deep_research.mcp.server import _build_settings_overrides
result = _build_settings_overrides()
assert result == {}
def test_build_settings_overrides_all_params(self):
"""Test building overrides with all parameters."""
from local_deep_research.mcp.server import _build_settings_overrides
result = _build_settings_overrides(
search_engine="wikipedia",
strategy="source-based",
iterations=3,
questions_per_iteration=5,
temperature=0.5,
)
assert result["search.tool"] == "wikipedia"
assert result["search.search_strategy"] == "source-based"
assert result["search.iterations"] == 3
assert result["search.questions_per_iteration"] == 5
assert result["llm.temperature"] == 0.5
def test_build_settings_overrides_partial(self):
"""Test building overrides with some parameters."""
from local_deep_research.mcp.server import _build_settings_overrides
result = _build_settings_overrides(search_engine="arxiv", iterations=2)
assert result["search.tool"] == "arxiv"
assert result["search.iterations"] == 2
assert "search.search_strategy" not in result
assert "llm.temperature" not in result
class TestMcpToolToResearchFunctionContext:
"""Contract tests: every MCP tool that calls a research function must
build a settings_snapshot via create_settings_snapshot() and thread it
to the underlying call. Without this, the user's stored settings (LLM
provider/model, API keys, embedding model, etc.) are silently ignored
and the tool falls back to JSON defaults + LDR_* env vars.
Pattern: capture-list mock (assertions run on a captured kwargs dict
after the response, not inside the mock itself — the MCP tool's broad
``except Exception`` would otherwise swallow AssertionError).
"""
def test_quick_research_threads_settings_snapshot(self):
"""quick_research → ldr_quick_summary must include settings_snapshot."""
from local_deep_research.mcp.server import quick_research
captured = {}
def _capture(*args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
return {
"summary": "ok",
"findings": [],
"sources": [],
"iterations": 0,
"formatted_findings": "",
}
with patch(
"local_deep_research.mcp.server.ldr_quick_summary",
side_effect=_capture,
):
result = quick_research(query="test query")
assert result["status"] == "success"
assert "settings_snapshot" in captured["kwargs"], (
f"ldr_quick_summary called without settings_snapshot. "
f"kwargs: {list(captured['kwargs'].keys())}"
)
assert captured["kwargs"]["settings_snapshot"] is not None
def test_detailed_research_threads_settings_snapshot(self):
"""detailed_research → ldr_detailed_research must include settings_snapshot."""
from local_deep_research.mcp.server import detailed_research
captured = {}
def _capture(*args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
return {
"query": "test",
"research_id": "test-123",
"summary": "ok",
"findings": [],
"iterations": 0,
"questions": {},
"formatted_findings": "",
"sources": [],
"metadata": {},
}
with patch(
"local_deep_research.mcp.server.ldr_detailed_research",
side_effect=_capture,
):
result = detailed_research(query="test query")
assert result["status"] == "success"
assert "settings_snapshot" in captured["kwargs"], (
f"ldr_detailed_research called without settings_snapshot. "
f"kwargs: {list(captured['kwargs'].keys())}"
)
assert captured["kwargs"]["settings_snapshot"] is not None
def test_generate_report_threads_settings_snapshot(self):
"""generate_report → ldr_generate_report must include settings_snapshot."""
from local_deep_research.mcp.server import generate_report
captured = {}
def _capture(*args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
return {"content": "# Report", "metadata": {}}
with patch(
"local_deep_research.mcp.server.ldr_generate_report",
side_effect=_capture,
):
result = generate_report(query="test query")
assert result["status"] == "success"
assert "settings_snapshot" in captured["kwargs"], (
f"ldr_generate_report called without settings_snapshot. "
f"kwargs: {list(captured['kwargs'].keys())}"
)
assert captured["kwargs"]["settings_snapshot"] is not None
def test_analyze_documents_threads_settings_snapshot(self):
"""analyze_documents → ldr_analyze_documents must include
settings_snapshot. This is the regression fence for the bug that
previously omitted user context from the call."""
from local_deep_research.mcp.server import analyze_documents
captured = {}
def _capture(*args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
return {
"summary": "ok",
"documents": [],
"collection": "test",
"document_count": 0,
}
with patch(
"local_deep_research.mcp.server.ldr_analyze_documents",
side_effect=_capture,
):
result = analyze_documents(
query="test query",
collection_name="test_collection",
)
assert result["status"] == "success"
assert "settings_snapshot" in captured["kwargs"], (
f"ldr_analyze_documents called without settings_snapshot — "
f"user-configured embedding model / LLM provider are ignored. "
f"kwargs: {list(captured['kwargs'].keys())}"
)
assert captured["kwargs"]["settings_snapshot"] is not None