Download stories and build epubs with FanFicFare
FanFicFare adapters fetch metadata and chapters, and its epub writer builds each book from the store. Environment variables configure the app, and a default personal.ini is copied to the config folder on first start.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"""Exercise the FanFicFare boundary without requests to a story site."""
|
||||
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from test_build import IMG_URL, PNG, URL, chapters, metahtml
|
||||
|
||||
from bindery import config, fff
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cover", [None, {}, {"url": IMG_URL, "ext": "png", "mime": "image/png", "data": PNG}])
|
||||
def test_metadata_snapshot_from_adapter(monkeypatch, cover):
|
||||
adapter = fff.get_adapter(URL)
|
||||
adapter.setStoryMetadata(metahtml())
|
||||
adapter.chapterUrls = chapters()
|
||||
adapter.story.img_store.cover = cover
|
||||
fetch = Mock(return_value=adapter.story)
|
||||
monkeypatch.setattr(adapter, "getStoryMetadataOnly", fetch)
|
||||
snap = fff.fetch_story(adapter)
|
||||
fetch.assert_called_once_with(get_cover=True)
|
||||
assert snap.title == "Test Story"
|
||||
assert snap.author == "Writer"
|
||||
assert snap.site == "www.royalroad.com"
|
||||
assert snap.chapters == [{"url": c["url"], "title": c["title"]} for c in chapters()]
|
||||
assert snap.calibre["authors"] == ["Writer"]
|
||||
assert snap.cover == (cover or None)
|
||||
assert "Test Story" in snap.metahtml
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error,message",
|
||||
[
|
||||
(fff.exceptions.StoryDoesNotExist(URL), "StoryDoesNotExist"),
|
||||
(fff.exceptions.AccessDenied("private"), "AccessDenied"),
|
||||
(fff.exceptions.FailedToLogin(URL, "reader"), "FailedToLogin"),
|
||||
(fff.exceptions.AdultCheckRequired(URL), "is_adult:true"),
|
||||
],
|
||||
)
|
||||
def test_adapter_errors_become_actionable_story_errors(error, message):
|
||||
adapter = SimpleNamespace(getStoryMetadataOnly=Mock(side_effect=error))
|
||||
with pytest.raises(fff.StoryError, match=message):
|
||||
fff.fetch_story(adapter)
|
||||
|
||||
|
||||
def test_downloaded_chapter_restores_original_image_urls():
|
||||
html = '<p>Text</p><img src="images/1.png" longdesc="https://images.test/1.png">'
|
||||
adapter = SimpleNamespace(getChapterTextNum=Mock(return_value=html))
|
||||
result = fff.fetch_chapter(adapter, "chapter", 3)
|
||||
adapter.getChapterTextNum.assert_called_once_with("chapter", 3)
|
||||
assert 'src="https://images.test/1.png"' in result
|
||||
for src in ["data:image/png;base64,abc", "failedtoload", "https://images.test/direct.png"]:
|
||||
markup = f'<img src="{src}"' + (' longdesc="original"' if src != "https://images.test/direct.png" else "") + ">"
|
||||
assert f'src="{src}"' in fff.canonical_html(markup)
|
||||
assert fff.canonical_html("<p>plain</p>") == "<p>plain</p>"
|
||||
|
||||
|
||||
def test_collected_images_skip_failed_and_empty_downloads():
|
||||
image = {"url": "image", "ext": "png", "mime": "image/png", "data": PNG}
|
||||
adapter = SimpleNamespace(
|
||||
story=SimpleNamespace(
|
||||
img_store=SimpleNamespace(
|
||||
infos=[{**image, "newsrc": "images/1.png"}, {**image, "data": b""}, {**image, "newsrc": "failedtoload"}]
|
||||
)
|
||||
)
|
||||
)
|
||||
assert fff.collected_images(adapter) == [image]
|
||||
|
||||
|
||||
def test_personal_ini_overrides_and_invalid_input(monkeypatch, tmp_path):
|
||||
ini = tmp_path / "personal.ini"
|
||||
ini.write_text("[overrides]\nis_adult: true\n", encoding="utf-8")
|
||||
monkeypatch.setattr(config, "PERSONAL_INI", ini)
|
||||
configuration = fff.get_configuration(URL)
|
||||
assert configuration.getConfig("is_adult")
|
||||
for text in ["[defaults]\nbroken line\n", "setting: before any section"]:
|
||||
with pytest.raises(ValueError, match="cannot read"):
|
||||
fff.check_personal_ini(text)
|
||||
with pytest.raises(fff.StoryError, match="does not support"):
|
||||
fff.get_configuration("https://unsupported.example/story")
|
||||
|
||||
|
||||
def test_adapter_rejects_invalid_story_url(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
fff.adapters, "getAdapter", Mock(side_effect=fff.exceptions.InvalidStoryURL(URL, "site", "example"))
|
||||
)
|
||||
with pytest.raises(fff.StoryError):
|
||||
fff.get_adapter(URL)
|
||||
|
||||
|
||||
def test_normalization_errors_are_not_site_requests(monkeypatch):
|
||||
monkeypatch.setattr(fff.adapters, "getNormalStoryURLSite", Mock(side_effect=ValueError("bad URL")))
|
||||
fff.normalize_url.cache_clear()
|
||||
try:
|
||||
assert fff.normalize_url("bad-input") is None
|
||||
finally:
|
||||
fff.normalize_url.cache_clear()
|
||||
|
||||
|
||||
def test_empty_chapters_fail_and_stored_cover_is_built(tmp_path):
|
||||
with pytest.raises(fff.StoryError, match="no chapters"):
|
||||
fff.build_epub(URL, metahtml(), [], [], None, [], {}, tmp_path / "empty.epub")
|
||||
cover = {"url": IMG_URL, "ext": "png", "mime": "image/png", "data": PNG}
|
||||
out = tmp_path / "cover.epub"
|
||||
fff.build_epub(URL, metahtml(), chapters()[:1], [], cover, [], {}, out)
|
||||
with zipfile.ZipFile(out) as book:
|
||||
assert any(book.read(n) == PNG for n in book.namelist() if n.endswith(".png"))
|
||||
Reference in New Issue
Block a user