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
303 lines
10 KiB
Python
303 lines
10 KiB
Python
"""
|
|
Tests for uncovered code paths in GutenbergSearchEngine.
|
|
|
|
Targets:
|
|
- _get_full_content: subjects, bookshelves, translators, content building, book text
|
|
- _fetch_book_text: success, boilerplate stripping, truncation, error handling
|
|
- get_book: success, rate limit, exception
|
|
- search_by_topic: topic switching, cleanup on exception
|
|
"""
|
|
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.web_search_engines.engines.search_engine_gutenberg import (
|
|
GutenbergSearchEngine,
|
|
)
|
|
from local_deep_research.web_search_engines.rate_limiting import RateLimitError
|
|
|
|
MODULE = (
|
|
"local_deep_research.web_search_engines.engines.search_engine_gutenberg"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def engine():
|
|
eng = GutenbergSearchEngine(max_results=5)
|
|
eng.rate_tracker = Mock()
|
|
eng.rate_tracker.apply_rate_limit.return_value = 0
|
|
return eng
|
|
|
|
|
|
def _mock_response(status_code=200, json_data=None, text=None):
|
|
resp = Mock()
|
|
resp.status_code = status_code
|
|
resp.json.return_value = json_data or {}
|
|
resp.text = text or ""
|
|
resp.raise_for_status = Mock()
|
|
if status_code >= 400:
|
|
resp.raise_for_status.side_effect = Exception(f"HTTP {status_code}")
|
|
return resp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_full_content
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetFullContent:
|
|
def test_builds_content_with_book_text(self, engine):
|
|
"""Content includes metadata header + actual book text when available."""
|
|
items = [
|
|
{
|
|
"title": "Alice in Wonderland",
|
|
"authors": ["Lewis Carroll"],
|
|
"download_count": 50000,
|
|
"read_url": "https://gutenberg.org/ebooks/11",
|
|
"_raw": {
|
|
"subjects": ["Fantasy", "Children's literature"],
|
|
"bookshelves": ["Children's Fiction"],
|
|
"translators": [{"name": "Someone", "birth_year": 1900}],
|
|
"formats": {
|
|
"text/plain; charset=utf-8": "https://www.gutenberg.org/files/11/11-0.txt"
|
|
},
|
|
},
|
|
}
|
|
]
|
|
|
|
with patch.object(
|
|
engine, "_fetch_book_text", return_value="Alice was beginning..."
|
|
) as mock_fetch:
|
|
results = engine._get_full_content(items)
|
|
mock_fetch.assert_called_once_with(
|
|
"https://www.gutenberg.org/files/11/11-0.txt"
|
|
)
|
|
|
|
assert len(results) == 1
|
|
content = results[0]["content"]
|
|
assert "Authors: Lewis Carroll" in content
|
|
assert "Subjects: Fantasy, Children's literature" in content
|
|
assert "Alice was beginning..." in content
|
|
# Metadata-only fields should NOT appear when text is available
|
|
assert "Bookshelves:" not in content
|
|
assert "Downloads:" not in content
|
|
assert "_raw" not in results[0]
|
|
|
|
def test_metadata_fallback_when_no_text(self, engine):
|
|
"""Falls back to metadata when book text is unavailable."""
|
|
items = [
|
|
{
|
|
"title": "Alice in Wonderland",
|
|
"authors": ["Lewis Carroll"],
|
|
"download_count": 50000,
|
|
"read_url": "https://gutenberg.org/ebooks/11",
|
|
"_raw": {
|
|
"subjects": ["Fantasy"],
|
|
"bookshelves": ["Children's Fiction"],
|
|
"translators": [],
|
|
},
|
|
}
|
|
]
|
|
|
|
results = engine._get_full_content(items)
|
|
|
|
content = results[0]["content"]
|
|
assert "Bookshelves: Children's Fiction" in content
|
|
assert "Downloads: 50000" in content
|
|
assert "Read online:" in content
|
|
|
|
def test_rejects_non_gutenberg_url(self, engine):
|
|
"""Skips text URLs not from gutenberg.org."""
|
|
items = [
|
|
{
|
|
"title": "Test",
|
|
"authors": [],
|
|
"_raw": {
|
|
"subjects": [],
|
|
"bookshelves": [],
|
|
"translators": [],
|
|
"formats": {
|
|
"text/plain; charset=utf-8": "https://evil.com/malicious.txt"
|
|
},
|
|
},
|
|
}
|
|
]
|
|
|
|
with patch.object(engine, "_fetch_book_text") as mock_fetch:
|
|
engine._get_full_content(items)
|
|
mock_fetch.assert_not_called()
|
|
|
|
def test_without_raw(self, engine):
|
|
"""Items without _raw pass through."""
|
|
items = [{"title": "Minimal"}]
|
|
|
|
results = engine._get_full_content(items)
|
|
assert len(results) == 1
|
|
assert "_raw" not in results[0]
|
|
|
|
def test_empty_raw_fields(self, engine):
|
|
"""Handles empty subjects, bookshelves, translators."""
|
|
items = [
|
|
{
|
|
"title": "Test",
|
|
"_raw": {
|
|
"subjects": [],
|
|
"bookshelves": [],
|
|
"translators": [],
|
|
},
|
|
}
|
|
]
|
|
|
|
results = engine._get_full_content(items)
|
|
content = results[0].get("content", "")
|
|
assert "Subjects:" not in content
|
|
assert "Bookshelves:" not in content
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _fetch_book_text
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFetchBookText:
|
|
@patch(f"{MODULE}.safe_get")
|
|
def test_strips_start_and_end_markers(self, mock_get, engine):
|
|
"""Strips Project Gutenberg header and footer boilerplate."""
|
|
raw_text = (
|
|
"The Project Gutenberg EBook of Test\n"
|
|
"*** START OF THE PROJECT GUTENBERG EBOOK TEST ***\n"
|
|
"Actual book content here.\n"
|
|
"*** END OF THE PROJECT GUTENBERG EBOOK TEST ***\n"
|
|
"End of Project Gutenberg..."
|
|
)
|
|
mock_get.return_value = _mock_response(200, text=raw_text)
|
|
|
|
result = engine._fetch_book_text(
|
|
"https://www.gutenberg.org/files/1/1-0.txt"
|
|
)
|
|
|
|
assert result == "Actual book content here."
|
|
|
|
@patch(f"{MODULE}.safe_get")
|
|
def test_handles_variant_markers(self, mock_get, engine):
|
|
"""Handles 'THIS' variant of the start/end markers."""
|
|
raw_text = (
|
|
"Header\n"
|
|
"*** START OF THIS PROJECT GUTENBERG EBOOK X ***\n"
|
|
"The real content.\n"
|
|
"*** END OF THIS PROJECT GUTENBERG EBOOK X ***\n"
|
|
)
|
|
mock_get.return_value = _mock_response(200, text=raw_text)
|
|
|
|
result = engine._fetch_book_text(
|
|
"https://www.gutenberg.org/files/1/1-0.txt"
|
|
)
|
|
|
|
assert result == "The real content."
|
|
|
|
@patch(f"{MODULE}.safe_get")
|
|
def test_truncates_to_max_content_chars(self, mock_get, engine):
|
|
"""Truncates text to max_content_chars."""
|
|
engine.max_content_chars = 100
|
|
body = "A" * 200
|
|
raw_text = f"*** START OF THE PROJECT GUTENBERG EBOOK T ***\n{body}\n*** END OF THE PROJECT GUTENBERG EBOOK T ***"
|
|
mock_get.return_value = _mock_response(200, text=raw_text)
|
|
|
|
result = engine._fetch_book_text(
|
|
"https://www.gutenberg.org/files/1/1-0.txt"
|
|
)
|
|
|
|
assert result.startswith("A" * 100)
|
|
assert "[... truncated ...]" in result
|
|
|
|
@patch(f"{MODULE}.safe_get")
|
|
def test_empty_response_returns_none(self, mock_get, engine):
|
|
"""Returns None for empty response text."""
|
|
mock_get.return_value = _mock_response(200, text="")
|
|
|
|
result = engine._fetch_book_text(
|
|
"https://www.gutenberg.org/files/1/1-0.txt"
|
|
)
|
|
assert result is None
|
|
|
|
@patch(f"{MODULE}.safe_get")
|
|
def test_http_error_returns_none(self, mock_get, engine):
|
|
"""Returns None on HTTP errors."""
|
|
mock_get.return_value = _mock_response(404)
|
|
|
|
result = engine._fetch_book_text(
|
|
"https://www.gutenberg.org/files/1/1-0.txt"
|
|
)
|
|
assert result is None
|
|
|
|
@patch(f"{MODULE}.safe_get")
|
|
def test_network_error_returns_none(self, mock_get, engine):
|
|
"""Returns None on network errors."""
|
|
mock_get.side_effect = ConnectionError("fail")
|
|
|
|
result = engine._fetch_book_text(
|
|
"https://www.gutenberg.org/files/1/1-0.txt"
|
|
)
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_book
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetBook:
|
|
@patch(f"{MODULE}.safe_get")
|
|
def test_success(self, mock_get, engine):
|
|
"""Returns book dict on success."""
|
|
book = {"id": 11, "title": "Alice in Wonderland"}
|
|
mock_get.return_value = _mock_response(200, book)
|
|
|
|
result = engine.get_book(11)
|
|
|
|
assert result["id"] == 11
|
|
assert result["title"] == "Alice in Wonderland"
|
|
|
|
@patch(f"{MODULE}.safe_get")
|
|
def test_rate_limit_propagates(self, mock_get, engine):
|
|
"""RateLimitError is re-raised."""
|
|
mock_get.return_value = _mock_response(429)
|
|
engine._raise_if_rate_limit = Mock(side_effect=RateLimitError("429"))
|
|
|
|
with pytest.raises(RateLimitError):
|
|
engine.get_book(11)
|
|
|
|
@patch(f"{MODULE}.safe_get")
|
|
def test_exception_returns_none(self, mock_get, engine):
|
|
"""Other exceptions return None."""
|
|
mock_get.side_effect = ConnectionError("fail")
|
|
|
|
result = engine.get_book(11)
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# search_by_topic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSearchByTopic:
|
|
def test_sets_topic_and_restores(self, engine):
|
|
"""Temporarily sets topic filter and restores."""
|
|
engine.topic = None
|
|
with patch.object(engine, "run", return_value=[]) as mock_run:
|
|
engine.search_by_topic("science fiction")
|
|
|
|
mock_run.assert_called_once_with("")
|
|
assert engine.topic is None
|
|
|
|
def test_restores_on_exception(self, engine):
|
|
"""Topic is restored even on exception."""
|
|
engine.topic = "original"
|
|
with patch.object(engine, "run", side_effect=Exception("fail")):
|
|
with pytest.raises(Exception, match="fail"):
|
|
engine.search_by_topic("new topic")
|
|
|
|
assert engine.topic == "original"
|