Files
learningcircuit--local-deep…/tests/search_engines/test_search_engine_gutenberg_deep_coverage.py
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

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"