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.
312 lines
13 KiB
Python
312 lines
13 KiB
Python
"""Worker and scheduler transitions, with real temporary stores and no background threads."""
|
|
|
|
from datetime import datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from bindery import config, fff
|
|
from bindery import orchestrator as o
|
|
from bindery.diffing import text_hash
|
|
from bindery.jobs import DownloadMode, Job, JobQueue, QueueStatus
|
|
from bindery.store import Store
|
|
|
|
|
|
class StopLoop(BaseException):
|
|
"""Stop an infinite loop without entering its application error handler."""
|
|
|
|
|
|
@pytest.fixture
|
|
def state(monkeypatch, tmp_path):
|
|
store = Store(tmp_path / "stories.db")
|
|
queue = JobQueue(tmp_path / "jobs.db")
|
|
monkeypatch.setattr(o, "store", store)
|
|
monkeypatch.setattr(o, "job_queue", queue)
|
|
monkeypatch.setattr(config, "CWA_URL", "")
|
|
monkeypatch.setattr(config, "SITE_DELAY", 7)
|
|
monkeypatch.setattr(config, "TMP_DIR", tmp_path / "tmp")
|
|
config.TMP_DIR.mkdir()
|
|
return store, queue
|
|
|
|
|
|
def add_story(store, url="https://www.royalroad.com/fiction/1234", **values):
|
|
sid = store.create_story(url, "www.royalroad.com", "Title", "Writer", [])
|
|
store.update_story(sid, **values)
|
|
return sid
|
|
|
|
|
|
@pytest.mark.parametrize("mode", list(DownloadMode))
|
|
def test_dispatches_each_download_mode(state, monkeypatch, mode):
|
|
handlers = {
|
|
name: Mock(return_value=name) for name in ("_run_import", "_run_import_file", "_run_rebuild", "_run_site_job")
|
|
}
|
|
for name, handler in handlers.items():
|
|
monkeypatch.setattr(o, name, handler)
|
|
expected = {
|
|
DownloadMode.IMPORT: "_run_import",
|
|
DownloadMode.IMPORT_FILE: "_run_import_file",
|
|
DownloadMode.REBUILD: "_run_rebuild",
|
|
}.get(mode, "_run_site_job")
|
|
job = Job(mode=mode)
|
|
assert o._run_job(job) == expected
|
|
handlers[expected].assert_called_once_with(job)
|
|
assert sum(h.call_count for h in handlers.values()) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("error", [None, o.StoryError("site refused"), RuntimeError("broken")])
|
|
@pytest.mark.parametrize("with_story", [False, True])
|
|
def test_worker_persists_outcomes_and_continues_after_failure(state, monkeypatch, tmp_path, error, with_story):
|
|
store, queue = state
|
|
sid = add_story(store) if with_story else None
|
|
first = queue.add(Job(mode=DownloadMode.NEW, story_id=sid, url="first"))
|
|
second = queue.add(Job(mode=DownloadMode.REBUILD, story_id=sid))
|
|
monkeypatch.setattr(queue, "get_next", Mock(side_effect=[None, first, second, StopLoop()]))
|
|
sleeps = Mock()
|
|
monkeypatch.setattr(o.time, "sleep", sleeps)
|
|
|
|
def run(job):
|
|
assert job.status == QueueStatus.RUNNING
|
|
if job is first and error:
|
|
raise error
|
|
return "Sent"
|
|
|
|
monkeypatch.setattr(o, "_run_job", run)
|
|
with pytest.raises(StopLoop):
|
|
o.download_loop()
|
|
saved = {j["id"]: j for j in JobQueue(tmp_path / "jobs.db").get_status()}
|
|
assert saved[second.id]["status"] == "done"
|
|
assert saved[second.id]["progress"] == 1.0
|
|
assert saved[first.id]["status"] == ("error" if error else "done")
|
|
expected = "RuntimeError: broken" if isinstance(error, RuntimeError) else str(error) if error else "Sent"
|
|
assert saved[first.id]["message"] == expected
|
|
if error and sid:
|
|
story = store.get_story(sid)
|
|
assert (story["status"], story["message"]) == ("error", expected)
|
|
assert story["last_checked"]
|
|
assert [c.args[0] for c in sleeps.call_args_list] == [2, 7]
|
|
|
|
|
|
def test_scheduler_skips_disabled_busy_and_recent_stories(state, monkeypatch):
|
|
store, queue = state
|
|
now = datetime(2026, 6, 1, 12)
|
|
|
|
class Clock(datetime):
|
|
@classmethod
|
|
def now(cls):
|
|
return now
|
|
|
|
monkeypatch.setattr(o, "datetime", Clock)
|
|
monkeypatch.setattr(config, "UPDATE_INTERVAL_HOURS", 2)
|
|
ids = [add_story(store, f"url-{n}") for n in range(7)]
|
|
store.update_story(ids[0], auto_update=0)
|
|
queue.add(Job(mode=DownloadMode.NEW, story_id=ids[1]))
|
|
store.update_story(ids[2], last_checked=(now - timedelta(hours=1)).isoformat())
|
|
store.update_story(ids[3], last_checked=(now - timedelta(hours=2)).isoformat())
|
|
for sid in ids[4:]:
|
|
store.set_setting(sid, "recheck_days", 3)
|
|
store.set_setting(ids[5], "last_full_recheck", (now - timedelta(days=3)).isoformat())
|
|
store.set_setting(ids[6], "last_full_recheck", (now - timedelta(days=1)).isoformat())
|
|
o._queue_due_updates()
|
|
queued = {j["story_id"]: j["mode"] for j in queue.get_status()}
|
|
assert queued == {ids[1]: "new", ids[3]: "new", ids[4]: "recheck_all", ids[5]: "recheck_all", ids[6]: "new"}
|
|
o._queue_due_updates()
|
|
assert len(queue.get_status()) == 5
|
|
monkeypatch.setattr(config, "UPDATE_INTERVAL_HOURS", 0)
|
|
extra = add_story(store, "extra")
|
|
o._queue_due_updates()
|
|
assert not queue.has_active(extra)
|
|
|
|
|
|
def test_scheduler_recovers_from_backup_and_queue_errors(state, monkeypatch):
|
|
backup = Mock(side_effect=[OSError("disk full"), None])
|
|
due = Mock(side_effect=[ValueError("bad date"), None])
|
|
sleep = Mock(side_effect=[None, StopLoop()])
|
|
monkeypatch.setattr(o, "daily_backup", backup)
|
|
monkeypatch.setattr(o, "_queue_due_updates", due)
|
|
monkeypatch.setattr(o.time, "sleep", sleep)
|
|
with pytest.raises(StopLoop):
|
|
o.scheduler_loop()
|
|
assert backup.call_count == due.call_count == 2
|
|
assert [c.args for c in sleep.call_args_list] == [(60,), (60,)]
|
|
|
|
|
|
@pytest.fixture
|
|
def site(state, monkeypatch):
|
|
store, queue = state
|
|
sid = add_story(store, ingested_at="2026-01-01", site_values={"title": "Title"})
|
|
for i in range(1, 4):
|
|
html = f"<p>old {i}</p>"
|
|
store.add_chapter(sid, f"chapter-{i}", f"Chapter {i}", html, text_hash(html), "new", i)
|
|
adapter = SimpleNamespace(normalize_chapterurl=lambda url: url)
|
|
snap = fff.SiteSnapshot(
|
|
url=store.get_story(sid)["url"],
|
|
site="www.royalroad.com",
|
|
title="Title",
|
|
author="Writer",
|
|
metahtml="metadata",
|
|
chapters=[
|
|
{"url": "chapter-1", "title": "Renamed"},
|
|
{"url": "chapter-2", "title": "Chapter 2"},
|
|
{"url": "chapter-4", "title": "New"},
|
|
],
|
|
calibre={"title": "Title"},
|
|
)
|
|
fetch = Mock(side_effect=lambda a, url, i: f"<p>fresh {url}</p>")
|
|
build = Mock(side_effect=lambda story, overrides, path: path.write_bytes(b"epub"))
|
|
deliver = Mock(return_value="Sent")
|
|
monkeypatch.setattr(fff, "get_adapter", lambda url: adapter)
|
|
monkeypatch.setattr(fff, "fetch_story", lambda a: snap)
|
|
monkeypatch.setattr(fff, "fetch_chapter", fetch)
|
|
monkeypatch.setattr(fff, "collected_images", lambda a: [])
|
|
monkeypatch.setattr(o, "_build", build)
|
|
monkeypatch.setattr(o, "_linked_book", lambda story: None)
|
|
monkeypatch.setattr(o, "_deliver", deliver)
|
|
return SimpleNamespace(
|
|
store=store, queue=queue, id=sid, snap=snap, adapter=adapter, fetch=fetch, build=build, deliver=deliver
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("mode", [DownloadMode.METADATA, DownloadMode.FULL, DownloadMode.RECHECK])
|
|
def test_download_modes_preserve_history_and_select_the_right_chapters(site, mode):
|
|
original = site.store.list_chapters(site.id)
|
|
job = Job(mode=mode, story_id=site.id, chapter_ids=[original[1]["id"], original[2]["id"]])
|
|
result = o._run_site_job(job)
|
|
chapters = site.store.list_chapters(site.id)
|
|
assert chapters[0]["title"] == "Renamed"
|
|
assert all(site.store.revision_html(c["id"], 1) == f"<p>old {n}</p>" for n, c in enumerate(original, 1))
|
|
fetched = [call.args[1] for call in site.fetch.call_args_list]
|
|
removed = site.store.get_chapter(original[2]["id"])
|
|
assert not removed["on_site"]
|
|
if mode == DownloadMode.METADATA:
|
|
assert fetched == []
|
|
assert len(chapters) == 3
|
|
assert removed["included"]
|
|
elif mode == DownloadMode.FULL:
|
|
assert set(fetched) == {"chapter-1", "chapter-2", "chapter-4"}
|
|
assert not removed["included"]
|
|
assert "1 taken out" in result
|
|
assert site.store.get_setting(site.id, "last_full_recheck")
|
|
else:
|
|
assert set(fetched) == {"chapter-1", "chapter-2"}
|
|
assert job.result["skipped"] == ["Chapter 3"]
|
|
assert removed["included"]
|
|
site.build.assert_called_once()
|
|
site.deliver.assert_called_once()
|
|
|
|
|
|
def test_failed_new_chapter_keeps_successful_chapters_and_images(site, monkeypatch):
|
|
site.snap.chapters.append({"url": "chapter-5", "title": "Broken"})
|
|
|
|
def fetch(adapter, url, index):
|
|
if url == "chapter-5":
|
|
raise OSError("timeout")
|
|
return "<p>ok</p>"
|
|
|
|
site.fetch.side_effect = fetch
|
|
image = {"url": "image", "ext": "png", "mime": "image/png", "data": b"image"}
|
|
site.snap.cover = {**image, "url": "cover"}
|
|
monkeypatch.setattr(fff, "collected_images", lambda a: [image, image])
|
|
job = Job(mode=DownloadMode.NEW, story_id=site.id)
|
|
assert "1 failed" in o._run_site_job(job)
|
|
assert job.result["failed"] == ["Broken: timeout"]
|
|
assert len(site.store.list_chapters(site.id)) == 4
|
|
assert site.store.get_story(site.id)["status"] == "error"
|
|
assert site.store.image_urls(site.id) == {"image"}
|
|
assert site.store.cover(site.id)["url"] == "cover"
|
|
|
|
|
|
def test_new_story_with_no_downloaded_chapters_fails_without_delivery(site):
|
|
site.fetch.side_effect = OSError("offline")
|
|
job = Job(mode=DownloadMode.NEW, url="new-url")
|
|
site.snap.url = "new-url"
|
|
with pytest.raises(o.StoryError, match="No chapters downloaded"):
|
|
o._run_site_job(job)
|
|
assert job.story_id != site.id
|
|
assert site.store.list_chapters(job.story_id) == []
|
|
site.deliver.assert_not_called()
|
|
|
|
|
|
def test_normalized_chapter_urls_keep_the_existing_identity(site):
|
|
original = site.store.list_chapters(site.id)[0]
|
|
site.adapter.normalize_chapterurl = lambda url: "renamed-url" if url == "chapter-1" else url
|
|
site.snap.chapters[0]["url"] = "renamed-url"
|
|
o._run_site_job(Job(mode=DownloadMode.RECHECK_ALL, story_id=site.id))
|
|
updated = site.store.get_chapter(original["id"])
|
|
assert updated["url"] == "renamed-url"
|
|
assert updated["current_rev"] == 2
|
|
assert len(site.store.list_chapters(site.id)) == 4
|
|
|
|
|
|
def test_inclusion_changes_only_the_requested_story_and_queues_a_rebuild(state):
|
|
store, queue = state
|
|
sid, other = add_story(store), add_story(store, "other")
|
|
cid = store.add_chapter(sid, "chapter", "One", "<p>text</p>", "h", "new", 0)
|
|
foreign = store.add_chapter(other, "foreign", "Other", "<p>other</p>", "h", "new", 0)
|
|
result = o.set_chapters_included(sid, [cid, foreign, 999], False)
|
|
assert result["mode"] == "rebuild"
|
|
assert not store.get_chapter(cid)["included"]
|
|
assert store.get_chapter(foreign)["included"]
|
|
assert store.revision_html(cid, 1) == "<p>text</p>"
|
|
o.set_chapters_included(sid, [cid], True)
|
|
assert store.get_chapter(cid)["included"]
|
|
assert len(queue.get_status()) == 1
|
|
|
|
|
|
def test_move_chapters_keeps_their_order_and_queues_a_rebuild(state):
|
|
store, queue = state
|
|
sid, other = add_story(store), add_story(store, "other")
|
|
ids = [store.add_chapter(sid, f"c{n}", str(n), "<p>x</p>", "h", "new", n) for n in range(5)]
|
|
foreign = store.add_chapter(other, "foreign", "Other", "<p>x</p>", "h", "new", 0)
|
|
|
|
def titles():
|
|
return [c["title"] for c in store.list_chapters(sid)]
|
|
|
|
# The selection order does not count, and a chapter of another story is ignored.
|
|
assert o.move_chapters(sid, [ids[3], ids[1], foreign], ids[4])["mode"] == "rebuild"
|
|
assert titles() == ["0", "2", "4", "1", "3"]
|
|
o.move_chapters(sid, [ids[4], ids[3]], None)
|
|
assert titles() == ["4", "3", "0", "2", "1"]
|
|
assert len(queue.get_status()) == 1
|
|
with pytest.raises(ValueError, match="Select"):
|
|
o.move_chapters(sid, [foreign], None)
|
|
with pytest.raises(ValueError, match="moves with them"):
|
|
o.move_chapters(sid, [ids[0], ids[1]], ids[1])
|
|
with pytest.raises(KeyError):
|
|
o.move_chapters(sid, [ids[0]], foreign)
|
|
|
|
|
|
def test_delete_chapters_removes_their_revisions_and_keeps_one_chapter(state):
|
|
store, queue = state
|
|
sid, other = add_story(store), add_story(store, "other")
|
|
ids = [store.add_chapter(sid, f"c{n}", str(n), "<p>x</p>", "h", "new", n) for n in range(3)]
|
|
store.add_revision(ids[1], "<p>y</p>", "h2", "recheck")
|
|
foreign = store.add_chapter(other, "foreign", "Other", "<p>x</p>", "h", "new", 0)
|
|
|
|
assert o.delete_chapters(sid, [ids[1], foreign])["mode"] == "rebuild"
|
|
assert [c["id"] for c in store.list_chapters(sid)] == [ids[0], ids[2]]
|
|
assert store.list_revisions(ids[1]) == []
|
|
assert store.get_chapter(foreign)
|
|
with pytest.raises(ValueError, match="Select"):
|
|
o.delete_chapters(sid, [foreign])
|
|
with pytest.raises(ValueError, match="at least one chapter"):
|
|
o.delete_chapters(sid, [ids[0], ids[2]])
|
|
assert len(store.list_chapters(sid)) == 2
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mode,chapters,fields,message",
|
|
[
|
|
("import", [], [], "library import"),
|
|
("recheck", [], [], "at least one chapter"),
|
|
("new", [], ["unknown"], "Unknown metadata"),
|
|
("invalid", [], [], "not a valid"),
|
|
],
|
|
)
|
|
def test_invalid_job_requests_do_not_enter_the_queue(state, mode, chapters, fields, message):
|
|
store, queue = state
|
|
sid = add_story(store)
|
|
with pytest.raises(ValueError, match=message):
|
|
o.queue_story(sid, mode, chapters, fields)
|
|
assert queue.get_status() == []
|