235 lines
7.0 KiB
Python
235 lines
7.0 KiB
Python
"""
|
|
Tests for engine.fetch module.
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from engine.fetch import (
|
|
_fetch_gutenberg,
|
|
fetch_all,
|
|
fetch_feed,
|
|
fetch_poetry,
|
|
load_cache,
|
|
save_cache,
|
|
)
|
|
|
|
|
|
class TestFetchFeed:
|
|
"""Tests for fetch_feed function."""
|
|
|
|
@patch("engine.fetch.urllib.request.urlopen")
|
|
def test_fetch_success(self, mock_urlopen):
|
|
"""Successful feed fetch returns parsed feed."""
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = b"<rss>test</rss>"
|
|
mock_urlopen.return_value = mock_response
|
|
|
|
result = fetch_feed("http://example.com/feed")
|
|
|
|
assert result is not None
|
|
|
|
@patch("engine.fetch.urllib.request.urlopen")
|
|
def test_fetch_network_error(self, mock_urlopen):
|
|
"""Network error returns None."""
|
|
mock_urlopen.side_effect = Exception("Network error")
|
|
|
|
result = fetch_feed("http://example.com/feed")
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestFetchAll:
|
|
"""Tests for fetch_all function."""
|
|
|
|
@patch("engine.fetch.fetch_feed")
|
|
@patch("engine.fetch.strip_tags")
|
|
@patch("engine.fetch.skip")
|
|
@patch("engine.fetch.boot_ln")
|
|
def test_fetch_all_success(self, mock_boot, mock_skip, mock_strip, mock_fetch_feed):
|
|
"""Successful fetch returns items."""
|
|
mock_feed = MagicMock()
|
|
mock_feed.bozo = False
|
|
mock_feed.entries = [
|
|
{"title": "Headline 1", "published_parsed": (2024, 1, 1, 12, 0, 0)},
|
|
{"title": "Headline 2", "updated_parsed": (2024, 1, 2, 12, 0, 0)},
|
|
]
|
|
mock_fetch_feed.return_value = mock_feed
|
|
mock_skip.return_value = False
|
|
mock_strip.side_effect = lambda x: x
|
|
|
|
items, linked, failed = fetch_all()
|
|
|
|
assert linked > 0
|
|
assert failed == 0
|
|
|
|
@patch("engine.fetch.fetch_feed")
|
|
@patch("engine.fetch.boot_ln")
|
|
def test_fetch_all_feed_error(self, mock_boot, mock_fetch_feed):
|
|
"""Feed error increments failed count."""
|
|
mock_fetch_feed.return_value = None
|
|
|
|
items, linked, failed = fetch_all()
|
|
|
|
assert failed > 0
|
|
|
|
@patch("engine.fetch.fetch_feed")
|
|
@patch("engine.fetch.strip_tags")
|
|
@patch("engine.fetch.skip")
|
|
@patch("engine.fetch.boot_ln")
|
|
def test_fetch_all_skips_filtered(
|
|
self, mock_boot, mock_skip, mock_strip, mock_fetch_feed
|
|
):
|
|
"""Filtered headlines are skipped."""
|
|
mock_feed = MagicMock()
|
|
mock_feed.bozo = False
|
|
mock_feed.entries = [
|
|
{"title": "Sports scores"},
|
|
{"title": "Valid headline"},
|
|
]
|
|
mock_fetch_feed.return_value = mock_feed
|
|
mock_skip.side_effect = lambda x: x == "Sports scores"
|
|
mock_strip.side_effect = lambda x: x
|
|
|
|
items, linked, failed = fetch_all()
|
|
|
|
assert any("Valid headline" in item[0] for item in items)
|
|
|
|
|
|
class TestFetchGutenberg:
|
|
"""Tests for _fetch_gutenberg function."""
|
|
|
|
@patch("engine.fetch.urllib.request.urlopen")
|
|
def test_gutenberg_success(self, mock_urlopen):
|
|
"""Successful gutenberg fetch returns items."""
|
|
text = """Project Gutenberg
|
|
|
|
*** START OF THE PROJECT GUTENBERG ***
|
|
This is a test poem with multiple lines
|
|
that should be parsed as a block.
|
|
|
|
Another stanza with more content here.
|
|
|
|
*** END OF THE PROJECT GUTENBERG ***
|
|
"""
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = text.encode("utf-8")
|
|
mock_urlopen.return_value = mock_response
|
|
|
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
|
|
|
assert len(result) > 0
|
|
|
|
@patch("engine.fetch.urllib.request.urlopen")
|
|
def test_gutenberg_network_error(self, mock_urlopen):
|
|
"""Network error returns empty list."""
|
|
mock_urlopen.side_effect = Exception("Network error")
|
|
|
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
|
|
|
assert result == []
|
|
|
|
@patch("engine.fetch.urllib.request.urlopen")
|
|
def test_gutenberg_skips_short_blocks(self, mock_urlopen):
|
|
"""Blocks shorter than 20 chars are skipped."""
|
|
text = """*** START OF THE ***
|
|
Short
|
|
*** END OF THE ***
|
|
"""
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = text.encode("utf-8")
|
|
mock_urlopen.return_value = mock_response
|
|
|
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
|
|
|
assert result == []
|
|
|
|
@patch("engine.fetch.urllib.request.urlopen")
|
|
def test_gutenberg_skips_all_caps_headers(self, mock_urlopen):
|
|
"""All-caps lines are skipped as headers."""
|
|
text = """*** START OF THE ***
|
|
THIS IS ALL CAPS HEADER
|
|
more content here
|
|
*** END OF THE ***
|
|
"""
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = text.encode("utf-8")
|
|
mock_urlopen.return_value = mock_response
|
|
|
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
|
|
|
assert len(result) > 0
|
|
|
|
|
|
class TestFetchPoetry:
|
|
"""Tests for fetch_poetry function."""
|
|
|
|
@patch("engine.fetch._fetch_gutenberg")
|
|
@patch("engine.fetch.boot_ln")
|
|
def test_fetch_poetry_success(self, mock_boot, mock_fetch):
|
|
"""Successful poetry fetch returns items."""
|
|
mock_fetch.return_value = [
|
|
("Stanza 1 content here", "Test", ""),
|
|
("Stanza 2 content here", "Test", ""),
|
|
]
|
|
|
|
items, linked, failed = fetch_poetry()
|
|
|
|
assert linked > 0
|
|
assert failed == 0
|
|
|
|
@patch("engine.fetch._fetch_gutenberg")
|
|
@patch("engine.fetch.boot_ln")
|
|
def test_fetch_poetry_failure(self, mock_boot, mock_fetch):
|
|
"""Failed fetch increments failed count."""
|
|
mock_fetch.return_value = []
|
|
|
|
items, linked, failed = fetch_poetry()
|
|
|
|
assert failed > 0
|
|
|
|
|
|
class TestCache:
|
|
"""Tests for cache functions."""
|
|
|
|
@patch("engine.fetch._cache_path")
|
|
def test_load_cache_success(self, mock_path):
|
|
"""Successful cache load returns items."""
|
|
mock_path.return_value.__str__ = MagicMock(return_value="/tmp/cache")
|
|
mock_path.return_value.exists.return_value = True
|
|
mock_path.return_value.read_text.return_value = json.dumps(
|
|
{"items": [("title", "source", "time")]}
|
|
)
|
|
|
|
result = load_cache()
|
|
|
|
assert result is not None
|
|
|
|
@patch("engine.fetch._cache_path")
|
|
def test_load_cache_missing_file(self, mock_path):
|
|
"""Missing cache file returns None."""
|
|
mock_path.return_value.exists.return_value = False
|
|
|
|
result = load_cache()
|
|
|
|
assert result is None
|
|
|
|
@patch("engine.fetch._cache_path")
|
|
def test_load_cache_invalid_json(self, mock_path):
|
|
"""Invalid JSON returns None."""
|
|
mock_path.return_value.exists.return_value = True
|
|
mock_path.return_value.read_text.side_effect = json.JSONDecodeError("", "", 0)
|
|
|
|
result = load_cache()
|
|
|
|
assert result is None
|
|
|
|
@patch("engine.fetch._cache_path")
|
|
def test_save_cache_success(self, mock_path):
|
|
"""Save cache writes to file."""
|
|
mock_path.return_value.__truediv__ = MagicMock(
|
|
return_value=mock_path.return_value
|
|
)
|
|
|
|
save_cache([("title", "source", "time")])
|