A key of the chapters, rules and metadata of a story tells if its book changed. A check without a change no longer builds the book again, also for a book that waits for an upload. A held book goes out without a new build. The Rebuild button still builds and sends in each case.
206 lines
9.0 KiB
Python
206 lines
9.0 KiB
Python
"""A book is built and sent only when its chapters, rules or metadata changed."""
|
|
|
|
import dataclasses
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from test_api import H
|
|
from test_api import client as client
|
|
from test_deliver import STORY_URL, add_story, ingest_files, linked_story, site, update
|
|
from test_deliver import fake as fake
|
|
from test_deliver import jobs as jobs
|
|
from test_deliver import story_epub as story_epub
|
|
|
|
from bindery import config, orchestrator
|
|
from bindery.jobs import DownloadMode, Job
|
|
|
|
ONE = [(1, "One", "a")]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def builds(fake, jobs, monkeypatch, tmp_path):
|
|
"""Count the builds, in a folder of built books that starts empty."""
|
|
monkeypatch.setattr(config, "TMP_DIR", tmp_path / "tmp")
|
|
config.TMP_DIR.mkdir()
|
|
spy = Mock(side_effect=orchestrator._build)
|
|
monkeypatch.setattr(orchestrator, "_build", spy)
|
|
return spy
|
|
|
|
|
|
def story(story_id):
|
|
return orchestrator.store.get_story(story_id)
|
|
|
|
|
|
def test_an_unchanged_story_is_not_built_or_sent_again(builds, monkeypatch):
|
|
story_id = linked_story(ONE)
|
|
site(monkeypatch, ONE)
|
|
assert "library book 1" in update(story_id)
|
|
changed = story(story_id)["last_changed"]
|
|
orchestrator.store.update_story(story_id, last_changed="2020-01-01T00:00:00")
|
|
for mode in (DownloadMode.NEW, DownloadMode.RECHECK_ALL, DownloadMode.METADATA, DownloadMode.FULL):
|
|
assert update(story_id, mode).startswith("No changes")
|
|
assert changed and story(story_id)["last_changed"] == "2020-01-01T00:00:00"
|
|
assert builds.call_count == 1
|
|
assert len(ingest_files()) == 2 # one epub and its sidecar
|
|
|
|
|
|
@pytest.mark.parametrize("change", ["chapter", "rule", "global rule", "paragraphs", "metadata", "included"])
|
|
def test_each_change_builds_and_sends_the_book(builds, monkeypatch, change):
|
|
story_id = linked_story([(1, "One", "a"), (2, "Two", "b")])
|
|
site(monkeypatch, [(1, "One", "a"), (2, "Two", "b")])
|
|
update(story_id)
|
|
if change == "chapter":
|
|
site(monkeypatch, [(1, "One", "a"), (2, "Two", "b"), (3, "Three", "c")])
|
|
elif change == "rule":
|
|
orchestrator.save_rules(story_id, [{"pattern": "a", "replacement": "b"}])
|
|
elif change == "global rule":
|
|
orchestrator.save_rules(None, [{"pattern": "a", "replacement": "b"}])
|
|
elif change == "paragraphs":
|
|
orchestrator.save_rules(story_id, [], paragraphs=True)
|
|
elif change == "metadata":
|
|
orchestrator.store.update_story(story_id, pinned={"title": "Edited"})
|
|
else:
|
|
chapter = orchestrator.store.list_chapters(story_id)[0]
|
|
orchestrator.store.update_chapter(chapter["id"], included=0)
|
|
assert "library book 1" in update(story_id)
|
|
assert builds.call_count == 2
|
|
assert len(ingest_files()) == 4
|
|
assert update(story_id).startswith("No changes")
|
|
|
|
|
|
def test_a_held_book_that_did_not_change_is_not_built_again(builds, monkeypatch):
|
|
monkeypatch.setattr(config, "INGEST_DIR", None)
|
|
site(monkeypatch, ONE)
|
|
job = Job(mode=DownloadMode.NEW, url=STORY_URL)
|
|
assert "Not sent. INGEST_DIR is not set." in orchestrator._run_site_job(job)
|
|
changed = story(job.story_id)["last_changed"]
|
|
orchestrator.store.update_story(job.story_id, last_changed="2020-01-01T00:00:00")
|
|
assert "Not sent. INGEST_DIR is not set." in update(job.story_id)
|
|
assert changed and story(job.story_id)["last_changed"] == "2020-01-01T00:00:00"
|
|
assert builds.call_count == 1
|
|
# The book changes. Bindery builds it, also while it cannot send it.
|
|
site(monkeypatch, [*ONE, (2, "Two", "b")])
|
|
update(job.story_id)
|
|
assert builds.call_count == 2
|
|
assert story(job.story_id)["last_changed"] != "2020-01-01T00:00:00"
|
|
|
|
|
|
def test_a_released_book_goes_out_without_another_build(builds, jobs):
|
|
story_id = add_story(ingested_at="2026-01-01T00:00:00", calibre_id=1, calibre_uuid="uuid-1")
|
|
orchestrator.save_settings(pause_uploads=True)
|
|
orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id))
|
|
assert builds.call_count == 1 and ingest_files() == []
|
|
orchestrator.save_settings(pause_uploads=False)
|
|
assert "library book 1" in orchestrator._run_rebuild(jobs.get_next())
|
|
assert builds.call_count == 1
|
|
assert len(ingest_files()) == 2
|
|
|
|
|
|
def test_a_lost_file_is_built_again_only_to_send_it(builds):
|
|
story_id = add_story(ingested_at="2026-01-01T00:00:00", calibre_id=1, calibre_uuid="uuid-1")
|
|
orchestrator.save_settings(pause_uploads=True)
|
|
job = Job(mode=DownloadMode.REBUILD, story_id=story_id)
|
|
orchestrator._run_rebuild(job)
|
|
for path in config.TMP_DIR.iterdir():
|
|
path.unlink()
|
|
assert "pause" in orchestrator._run_rebuild(job)
|
|
assert builds.call_count == 1
|
|
orchestrator.store.set_setting(None, "pause_uploads", None)
|
|
assert "library book 1" in orchestrator._run_rebuild(job)
|
|
assert builds.call_count == 2
|
|
|
|
|
|
def test_a_rebuild_without_a_change_does_nothing_unless_the_user_asks_for_it(builds, client, jobs):
|
|
story_id = add_story(ingested_at="2026-01-01T00:00:00", calibre_id=1, calibre_uuid="uuid-1")
|
|
job = Job(mode=DownloadMode.REBUILD, story_id=story_id)
|
|
assert "library book 1" in orchestrator._run_rebuild(job)
|
|
orchestrator.store.update_story(story_id, last_changed=None, message="before")
|
|
assert orchestrator._run_rebuild(job) == "No changes"
|
|
assert (story(story_id)["last_changed"], story(story_id)["message"]) == (None, "before")
|
|
assert builds.call_count == 1
|
|
# The Rebuild button of the story page.
|
|
queued = client.post(f"/api/stories/{story_id}/download", json={"mode": "rebuild"}, headers=H).json
|
|
assert queued["options"] == {"force": True}
|
|
assert "library book 1" in orchestrator._run_rebuild(jobs.get_next())
|
|
assert builds.call_count == 2
|
|
assert len(ingest_files()) == 4
|
|
assert story(story_id)["last_changed"]
|
|
# Other modes take no such option.
|
|
queued = client.post(f"/api/stories/{story_id}/download", json={"mode": "new"}, headers=H).json
|
|
assert queued["options"] == {}
|
|
|
|
|
|
def test_a_changed_link_gets_the_book_without_a_build(builds, fake):
|
|
story_id = add_story(ingested_at="2026-01-01T00:00:00", calibre_id=1, calibre_uuid="uuid-1")
|
|
job = Job(mode=DownloadMode.REBUILD, story_id=story_id)
|
|
orchestrator._run_rebuild(job)
|
|
fake.books.append(dataclasses.replace(fake.books[0], id=2, uuid="uuid-2", title="Other"))
|
|
orchestrator.link_story(story_id, "uuid-2")
|
|
assert "library book 2" in orchestrator._run_rebuild(job)
|
|
assert builds.call_count == 1
|
|
|
|
|
|
def test_a_failed_delivery_is_tried_again_without_a_build(builds, fake, monkeypatch):
|
|
story_id = linked_story(ONE)
|
|
site(monkeypatch, ONE)
|
|
send = orchestrator.IngestFolder.send_update
|
|
monkeypatch.setattr(orchestrator.IngestFolder, "send_update", Mock(side_effect=OSError("disk full")))
|
|
with pytest.raises(OSError, match="disk full"):
|
|
update(story_id)
|
|
monkeypatch.setattr(orchestrator.IngestFolder, "send_update", send)
|
|
assert "library book 1" in update(story_id)
|
|
assert builds.call_count == 1
|
|
|
|
|
|
def test_a_new_build_removes_the_older_file(builds, monkeypatch):
|
|
story_id = linked_story(ONE)
|
|
site(monkeypatch, ONE)
|
|
update(story_id)
|
|
(first,) = config.TMP_DIR.iterdir()
|
|
site(monkeypatch, [*ONE, (2, "Two", "b")])
|
|
update(story_id)
|
|
(second,) = config.TMP_DIR.iterdir()
|
|
assert first != second and second.name.startswith(f"story-{story_id}-")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("changes", "held", "built", "sent"),
|
|
[
|
|
({"calibre_id": 1, "calibre_uuid": "uuid-1"}, False, True, True),
|
|
({"ingested_at": "2026-01-01"}, False, True, True),
|
|
({"ingested_at": "2026-01-01"}, True, True, False),
|
|
({}, False, True, False),
|
|
({"ingested_at": "2026-01-01", "status": "error"}, False, False, False),
|
|
({"ingested_at": "2026-01-01", "no_metadata": True}, False, False, False),
|
|
],
|
|
)
|
|
def test_stories_of_an_older_bindery_get_their_keys(builds, changes, held, built, sent):
|
|
no_metadata = changes.pop("no_metadata", False)
|
|
story_id = add_story(**changes)
|
|
if no_metadata:
|
|
orchestrator.store.update_story(story_id, metahtml=None)
|
|
orchestrator.store.set_setting(story_id, "upload_held", held or None)
|
|
orchestrator.adopt_build_keys()
|
|
settings = orchestrator.store
|
|
assert bool(settings.get_setting(story_id, "built_key")) is built
|
|
assert bool(settings.get_setting(story_id, "sent_key")) is sent
|
|
if sent:
|
|
assert orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id)) == "No changes"
|
|
assert builds.call_count == 0
|
|
|
|
|
|
def test_a_story_with_a_waiting_job_gets_no_key(builds, jobs):
|
|
story_id = add_story(ingested_at="2026-01-01")
|
|
jobs.add(Job(mode=DownloadMode.NEW, story_id=story_id))
|
|
orchestrator.adopt_build_keys()
|
|
assert orchestrator.store.get_setting(story_id, "built_key") is None
|
|
|
|
|
|
def test_the_first_check_after_an_upgrade_sends_nothing(builds, monkeypatch):
|
|
story_id = linked_story(ONE)
|
|
orchestrator.store.update_story(story_id, pinned={"title": "My Title"}, site_values={})
|
|
orchestrator.adopt_build_keys()
|
|
site(monkeypatch, ONE)
|
|
assert update(story_id).startswith("No changes")
|
|
assert builds.call_count == 0 and ingest_files() == []
|