|
|
|
@@ -0,0 +1,205 @@
|
|
|
|
|
"""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() == []
|