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
954 lines
35 KiB
Python
954 lines
35 KiB
Python
"""
|
|
Comprehensive tests for ContentFetcher to improve coverage of fetcher.py.
|
|
|
|
Covers: get_url_info, _get_downloader (caching + all branches + ImportError),
|
|
fetch (invalid, SSRF, success, PDF, unicode errors, truncation, metadata,
|
|
failed download, exceptions, no downloader fallback), fetch_text, and
|
|
DEFAULT_MAX_CONTENT_LENGTH usage.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
from local_deep_research.content_fetcher.fetcher import (
|
|
ContentFetcher,
|
|
DEFAULT_MAX_CONTENT_LENGTH,
|
|
)
|
|
from local_deep_research.content_fetcher.url_classifier import URLType
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_download_result(
|
|
content: bytes, is_success: bool = True, skip_reason=None
|
|
):
|
|
"""Build a mock DownloadResult."""
|
|
result = MagicMock()
|
|
result.is_success = is_success
|
|
result.content = content
|
|
result.skip_reason = skip_reason
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_url_info
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetUrlInfo:
|
|
def test_arxiv_url(self):
|
|
fetcher = ContentFetcher()
|
|
info = fetcher.get_url_info("https://arxiv.org/abs/2301.12345")
|
|
assert info["url_type"] == "arxiv"
|
|
assert info["source_name"] == "arXiv"
|
|
assert info["extracted_id"] == "2301.12345"
|
|
|
|
def test_pubmed_url(self):
|
|
fetcher = ContentFetcher()
|
|
info = fetcher.get_url_info("https://pubmed.ncbi.nlm.nih.gov/12345678/")
|
|
assert info["url_type"] == "pubmed"
|
|
assert info["source_name"] == "PubMed"
|
|
assert info["extracted_id"] == "12345678"
|
|
|
|
def test_html_url(self):
|
|
fetcher = ContentFetcher()
|
|
info = fetcher.get_url_info("https://example.com/article")
|
|
assert info["url_type"] == "html"
|
|
assert info["source_name"] == "Web Page"
|
|
assert info["extracted_id"] is None
|
|
|
|
def test_invalid_url(self):
|
|
fetcher = ContentFetcher()
|
|
info = fetcher.get_url_info("javascript:alert(1)")
|
|
assert info["url_type"] == "invalid"
|
|
assert info["source_name"] == "Invalid URL"
|
|
|
|
def test_doi_url(self):
|
|
fetcher = ContentFetcher()
|
|
info = fetcher.get_url_info("https://doi.org/10.1234/test")
|
|
assert info["url_type"] == "doi"
|
|
assert info["extracted_id"] == "10.1234/test"
|
|
|
|
def test_pmc_url(self):
|
|
fetcher = ContentFetcher()
|
|
info = fetcher.get_url_info(
|
|
"https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1234567/"
|
|
)
|
|
assert info["url_type"] == "pmc"
|
|
assert info["extracted_id"] == "PMC1234567"
|
|
|
|
def test_semantic_scholar_url(self):
|
|
fetcher = ContentFetcher()
|
|
hex40 = "a" * 40
|
|
info = fetcher.get_url_info(
|
|
f"https://www.semanticscholar.org/paper/Title/{hex40}"
|
|
)
|
|
assert info["url_type"] == "semantic_scholar"
|
|
assert info["extracted_id"] == hex40
|
|
|
|
def test_biorxiv_url(self):
|
|
fetcher = ContentFetcher()
|
|
info = fetcher.get_url_info(
|
|
"https://www.biorxiv.org/content/10.1101/2023"
|
|
)
|
|
assert info["url_type"] == "biorxiv"
|
|
|
|
def test_medrxiv_url(self):
|
|
fetcher = ContentFetcher()
|
|
info = fetcher.get_url_info(
|
|
"https://www.medrxiv.org/content/10.1101/2023"
|
|
)
|
|
assert info["url_type"] == "medrxiv"
|
|
|
|
def test_pdf_url(self):
|
|
fetcher = ContentFetcher()
|
|
info = fetcher.get_url_info("https://example.com/paper.pdf")
|
|
assert info["url_type"] == "pdf"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_downloader
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetDownloader:
|
|
"""Test _get_downloader caching and all URLType branches."""
|
|
|
|
def test_caching(self):
|
|
"""Second call for same type returns cached downloader."""
|
|
fetcher = ContentFetcher()
|
|
mock_dl = MagicMock()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
assert fetcher._get_downloader(URLType.HTML) is mock_dl
|
|
|
|
def test_downloader_caching_per_type(self):
|
|
"""Pre-cached downloader for a specific type is returned directly."""
|
|
fetcher = ContentFetcher()
|
|
mock_dl = MagicMock()
|
|
fetcher._downloaders[URLType.ARXIV] = mock_dl
|
|
assert fetcher._get_downloader(URLType.ARXIV) is mock_dl
|
|
# A different type should NOT return the cached one
|
|
assert fetcher._get_downloader(URLType.INVALID) is None
|
|
|
|
@pytest.mark.parametrize(
|
|
"url_type,module_path,class_name",
|
|
[
|
|
(
|
|
URLType.ARXIV,
|
|
"local_deep_research.research_library.downloaders.arxiv",
|
|
"ArxivDownloader",
|
|
),
|
|
(
|
|
URLType.PUBMED,
|
|
"local_deep_research.research_library.downloaders.pubmed",
|
|
"PubMedDownloader",
|
|
),
|
|
(
|
|
URLType.PMC,
|
|
"local_deep_research.research_library.downloaders.pubmed",
|
|
"PubMedDownloader",
|
|
),
|
|
(
|
|
URLType.SEMANTIC_SCHOLAR,
|
|
"local_deep_research.research_library.downloaders.semantic_scholar",
|
|
"SemanticScholarDownloader",
|
|
),
|
|
(
|
|
URLType.BIORXIV,
|
|
"local_deep_research.research_library.downloaders.biorxiv",
|
|
"BioRxivDownloader",
|
|
),
|
|
(
|
|
URLType.MEDRXIV,
|
|
"local_deep_research.research_library.downloaders.biorxiv",
|
|
"BioRxivDownloader",
|
|
),
|
|
(
|
|
URLType.PDF,
|
|
"local_deep_research.research_library.downloaders.direct_pdf",
|
|
"DirectPDFDownloader",
|
|
),
|
|
(
|
|
URLType.HTML,
|
|
"local_deep_research.research_library.downloaders.playwright_html",
|
|
"AutoHTMLDownloader",
|
|
),
|
|
(
|
|
URLType.DOI,
|
|
"local_deep_research.research_library.downloaders.playwright_html",
|
|
"AutoHTMLDownloader",
|
|
),
|
|
],
|
|
)
|
|
def test_import_error_returns_none(
|
|
self, url_type, module_path, class_name, monkeypatch
|
|
):
|
|
"""When the downloader module cannot be imported, returns None."""
|
|
import sys
|
|
|
|
fetcher = ContentFetcher()
|
|
# Use monkeypatch for safe sys.modules manipulation (auto-restored after test)
|
|
monkeypatch.setitem(sys.modules, module_path, None)
|
|
result = fetcher._get_downloader(url_type)
|
|
assert result is None
|
|
|
|
def test_returns_none_for_invalid(self):
|
|
"""INVALID type has no branch, returns None."""
|
|
fetcher = ContentFetcher()
|
|
assert fetcher._get_downloader(URLType.INVALID) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFetch:
|
|
"""Tests for ContentFetcher.fetch()."""
|
|
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def _fetch_with_mocks(
|
|
self,
|
|
url,
|
|
url_type,
|
|
downloader,
|
|
classifier_mock,
|
|
validate_mock,
|
|
**kwargs,
|
|
):
|
|
"""Helper: run fetch() with classifier and validate_url mocked."""
|
|
classifier_mock.classify.return_value = url_type
|
|
classifier_mock.get_source_name.return_value = "Test Source"
|
|
fetcher = ContentFetcher()
|
|
if downloader is not None:
|
|
fetcher._downloaders[url_type] = downloader
|
|
# Also patch _get_downloader to return what's cached or None
|
|
if downloader is None:
|
|
with patch.object(fetcher, "_get_downloader", return_value=None):
|
|
# Also block the HTML fallback import
|
|
with patch(
|
|
"local_deep_research.research_library.downloaders.html.HTMLDownloader",
|
|
create=True,
|
|
side_effect=ImportError("no html"),
|
|
):
|
|
return fetcher.fetch(url, **kwargs)
|
|
return fetcher.fetch(url, **kwargs)
|
|
|
|
# --- Invalid URL ---
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_invalid_url_returns_error(self, classifier_mock):
|
|
classifier_mock.classify.return_value = URLType.INVALID
|
|
classifier_mock.get_source_name.return_value = "Invalid URL"
|
|
fetcher = ContentFetcher()
|
|
result = fetcher.fetch("javascript:alert(1)")
|
|
assert result["status"] == "error"
|
|
assert "Invalid" in result["error"]
|
|
|
|
# --- SSRF blocked ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=False,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_ssrf_blocked(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
fetcher = ContentFetcher()
|
|
result = fetcher.fetch("http://169.254.169.254/metadata")
|
|
assert result["status"] == "error"
|
|
assert "SSRF" in result["error"]
|
|
|
|
# --- Successful UTF-8 fetch ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_success_utf8(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
b"Hello, world!"
|
|
)
|
|
# Remove get_metadata so the hasattr check is False
|
|
del mock_dl.get_metadata
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
result = fetcher.fetch("https://example.com")
|
|
assert result["status"] == "success"
|
|
assert result["content"] == "Hello, world!"
|
|
|
|
# --- PDF magic bytes -> text extraction success ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_pdf_content(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.PDF
|
|
classifier_mock.get_source_name.return_value = "PDF"
|
|
|
|
pdf_bytes = b"%PDF-1.4 fake pdf content"
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
pdf_bytes
|
|
)
|
|
del mock_dl.get_metadata
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.PDF] = mock_dl
|
|
|
|
with patch(
|
|
"local_deep_research.content_fetcher.fetcher.BaseDownloader",
|
|
create=True,
|
|
) as base_mock:
|
|
base_mock.extract_text_from_pdf.return_value = "Extracted PDF text"
|
|
# Need to patch the actual import inside fetch
|
|
with patch(
|
|
"local_deep_research.research_library.downloaders.base.BaseDownloader"
|
|
) as real_base:
|
|
real_base.extract_text_from_pdf.return_value = (
|
|
"Extracted PDF text"
|
|
)
|
|
result = fetcher.fetch("https://example.com/paper.pdf")
|
|
|
|
assert result["status"] == "success"
|
|
assert result["content"] == "Extracted PDF text"
|
|
|
|
# --- PDF text extraction failure ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_pdf_extraction_failure(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.PDF
|
|
classifier_mock.get_source_name.return_value = "PDF"
|
|
|
|
pdf_bytes = b"%PDF-1.4 corrupt"
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
pdf_bytes
|
|
)
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.PDF] = mock_dl
|
|
|
|
with patch(
|
|
"local_deep_research.research_library.downloaders.base.BaseDownloader"
|
|
) as real_base:
|
|
real_base.extract_text_from_pdf.return_value = None
|
|
result = fetcher.fetch("https://example.com/paper.pdf")
|
|
|
|
assert result["status"] == "error"
|
|
assert "Could not extract text from PDF" in result["error"]
|
|
|
|
# --- UnicodeDecodeError ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_unicode_decode_error(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
# Content that is NOT pdf and NOT valid UTF-8
|
|
bad_bytes = b"\x80\x81\x82\x83"
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
bad_bytes
|
|
)
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
result = fetcher.fetch("https://example.com")
|
|
assert result["status"] == "error"
|
|
assert "not valid UTF-8" in result["error"]
|
|
|
|
# --- Content truncation ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_content_truncation(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
long_content = b"A" * 200
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
long_content
|
|
)
|
|
del mock_dl.get_metadata
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
result = fetcher.fetch("https://example.com", max_length=50)
|
|
assert result["status"] == "success"
|
|
assert len(result["content"]) < 200
|
|
assert "[... content truncated ...]" in result["content"]
|
|
# First 50 chars should be preserved
|
|
assert result["content"].startswith("A" * 50)
|
|
|
|
# --- Metadata extraction ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_with_metadata(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
b"Content"
|
|
)
|
|
mock_dl.get_metadata.return_value = {
|
|
"title": "Test Title",
|
|
"author": "Author Name",
|
|
"published_date": "2025-01-01",
|
|
}
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
result = fetcher.fetch("https://example.com")
|
|
assert result["status"] == "success"
|
|
assert result["title"] == "Test Title"
|
|
assert result["author"] == "Author Name"
|
|
assert result["published_date"] == "2025-01-01"
|
|
|
|
# --- Metadata extraction raises exception (should be silenced) ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_metadata_exception_silenced(
|
|
self, classifier_mock, validate_mock
|
|
):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
b"Content"
|
|
)
|
|
mock_dl.get_metadata.side_effect = RuntimeError("metadata boom")
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
result = fetcher.fetch("https://example.com")
|
|
assert result["status"] == "success"
|
|
# title should be None because metadata retrieval failed
|
|
assert result["title"] is None
|
|
|
|
# --- Download failed (is_success=False) ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_failed_download(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
b"", is_success=False, skip_reason="403 Forbidden"
|
|
)
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
result = fetcher.fetch("https://example.com")
|
|
assert result["status"] == "error"
|
|
assert result["error"] == "403 Forbidden"
|
|
|
|
# --- Download failed with no skip_reason ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_failed_download_no_reason(
|
|
self, classifier_mock, validate_mock
|
|
):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
b"", is_success=False, skip_reason=None
|
|
)
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
result = fetcher.fetch("https://example.com")
|
|
assert result["status"] == "error"
|
|
assert result["error"] == "Download failed"
|
|
|
|
# --- Downloader throws exception ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_downloader_exception(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.side_effect = ConnectionError("timeout")
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
result = fetcher.fetch("https://example.com")
|
|
assert result["status"] == "error"
|
|
assert "timeout" in result["error"]
|
|
|
|
# --- No downloader and HTML fallback also fails (ImportError) ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_no_downloader_no_html_fallback(
|
|
self, classifier_mock, validate_mock, monkeypatch
|
|
):
|
|
import sys
|
|
|
|
classifier_mock.classify.return_value = URLType.SEMANTIC_SCHOLAR
|
|
classifier_mock.get_source_name.return_value = "Semantic Scholar"
|
|
|
|
fetcher = ContentFetcher()
|
|
# _get_downloader returns None, AND the inline HTML fallback import fails
|
|
# Block the playwright_html module so the fallback import raises ImportError
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"local_deep_research.research_library.downloaders.playwright_html",
|
|
None,
|
|
)
|
|
with patch.object(fetcher, "_get_downloader", return_value=None):
|
|
result = fetcher.fetch("https://www.semanticscholar.org/paper/xyz")
|
|
|
|
assert result["status"] == "error"
|
|
assert "No suitable downloader" in result["error"]
|
|
|
|
# --- No downloader but HTML fallback works ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_html_fallback_success(self, classifier_mock, validate_mock):
|
|
"""When specialized downloader import fails, fall back to HTML via _get_downloader."""
|
|
classifier_mock.classify.return_value = URLType.SEMANTIC_SCHOLAR
|
|
classifier_mock.get_source_name.return_value = "Semantic Scholar"
|
|
|
|
mock_html_dl = MagicMock()
|
|
mock_html_dl.download_with_result.return_value = _make_download_result(
|
|
b"Fallback content"
|
|
)
|
|
del mock_html_dl.get_metadata
|
|
|
|
fetcher = ContentFetcher()
|
|
|
|
# First call (SEMANTIC_SCHOLAR) returns None (import failed),
|
|
# second call (HTML fallback) returns the HTML downloader.
|
|
def side_effect(url_type):
|
|
if url_type == URLType.HTML:
|
|
return mock_html_dl
|
|
return None
|
|
|
|
with patch.object(fetcher, "_get_downloader", side_effect=side_effect):
|
|
result = fetcher.fetch("https://www.semanticscholar.org/paper/xyz")
|
|
|
|
assert result["status"] == "success"
|
|
assert result["content"] == "Fallback content"
|
|
|
|
# --- Default max_length is applied ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_default_max_length_applied(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
# Content larger than DEFAULT_MAX_CONTENT_LENGTH
|
|
huge_content = b"X" * (DEFAULT_MAX_CONTENT_LENGTH + 100)
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
huge_content
|
|
)
|
|
del mock_dl.get_metadata
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
# Do NOT pass max_length -> should use DEFAULT_MAX_CONTENT_LENGTH
|
|
result = fetcher.fetch("https://example.com")
|
|
assert result["status"] == "success"
|
|
assert "[... content truncated ...]" in result["content"]
|
|
# The content before truncation suffix should be DEFAULT_MAX_CONTENT_LENGTH chars
|
|
assert result["content"].startswith("X" * DEFAULT_MAX_CONTENT_LENGTH)
|
|
|
|
# --- prefer_text=False sets PDF content type ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_prefer_text_false(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
b"data"
|
|
)
|
|
del mock_dl.get_metadata
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
fetcher.fetch("https://example.com", prefer_text=False)
|
|
|
|
# Verify download_with_result was called with ContentType.PDF
|
|
from local_deep_research.research_library.downloaders.base import (
|
|
ContentType,
|
|
)
|
|
|
|
call_args = mock_dl.download_with_result.call_args
|
|
assert call_args[0][1] == ContentType.PDF
|
|
|
|
# --- is_success True but content is None/empty ---
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_success_but_empty_content(
|
|
self, classifier_mock, validate_mock
|
|
):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
result_obj = MagicMock()
|
|
result_obj.is_success = True
|
|
result_obj.content = None
|
|
result_obj.skip_reason = None
|
|
mock_dl.download_with_result.return_value = result_obj
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
result = fetcher.fetch("https://example.com")
|
|
# Falls into the else branch because content is falsy
|
|
assert result["status"] == "error"
|
|
assert result["error"] == "Download failed"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_text
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFetchText:
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_text_success(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
b"Text content"
|
|
)
|
|
del mock_dl.get_metadata
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
text = fetcher.fetch_text("https://example.com")
|
|
assert text == "Text content"
|
|
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_text_failure_returns_none(
|
|
self, classifier_mock, validate_mock
|
|
):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
b"", is_success=False, skip_reason="error"
|
|
)
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
text = fetcher.fetch_text("https://example.com")
|
|
assert text is None
|
|
|
|
def test_fetch_text_invalid_url_returns_none(self):
|
|
fetcher = ContentFetcher()
|
|
text = fetcher.fetch_text("javascript:void(0)")
|
|
assert text is None
|
|
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_fetch_text_with_max_length(self, classifier_mock, validate_mock):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
b"A" * 200
|
|
)
|
|
del mock_dl.get_metadata
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
text = fetcher.fetch_text("https://example.com", max_length=50)
|
|
assert text is not None
|
|
assert "[... content truncated ...]" in text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# __init__
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInit:
|
|
def test_default_timeout(self):
|
|
fetcher = ContentFetcher()
|
|
assert fetcher.timeout == 30
|
|
|
|
def test_custom_timeout(self):
|
|
fetcher = ContentFetcher(timeout=60)
|
|
assert fetcher.timeout == 60
|
|
|
|
def test_downloaders_initially_empty(self):
|
|
fetcher = ContentFetcher()
|
|
assert fetcher._downloaders == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DEFAULT_MAX_CONTENT_LENGTH constant
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConstants:
|
|
def test_default_max_content_length_value(self):
|
|
assert DEFAULT_MAX_CONTENT_LENGTH == 500_000
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# language parameter
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLanguageParam:
|
|
def test_default_language(self):
|
|
fetcher = ContentFetcher()
|
|
assert fetcher.language == "English"
|
|
|
|
def test_custom_language(self):
|
|
fetcher = ContentFetcher(language="German")
|
|
assert fetcher.language == "German"
|
|
|
|
def test_language_forwarded_to_html_downloader(self):
|
|
"""_get_downloader(HTML) passes language to AutoHTMLDownloader."""
|
|
fetcher = ContentFetcher(language="French")
|
|
|
|
with patch(
|
|
"local_deep_research.research_library.downloaders."
|
|
"playwright_html.AutoHTMLDownloader"
|
|
) as dl_cls:
|
|
dl_cls.return_value = MagicMock()
|
|
downloader = fetcher._get_downloader(URLType.HTML)
|
|
dl_cls.assert_called_once_with(
|
|
timeout=30,
|
|
language="French",
|
|
enable_js_rendering=False,
|
|
)
|
|
assert downloader is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTML fallback for specialized downloader failures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHtmlFallback:
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_specialized_fails_html_fallback_succeeds(
|
|
self, classifier_mock, validate_mock
|
|
):
|
|
"""When ArXiv downloader fails, HTML fallback returns content."""
|
|
classifier_mock.classify.return_value = URLType.ARXIV
|
|
classifier_mock.get_source_name.return_value = "arXiv"
|
|
|
|
# Specialized downloader fails
|
|
mock_arxiv = MagicMock()
|
|
mock_arxiv.download_with_result.return_value = _make_download_result(
|
|
content=None, is_success=False, skip_reason="PDF unavailable"
|
|
)
|
|
|
|
# HTML fallback succeeds
|
|
mock_html = MagicMock()
|
|
mock_html.download_with_result.return_value = _make_download_result(
|
|
content=b"arXiv abstract content"
|
|
)
|
|
del mock_html.get_metadata
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.ARXIV] = mock_arxiv
|
|
fetcher._downloaders[URLType.HTML] = mock_html
|
|
|
|
result = fetcher.fetch("https://arxiv.org/abs/2301.12345")
|
|
assert result["status"] == "success"
|
|
assert "arXiv abstract content" in result["content"]
|
|
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_specialized_fails_html_fallback_also_fails(
|
|
self, classifier_mock, validate_mock
|
|
):
|
|
"""When both specialized and HTML downloaders fail, return error."""
|
|
classifier_mock.classify.return_value = URLType.PUBMED
|
|
classifier_mock.get_source_name.return_value = "PubMed"
|
|
|
|
mock_pubmed = MagicMock()
|
|
mock_pubmed.download_with_result.return_value = _make_download_result(
|
|
content=None, is_success=False, skip_reason="Paywalled"
|
|
)
|
|
|
|
mock_html = MagicMock()
|
|
mock_html.download_with_result.return_value = _make_download_result(
|
|
content=None, is_success=False, skip_reason="No content"
|
|
)
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.PUBMED] = mock_pubmed
|
|
fetcher._downloaders[URLType.HTML] = mock_html
|
|
|
|
result = fetcher.fetch("https://pubmed.ncbi.nlm.nih.gov/12345/")
|
|
assert result["status"] == "error"
|
|
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_pdf_type_no_html_fallback(self, classifier_mock, validate_mock):
|
|
"""URLType.PDF failure does NOT trigger HTML fallback."""
|
|
classifier_mock.classify.return_value = URLType.PDF
|
|
classifier_mock.get_source_name.return_value = "Direct PDF"
|
|
|
|
mock_pdf = MagicMock()
|
|
mock_pdf.download_with_result.return_value = _make_download_result(
|
|
content=None, is_success=False, skip_reason="404"
|
|
)
|
|
|
|
mock_html = MagicMock()
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.PDF] = mock_pdf
|
|
fetcher._downloaders[URLType.HTML] = mock_html
|
|
|
|
result = fetcher.fetch("https://example.com/paper.pdf")
|
|
assert result["status"] == "error"
|
|
# HTML downloader should NOT have been called
|
|
mock_html.download_with_result.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_batch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFetchBatch:
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_returns_dict_with_correct_keys(
|
|
self, classifier_mock, validate_mock
|
|
):
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.return_value = _make_download_result(
|
|
b"Page content"
|
|
)
|
|
del mock_dl.get_metadata
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
|
|
urls = ["https://a.com", "https://b.com"]
|
|
result = fetcher.fetch_batch(urls)
|
|
|
|
assert isinstance(result, dict)
|
|
assert set(result.keys()) == set(urls)
|
|
assert all(v is not None for v in result.values())
|
|
|
|
@patch(
|
|
"local_deep_research.content_fetcher.fetcher.policy_aware_validate_url",
|
|
return_value=True,
|
|
)
|
|
@patch("local_deep_research.content_fetcher.fetcher.URLClassifier")
|
|
def test_mixed_results(self, classifier_mock, validate_mock):
|
|
"""One URL succeeds, one fails — both appear in output."""
|
|
classifier_mock.classify.return_value = URLType.HTML
|
|
classifier_mock.get_source_name.return_value = "Web Page"
|
|
|
|
success_result = _make_download_result(b"OK content")
|
|
fail_result = _make_download_result(
|
|
content=None, is_success=False, skip_reason="Error"
|
|
)
|
|
|
|
mock_dl = MagicMock()
|
|
mock_dl.download_with_result.side_effect = [
|
|
success_result,
|
|
fail_result,
|
|
]
|
|
del mock_dl.get_metadata
|
|
|
|
fetcher = ContentFetcher()
|
|
fetcher._downloaders[URLType.HTML] = mock_dl
|
|
|
|
result = fetcher.fetch_batch(["https://ok.com", "https://fail.com"])
|
|
assert result["https://ok.com"] is not None
|
|
assert result["https://fail.com"] is None
|