"""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() == []