Files
bindery/tests/test_fff_flows.py
T
tim c30622e3a0 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.
2026-09-27 23:38:28 +02:00

109 lines
4.5 KiB
Python

"""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"))