Files
pi_mcps/mcp/webscraper/tests/test_server.py
T
Patrick Plate 2ab847f51d feat(webscraper): add Brave Search hint tool and User-Agent header
- Add webscraper_search_hint() tool using Brave Search as backend
  (no CAPTCHA/GDPR consent wall, works with plain httpx)
- Add User-Agent header to _fetch_page() — fixes 403 on Wikipedia,
  Feynman Lectures, and other sites that block headless requests
- Add 5 new tests for search hint (23 total, 90% coverage)

Brave Search URL: https://search.brave.com/search?q={query}&source=web
Use sparingly — once per research task as orientation, not in loops
2026-04-05 09:37:30 +02:00

287 lines
11 KiB
Python

"""Comprehensive tests for webscraper server."""
import pytest
import httpx
from unittest.mock import MagicMock, patch
from src.server import (
webscraper_fetch, webscraper_fetch_links, webscraper_fetch_tables,
webscraper_fetch_all, webscraper_fetch_section, webscraper_fetch_meta,
webscraper_fetch_sitemap, webscraper_search_hint, clean_soup, filter_junk_links
)
@pytest.fixture
def mock_response():
"""Mock httpx response."""
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = """
<html>
<head><title>Test Page</title><meta name="description" content="Test desc">
<meta property="og:title" content="OG Title">
<meta property="og:description" content="OG Desc">
</head>
<body>
<h1>Header</h1>
<p>Paragraph 1</p>
<a href="https://example.com/link1">Link 1</a>
<a href="mailto:foo@bar.com">Junk Mail</a>
<a href="javascript:alert()">Junk JS</a>
<a href="relative.html">Relative Link</a>
<a href="../dir/page.html">Parent Relative</a>
<table><tr><td>Cell1</td><td>Cell2</td></tr></table>
<div class="content">Selected content</div>
</body>
</html>
"""
mock_resp.headers = {"content-type": "text/html"}
return mock_resp
@pytest.fixture
def mock_sitemap_response():
"""Mock sitemap response."""
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = """
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://example.com/page1</loc></url>
<url><loc>https://example.com/page2</loc></url>
<url><loc>https://example.com/sitemap.xml</loc></url>
</urlset>
"""
return mock_resp
@patch('httpx.get')
def test_webscraper_fetch(mock_get, mock_response):
"""Test webscraper_fetch tool."""
mock_get.return_value = mock_response
result = webscraper_fetch("https://example.com", max_chars=100)
assert "# Test Page" in result
assert "Paragraph 1" in result
assert "URL: https://example.com" in result
assert len(result) < 500 # Truncated
@patch('httpx.get')
def test_webscraper_fetch_error(mock_get):
"""Test error handling in webscraper_fetch."""
mock_get.side_effect = httpx.RequestError("Connection failed")
result = webscraper_fetch("https://fail.com")
assert "Error fetching" in result
@patch('httpx.get')
def test_webscraper_fetch_links(mock_get, mock_response):
"""Test webscraper_fetch_links tool."""
mock_get.return_value = mock_response
result = webscraper_fetch_links("https://example.com", deduplicate=True)
assert isinstance(result, list)
assert "https://example.com/link1" in result
assert "https://example.com/relative.html" in result
assert "https://example.com/dir/page.html" in result
assert len(result) == 3 # Valid links only
@patch('httpx.get')
def test_webscraper_fetch_links_no_dedup(mock_get, mock_response):
"""Test without deduplication."""
mock_get.return_value = mock_response
result = webscraper_fetch_links("https://example.com", deduplicate=False)
assert len(result) == 3 # Still three unique
@patch('httpx.get')
def test_webscraper_fetch_tables(mock_get, mock_response):
"""Test webscraper_fetch_tables tool."""
mock_get.return_value = mock_response
result = webscraper_fetch_tables("https://example.com")
assert isinstance(result, list)
assert "Cell1" in result[0]
assert "Cell2" in result[0]
@patch('httpx.get')
def test_webscraper_fetch_all(mock_get, mock_response):
"""Test webscraper_fetch_all tool."""
mock_get.return_value = mock_response
result = webscraper_fetch_all("https://example.com", max_chars=100)
assert "markdown" in result
assert "links" in result
assert "tables" in result
assert "meta" in result
@patch('httpx.get')
def test_webscraper_fetch_section(mock_get, mock_response):
"""Test webscraper_fetch_section tool."""
mock_get.return_value = mock_response
result = webscraper_fetch_section("https://example.com", ".content")
assert "Selected content" in result
@patch('httpx.get')
def test_webscraper_fetch_section_no_match(mock_get, mock_response):
"""Test selector with no match."""
mock_get.return_value = mock_response
result = webscraper_fetch_section("https://example.com", ".nonexistent")
assert "No element found" in result
@patch('httpx.get')
def test_webscraper_fetch_meta(mock_get, mock_response):
"""Test webscraper_fetch_meta tool."""
mock_get.return_value = mock_response
result = webscraper_fetch_meta("https://example.com")
assert result["title"] == "Test Page"
assert result["description"] == "Test desc"
assert result["og:title"] == "OG Title"
@patch('httpx.get')
def test_webscraper_fetch_sitemap(mock_get, mock_sitemap_response):
"""Test webscraper_fetch_sitemap tool."""
mock_get.return_value = mock_sitemap_response
result = webscraper_fetch_sitemap("https://example.com/sitemap.xml", max_urls=2)
assert isinstance(result, list)
assert "https://example.com/page1" in result
assert len(result) == 2 # Limited by max_urls
@patch('httpx.get')
def test_webscraper_fetch_sitemap_loop_protection(mock_get, mock_sitemap_response):
"""Test sitemap loop protection."""
mock_get.return_value = mock_sitemap_response
result = webscraper_fetch_sitemap("https://example.com/sitemap.xml")
assert "https://example.com/sitemap.xml" not in result # Self-reference removed
def test_clean_soup():
"""Test clean_soup helper."""
from bs4 import BeautifulSoup
soup = BeautifulSoup('<html><script>alert()</script><p>Text</p></html>', 'lxml')
cleaned = clean_soup(soup)
assert '<script>' not in str(cleaned)
assert '<p>Text</p>' in str(cleaned)
def test_filter_junk_links():
"""Test filter_junk_links helper."""
assert filter_junk_links("https://example.com") == True
assert filter_junk_links("mailto:foo@bar.com") == False
assert filter_junk_links("javascript:alert()") == False
@patch('httpx.get')
def test_word_count_before_truncation(mock_get, mock_response):
"""Test word count before truncation (from memory bug fix)."""
mock_get.return_value = mock_response
result = webscraper_fetch("https://example.com", max_chars=10)
# Implementation uses len(body) > max_chars, which is char count, but test ensures no post-trunc count bug
assert "..." in result # Truncated
# Additional edge cases
@patch('httpx.get')
def test_empty_page(mock_get):
"""Test empty HTML response."""
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = ""
mock_get.return_value = mock_resp
result = webscraper_fetch("https://empty.com")
assert "No Title" in result
@patch('httpx.get')
def test_404(mock_get):
"""Test 404 response."""
mock_req = MagicMock()
mock_resp = MagicMock()
mock_resp.status_code = 404
mock_resp.text = "Not Found"
mock_get.side_effect = httpx.HTTPStatusError("404 Not Found", request=mock_req, response=mock_resp)
result = webscraper_fetch("https://notfound.com")
assert "Error fetching" in result
assert "404" in result
@patch('httpx.get')
def test_invalid_selector(mock_get, mock_response):
"""Test invalid CSS selector handling."""
mock_get.return_value = mock_response
# Implementation uses select_one, which returns None for invalid — already tested in no_match
pass
@patch('httpx.get')
def test_sitemap_max_urls(mock_get, mock_sitemap_response):
"""Test sitemap max_urls limit."""
mock_get.return_value = mock_sitemap_response
result = webscraper_fetch_sitemap("https://example.com/sitemap.xml", max_urls=1)
assert len(result) == 1
# --- webscraper_search_hint tests ---
@pytest.fixture
def mock_brave_response():
"""Mock Brave Search HTML response with result cards."""
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = """
<html><body>
<div class="snippet">
<a href="https://example.com/article1" class="snippet-title">Feynman on Electric Fields</a>
<div class="snippet-title">Feynman on Electric Fields</div>
<div class="snippet-description">Richard Feynman explains that all matter has an electric field.</div>
</div>
<div class="snippet">
<a href="https://example.com/article2" class="snippet-title">Electric Fields Everywhere</a>
<div class="snippet-title">Electric Fields Everywhere</div>
<div class="snippet-description">Everything in the universe is surrounded by electric fields.</div>
</div>
<div class="snippet">
<a href="javascript:void(0)" class="snippet-title">JS Junk</a>
<div class="snippet-title">JS Junk</div>
<div class="snippet-description">Should be filtered out.</div>
</div>
</body></html>
"""
mock_resp.headers = {"content-type": "text/html"}
return mock_resp
@patch('httpx.get')
def test_webscraper_search_hint_returns_structure(mock_get, mock_brave_response):
"""Test that search hint returns correct dict structure."""
mock_get.return_value = mock_brave_response
result = webscraper_search_hint("Feynman electric field")
assert isinstance(result, dict)
assert "query" in result
assert "results" in result
assert "hint" in result
assert result["query"] == "Feynman electric field"
@patch('httpx.get')
def test_webscraper_search_hint_filters_non_http(mock_get, mock_brave_response):
"""Test that javascript: URLs are excluded from results."""
mock_get.return_value = mock_brave_response
result = webscraper_search_hint("Feynman electric field")
urls = [r["url"] for r in result["results"]]
assert all(u.startswith("http") for u in urls)
assert "javascript:void(0)" not in urls
@patch('httpx.get')
def test_webscraper_search_hint_max_results(mock_get, mock_brave_response):
"""Test max_results limits output."""
mock_get.return_value = mock_brave_response
result = webscraper_search_hint("Feynman electric field", max_results=1)
assert len(result["results"]) <= 1
@patch('httpx.get')
def test_webscraper_search_hint_error(mock_get):
"""Test error handling in search hint."""
mock_get.side_effect = httpx.RequestError("Connection failed")
result = webscraper_search_hint("something")
assert result["results"] == []
assert "Error" in result["hint"]
@patch('httpx.get')
def test_webscraper_search_hint_hint_string(mock_get, mock_brave_response):
"""Test that hint string is non-empty when results exist."""
mock_get.return_value = mock_brave_response
result = webscraper_search_hint("Feynman electric field")
# hint should summarise results
assert len(result["hint"]) > 0
assert "No results found" not in result["hint"]
# Total: 23 tests covering all tools and edge cases