The search through CWA gave no results for books that the full list shows. The search now filters the list that "Show all stories in the library" loads, by title, author and site. The buttons stay disabled until the answer comes. A book that the user imports leaves the list.
290 lines
16 KiB
Python
290 lines
16 KiB
Python
"""State-changing API flows, using a real store and queue without site requests."""
|
|
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from test_api import H
|
|
from test_api import client as client
|
|
from test_build import URL, chapters, metahtml
|
|
|
|
from bindery import config
|
|
from bindery import orchestrator as o
|
|
from bindery.cwa import Book, CwaError
|
|
from bindery.jobs import JobQueue
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_queue(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(o, "job_queue", JobQueue(tmp_path / "jobs.db"))
|
|
|
|
|
|
@pytest.fixture
|
|
def story(client):
|
|
sid = o.store.create_story("https://www.royalroad.com/fiction/1234", "www.royalroad.com", "Story", "Writer", [])
|
|
c = chapters()[0]
|
|
cid = o.store.add_chapter(sid, c["url"], c["title"], c["html"], "hash", "new", 0)
|
|
return sid, cid
|
|
|
|
|
|
def test_add_list_download_and_cancel_jobs(client, story):
|
|
sid, _ = story
|
|
response = client.post("/api/stories", json={"urls": f" {URL}\ninvalid\n"}, headers=H)
|
|
assert response.status_code == 200
|
|
queued = response.json["jobs"][0]
|
|
assert queued["story_id"] == sid
|
|
assert response.json["errors"] == ["Not a supported story URL: invalid"]
|
|
assert client.get("/api/stories").json[0]["busy"]
|
|
assert client.get(f"/api/jobs?story_id={sid}").json[0]["id"] == queued["id"]
|
|
assert client.get("/api/jobs?story_id=999").json == []
|
|
assert client.post(f"/api/jobs/{queued['id']}/cancel", headers=H).json == {"cancelled": True}
|
|
assert client.post(f"/api/jobs/{queued['id']}/cancel", headers=H).json == {"cancelled": False}
|
|
result = client.post(
|
|
f"/api/stories/{sid}/download", json={"mode": "metadata", "force_fields": ["title"]}, headers=H
|
|
)
|
|
assert result.status_code == 200
|
|
assert result.json["force_fields"] == ["title"]
|
|
assert result.json["mode"] == "metadata"
|
|
app_config = client.get("/api/config").json
|
|
assert app_config["metadata_fields"]["title"]
|
|
assert app_config["cwa_enabled"] is True
|
|
assert (app_config["story_jobs_hours"], app_config["story_jobs_count"]) == (36, 10)
|
|
assert (app_config["main_jobs_hours"], app_config["main_jobs_count"]) == (6, 25)
|
|
assert (app_config["job_list_max_hours"], app_config["job_list_max_count"]) == (720, 500)
|
|
|
|
|
|
def test_settings_and_inclusion_persist_and_delete_cascades(client, story):
|
|
sid, cid = story
|
|
response = client.put(f"/api/stories/{sid}/settings", json={"auto_update": False, "recheck_days": 4}, headers=H)
|
|
assert response.status_code == 200
|
|
assert not response.json["auto_update"]
|
|
assert response.json["recheck_days"] == 4
|
|
for data in [{"recheck_days": -1}, {"locked_fields": ["unknown"]}]:
|
|
assert client.put(f"/api/stories/{sid}/settings", json=data, headers=H).status_code == 400
|
|
response = client.post(
|
|
f"/api/stories/{sid}/chapters", json={"chapter_ids": [str(cid)], "included": False}, headers=H
|
|
)
|
|
assert response.json["mode"] == "rebuild"
|
|
assert not o.store.get_chapter(cid)["included"]
|
|
response = client.post(f"/api/stories/{sid}/chapters/move", json={"chapter_ids": [cid], "after": None}, headers=H)
|
|
assert response.json["mode"] == "rebuild"
|
|
move = client.post(f"/api/stories/{sid}/chapters/move", json={"chapter_ids": [cid], "after": 999}, headers=H)
|
|
assert move.status_code == 404
|
|
# The story has only this chapter, so it cannot go.
|
|
response = client.post(f"/api/stories/{sid}/chapters/delete", json={"chapter_ids": [cid]}, headers=H)
|
|
assert response.status_code == 400
|
|
assert "at least one chapter" in response.json["error"]
|
|
o.store.set_rules(sid, [{"pattern": "a", "replacement": "b", "enabled": True}])
|
|
response = client.delete(f"/api/stories/{sid}", headers=H)
|
|
assert response.json == {"deleted": sid}
|
|
assert o.store.get_story(sid) is None
|
|
assert o.store.get_chapter(cid) is None
|
|
assert o.store.revision_html(cid, 1) is None
|
|
assert o.store.rules(sid) == []
|
|
|
|
|
|
def test_library_search_link_import_and_unlink(client, story, monkeypatch):
|
|
sid, _ = story
|
|
book = Book(7, "book-7", "Title", ["Writer"], "www.royalroad.com", {"epub": "/book.epub"})
|
|
cwa = Mock()
|
|
cwa.search.return_value = [book]
|
|
cwa.site_books.return_value = [book]
|
|
cwa.get_book.return_value = book.to_dict()
|
|
monkeypatch.setattr(o, "cwa", lambda: cwa)
|
|
monkeypatch.setattr(config, "CWA_URL", "https://cwa.example")
|
|
assert client.get("/api/library/candidates").json[0]["id"] == 7
|
|
assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] is None
|
|
cwa.search.assert_called_once_with("Title")
|
|
linked = client.post(f"/api/stories/{sid}/link", json={"uuid": "book-7"}, headers=H)
|
|
assert linked.json["calibre_id"] == 7
|
|
assert client.get("/api/library/candidates").json == []
|
|
assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] == "Story"
|
|
cwa.get_book.side_effect = CwaError("offline")
|
|
assert client.get(f"/api/stories/{sid}").json["library_error"] == "offline"
|
|
cwa.get_book.assert_called_with("book-7", timeout=5) # the page does not wait long for CWA
|
|
cwa.get_book.side_effect = None
|
|
cwa.get_book.return_value = None
|
|
assert client.post(f"/api/stories/{sid}/link", json={"uuid": "missing"}, headers=H).status_code == 404
|
|
unlinked = client.post(f"/api/stories/{sid}/link", json={"uuid": None}, headers=H)
|
|
assert unlinked.json["calibre_id"] is None
|
|
assert unlinked.json["ingested_at"] is None
|
|
o.save_settings(hold_new_links=True)
|
|
assert client.get(f"/api/stories/{sid}").json["story"]["needs_approval"]
|
|
wrong = client.post(f"/api/stories/{sid}/approve", json={"uuid": "book-7"}, headers=H)
|
|
assert wrong.status_code == 400 and "changed" in wrong.json["error"]
|
|
approved = client.post(f"/api/stories/{sid}/approve", json={"uuid": None}, headers=H).json
|
|
assert (approved["story"]["needs_approval"], approved["job"]) == (False, None)
|
|
response = client.post("/api/library/import", json={"books": [{"id": 7}, {}, "invalid"], "check": False}, headers=H)
|
|
assert len(response.json) == 1
|
|
assert response.json[0]["title"] == "Library book 7"
|
|
assert response.json[0]["options"] == {"uuid": "", "check": False}
|
|
|
|
|
|
def test_invalid_rules_and_chapter_requests_do_not_change_stored_data(client, story):
|
|
sid, cid = story
|
|
assert client.put("/api/rules", json={"rules": [{"pattern": "["}]}, headers=H).json["index"] == 0
|
|
assert o.store.rules(None) == []
|
|
for data in [{"recent_chapters": "bad"}, {"recent_chapters": -1}]:
|
|
assert client.put("/api/settings", json=data, headers=H).status_code == 400
|
|
assert client.get("/api/chapters/999/diff").status_code == 404
|
|
assert client.get(f"/api/chapters/{cid}/diff?a=1&b=999").status_code == 404
|
|
assert client.post(f"/api/chapters/{cid}/revision", json={"rev": 999}, headers=H).status_code == 404
|
|
assert o.store.get_chapter(cid)["current_rev"] == 1
|
|
response = client.post(f"/api/stories/{sid + 1}/rules/preview", json={"chapter_id": cid, "rules": []}, headers=H)
|
|
assert response.status_code == 404
|
|
response = client.put("/api/rules", json={"rules": [], "paragraphs": None}, headers=H)
|
|
assert response.json["paragraphs"] is False
|
|
|
|
|
|
def test_download_builds_from_stored_chapters_without_changing_them(client, story):
|
|
sid, cid = story
|
|
o.store.update_story(sid, metahtml=metahtml(), title="A/B")
|
|
original = o.store.revision_html(cid, 1)
|
|
response = client.get(f"/api/stories/{sid}/epub")
|
|
assert response.status_code == 200
|
|
assert response.data.startswith(b"PK")
|
|
assert 'filename="A_B - Writer.epub"' in response.headers["Content-Disposition"]
|
|
assert o.store.revision_html(cid, 1) == original
|
|
response.close()
|
|
|
|
|
|
def download_name(client, sid):
|
|
response = client.get(f"/api/stories/{sid}/epub")
|
|
response.close()
|
|
return response.headers["Content-Disposition"]
|
|
|
|
|
|
def test_download_template_names_the_file(client, story):
|
|
sid, _ = story
|
|
site = {"title": "The Book", "authors": ["Ann Writer"], "series": "The Saga", "series_index": 2.0}
|
|
o.store.update_story(sid, metahtml=metahtml(), title="The Book", site_values=site)
|
|
assert client.get("/api/config").json["download_fields"]["id"].startswith("The CWA book ID")
|
|
saved = client.put("/api/settings", json={"download_template": " {series:|| - }{title} - {id} "}, headers=H)
|
|
assert saved.json["download_template"] == "{series:|| - }{title} - {id}"
|
|
assert f'filename="The Saga - The Book - {sid}.epub"' in download_name(client, sid)
|
|
# The default moves the article to the end, as Calibre's default does.
|
|
client.put("/api/settings", json={"download_template": ""}, headers=H)
|
|
assert 'filename="Book, The - Ann Writer.epub"' in download_name(client, sid)
|
|
client.put("/api/settings", json={"download_template": "{series_sort} - {title} - {id}"}, headers=H)
|
|
assert f'filename="Saga, The - The Book - {sid}.epub"' in download_name(client, sid)
|
|
# The ID is that of the CWA book, when the story has one.
|
|
o.store.update_story(sid, calibre_id=77, pinned={"title": "Caf\u00e9", "series": ["", None]})
|
|
client.put("/api/settings", json={"download_template": "{series:|| - }{title} - {id}"}, headers=H)
|
|
name = download_name(client, sid)
|
|
assert 'filename="Cafe - 77.epub"' in name and "filename*=UTF-8''Caf%C3%A9%20-%2077.epub" in name
|
|
# A template with folders gives the name of the file alone.
|
|
client.put("/api/settings", json={"download_template": "{author_sort}/{title}"}, headers=H)
|
|
assert "filename*=UTF-8''Caf%C3%A9.epub" in download_name(client, sid)
|
|
# An empty template restores the default.
|
|
saved = client.put("/api/settings", json={"download_template": " "}, headers=H).json
|
|
assert saved["download_template"] == "{title_sort} - {authors}"
|
|
assert "filename*=UTF-8''Caf%C3%A9%20-%20Ann%20Writer.epub" in download_name(client, sid)
|
|
|
|
|
|
def test_date_format_of_download_names(client, story):
|
|
sid, _ = story
|
|
o.store.update_story(sid, metahtml=metahtml(), site_values={"pubdate": "2020-05-06T00:00:00"})
|
|
client.put("/api/settings", json={"download_template": "{title} {pubdate}"}, headers=H)
|
|
assert 'filename="Story May, 2020.epub"' in download_name(client, sid)
|
|
saved = client.put("/api/settings", json={"download_date_format": " %Y-%m-%d "}, headers=H).json
|
|
assert saved["download_date_format"] == "%Y-%m-%d"
|
|
assert "filename=Story 2020-05-06.epub" in download_name(client, sid).replace('"', "")
|
|
response = client.put("/api/settings", json={"download_date_format": 5}, headers=H)
|
|
assert (response.status_code, response.json) == (400, {"error": "The date format must be text"})
|
|
response = client.put("/api/settings", json={"download_date_format": "%Y" * 51}, headers=H)
|
|
assert (response.status_code, response.json) == (400, {"error": "A date format can have 100 characters at most"})
|
|
# An empty format restores the default.
|
|
assert client.put("/api/settings", json={"download_date_format": ""}, headers=H).json["download_date_format"] == (
|
|
"%b, %Y"
|
|
)
|
|
|
|
|
|
def test_download_template_that_fails_gives_the_title(client, story, caplog):
|
|
sid, _ = story
|
|
o.store.update_story(sid, metahtml=metahtml())
|
|
for template in ("{title:d}", "{series}/{series_index}"):
|
|
client.put("/api/settings", json={"download_template": template}, headers=H)
|
|
assert "filename=Story.epub" in download_name(client, sid)
|
|
assert "The download template fails for https://www.royalroad.com/fiction/1234" in caplog.text
|
|
assert caplog.text.count("The download template fails") == 1
|
|
|
|
|
|
def test_bad_download_template_is_not_saved(client):
|
|
client.put("/api/settings", json={"download_template": "{title}"}, headers=H)
|
|
for template, error in [
|
|
("{title:uppercase()}", "Bindery does not support template functions, such as {title:uppercase()}"),
|
|
("{title", "The braces of the template do not match: expected '}' before end of string"),
|
|
(7, "The download template must be text"),
|
|
]:
|
|
response = client.put("/api/settings", json={"download_template": template, "recent_chapters": 9}, headers=H)
|
|
assert (response.status_code, response.json) == (400, {"error": error})
|
|
assert client.get("/api/settings").json["download_template"] == "{title}"
|
|
assert client.get("/api/settings").json["recent_chapters"] == 0
|
|
|
|
|
|
def test_unexpected_backend_error_returns_json(client, monkeypatch, caplog):
|
|
monkeypatch.setattr(o, "list_stories", Mock(side_effect=RuntimeError("broken")))
|
|
response = client.get("/api/stories")
|
|
assert response.status_code == 500
|
|
assert response.json == {"error": "Bindery hit an unexpected error. The log has the details."}
|
|
assert "RuntimeError: broken" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("method", "path", "body", "error"),
|
|
[
|
|
("post", "stories", {"urls": 5}, "urls must be a list"),
|
|
("post", "stories/{sid}/chapters", {"chapter_ids": 5}, "chapter_ids must be a list"),
|
|
("post", "stories/{sid}/chapters/move", {"chapter_ids": [[1]]}, "chapter_ids must be a whole number"),
|
|
("post", "stories/{sid}/chapters/move", {"chapter_ids": [1], "after": [2]}, "after must be a whole number"),
|
|
("post", "stories/{sid}/chapters/delete", {"chapter_ids": "all"}, "chapter_ids must be a list"),
|
|
("post", "stories/{sid}/download", {"mode": "recheck", "chapter_ids": ["x"]}, "chapter_ids must be a whole"),
|
|
("post", "stories/{sid}/download", {"force_fields": "title"}, "force_fields must be a list"),
|
|
("post", "stories/{sid}/download", {"force_fields": [["title"]]}, "force_fields must be a list of names"),
|
|
("put", "stories/{sid}/settings", {"locked_fields": [{}]}, "locked_fields must be a list of names"),
|
|
("put", "stories/{sid}/settings", {"recheck_days": [7]}, "recheck_days must be a whole number"),
|
|
("put", "rules", {"rules": ["a"]}, "rules must be a list of rules"),
|
|
("post", "rules/preview", {"rules": [], "chapter_id": None}, "chapter_id must be a whole number"),
|
|
("post", "chapters/{cid}/revision", {"rev": {}}, "rev must be a whole number"),
|
|
("post", "library/import", {"books": 7}, "books must be a list"),
|
|
("post", "library/import", {"books": [{"id": "x"}]}, "id must be a whole number"),
|
|
],
|
|
)
|
|
def test_a_value_of_the_wrong_type_is_a_bad_request(client, story, method, path, body, error):
|
|
sid, cid = story
|
|
response = getattr(client, method)("/api/" + path.format(sid=sid, cid=cid), json=body, headers=H)
|
|
assert response.status_code == 400
|
|
assert error in response.json["error"]
|
|
|
|
|
|
def test_chapter_requests_without_ids_name_what_to_select(client, story):
|
|
sid, _ = story
|
|
for body in ({}, {"chapter_ids": None}):
|
|
response = client.post(f"/api/stories/{sid}/chapters/delete", json=body, headers=H)
|
|
assert (response.status_code, response.json["error"]) == (400, "Select the chapters to delete")
|
|
|
|
|
|
def test_a_story_change_does_not_ask_cwa_for_the_book(client, story, monkeypatch):
|
|
sid, _ = story
|
|
o.store.update_story(sid, calibre_id=7, calibre_uuid="book-7")
|
|
monkeypatch.setattr(config, "CWA_URL", "https://cwa.example")
|
|
cwa = Mock()
|
|
monkeypatch.setattr(o, "cwa", lambda: cwa)
|
|
response = client.put(f"/api/stories/{sid}/settings", json={"recheck_days": 4}, headers=H)
|
|
assert response.json["recheck_days"] == 4
|
|
assert "chapters" not in response.json
|
|
cwa.get_book.assert_not_called()
|
|
|
|
|
|
def test_settings_are_saved_by_name(client):
|
|
with pytest.raises(ValueError, match="Unknown settings: \\['recent'\\]"):
|
|
o.save_settings(recent=3)
|
|
# The route passes only the known settings on.
|
|
response = client.put("/api/settings", json={"recent": 3, "recent_chapters": 4, "main_jobs_count": 9}, headers=H)
|
|
assert (response.status_code, response.json["recent_chapters"], response.json["main_jobs_count"]) == (200, 4, 9)
|
|
for data, error in [
|
|
({"recent_chapters": -1}, "The number of recent chapters cannot be negative"),
|
|
({"main_jobs_count": 501}, "main_jobs_count cannot be more than 500"),
|
|
({"story_jobs_hours": "x"}, "story_jobs_hours must be a whole number"),
|
|
]:
|
|
assert client.put("/api/settings", json=data, headers=H).json == {"error": error}
|