MCP DuckDuckGo Search Plugin
- tests
"""
Tests for the DuckDuckGo search functionality.
"""
import pytest
import httpx
from bs4 import BeautifulSoup
from unittest.mock import AsyncMock, patch, MagicMock
from mcp_duckduckgo.search import duckduckgo_search, extract_domain
class TestExtractDomain:
"""Tests for the extract_domain function."""
def test_extract_domain_valid_url(self):
"""Test that extract_domain works with valid URLs."""
url = "https://example.com/page?query=test"
domain = extract_domain(url)
assert domain == "example.com"
def test_extract_domain_with_subdomain(self):
"""Test extract_domain with subdomains."""
url = "https://blog.example.com/article"
domain = extract_domain(url)
assert domain == "blog.example.com"
def test_extract_domain_invalid_url(self):
"""Test extract_domain with invalid URLs."""
url = "not a url"
domain = extract_domain(url)
assert domain == ""
def test_extract_domain_empty_string(self):
"""Test extract_domain with empty string."""
url = ""
domain = extract_domain(url)
assert domain == ""
class TestDuckDuckGoSearch:
"""Tests for the duckduckgo_search function."""
@pytest.mark.asyncio
async def test_basic_search(self, mock_context, mock_http_client, sample_search_params):
"""Test a basic search with mocked response."""
# Set up the mock client in the context
mock_context.lifespan_context['http_client'] = mock_http_client
# Run the search function
result = await duckduckgo_search(sample_search_params, mock_context)
# Verify the result structure
assert 'results' in result
assert 'total_results' in result
assert isinstance(result['results'], list)
assert isinstance(result['total_results'], int)
# Verify that the HTTP client was called correctly
mock_http_client.post.assert_called_once()
call_args = mock_http_client.post.call_args
assert call_args[0][0] == "https://lite.duckduckgo.com/lite/"
assert 'data' in call_args[1]
assert call_args[1]['data']['q'] == sample_search_params['query']
@pytest.mark.asyncio
async def test_search_with_pagination(self, mock_context, mock_http_client):
"""Test search with pagination parameters."""
# Set up the mock client in the context
mock_context.lifespan_context['http_client'] = mock_http_client
# Set up the search parameters with pagination
search_params = {
"query": "test query",
"count": 5,
"offset": 10,
"page": 3
}
# Run the search function
await duckduckgo_search(search_params, mock_context)
# Verify that the HTTP client was called with the right offset
mock_http_client.post.assert_called_once()
call_args = mock_http_client.post.call_args
assert call_args[1]['data']['s'] == 10 # Check the offset was passed
@pytest.mark.asyncio
async def test_search_without_context_client(self, mock_context):
"""Test search without a client in the context."""
# Set up context without http_client
mock_context.lifespan_context = {}
# Set up a mock for httpx.AsyncClient to be used in the function
mock_client = AsyncMock()
mock_client.post.return_value = MagicMock(
text="""
<html>
<body>
<table>
<tr class="result-link">
<td>
<a href="https://example.com/page1">Example Page 1</a>
</td>
</tr>
<tr class="result-snippet">
<td>This is a description for Example Page 1</td>
</tr>
</table>
</body>
</html>
""",
status_code=200,
raise_for_status=MagicMock()
)
# Mock the AsyncClient constructor
with patch('httpx.AsyncClient', return_value=mock_client):
# Run the search function
search_params = {"query": "test query"}
result = await duckduckgo_search(search_params, mock_context)
# Verify the client was created and used
mock_client.post.assert_called_once()
mock_client.aclose.assert_called_once() # Check client was closed
# Verify results
assert 'results' in result
assert len(result['results']) > 0
@pytest.mark.asyncio
async def test_search_with_no_results(self, mock_context, mock_http_client):
"""Test search with no results."""
# Set up the mock client to return a response with no results
empty_html = "<html><body><table></table></body></html>"
mock_http_client.post.return_value = MagicMock(
text=empty_html,
status_code=200,
raise_for_status=MagicMock()
)
mock_context.lifespan_context['http_client'] = mock_http_client
# Run the search function
search_params = {"query": "nonexistent query"}
result = await duckduckgo_search(search_params, mock_context)
# Verify empty results
assert 'results' in result
assert len(result['results']) == 0
@pytest.mark.asyncio
async def test_search_with_http_error(self, mock_context, mock_http_client):
"""Test search with HTTP error."""
# Set up the mock client to raise an HTTP error
mock_http_client.post.return_value = MagicMock(
status_code=404,
raise_for_status=MagicMock(side_effect=httpx.HTTPStatusError(
message="404 Not Found",
request=MagicMock(),
response=MagicMock(status_code=404)
))
)
mock_context.lifespan_context['http_client'] = mock_http_client
# Run the search function and expect an exception
search_params = {"query": "test query"}
with pytest.raises(ValueError) as excinfo:
await duckduckgo_search(search_params, mock_context)
assert "HTTP error" in str(excinfo.value)
@pytest.mark.asyncio
async def test_search_with_request_error(self, mock_context, mock_http_client):
"""Test search with request error."""
# Set up the mock client to raise a request error
mock_http_client.post.side_effect = httpx.RequestError("Connection error", request=MagicMock())
mock_context.lifespan_context['http_client'] = mock_http_client
# Run the search function and expect an exception
search_params = {"query": "test query"}
with pytest.raises(ValueError) as excinfo:
await duckduckgo_search(search_params, mock_context)
assert "Request error" in str(excinfo.value)
@pytest.mark.asyncio
async def test_search_with_fallback_parsing(self, mock_context, mock_http_client):
"""Test search with fallback HTML parsing approach."""
# HTML without the expected structure but with links
fallback_html = """
<html>
<body>
<div>
<a href="https://example.com/fallback">Fallback Result</a>
<p>This is a fallback description</p>
</div>
</body>
</html>
"""
mock_http_client.post.return_value = MagicMock(
text=fallback_html,
status_code=200,
raise_for_status=MagicMock()
)
mock_context.lifespan_context['http_client'] = mock_http_client
# Run the search function
search_params = {"query": "test query"}
result = await duckduckgo_search(search_params, mock_context)
# Verify results using fallback mechanism
assert 'results' in result
assert len(result['results']) > 0
# The fallback should have found the link
found_url = False
for item in result['results']:
if item['url'] == 'https://example.com/fallback':
found_url = True
break
assert found_url, "Fallback parsing didn't find the expected URL"
@pytest.mark.asyncio
async def test_missing_query_parameter(self, mock_context):
"""Test that an error is raised when query parameter is missing."""
# Run the search function without a query
search_params = {}
with pytest.raises(ValueError) as excinfo:
await duckduckgo_search(search_params, mock_context)
assert "Query parameter is required" in str(excinfo.value)
@pytest.mark.asyncio
async def test_progress_reporting(self, mock_context, mock_http_client, sample_search_params):
"""Test that progress is reported correctly."""
# Set up the context with report_progress method
mock_context.report_progress = AsyncMock()
mock_context.lifespan_context['http_client'] = mock_http_client
# Run the search function
await duckduckgo_search(sample_search_params, mock_context)
# Verify that report_progress was called at least once
assert mock_context.report_progress.called
@pytest.mark.asyncio
async def test_info_reporting(self, mock_context, mock_http_client, sample_search_params):
"""Test that info is reported correctly."""
# Set up the context with info method
mock_context.info = AsyncMock()
mock_context.lifespan_context['http_client'] = mock_http_client
# Run the search function
await duckduckgo_search(sample_search_params, mock_context)
# Verify that info was called at least once
assert mock_context.info.called