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.
410 lines
19 KiB
Python
410 lines
19 KiB
Python
"""Downloads and builds work with uploads or library matching disabled."""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
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, rebuild, site
|
|
from test_deliver import jobs as jobs
|
|
|
|
from bindery import config, orchestrator
|
|
from bindery.cwa import Book
|
|
from bindery.jobs import DownloadMode, Job
|
|
from bindery.store import Store
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated(client, jobs, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "ingest")
|
|
monkeypatch.setattr(config, "TMP_DIR", tmp_path / "tmp")
|
|
config.TMP_DIR.mkdir()
|
|
monkeypatch.setattr(config, "CWA_URL", "")
|
|
monkeypatch.setattr(config, "CALIBRE_DB_PATH", None)
|
|
monkeypatch.setattr(orchestrator, "cwa", Mock(side_effect=AssertionError("Unexpected CWA request")))
|
|
monkeypatch.setattr(orchestrator, "_find_book", Mock(side_effect=AssertionError("Unexpected book search")))
|
|
|
|
|
|
@pytest.fixture
|
|
def library(isolated, monkeypatch):
|
|
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
|
|
client = Mock()
|
|
client.get_book.return_value = Book(7, "uuid-7", "Test Story").to_dict()
|
|
monkeypatch.setattr(orchestrator, "cwa", Mock(return_value=client))
|
|
monkeypatch.setattr(orchestrator, "_find_book", Mock(return_value=None))
|
|
|
|
|
|
def test_app_starts_without_any_cwa_configuration(tmp_path):
|
|
env = {
|
|
**os.environ,
|
|
"CONFIG_DIR": str(tmp_path / "standalone"),
|
|
"TMP_DIR": str(tmp_path / "builds"),
|
|
"AUTH_METHOD": "none",
|
|
"BINDERY_START_THREADS": "false",
|
|
"URL_BASE": "",
|
|
}
|
|
for name in ("INGEST_DIR", "CWA_URL", "CWA_DB_PATH", "CALIBRE_DB_PATH"):
|
|
env.pop(name, None)
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"from bindery.web import app; "
|
|
"client = app.test_client(); "
|
|
"assert client.get('/api/health').status_code == 200; "
|
|
"settings = client.get('/api/settings').json; "
|
|
"assert settings['pause_uploads'] is True; "
|
|
"assert settings['ingest_available'] is False; "
|
|
"assert settings['link_books'] is False; "
|
|
"assert settings['linking_available'] is False",
|
|
],
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
assert "WARNING" not in result.stderr
|
|
assert "ERROR" not in result.stderr
|
|
|
|
|
|
def test_link_setting_defaults_on_and_persists(client, monkeypatch, tmp_path, library):
|
|
assert client.get("/api/settings").json["link_books"] is True
|
|
response = client.put("/api/settings", json={"link_books": False}, headers=H)
|
|
assert response.status_code == 200
|
|
assert response.json["link_books"] is False
|
|
monkeypatch.setattr(orchestrator, "store", Store(tmp_path / "bindery.db"))
|
|
assert client.get("/api/settings").json["link_books"] is False
|
|
response = client.put("/api/settings", json={"hold_new_links": True}, headers=H)
|
|
assert response.json["link_books"] is False
|
|
assert client.put("/api/settings", json={"link_books": True}, headers=H).json["link_books"] is True
|
|
|
|
|
|
@pytest.mark.parametrize("url", ["", "https://cwa.invalid"])
|
|
@pytest.mark.parametrize("database", [False, True])
|
|
def test_linking_requires_either_library_source(client, monkeypatch, tmp_path, url, database):
|
|
monkeypatch.setattr(config, "CWA_URL", url)
|
|
monkeypatch.setattr(config, "CALIBRE_DB_PATH", tmp_path / "metadata.db" if database else None)
|
|
available = bool(url or database)
|
|
settings = client.get("/api/settings").json
|
|
assert settings["linking_available"] is available
|
|
assert settings["link_books"] is available
|
|
settings = client.put("/api/settings", json={"link_books": True}, headers=H).json
|
|
assert settings["linking_available"] is available
|
|
assert settings["link_books"] is available
|
|
|
|
|
|
@pytest.mark.parametrize("saved", [None, False, True])
|
|
def test_missing_sources_force_linking_off_without_changing_preferences(client, monkeypatch, tmp_path, jobs, saved):
|
|
monkeypatch.setattr(config, "CWA_DB_PATH", tmp_path / "app.db")
|
|
orchestrator.store.set_setting(None, "link_books", saved)
|
|
orchestrator.store.set_setting(None, "hold_new_links", True)
|
|
story_id = add_story()
|
|
orchestrator.store.set_setting(story_id, "upload_held", True)
|
|
for requested in (True, False):
|
|
response = client.put("/api/settings", json={"link_books": requested}, headers=H)
|
|
assert response.status_code == 200
|
|
assert response.json["link_books"] is False
|
|
assert response.json["linking_available"] is False
|
|
assert response.json["hold_new_links"] is True
|
|
assert orchestrator.store.get_setting(None, "link_books") is saved
|
|
assert jobs.get_next() is None
|
|
monkeypatch.setattr(config, "CALIBRE_DB_PATH", tmp_path / "metadata.db")
|
|
assert client.get("/api/settings").json["linking_available"] is True
|
|
assert client.get("/api/settings").json["link_books"] is (saved is not False)
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["false", 0, [], {}])
|
|
def test_link_setting_requires_a_boolean(client, value):
|
|
response = client.put("/api/settings", json={"link_books": value}, headers=H)
|
|
assert response.status_code == 400
|
|
assert client.get("/api/settings").json["link_books"] is False
|
|
|
|
|
|
@pytest.mark.parametrize("paused", [False, True])
|
|
def test_missing_ingest_forces_pause_without_changing_saved_preference(client, monkeypatch, tmp_path, paused):
|
|
orchestrator.save_settings(pause_uploads=paused)
|
|
monkeypatch.setattr(config, "INGEST_DIR", None)
|
|
settings = client.get("/api/settings").json
|
|
assert settings["pause_uploads"] is True
|
|
assert settings["ingest_available"] is False
|
|
for requested in (False, True):
|
|
response = client.put("/api/settings", json={"pause_uploads": requested}, headers=H)
|
|
assert response.status_code == 200
|
|
assert response.json["pause_uploads"] is True
|
|
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "enabled")
|
|
assert client.get("/api/settings").json["pause_uploads"] is paused
|
|
|
|
|
|
@pytest.mark.parametrize("existing", [False, True])
|
|
@pytest.mark.parametrize("link_books", [False, True])
|
|
def test_build_without_ingest_succeeds_and_keeps_the_book_local(monkeypatch, tmp_path, existing, link_books, library):
|
|
orchestrator.save_settings(link_books=link_books)
|
|
monkeypatch.setattr(config, "INGEST_DIR", None)
|
|
story_id = add_story(ingested_at="2026-01-01" if existing else None)
|
|
assert rebuild(story_id) == "Not sent. INGEST_DIR is not set."
|
|
story = orchestrator.store.get_story(story_id)
|
|
assert story["status"] == "ok"
|
|
assert story["ingested_at"] == ("2026-01-01" if existing else None)
|
|
assert list(config.TMP_DIR.glob(f"story-{story_id}-*.epub"))
|
|
assert not (tmp_path / "ingest").exists()
|
|
details = orchestrator.story_details(story_id)
|
|
assert details["uploads_paused"] is True
|
|
assert details["story"]["upload_held"] is True
|
|
assert details["library_error"] is None
|
|
|
|
|
|
@pytest.mark.parametrize("configured", [False, True])
|
|
@pytest.mark.parametrize("saved_id", [None, 7])
|
|
@pytest.mark.parametrize("paused", [False, True])
|
|
def test_linking_off_exports_books_and_respects_pause(monkeypatch, tmp_path, configured, saved_id, paused):
|
|
if configured:
|
|
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
|
|
monkeypatch.setattr(config, "CALIBRE_DB_PATH", tmp_path / "unreadable.db")
|
|
orchestrator.save_settings(link_books=False, pause_uploads=paused)
|
|
story_id = add_story(calibre_id=saved_id, calibre_uuid="saved-uuid", ingested_at="2026-01-01")
|
|
message = rebuild(story_id)
|
|
assert orchestrator.store.get_story(story_id)["status"] == "ok"
|
|
assert orchestrator.story_details(story_id)["library_error"] is None
|
|
orchestrator.cwa.assert_not_called()
|
|
orchestrator._find_book.assert_not_called()
|
|
if not paused:
|
|
if saved_id is not None:
|
|
assert message == "Updated library book 7"
|
|
manifest = next(config.INGEST_DIR.glob("*.cwa.json"))
|
|
assert json.loads(manifest.read_text()) == {"action": "add_format", "book_id": 7}
|
|
else:
|
|
assert message == "Sent to ingest folder"
|
|
assert not list(config.INGEST_DIR.glob("*.cwa.json"))
|
|
assert len(list(config.INGEST_DIR.glob("*.epub"))) == 1
|
|
assert orchestrator.store.get_setting(story_id, "upload_held") is None
|
|
else:
|
|
assert message.startswith("Not sent.")
|
|
assert not config.INGEST_DIR.exists()
|
|
assert orchestrator.store.get_setting(story_id, "upload_held") is True
|
|
|
|
|
|
def test_disabling_linking_releases_approval_holds_for_saved_ids(jobs, library):
|
|
orchestrator.save_settings(hold_new_links=True)
|
|
story_id = add_story(calibre_id=7, calibre_uuid="uuid-7", ingested_at="2026-01-01")
|
|
orchestrator._link_changed(story_id)
|
|
assert "Approve link" in rebuild(story_id)
|
|
orchestrator.save_settings(link_books=False)
|
|
assert orchestrator.get_settings()["hold_new_links"] is True
|
|
assert orchestrator.story_details(story_id)["story"]["needs_approval"] is False
|
|
job = jobs.get_next()
|
|
assert job.story_id == story_id
|
|
assert orchestrator._run_rebuild(job) == "Updated library book 7"
|
|
|
|
|
|
@pytest.mark.parametrize("link_books", [True, False])
|
|
def test_story_response_reports_linking_and_approval_settings(client, link_books, library):
|
|
orchestrator.save_settings(link_books=link_books, hold_new_links=True)
|
|
story_id = add_story()
|
|
orchestrator._link_changed(story_id)
|
|
response = client.get(f"/api/stories/{story_id}")
|
|
assert response.status_code == 200
|
|
assert response.json["link_books"] is link_books
|
|
assert response.json["story"]["needs_approval"] is link_books
|
|
|
|
|
|
def test_enabling_linking_restores_the_saved_approval_preference(library):
|
|
orchestrator.save_settings(link_books=False, hold_new_links=True)
|
|
story_id = add_story(calibre_id=7, calibre_uuid="uuid-7", ingested_at="2026-01-01")
|
|
orchestrator._link_changed(story_id)
|
|
assert orchestrator.story_details(story_id)["story"]["needs_approval"] is False
|
|
orchestrator.save_settings(link_books=True)
|
|
assert orchestrator.get_settings()["hold_new_links"] is True
|
|
assert orchestrator.story_details(story_id)["story"]["needs_approval"] is True
|
|
assert "Approve link" in rebuild(story_id)
|
|
assert not config.INGEST_DIR.exists()
|
|
|
|
|
|
def test_linking_off_exports_every_rebuild_without_an_id_or_approval():
|
|
orchestrator.save_settings(link_books=False, hold_new_links=True)
|
|
story_id = add_story()
|
|
orchestrator._link_changed(story_id)
|
|
for _ in range(2):
|
|
assert rebuild(story_id) == "Sent to ingest folder"
|
|
assert orchestrator.store.get_story(story_id)["ingested_at"] is not None
|
|
assert len(list(config.INGEST_DIR.glob("*.epub"))) == 2
|
|
assert not list(config.INGEST_DIR.glob("*.cwa.json"))
|
|
assert orchestrator.store.get_setting(story_id, "upload_held") is None
|
|
|
|
|
|
def test_disabling_linking_releases_an_unlinked_book_without_approval(jobs, library):
|
|
orchestrator.save_settings(hold_new_links=True)
|
|
story_id = add_story()
|
|
orchestrator._link_changed(story_id)
|
|
assert "Approve as new book" in rebuild(story_id)
|
|
orchestrator.save_settings(link_books=False)
|
|
assert orchestrator.list_stories()[0]["approval_held"] is False
|
|
assert orchestrator._run_rebuild(jobs.get_next()) == "Sent to ingest folder"
|
|
assert orchestrator.store.get_setting(story_id, "upload_held") is None
|
|
|
|
|
|
@pytest.mark.parametrize("standalone", [False, True])
|
|
def test_new_story_download_skips_library_matching(monkeypatch, standalone):
|
|
if standalone:
|
|
monkeypatch.setattr(config, "INGEST_DIR", None)
|
|
else:
|
|
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
|
|
orchestrator.save_settings(link_books=False)
|
|
site(monkeypatch, [(1, "One", "First chapter")])
|
|
job = Job(mode=DownloadMode.NEW, url=STORY_URL)
|
|
message = orchestrator._run_site_job(job)
|
|
assert ("Not sent." if standalone else "Sent to ingest folder") in message
|
|
assert len(orchestrator.store.list_chapters(job.story_id)) == 1
|
|
assert orchestrator.store.get_story(job.story_id)["status"] == "ok"
|
|
assert list(config.TMP_DIR.glob(f"story-{job.story_id}-*.epub"))
|
|
orchestrator.cwa.assert_not_called()
|
|
orchestrator._find_book.assert_not_called()
|
|
|
|
|
|
def test_site_updates_export_without_a_library_or_saved_id(monkeypatch):
|
|
assert orchestrator.get_settings()["link_books"] is False
|
|
site(monkeypatch, [(1, "One", "First chapter")])
|
|
job = Job(mode=DownloadMode.NEW, url=STORY_URL)
|
|
assert "Sent to ingest folder" in orchestrator._run_site_job(job)
|
|
site(monkeypatch, [(1, "One", "First chapter"), (2, "Two", "Second chapter")])
|
|
assert "Sent to ingest folder" in orchestrator._run_site_job(Job(mode=DownloadMode.NEW, story_id=job.story_id))
|
|
assert len(orchestrator.store.list_chapters(job.story_id)) == 2
|
|
assert len(list(config.INGEST_DIR.glob("*.epub"))) == 2
|
|
assert not list(config.INGEST_DIR.glob("*.cwa.json"))
|
|
orchestrator.cwa.assert_not_called()
|
|
orchestrator._find_book.assert_not_called()
|
|
|
|
|
|
def test_saved_id_without_uuid_works_without_library_configuration():
|
|
story_id = add_story(calibre_id=7, ingested_at="2026-01-01")
|
|
assert rebuild(story_id) == "Updated library book 7"
|
|
orchestrator.cwa.assert_not_called()
|
|
orchestrator._find_book.assert_not_called()
|
|
|
|
|
|
def test_paused_unlinked_update_does_not_raise_not_found(monkeypatch):
|
|
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
|
|
orchestrator._find_book.return_value = None
|
|
orchestrator._find_book.side_effect = None
|
|
orchestrator.save_settings(pause_uploads=True)
|
|
story_id = add_story(ingested_at="2026-01-01")
|
|
assert rebuild(story_id) == "Not sent. Turn off pause to send."
|
|
assert orchestrator.store.get_story(story_id)["status"] == "ok"
|
|
assert not config.INGEST_DIR.exists()
|
|
|
|
|
|
def test_enabling_linking_rebuilds_held_books_and_matches_again(monkeypatch, jobs, tmp_path):
|
|
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
|
|
orchestrator.save_settings(link_books=False, pause_uploads=True)
|
|
story_id = add_story()
|
|
assert "Turn off pause" in rebuild(story_id)
|
|
downloaded = tmp_path / "library.epub"
|
|
downloaded.touch()
|
|
orchestrator._find_book.side_effect = None
|
|
orchestrator._find_book.return_value = (Book(7, "uuid-7", "Test Story"), downloaded)
|
|
orchestrator.save_settings(link_books=True, pause_uploads=False)
|
|
job = jobs.get_next()
|
|
assert job.mode == DownloadMode.REBUILD
|
|
assert job.story_id == story_id
|
|
assert orchestrator._run_rebuild(job) == "Updated library book 7"
|
|
assert orchestrator.store.get_story(story_id)["calibre_id"] == 7
|
|
assert orchestrator.store.get_setting(story_id, "upload_held") is None
|
|
|
|
|
|
@pytest.mark.parametrize("link_books", [False, True])
|
|
@pytest.mark.parametrize("hold_new_links", [False, True])
|
|
@pytest.mark.parametrize("paused", [False, True])
|
|
@pytest.mark.parametrize("saved_id", [None, 7])
|
|
def test_upload_settings_matrix(client, link_books, hold_new_links, paused, saved_id, library):
|
|
orchestrator.save_settings(link_books=link_books, hold_new_links=hold_new_links, pause_uploads=paused)
|
|
story_id = add_story(
|
|
calibre_id=saved_id,
|
|
calibre_uuid="uuid-7" if saved_id else None,
|
|
ingested_at="2026-01-01" if saved_id else None,
|
|
)
|
|
orchestrator._link_changed(story_id)
|
|
assert client.get("/api/stories").json[0]["approval_held"] is False
|
|
message = rebuild(story_id)
|
|
pending = paused or (link_books and hold_new_links)
|
|
assert message.startswith("Not sent.") is pending
|
|
listed = client.get("/api/stories").json[0]
|
|
assert listed["upload_held"] is pending
|
|
assert listed["approval_held"] is (link_books and hold_new_links and not paused)
|
|
assert bool(orchestrator.store.get_setting(story_id, "upload_held")) is pending
|
|
assert config.INGEST_DIR.exists() is (not pending)
|
|
|
|
|
|
@pytest.mark.parametrize("missing_ingest", [False, True])
|
|
def test_approval_while_paused_preserves_pending_upload_until_resume(
|
|
client, monkeypatch, jobs, missing_ingest, library
|
|
):
|
|
ingest = config.INGEST_DIR
|
|
orchestrator.save_settings(hold_new_links=True, pause_uploads=True)
|
|
if missing_ingest:
|
|
monkeypatch.setattr(config, "INGEST_DIR", None)
|
|
story_id = add_story()
|
|
orchestrator._link_changed(story_id)
|
|
rebuild(story_id)
|
|
response = client.post(f"/api/stories/{story_id}/approve", json={"uuid": None}, headers=H)
|
|
assert response.status_code == 200
|
|
assert response.json["job"] is None
|
|
assert response.json["story"]["needs_approval"] is False
|
|
assert response.json["story"]["upload_held"] is True
|
|
assert jobs.get_next() is None
|
|
assert client.get("/api/stories").json[0]["approval_held"] is False
|
|
assert orchestrator.store.get_setting(story_id, "upload_held") is True
|
|
monkeypatch.setattr(config, "INGEST_DIR", ingest)
|
|
orchestrator.save_settings(pause_uploads=False)
|
|
job = jobs.get_next()
|
|
assert job.story_id == story_id
|
|
assert orchestrator._run_rebuild(job) == "Sent to CWA"
|
|
assert orchestrator.store.get_setting(story_id, "upload_held") is None
|
|
assert client.get("/api/stories").json[0]["approval_held"] is False
|
|
|
|
|
|
def test_resuming_uploads_does_not_bypass_approval(client, jobs, library):
|
|
orchestrator.save_settings(hold_new_links=True, pause_uploads=True)
|
|
story_id = add_story(calibre_id=7, calibre_uuid="uuid-7", ingested_at="2026-01-01")
|
|
orchestrator._link_changed(story_id)
|
|
rebuild(story_id)
|
|
assert client.get("/api/stories").json[0]["approval_held"] is False
|
|
orchestrator.save_settings(pause_uploads=False)
|
|
assert client.get("/api/stories").json[0]["approval_held"] is True
|
|
assert "Approve link" in orchestrator._run_rebuild(jobs.get_next())
|
|
assert orchestrator.store.get_setting(story_id, "upload_held") is True
|
|
assert not config.INGEST_DIR.exists()
|
|
|
|
|
|
def test_missing_library_connection_is_not_an_approval_hold(client, monkeypatch):
|
|
orchestrator.save_settings(hold_new_links=True)
|
|
story_id = add_story(ingested_at="2026-01-01")
|
|
orchestrator._link_changed(story_id)
|
|
assert rebuild(story_id) == "Sent to ingest folder"
|
|
assert client.get("/api/stories").json[0]["approval_held"] is False
|
|
details = client.get(f"/api/stories/{story_id}").json
|
|
assert details["story"]["upload_held"] is False
|
|
assert details["story"]["needs_approval"] is False
|
|
assert details["link_books"] is False
|
|
assert details["library_matching_available"] is False
|
|
assert details["ingest_available"] is True
|
|
monkeypatch.setattr(config, "INGEST_DIR", None)
|
|
assert client.get(f"/api/stories/{story_id}").json["ingest_available"] is False
|
|
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
|
|
assert client.get(f"/api/stories/{story_id}").json["library_matching_available"] is True
|
|
|
|
|
|
@pytest.mark.parametrize("missing_ingest", [False, True])
|
|
def test_releasing_other_holds_does_not_queue_uploads_while_paused(monkeypatch, jobs, missing_ingest, library):
|
|
orchestrator.save_settings(link_books=False, hold_new_links=True, pause_uploads=True)
|
|
story_id = add_story()
|
|
rebuild(story_id)
|
|
if missing_ingest:
|
|
monkeypatch.setattr(config, "INGEST_DIR", None)
|
|
orchestrator.save_settings(link_books=True, hold_new_links=False)
|
|
assert jobs.get_next() is None
|
|
assert orchestrator.get_settings()["pause_uploads"] is True
|