Files
bindery/tests/test_optional_cwa.py
tim ca3d730129 Turn CWA off unless CWA_ENABLED is true
CWA_ENABLED now defaults to false. If a CWA variable is set while CWA is off, the start log has a warning that names it.

On the Settings page, the alert about INGEST_DIR moves to Book Downloads and shows with CWA on or off. The Uploads to CWA card moves below Book Downloads.
2026-10-06 16:59:15 +02:00

491 lines
23 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, update
from test_deliver import jobs as jobs
from bindery import config, orchestrator
from bindery.cwa import Book
from bindery.ingest import IngestError
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")
config.INGEST_DIR.mkdir()
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))
@pytest.mark.parametrize("enabled", [None, False, True])
def test_app_starts_without_any_cwa_configuration(tmp_path, enabled):
env = {
**os.environ,
"CONFIG_DIR": str(tmp_path / "standalone"),
"TMP_DIR": str(tmp_path / "builds"),
"AUTH_METHOD": "none",
"BINDERY_START_THREADS": "false",
"URL_BASE": "",
"CWA_ENABLED": str(enabled),
}
# CWA is off unless CWA_ENABLED turns it on.
for name in (
"INGEST_DIR",
"CWA_URL",
"CWA_DB_PATH",
"CALIBRE_DB_PATH",
*(["CWA_ENABLED"] if enabled is None else []),
):
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; "
f"assert client.get('/api/config').json['cwa_enabled'] is {bool(enabled)}; "
"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 "ERROR" not in result.stderr
# Bindery names what is missing only for a user who turned CWA on.
for text in ("WARNING", "INGEST_DIR is not set", "CWA_URL and CALIBRE_DB_PATH are not set", "remove CWA_ENABLED"):
assert (text in result.stderr) is bool(enabled)
if not enabled:
assert "CWA" not in result.stderr and "INGEST" not in result.stderr
@pytest.fixture
def cwa_off(monkeypatch):
monkeypatch.setattr(config, "CWA_ENABLED", False)
def test_without_cwa_and_an_ingest_folder_a_job_only_builds_a_changed_book(monkeypatch, cwa_off):
monkeypatch.setattr(config, "INGEST_DIR", None)
site(monkeypatch, [(1, "One", "First chapter")])
job = Job(mode=DownloadMode.NEW, url=STORY_URL)
assert orchestrator._run_site_job(job).endswith("Built the book")
story_id = job.story_id
story = orchestrator.store.get_story(story_id)
assert (story["status"], story["message"], story["ingested_at"]) == ("ok", "Built the book", None)
assert orchestrator.store.get_setting(story_id, "upload_held") is None
assert len(list(config.TMP_DIR.glob(f"story-{story_id}-*.epub"))) == 1
assert update(story_id).startswith("No changes")
assert orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id)) == "No changes"
assert rebuild(story_id) == "Built the book"
site(monkeypatch, [(1, "One", "First chapter"), (2, "Two", "Second chapter")])
assert update(story_id).endswith("Built the book")
orchestrator.cwa.assert_not_called()
@pytest.mark.parametrize("saved_id", [None, 7])
def test_without_cwa_the_ingest_folder_gets_each_changed_book_as_a_plain_epub(cwa_off, saved_id):
# A pause, a hold and a saved book ID are from a time with CWA. They do not apply.
orchestrator.save_settings(pause_uploads=True, hold_new_links=True)
story_id = add_story(calibre_id=saved_id, calibre_uuid="saved-uuid" if saved_id else None)
orchestrator._link_changed(story_id)
orchestrator.store.set_setting(story_id, "upload_held", True)
assert rebuild(story_id) == "Sent to ingest folder"
assert [p.suffix for p in config.INGEST_DIR.iterdir()] == [".epub"]
assert orchestrator.store.get_story(story_id)["ingested_at"] is None
assert orchestrator.store.get_setting(story_id, "upload_held") is None
assert orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id)) == "No changes"
assert len(list(config.INGEST_DIR.iterdir())) == 1
orchestrator.cwa.assert_not_called()
def test_without_cwa_a_missing_ingest_folder_is_still_an_error(monkeypatch, tmp_path, cwa_off):
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "missing")
story_id = add_story()
with pytest.raises(IngestError, match="does not exist"):
rebuild(story_id)
# The book counts as sent only when it is in the folder.
assert orchestrator.store.get_setting(story_id, "sent_key") is None
def test_cwa_off_shows_in_the_config_and_refuses_cwa_logins(client, monkeypatch, cwa_off):
assert client.get("/api/config").json["cwa_enabled"] is False
monkeypatch.setattr(config, "AUTH_METHOD", "cwa")
monkeypatch.setattr(config, "CWA_DB_PATH", "/cwa/app.db")
response = client.get("/api/stories")
assert response.status_code == 503
assert response.json["error"].startswith("AUTH_METHOD=cwa needs CWA_ENABLED=true.")
def test_without_cwa_the_download_template_uses_the_story_id(client, cwa_off):
assert client.get("/api/config").json["download_fields"]["id"] == "The Bindery story ID"
story_id = add_story(calibre_id=77)
client.put("/api/settings", json={"download_template": "{title} {id}"}, headers=H)
response = client.get(f"/api/stories/{story_id}/epub")
response.close()
assert f'filename="My Title {story_id}.epub"' in response.headers["Content-Disposition"]
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 list((tmp_path / "ingest").iterdir())
details = orchestrator.story_details(story_id)
assert details["pause_uploads"] 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 list(config.INGEST_DIR.iterdir())
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 list(config.INGEST_DIR.iterdir())
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 list(config.INGEST_DIR.iterdir())
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 bool(list(config.INGEST_DIR.iterdir())) 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 list(config.INGEST_DIR.iterdir())
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["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["link_books"] 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