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.
297 lines
12 KiB
Python
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
|