Files
bindery/tests/test_build_keys.py
T
tim 869e56eaf0 Do not make the ingest folder
A folder that Bindery made hid a missing volume mount. The books went
to a folder inside the container, and CWA never saw them. A missing
folder is now an error in the log at the start, and each upload fails
with a message that names the mount.
2026-10-02 23:37:33 +02:00

297 lines
12 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() == []
# Downloads in the browser
def download(client, story_id):
response = client.get(f"/api/stories/{story_id}/epub")
assert response.status_code == 200 and response.data.startswith(b"PK")
assert response.content_length == len(response.data)
response.close()
return response.data
def test_a_download_uses_the_built_book(builds, client, monkeypatch):
story_id = linked_story(ONE)
site(monkeypatch, ONE)
update(story_id)
(built,) = config.TMP_DIR.iterdir()
assert download(client, story_id) == download(client, story_id) == built.read_bytes()
assert builds.call_count == 1
def test_a_download_builds_a_book_that_has_no_file_or_that_changed(builds, client, monkeypatch):
story_id = add_story()
first = download(client, story_id)
download(client, story_id)
assert builds.call_count == 1
orchestrator.save_rules(story_id, [{"pattern": "Zorian", "replacement": "Zach"}])
assert download(client, story_id) != first
assert builds.call_count == 2
assert len(list(config.TMP_DIR.iterdir())) == 1
# The build of a download is not the build of a job. The job sends the book, and marks the change.
assert orchestrator.store.get_setting(story_id, "built_key") is None
job = Job(mode=DownloadMode.REBUILD, story_id=story_id)
assert "library book 1" in orchestrator._run_rebuild(job)
assert builds.call_count == 2
assert story(story_id)["last_changed"]
def test_stop_tracking_deletes_the_built_book(builds, client):
story_id, other = add_story(), orchestrator.store.create_story("https://other", "s", "Other", "W", [])
download(client, story_id)
kept = config.TMP_DIR / f"story-{other}-0123456789abcdef.epub"
kept.write_bytes(b"other")
orchestrator.delete_story(story_id)
assert list(config.TMP_DIR.iterdir()) == [kept]
# Uploaded files
@pytest.fixture
def uploads(monkeypatch, tmp_path):
monkeypatch.setattr(config, "UPLOAD_DIR", tmp_path / "uploads")
config.UPLOAD_DIR.mkdir()
return config.UPLOAD_DIR
def test_a_cancelled_import_deletes_its_uploaded_file(builds, jobs, uploads, story_epub):
path = uploads / "one.epub"
path.write_bytes(story_epub)
queued = orchestrator.queue_epub_file(path, "story.epub")
assert "path" not in queued["options"]
assert orchestrator.cancel_job(queued["id"]) is True
assert not path.exists()
assert orchestrator.cancel_job(queued["id"]) is False
assert orchestrator.cancel_job("no-such-job") is False
def test_the_start_deletes_uploads_that_no_job_needs(builds, jobs, uploads, story_epub):
waiting, stale = uploads / "waiting.epub", uploads / "stale.epub"
for path in (waiting, stale):
path.write_bytes(story_epub)
orchestrator.queue_epub_file(waiting, "story.epub")
orchestrator.remove_stale_uploads()
assert list(uploads.iterdir()) == [waiting]
def test_a_missing_ingest_folder_fails_the_job_until_the_folder_exists(builds, jobs, monkeypatch, tmp_path):
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "not-mounted")
story_id = linked_story(ONE)
site(monkeypatch, ONE)
monkeypatch.setattr(orchestrator.time, "sleep", Mock())
jobs.add(Job(mode=DownloadMode.NEW, story_id=story_id))
job = orchestrator._run_next_job()
assert job.status == "error" and "does not exist. Check INGEST_DIR and the volume mount." in job.message
assert story(story_id)["status"] == "error"
assert not config.INGEST_DIR.exists()
config.INGEST_DIR.mkdir()
assert "library book 1" in update(story_id)
assert builds.call_count == 1