refactor: move the queue routes into core/queue_routes.py (#1637)

Next from the route plan in Discussion #1536 are the queue routes:
status, cancel, retry, priority, reorder, queue order, active downloads,
the local file download and the cover proxy. They move from `main.py` to
`core/queue_routes.py`, registered with `register_queue_routes(app,
login_required, ...)`, and `main.py` is about 600 lines shorter.

The first commit adds 33 tests for what the guardrail tests did not
cover, mostly the local download in each shape, the admin paths, the
validation errors and the failure responses. They pass on current
`main`. The second commit is the move.

`resolve_status_scope` and the request update emitter stay in `main.py`
because the release routes and the request code still use them, so they
come in as parameters, with the user database and the history service as
callables evaluated per request. Two test lines changed: the patches of
`send_file` and of the delivery state sync now point at `queue_routes`,
since that is where they get looked up. Log lines from these routes now
name `shelfmark.core.queue_routes`.

The URL map is identical, all 77 rules. The full suite, ruff and
basedpyright are clean, and I ran the app from the branch and hit
status, queue order, active downloads, the local download and the cover
route, which answer as before. The exception text a few of them return
in a 500 is unchanged, that is for the follow-up on exception text.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Improvements**
* Queue status, downloads, and queue controls respect account ownership,
with administrative access retained.
* More specific errors clarify when queue items or files are missing or
unavailable.
* Queue actions support cancellation, retrying, prioritization, and
reordering.
  * Cover requests can use cached images when caching is enabled.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
splitsec2 authored and GitHub committed 2026-11-01 18:28:10 -07:00
1 parent f06585aa01
commit 384adc4c75
4 files changed
+1157 -600

No files matched your search

+650
View File
@@ -0,0 +1,650 @@
"""Download queue routes: status, cancel, retry, priority, order, local file and cover."""
from __future__ import annotations
import binascii
import sqlite3
import unicodedata
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import quote
from flask import Response, jsonify, request, send_file, session
from shelfmark.core.download_history_service import DownloadHistoryService
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import build_filename
from shelfmark.core.request_helpers import normalize_optional_text, normalize_positive_int
from shelfmark.core.requests_service import sync_delivery_states_from_queue_status
from shelfmark.core.viewer_redaction import (
is_public_download_id,
match_public_download_id,
redact_status,
viewer_download_id,
)
from shelfmark.download import orchestrator as backend
from shelfmark.download.zip_stream import stream_zip
if TYPE_CHECKING:
from collections.abc import Callable
from flask import Flask
from shelfmark.core.models import DownloadTask
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
def _task_owned_by_actor(
task: Any, *, actor_user_id: int | None, actor_username: str | None
) -> bool:
raw_task_user_id = getattr(task, "user_id", None)
try:
task_user_id = int(raw_task_user_id) if raw_task_user_id is not None else None
except TypeError, ValueError:
task_user_id = None
if actor_user_id is not None and task_user_id is not None:
return task_user_id == actor_user_id
task_username = getattr(task, "username", None)
if isinstance(task_username, str) and task_username.strip() and isinstance(actor_username, str):
return task_username.strip() == actor_username.strip()
return False
def _download_row_owned_by_actor(
row: dict[str, Any],
*,
actor_user_id: int | None,
actor_username: str | None,
) -> bool:
owner_user_id = normalize_positive_int(row.get("user_id"))
if actor_user_id is not None and owner_user_id is not None:
return owner_user_id == actor_user_id
row_username = normalize_optional_text(row.get("username"))
if row_username is not None and isinstance(actor_username, str):
return row_username == actor_username.strip()
return False
def _queue_task_visible_to_actor(
task_id: str,
*,
is_admin: bool,
actor_user_id: int | None,
actor_username: str | None,
) -> bool:
if is_admin:
return True
task = backend.book_queue.get_task(task_id)
if task is None:
return False
return _task_owned_by_actor(
task,
actor_user_id=actor_user_id,
actor_username=actor_username,
)
def _viewer_queue_task_id(task_id: str) -> str:
"""Return the id a non-admin sees for a live queue task."""
task = backend.book_queue.get_task(task_id)
return viewer_download_id(task_id, getattr(task, "request_id", None))
def _attachment_filename_options(download_name: str) -> dict[str, str]:
"""Content-Disposition filename options, RFC 2231-encoded when not plain ASCII.
Mirrors what send_file does, for responses built from a generator.
"""
try:
download_name.encode("ascii")
except UnicodeEncodeError:
simple = unicodedata.normalize("NFKD", download_name)
simple = simple.encode("ascii", "ignore").decode("ascii")
quoted = quote(download_name, safe="!#$&+^`|~")
return {"filename": simple, "filename*": f"UTF-8''{quoted}"}
return {"filename": download_name}
def _zip_download_response(file_paths: list[str], task: DownloadTask) -> Response:
"""Send a multi-file download (audiobook chapters, a pack) as one streamed ZIP."""
download_name = build_filename(task.title or "download", task.author, task.year, "zip")
response = Response(stream_zip(file_paths), mimetype="application/zip")
response.headers.set(
"Content-Disposition", "attachment", **_attachment_filename_options(download_name)
)
return response
def register_queue_routes(
app: Flask,
login_required: Callable[..., Any],
*,
get_user_db: Callable[[], UserDB | None],
get_download_history_service: Callable[[], DownloadHistoryService | None],
resolve_status_scope: Callable[..., tuple[bool, int | None, bool]],
emit_request_update_events: Callable[[list[dict[str, Any]]], None],
) -> None:
"""Register the queue routes.
The history service and the user database are called per request, so a runtime patch of
either still takes effect. ``resolve_status_scope`` and ``emit_request_update_events`` stay
in ``main.py`` because the release routes and the request routes use them too.
"""
def _resolve_queue_actor() -> tuple[bool, int | None, str | None, Response | None]:
is_admin, db_user_id, can_access_status = resolve_status_scope()
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not is_admin and (not can_access_status or db_user_id is None):
return (
is_admin,
db_user_id,
normalized_actor_username,
jsonify(
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
),
)
return is_admin, db_user_id, normalized_actor_username, None
def _resolve_viewer_download_id(book_id: str) -> str:
"""Map a non-admin's opaque download id back to the task it names (#1418).
Only the caller's own request-linked downloads are searched, so an id that
doesn't resolve comes back unchanged and fails the route's usual lookup.
"""
download_history_service = get_download_history_service()
if not is_public_download_id(book_id):
return book_id
is_admin, db_user_id, _ = resolve_status_scope()
if is_admin or db_user_id is None:
return book_id
candidates = [
task_id
for tasks in backend.book_queue.get_status(user_id=db_user_id).values()
for task_id, task in tasks.items()
if getattr(task, "request_id", None) is not None
]
if download_history_service is not None:
candidates.extend(download_history_service.list_request_task_ids(user_id=db_user_id))
return match_public_download_id(book_id, candidates) or book_id
@app.route("/api/status", methods=["GET"])
@login_required
def api_status() -> Response | tuple[Response, int]:
"""Get current download queue status.
Returns:
flask.Response: JSON object with queue status.
"""
user_db = get_user_db()
try:
is_admin, db_user_id, can_access_status = resolve_status_scope()
if not can_access_status:
return jsonify({})
user_id = None if is_admin else db_user_id
status = backend.queue_status(user_id=user_id)
if user_db is not None:
updated_requests = sync_delivery_states_from_queue_status(
user_db,
queue_status=status,
user_id=user_id,
)
emit_request_update_events(updated_requests)
return jsonify(status if is_admin else redact_status(status))
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Status error: {e}")
return jsonify({"error": "Could not read the download status"}), 500
@app.route("/api/localdownload", methods=["GET"])
@login_required
def api_local_download() -> Response | tuple[Response, int]:
"""Download a completed book from local storage if available.
Query Parameters:
id (str): Book identifier (MD5 hash)
Returns:
flask.Response: The file if found, a ZIP of all of them when the download
produced several (a live task only; history keeps just the first), otherwise
an error response.
"""
download_history_service = get_download_history_service()
book_id = request.args.get("id", "")
if not book_id:
return jsonify({"error": "No book ID provided"}), 400
book_id = _resolve_viewer_download_id(book_id)
try:
# The path, not the bytes: send_file streams it off disk, where reading it
# first cost two resident copies of the whole book.
file_paths, book_info = backend.get_book_paths(book_id)
if not file_paths:
# Fallback for dismissed/history entries where queue task may no longer exist.
if download_history_service is not None:
is_admin, db_user_id, can_access_status = resolve_status_scope()
if can_access_status:
history_row = download_history_service.get_by_task_id(book_id)
if history_row is not None:
owner_user_id = history_row.get("user_id")
if is_admin or owner_user_id == db_user_id:
download_path = (
DownloadHistoryService._resolve_existing_download_path(
history_row.get("download_path")
)
)
if download_path:
return send_file(
download_path,
download_name=Path(download_path).name,
as_attachment=True,
)
# Book data not found or not available
return jsonify({"error": "File not found"}), 404
is_admin, db_user_id, can_access_status = resolve_status_scope()
if not is_admin:
actor_username = session.get("user_id")
if not can_access_status or not _task_owned_by_actor(
book_info,
actor_user_id=db_user_id,
actor_username=actor_username if isinstance(actor_username, str) else None,
):
return jsonify({"error": "File not found"}), 404
if book_info is not None and len(file_paths) > 1:
return _zip_download_response(file_paths, book_info)
file_name = book_info.get_filename() if book_info is not None else Path(book_id).name
return send_file(file_paths[0], download_name=file_name, as_attachment=True)
except _OPERATIONAL_ERRORS as e:
# The details are logged. The message of an OSError names the file's path on the server,
# which a non-admin never sees anywhere else.
logger.error_trace(f"Local download error: {e}")
return jsonify({"error": "Could not read the file"}), 500
@app.route("/api/covers/<cover_id>", methods=["GET"])
@login_required
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
"""Serve a cached book cover image.
This endpoint proxies and caches cover images from external sources.
Images are cached to disk for faster subsequent requests.
Path Parameters:
cover_id (str): Cover identifier (book ID or composite key for universal mode).
Kept for the link shape only; the cache entry is chosen by `url`.
Query Parameters:
url (str): Base64-encoded original image URL (required)
Returns:
flask.Response: Binary image data with appropriate Content-Type, or 404.
"""
try:
import base64
from shelfmark.config.env import is_covers_cache_enabled
from shelfmark.core.image_cache import cover_cache_key, get_image_cache
# Check if caching is enabled
if not is_covers_cache_enabled():
return jsonify({"error": "Cover caching is disabled"}), 404
encoded_url = request.args.get("url")
if not encoded_url:
return jsonify({"error": "Cover URL not provided"}), 404
try:
original_url = base64.urlsafe_b64decode(encoded_url).decode()
except (binascii.Error, UnicodeDecodeError) as e:
logger.warning("Failed to decode cover URL: %s", e)
return jsonify({"error": "Invalid cover URL encoding"}), 400
# The entry is keyed by the URL, never by the caller-supplied id: the id would
# let any user cache an image of their choosing under another book's link.
cache = get_image_cache()
cache_key = cover_cache_key(original_url)
cached = cache.get(cache_key)
if cached:
image_data, content_type = cached
response = app.response_class(
response=image_data, status=200, mimetype=content_type
)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = "HIT"
return response
# Fetch and cache the image
result = cache.fetch_and_cache(cache_key, original_url)
if not result:
return jsonify({"error": "Failed to fetch cover image"}), 404
image_data, content_type = result
response = app.response_class(response=image_data, status=200, mimetype=content_type)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = "MISS"
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Cover fetch error: {e}")
return jsonify({"error": str(e)}), 500
else:
return response
@app.route("/api/download/<path:book_id>/cancel", methods=["DELETE"])
@login_required
def api_cancel_download(book_id: str) -> Response | tuple[Response, int]:
"""Cancel a download.
Path Parameters:
book_id (str): Book identifier to cancel
Returns:
flask.Response: JSON status indicating success or failure.
"""
viewer_book_id = book_id
book_id = _resolve_viewer_download_id(book_id)
try:
task = backend.book_queue.get_task(book_id)
if task is None:
return jsonify({"error": "Failed to cancel download or book not found"}), 404
is_admin, db_user_id, can_access_status = resolve_status_scope()
if not is_admin:
if not can_access_status or db_user_id is None:
return jsonify(
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
), 403
actor_username = session.get("user_id")
normalized_actor_username = (
actor_username if isinstance(actor_username, str) else None
)
if not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
if getattr(task, "request_id", None) is not None:
return jsonify(
{"error": "Forbidden", "code": "requested_download_cancel_forbidden"}
), 403
success = backend.cancel_download(book_id)
if success:
return jsonify({"status": "cancelled", "book_id": viewer_book_id})
return jsonify({"error": "Failed to cancel download or book not found"}), 404
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Cancel download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/download/<path:book_id>/retry", methods=["POST"])
@login_required
def api_retry_download(book_id: str) -> Response | tuple[Response, int]:
"""Retry a failed download."""
download_history_service = get_download_history_service()
viewer_book_id = book_id
book_id = _resolve_viewer_download_id(book_id)
try:
task = backend.book_queue.get_task(book_id)
history_row = None
if task is None and download_history_service is not None:
history_row = download_history_service.get_by_task_id(book_id)
if task is None and history_row is None:
return jsonify({"error": "Download not found"}), 404
is_admin, db_user_id, can_access_status = resolve_status_scope()
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not is_admin:
if not can_access_status or db_user_id is None:
return jsonify(
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
), 403
if task is not None:
if not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
elif history_row is None or not _download_row_owned_by_actor(
history_row,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
if task is not None:
task_status = backend.book_queue.get_task_status(book_id)
if getattr(
task, "request_id", None
) is not None and not backend.can_retry_download_task(task, task_status):
return jsonify(
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
), 403
success, error = backend.retry_download(book_id)
else:
if history_row is None:
logger.error("Download history row disappeared while retrying task %s", book_id)
return jsonify({"error": "Download history not found"}), 404
request_id = normalize_positive_int(history_row.get("request_id"))
retry_payload = history_row.get("retry_payload")
final_status = history_row.get("final_status")
if request_id is not None:
history_service = download_history_service
if history_service is None:
logger.error(
"Download history service unavailable while retrying task %s", book_id
)
return jsonify({"error": "Download history unavailable"}), 500
if not history_service.is_retry_available(history_row):
return jsonify(
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
), 403
success, error = backend.retry_persisted_download(
retry_payload,
final_status=final_status,
)
if success:
return jsonify({"status": "queued", "book_id": viewer_book_id})
if error == "Download not found":
return jsonify({"error": error}), 404
return jsonify({"error": error or "Download cannot be retried"}), 409
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Retry download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/queue/<path:book_id>/priority", methods=["PUT"])
@login_required
def api_set_priority(book_id: str) -> Response | tuple[Response, int]:
"""Set priority for a queued book.
Path Parameters:
book_id (str): Book identifier
Request Body:
priority (int): New priority level (lower number = higher priority)
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
data = request.get_json(silent=True)
if not data or "priority" not in data:
return jsonify({"error": "Priority not provided"}), 400
priority = int(data["priority"])
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
viewer_book_id = book_id
book_id = _resolve_viewer_download_id(book_id)
task = backend.book_queue.get_task(book_id)
if task is None:
return jsonify({"error": "Failed to update priority or book not found"}), 404
if not is_admin and not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
success = backend.set_book_priority(book_id, priority)
if success:
return jsonify(
{"status": "updated", "book_id": viewer_book_id, "priority": priority}
)
return jsonify({"error": "Failed to update priority or book not found"}), 404
except ValueError:
return jsonify({"error": "Invalid priority value"}), 400
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Set priority error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/queue/reorder", methods=["POST"])
@login_required
def api_reorder_queue() -> Response | tuple[Response, int]:
"""Bulk reorder queue by setting new priorities.
Request Body:
book_priorities (dict): Mapping of book_id to new priority
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
data = request.get_json(silent=True)
if not data or "book_priorities" not in data:
return jsonify({"error": "book_priorities not provided"}), 400
book_priorities = data["book_priorities"]
if not isinstance(book_priorities, dict):
return jsonify({"error": "book_priorities must be a dictionary"}), 400
# Validate all priorities are integers
for book_id, priority in book_priorities.items():
if not isinstance(priority, int):
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
book_priorities = {
_resolve_viewer_download_id(str(book_id)): priority
for book_id, priority in book_priorities.items()
}
owned_book_priorities = {}
for book_id in book_priorities:
task = backend.book_queue.get_task(str(book_id))
if task is None:
continue
if not _task_owned_by_actor(
task, actor_user_id=db_user_id, actor_username=actor_username
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
owned_book_priorities[book_id] = book_priorities[book_id]
book_priorities = owned_book_priorities
success = backend.reorder_queue(book_priorities)
if success:
return jsonify({"status": "reordered", "updated_count": len(book_priorities)})
return jsonify({"error": "Failed to reorder queue"}), 500
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Reorder queue error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/queue/order", methods=["GET"])
@login_required
def api_queue_order() -> Response | tuple[Response, int]:
"""Get current queue order for display.
Returns:
flask.Response: JSON array of queued books with their order and priorities.
"""
try:
queue_order = backend.get_queue_order()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
queue_order = [
{**item, "id": _viewer_queue_task_id(str(item.get("id", "")))}
for item in queue_order
if _queue_task_visible_to_actor(
str(item.get("id", "")),
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"queue": queue_order})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Queue order error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/downloads/active", methods=["GET"])
@login_required
def api_active_downloads() -> Response | tuple[Response, int]:
"""Get list of currently active downloads.
Returns:
flask.Response: JSON array of active download book IDs.
"""
try:
active_downloads = backend.get_active_downloads()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
active_downloads = [
_viewer_queue_task_id(task_id)
for task_id in active_downloads
if _queue_task_visible_to_actor(
task_id,
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"active_downloads": active_downloads})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Active downloads error: {e}")
return jsonify({"error": str(e)}), 500
+12 -599
View File
@@ -1,23 +1,19 @@
"""Flask app - routes, WebSocket handlers, and middleware."""
import binascii
import os
import re
import sqlite3
import time
import unicodedata
from functools import wraps
from importlib import import_module
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast
from urllib.parse import quote
from flask import Flask, g, jsonify, request, send_file, session
from flask import Flask, g, jsonify, request, session
from flask_cors import CORS
from flask_socketio import SocketIO
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.security import check_password_hash
from werkzeug.wrappers import Response
from shelfmark.api.websocket import ws_manager
from shelfmark.config.env import (
@@ -73,7 +69,6 @@ from shelfmark.core.models import (
TERMINAL_QUEUE_STATUSES,
QueueStatus,
SearchFilters,
build_filename,
)
from shelfmark.core.notifications import (
NotificationContext,
@@ -83,6 +78,7 @@ from shelfmark.core.notifications import (
)
from shelfmark.core.onboarding_routes import register_onboarding_routes
from shelfmark.core.prefix_middleware import PrefixMiddleware
from shelfmark.core.queue_routes import register_queue_routes
from shelfmark.core.release_inspect_routes import register_release_inspect_routes
from shelfmark.core.request_helpers import (
coerce_bool,
@@ -114,14 +110,11 @@ from shelfmark.core.user_db import UserDB
from shelfmark.core.utils import AUDIOBOOK_FORMATS, normalize_base_path
from shelfmark.core.viewer_redaction import (
configure_public_id_key,
is_public_download_id,
match_public_download_id,
redact_status,
viewer_download_id,
)
from shelfmark.download import orchestrator as backend
from shelfmark.download import warmup
from shelfmark.download.zip_stream import stream_zip
from shelfmark.release_sources import (
BrowseRecord,
Release,
@@ -130,7 +123,8 @@ from shelfmark.release_sources import (
)
if TYPE_CHECKING:
from shelfmark.core.models import DownloadTask
from werkzeug.wrappers import Response
from shelfmark.metadata_providers import BookMetadata
logger = setup_logger(__name__)
@@ -1358,107 +1352,6 @@ def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task:
)
def _task_owned_by_actor(
task: Any, *, actor_user_id: int | None, actor_username: str | None
) -> bool:
raw_task_user_id = getattr(task, "user_id", None)
try:
task_user_id = int(raw_task_user_id) if raw_task_user_id is not None else None
except TypeError, ValueError:
task_user_id = None
if actor_user_id is not None and task_user_id is not None:
return task_user_id == actor_user_id
task_username = getattr(task, "username", None)
if isinstance(task_username, str) and task_username.strip() and isinstance(actor_username, str):
return task_username.strip() == actor_username.strip()
return False
def _download_row_owned_by_actor(
row: dict[str, Any],
*,
actor_user_id: int | None,
actor_username: str | None,
) -> bool:
owner_user_id = normalize_positive_int(row.get("user_id"))
if actor_user_id is not None and owner_user_id is not None:
return owner_user_id == actor_user_id
row_username = normalize_optional_text(row.get("username"))
if row_username is not None and isinstance(actor_username, str):
return row_username == actor_username.strip()
return False
def _resolve_queue_actor() -> tuple[bool, int | None, str | None, Response | None]:
is_admin, db_user_id, can_access_status = _resolve_status_scope()
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not is_admin and (not can_access_status or db_user_id is None):
return (
is_admin,
db_user_id,
normalized_actor_username,
jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}),
)
return is_admin, db_user_id, normalized_actor_username, None
def _queue_task_visible_to_actor(
task_id: str,
*,
is_admin: bool,
actor_user_id: int | None,
actor_username: str | None,
) -> bool:
if is_admin:
return True
task = backend.book_queue.get_task(task_id)
if task is None:
return False
return _task_owned_by_actor(
task,
actor_user_id=actor_user_id,
actor_username=actor_username,
)
def _resolve_viewer_download_id(book_id: str) -> str:
"""Map a non-admin's opaque download id back to the task it names (#1418).
Only the caller's own request-linked downloads are searched, so an id that
doesn't resolve comes back unchanged and fails the route's usual lookup.
"""
if not is_public_download_id(book_id):
return book_id
is_admin, db_user_id, _ = _resolve_status_scope()
if is_admin or db_user_id is None:
return book_id
candidates = [
task_id
for tasks in backend.book_queue.get_status(user_id=db_user_id).values()
for task_id, task in tasks.items()
if getattr(task, "request_id", None) is not None
]
if download_history_service is not None:
candidates.extend(download_history_service.list_request_task_ids(user_id=db_user_id))
return match_public_download_id(book_id, candidates) or book_id
def _viewer_queue_task_id(task_id: str) -> str:
"""Return the id a non-admin sees for a live queue task."""
task = backend.book_queue.get_task(task_id)
return viewer_download_id(task_id, getattr(task, "request_id", None))
backend.book_queue.set_queue_hook(_record_download_queued)
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
@@ -1489,494 +1382,14 @@ def _emit_request_update_events(updated_requests: list[dict[str, Any]]) -> None:
)
@app.route("/api/status", methods=["GET"])
@login_required
def api_status() -> Response | tuple[Response, int]:
"""Get current download queue status.
Returns:
flask.Response: JSON object with queue status.
"""
try:
is_admin, db_user_id, can_access_status = _resolve_status_scope()
if not can_access_status:
return jsonify({})
user_id = None if is_admin else db_user_id
status = backend.queue_status(user_id=user_id)
if user_db is not None:
updated_requests = sync_delivery_states_from_queue_status(
user_db,
queue_status=status,
user_id=user_id,
)
_emit_request_update_events(updated_requests)
return jsonify(status if is_admin else redact_status(status))
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Status error: {e}")
return jsonify({"error": "Could not read the download status"}), 500
def _attachment_filename_options(download_name: str) -> dict[str, str]:
"""Content-Disposition filename options, RFC 2231-encoded when not plain ASCII.
Mirrors what send_file does, for responses built from a generator.
"""
try:
download_name.encode("ascii")
except UnicodeEncodeError:
simple = unicodedata.normalize("NFKD", download_name)
simple = simple.encode("ascii", "ignore").decode("ascii")
quoted = quote(download_name, safe="!#$&+^`|~")
return {"filename": simple, "filename*": f"UTF-8''{quoted}"}
return {"filename": download_name}
def _zip_download_response(file_paths: list[str], task: DownloadTask) -> Response:
"""Send a multi-file download (audiobook chapters, a pack) as one streamed ZIP."""
download_name = build_filename(task.title or "download", task.author, task.year, "zip")
response = Response(stream_zip(file_paths), mimetype="application/zip")
response.headers.set(
"Content-Disposition", "attachment", **_attachment_filename_options(download_name)
)
return response
@app.route("/api/localdownload", methods=["GET"])
@login_required
def api_local_download() -> Response | tuple[Response, int]:
"""Download a completed book from local storage if available.
Query Parameters:
id (str): Book identifier (MD5 hash)
Returns:
flask.Response: The file if found, a ZIP of all of them when the download
produced several (a live task only; history keeps just the first), otherwise
an error response.
"""
book_id = request.args.get("id", "")
if not book_id:
return jsonify({"error": "No book ID provided"}), 400
book_id = _resolve_viewer_download_id(book_id)
try:
# The path, not the bytes: send_file streams it off disk, where reading it
# first cost two resident copies of the whole book.
file_paths, book_info = backend.get_book_paths(book_id)
if not file_paths:
# Fallback for dismissed/history entries where queue task may no longer exist.
if download_history_service is not None:
is_admin, db_user_id, can_access_status = _resolve_status_scope()
if can_access_status:
history_row = download_history_service.get_by_task_id(book_id)
if history_row is not None:
owner_user_id = history_row.get("user_id")
if is_admin or owner_user_id == db_user_id:
download_path = DownloadHistoryService._resolve_existing_download_path(
history_row.get("download_path")
)
if download_path:
return send_file(
download_path,
download_name=Path(download_path).name,
as_attachment=True,
)
# Book data not found or not available
return jsonify({"error": "File not found"}), 404
is_admin, db_user_id, can_access_status = _resolve_status_scope()
if not is_admin:
actor_username = session.get("user_id")
if not can_access_status or not _task_owned_by_actor(
book_info,
actor_user_id=db_user_id,
actor_username=actor_username if isinstance(actor_username, str) else None,
):
return jsonify({"error": "File not found"}), 404
if book_info is not None and len(file_paths) > 1:
return _zip_download_response(file_paths, book_info)
file_name = book_info.get_filename() if book_info is not None else Path(book_id).name
return send_file(file_paths[0], download_name=file_name, as_attachment=True)
except _OPERATIONAL_ERRORS as e:
# The details are logged. The message of an OSError names the file's path on the server,
# which a non-admin never sees anywhere else.
logger.error_trace(f"Local download error: {e}")
return jsonify({"error": "Could not read the file"}), 500
@app.route("/api/covers/<cover_id>", methods=["GET"])
@login_required
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
"""Serve a cached book cover image.
This endpoint proxies and caches cover images from external sources.
Images are cached to disk for faster subsequent requests.
Path Parameters:
cover_id (str): Cover identifier (book ID or composite key for universal mode).
Kept for the link shape only; the cache entry is chosen by `url`.
Query Parameters:
url (str): Base64-encoded original image URL (required)
Returns:
flask.Response: Binary image data with appropriate Content-Type, or 404.
"""
try:
import base64
from shelfmark.config.env import is_covers_cache_enabled
from shelfmark.core.image_cache import cover_cache_key, get_image_cache
# Check if caching is enabled
if not is_covers_cache_enabled():
return jsonify({"error": "Cover caching is disabled"}), 404
encoded_url = request.args.get("url")
if not encoded_url:
return jsonify({"error": "Cover URL not provided"}), 404
try:
original_url = base64.urlsafe_b64decode(encoded_url).decode()
except (binascii.Error, UnicodeDecodeError) as e:
logger.warning("Failed to decode cover URL: %s", e)
return jsonify({"error": "Invalid cover URL encoding"}), 400
# The entry is keyed by the URL, never by the caller-supplied id: the id would
# let any user cache an image of their choosing under another book's link.
cache = get_image_cache()
cache_key = cover_cache_key(original_url)
cached = cache.get(cache_key)
if cached:
image_data, content_type = cached
response = app.response_class(response=image_data, status=200, mimetype=content_type)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = "HIT"
return response
# Fetch and cache the image
result = cache.fetch_and_cache(cache_key, original_url)
if not result:
return jsonify({"error": "Failed to fetch cover image"}), 404
image_data, content_type = result
response = app.response_class(response=image_data, status=200, mimetype=content_type)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = "MISS"
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Cover fetch error: {e}")
return jsonify({"error": str(e)}), 500
else:
return response
@app.route("/api/download/<path:book_id>/cancel", methods=["DELETE"])
@login_required
def api_cancel_download(book_id: str) -> Response | tuple[Response, int]:
"""Cancel a download.
Path Parameters:
book_id (str): Book identifier to cancel
Returns:
flask.Response: JSON status indicating success or failure.
"""
viewer_book_id = book_id
book_id = _resolve_viewer_download_id(book_id)
try:
task = backend.book_queue.get_task(book_id)
if task is None:
return jsonify({"error": "Failed to cancel download or book not found"}), 404
is_admin, db_user_id, can_access_status = _resolve_status_scope()
if not is_admin:
if not can_access_status or db_user_id is None:
return jsonify(
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
), 403
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
if getattr(task, "request_id", None) is not None:
return jsonify(
{"error": "Forbidden", "code": "requested_download_cancel_forbidden"}
), 403
success = backend.cancel_download(book_id)
if success:
return jsonify({"status": "cancelled", "book_id": viewer_book_id})
return jsonify({"error": "Failed to cancel download or book not found"}), 404
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Cancel download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/download/<path:book_id>/retry", methods=["POST"])
@login_required
def api_retry_download(book_id: str) -> Response | tuple[Response, int]:
"""Retry a failed download."""
viewer_book_id = book_id
book_id = _resolve_viewer_download_id(book_id)
try:
task = backend.book_queue.get_task(book_id)
history_row = None
if task is None and download_history_service is not None:
history_row = download_history_service.get_by_task_id(book_id)
if task is None and history_row is None:
return jsonify({"error": "Download not found"}), 404
is_admin, db_user_id, can_access_status = _resolve_status_scope()
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not is_admin:
if not can_access_status or db_user_id is None:
return jsonify(
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
), 403
if task is not None:
if not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
elif history_row is None or not _download_row_owned_by_actor(
history_row,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
if task is not None:
task_status = backend.book_queue.get_task_status(book_id)
if getattr(
task, "request_id", None
) is not None and not backend.can_retry_download_task(task, task_status):
return jsonify(
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
), 403
success, error = backend.retry_download(book_id)
else:
if history_row is None:
logger.error("Download history row disappeared while retrying task %s", book_id)
return jsonify({"error": "Download history not found"}), 404
request_id = normalize_positive_int(history_row.get("request_id"))
retry_payload = history_row.get("retry_payload")
final_status = history_row.get("final_status")
if request_id is not None:
history_service = download_history_service
if history_service is None:
logger.error(
"Download history service unavailable while retrying task %s", book_id
)
return jsonify({"error": "Download history unavailable"}), 500
if not history_service.is_retry_available(history_row):
return jsonify(
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
), 403
success, error = backend.retry_persisted_download(
retry_payload,
final_status=final_status,
)
if success:
return jsonify({"status": "queued", "book_id": viewer_book_id})
if error == "Download not found":
return jsonify({"error": error}), 404
return jsonify({"error": error or "Download cannot be retried"}), 409
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Retry download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/queue/<path:book_id>/priority", methods=["PUT"])
@login_required
def api_set_priority(book_id: str) -> Response | tuple[Response, int]:
"""Set priority for a queued book.
Path Parameters:
book_id (str): Book identifier
Request Body:
priority (int): New priority level (lower number = higher priority)
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
data = request.get_json(silent=True)
if not data or "priority" not in data:
return jsonify({"error": "Priority not provided"}), 400
priority = int(data["priority"])
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
viewer_book_id = book_id
book_id = _resolve_viewer_download_id(book_id)
task = backend.book_queue.get_task(book_id)
if task is None:
return jsonify({"error": "Failed to update priority or book not found"}), 404
if not is_admin and not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
success = backend.set_book_priority(book_id, priority)
if success:
return jsonify({"status": "updated", "book_id": viewer_book_id, "priority": priority})
return jsonify({"error": "Failed to update priority or book not found"}), 404
except ValueError:
return jsonify({"error": "Invalid priority value"}), 400
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Set priority error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/queue/reorder", methods=["POST"])
@login_required
def api_reorder_queue() -> Response | tuple[Response, int]:
"""Bulk reorder queue by setting new priorities.
Request Body:
book_priorities (dict): Mapping of book_id to new priority
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
data = request.get_json(silent=True)
if not data or "book_priorities" not in data:
return jsonify({"error": "book_priorities not provided"}), 400
book_priorities = data["book_priorities"]
if not isinstance(book_priorities, dict):
return jsonify({"error": "book_priorities must be a dictionary"}), 400
# Validate all priorities are integers
for book_id, priority in book_priorities.items():
if not isinstance(priority, int):
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
book_priorities = {
_resolve_viewer_download_id(str(book_id)): priority
for book_id, priority in book_priorities.items()
}
owned_book_priorities = {}
for book_id in book_priorities:
task = backend.book_queue.get_task(str(book_id))
if task is None:
continue
if not _task_owned_by_actor(
task, actor_user_id=db_user_id, actor_username=actor_username
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
owned_book_priorities[book_id] = book_priorities[book_id]
book_priorities = owned_book_priorities
success = backend.reorder_queue(book_priorities)
if success:
return jsonify({"status": "reordered", "updated_count": len(book_priorities)})
return jsonify({"error": "Failed to reorder queue"}), 500
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Reorder queue error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/queue/order", methods=["GET"])
@login_required
def api_queue_order() -> Response | tuple[Response, int]:
"""Get current queue order for display.
Returns:
flask.Response: JSON array of queued books with their order and priorities.
"""
try:
queue_order = backend.get_queue_order()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
queue_order = [
{**item, "id": _viewer_queue_task_id(str(item.get("id", "")))}
for item in queue_order
if _queue_task_visible_to_actor(
str(item.get("id", "")),
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"queue": queue_order})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Queue order error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/downloads/active", methods=["GET"])
@login_required
def api_active_downloads() -> Response | tuple[Response, int]:
"""Get list of currently active downloads.
Returns:
flask.Response: JSON array of active download book IDs.
"""
try:
active_downloads = backend.get_active_downloads()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
active_downloads = [
_viewer_queue_task_id(task_id)
for task_id in active_downloads
if _queue_task_visible_to_actor(
task_id,
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"active_downloads": active_downloads})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Active downloads error: {e}")
return jsonify({"error": str(e)}), 500
register_queue_routes(
app,
login_required,
get_user_db=lambda: user_db,
get_download_history_service=lambda: download_history_service,
resolve_status_scope=_resolve_status_scope,
emit_request_update_events=_emit_request_update_events,
)
@app.errorhandler(404)
+1 -1
View File
@@ -384,7 +384,7 @@ class TestActivityRoutes:
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
with (
patch.object(main_module, "get_auth_mode", return_value="builtin"),
patch.object(main_module, "send_file", side_effect=failure),
patch("shelfmark.core.queue_routes.send_file", side_effect=failure),
):
response = client.get(f"/api/localdownload?id={task_id}")
finally:
+494
View File
@@ -0,0 +1,494 @@
"""Characterization tests for the queue routes, written before they moved out of main.py.
They pin what the routes do today: the status codes, the bodies and who may do what. The
guardrail tests cover the owner and non-owner cases for cancel, retry, priority and reorder,
so these fill in the rest: the local download route, the admin paths, the validation errors
and the failure responses.
"""
from __future__ import annotations
import importlib
import uuid
import zipfile
from io import BytesIO
from unittest.mock import MagicMock, patch
import pytest
from shelfmark.core.models import DownloadTask
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def client(main_module):
return main_module.app.test_client()
def _login(client, *, user_id="alice", db_user_id: int | None = 7, is_admin=False) -> None:
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["is_admin"] = is_admin
if db_user_id is not None:
sess["db_user_id"] = db_user_id
def _create_user(main_module, *, prefix: str) -> dict:
return main_module.user_db.create_user(username=f"{prefix}-{uuid.uuid4().hex[:8]}", role="user")
def _task(task_id="task-1", *, user=None, request_id=None) -> DownloadTask:
return DownloadTask(
task_id=task_id,
source="direct_download",
title="A Book",
author="An Author",
user_id=user["id"] if user else None,
username=user["username"] if user else None,
request_id=request_id,
)
def _builtin(main_module):
return patch.object(main_module, "get_auth_mode", return_value="builtin")
class TestLocalDownload:
def test_missing_id_is_a_400(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
resp = client.get("/api/localdownload")
assert resp.status_code == 400
assert resp.get_json() == {"error": "No book ID provided"}
def test_requires_a_login(self, main_module, client):
with _builtin(main_module):
resp = client.get("/api/localdownload?id=task-1")
assert resp.status_code == 401
def test_unknown_book_is_a_404_when_history_has_nothing(self, main_module, client):
_login(client, is_admin=True)
history = MagicMock()
history.get_by_task_id.return_value = None
with _builtin(main_module):
with patch.object(main_module.backend, "get_book_paths", return_value=([], None)):
with patch.object(main_module, "download_history_service", history):
resp = client.get("/api/localdownload?id=gone")
assert resp.status_code == 404
assert resp.get_json() == {"error": "File not found"}
def test_admin_gets_the_file_as_an_attachment(self, main_module, client, tmp_path):
book = tmp_path / "book.epub"
book.write_bytes(b"epub-bytes")
_login(client, is_admin=True)
task = _task()
with _builtin(main_module):
with patch.object(
main_module.backend, "get_book_paths", return_value=([str(book)], task)
):
resp = client.get("/api/localdownload?id=task-1")
assert resp.status_code == 200
assert resp.data == b"epub-bytes"
assert resp.headers["Content-Disposition"].startswith("attachment")
def test_owner_gets_the_file(self, main_module, client, tmp_path):
book = tmp_path / "book.epub"
book.write_bytes(b"mine")
user = _create_user(main_module, prefix="reader")
_login(client, user_id=user["username"], db_user_id=user["id"])
with _builtin(main_module):
with patch.object(
main_module.backend,
"get_book_paths",
return_value=([str(book)], _task(user=user)),
):
resp = client.get("/api/localdownload?id=task-1")
assert resp.status_code == 200
assert resp.data == b"mine"
def test_non_owner_gets_a_404_not_a_403(self, main_module, client, tmp_path):
book = tmp_path / "book.epub"
book.write_bytes(b"theirs")
owner = _create_user(main_module, prefix="owner")
other = _create_user(main_module, prefix="other")
_login(client, user_id=other["username"], db_user_id=other["id"])
with _builtin(main_module):
with patch.object(
main_module.backend,
"get_book_paths",
return_value=([str(book)], _task(user=owner)),
):
resp = client.get("/api/localdownload?id=task-1")
assert resp.status_code == 404
assert resp.get_json() == {"error": "File not found"}
def test_several_files_come_back_as_one_zip(self, main_module, client, tmp_path):
first = tmp_path / "01.mp3"
second = tmp_path / "02.mp3"
first.write_bytes(b"one")
second.write_bytes(b"two")
_login(client, is_admin=True)
with _builtin(main_module):
with patch.object(
main_module.backend,
"get_book_paths",
return_value=([str(first), str(second)], _task()),
):
resp = client.get("/api/localdownload?id=task-1")
assert resp.status_code == 200
assert resp.mimetype == "application/zip"
assert resp.headers["Content-Disposition"].startswith("attachment")
with zipfile.ZipFile(BytesIO(resp.data)) as archive:
assert sorted(archive.namelist()) == ["01.mp3", "02.mp3"]
def test_a_finished_download_is_served_from_history_to_its_owner(
self, main_module, client, tmp_path
):
book = tmp_path / "old.epub"
book.write_bytes(b"from-history")
user = _create_user(main_module, prefix="reader")
_login(client, user_id=user["username"], db_user_id=user["id"])
history = MagicMock()
history.get_by_task_id.return_value = {"user_id": user["id"], "download_path": str(book)}
with _builtin(main_module):
with patch.object(main_module.backend, "get_book_paths", return_value=([], None)):
with patch.object(main_module, "download_history_service", history):
with patch.object(
main_module.DownloadHistoryService,
"_resolve_existing_download_path",
return_value=str(book),
):
resp = client.get("/api/localdownload?id=task-1")
assert resp.status_code == 200
assert resp.data == b"from-history"
def test_a_history_entry_of_someone_else_is_a_404(self, main_module, client, tmp_path):
book = tmp_path / "old.epub"
book.write_bytes(b"not-yours")
user = _create_user(main_module, prefix="reader")
_login(client, user_id=user["username"], db_user_id=user["id"])
history = MagicMock()
history.get_by_task_id.return_value = {
"user_id": user["id"] + 1000,
"download_path": str(book),
}
with _builtin(main_module):
with patch.object(main_module.backend, "get_book_paths", return_value=([], None)):
with patch.object(main_module, "download_history_service", history):
resp = client.get("/api/localdownload?id=task-1")
assert resp.status_code == 404
def test_a_read_failure_returns_a_fixed_message(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
with patch.object(
main_module.backend,
"get_book_paths",
side_effect=OSError("/srv/secret/path.epub is unreadable"),
):
resp = client.get("/api/localdownload?id=task-1")
assert resp.status_code == 500
assert resp.get_json() == {"error": "Could not read the file"}
class TestCancel:
def test_unknown_task_is_a_404(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
with patch.object(main_module.backend.book_queue, "get_task", return_value=None):
resp = client.delete("/api/download/nope/cancel")
assert resp.status_code == 404
assert resp.get_json() == {"error": "Failed to cancel download or book not found"}
def test_admin_can_cancel_anyones_download(self, main_module, client):
owner = _create_user(main_module, prefix="owner")
_login(client, user_id="root", db_user_id=None, is_admin=True)
with _builtin(main_module):
with patch.object(
main_module.backend.book_queue, "get_task", return_value=_task(user=owner)
):
with patch.object(
main_module.backend, "cancel_download", return_value=True
) as cancel:
resp = client.delete("/api/download/task-1/cancel")
assert resp.status_code == 200
assert resp.get_json() == {"status": "cancelled", "book_id": "task-1"}
cancel.assert_called_once_with("task-1")
def test_a_cancel_the_backend_refuses_is_a_404(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
with patch.object(main_module.backend.book_queue, "get_task", return_value=_task()):
with patch.object(main_module.backend, "cancel_download", return_value=False):
resp = client.delete("/api/download/task-1/cancel")
assert resp.status_code == 404
def test_a_user_without_a_database_identity_is_a_403(self, main_module, client):
_login(client, user_id="ghost", db_user_id=None)
with _builtin(main_module):
with patch.object(main_module.backend.book_queue, "get_task", return_value=_task()):
resp = client.delete("/api/download/task-1/cancel")
assert resp.status_code == 403
assert resp.get_json() == {
"error": "User identity unavailable",
"code": "user_identity_unavailable",
}
def test_a_failure_in_the_backend_is_a_500_with_its_message(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
with patch.object(
main_module.backend.book_queue, "get_task", side_effect=RuntimeError("queue broke")
):
resp = client.delete("/api/download/task-1/cancel")
assert resp.status_code == 500
assert resp.get_json() == {"error": "queue broke"}
def test_the_opaque_id_a_viewer_sees_cancels_the_real_task(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_login(client, user_id=user["username"], db_user_id=user["id"])
real_id = "21:https://tracker.example/t/1"
viewer_id = main_module.viewer_download_id(real_id, 5)
assert viewer_id != real_id
task = _task(real_id, user=user, request_id=None)
live = MagicMock()
live.get_status.return_value = {
"queued": {real_id: _task(real_id, user=user, request_id=5)}
}
with _builtin(main_module):
with patch.object(main_module.backend, "book_queue", live):
live.get_task.return_value = task
with patch.object(main_module, "download_history_service", None):
with patch.object(
main_module.backend, "cancel_download", return_value=True
) as cancel:
resp = client.delete(f"/api/download/{viewer_id}/cancel")
assert resp.status_code == 200
assert resp.get_json() == {"status": "cancelled", "book_id": viewer_id}
cancel.assert_called_once_with(real_id)
class TestPriority:
def test_a_missing_body_is_a_400(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
resp = client.put("/api/queue/task-1/priority", json={})
assert resp.status_code == 400
assert resp.get_json() == {"error": "Priority not provided"}
def test_a_priority_that_is_not_a_number_is_a_400(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
resp = client.put("/api/queue/task-1/priority", json={"priority": "high"})
assert resp.status_code == 400
assert resp.get_json() == {"error": "Invalid priority value"}
def test_a_user_without_a_database_identity_is_a_403(self, main_module, client):
_login(client, user_id="ghost", db_user_id=None)
with _builtin(main_module):
resp = client.put("/api/queue/task-1/priority", json={"priority": 1})
assert resp.status_code == 403
assert resp.get_json()["code"] == "user_identity_unavailable"
def test_unknown_task_is_a_404(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
with patch.object(main_module.backend.book_queue, "get_task", return_value=None):
resp = client.put("/api/queue/nope/priority", json={"priority": 1})
assert resp.status_code == 404
assert resp.get_json() == {"error": "Failed to update priority or book not found"}
def test_admin_can_set_anyones_priority(self, main_module, client):
owner = _create_user(main_module, prefix="owner")
_login(client, user_id="root", db_user_id=None, is_admin=True)
with _builtin(main_module):
with patch.object(
main_module.backend.book_queue, "get_task", return_value=_task(user=owner)
):
with patch.object(
main_module.backend, "set_book_priority", return_value=True
) as set_priority:
resp = client.put("/api/queue/task-1/priority", json={"priority": "3"})
assert resp.status_code == 200
assert resp.get_json() == {"status": "updated", "book_id": "task-1", "priority": 3}
set_priority.assert_called_once_with("task-1", 3)
class TestReorder:
def test_a_missing_body_is_a_400(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
resp = client.post("/api/queue/reorder", json={})
assert resp.status_code == 400
assert resp.get_json() == {"error": "book_priorities not provided"}
def test_priorities_that_are_not_a_dict_are_a_400(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
resp = client.post("/api/queue/reorder", json={"book_priorities": [1, 2]})
assert resp.status_code == 400
assert resp.get_json() == {"error": "book_priorities must be a dictionary"}
def test_a_priority_that_is_not_an_integer_names_the_book(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
resp = client.post("/api/queue/reorder", json={"book_priorities": {"a": "1"}})
assert resp.status_code == 400
assert resp.get_json() == {"error": "Invalid priority for book a"}
def test_admin_reorders_everything_it_names(self, main_module, client):
_login(client, user_id="root", db_user_id=None, is_admin=True)
with _builtin(main_module):
with patch.object(main_module.backend, "reorder_queue", return_value=True) as reorder:
resp = client.post("/api/queue/reorder", json={"book_priorities": {"a": 0, "b": 1}})
assert resp.status_code == 200
assert resp.get_json() == {"status": "reordered", "updated_count": 2}
reorder.assert_called_once_with({"a": 0, "b": 1})
def test_a_backend_that_refuses_is_a_500(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
with patch.object(main_module.backend, "reorder_queue", return_value=False):
resp = client.post("/api/queue/reorder", json={"book_priorities": {"a": 0}})
assert resp.status_code == 500
assert resp.get_json() == {"error": "Failed to reorder queue"}
def test_a_non_admin_skips_ids_that_name_no_task(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_login(client, user_id=user["username"], db_user_id=user["id"])
mine = _task("mine", user=user)
with _builtin(main_module):
with patch.object(
main_module.backend.book_queue,
"get_task",
side_effect=lambda task_id: {"mine": mine}.get(task_id),
):
with patch.object(
main_module.backend, "reorder_queue", return_value=True
) as reorder:
resp = client.post(
"/api/queue/reorder", json={"book_priorities": {"mine": 0, "gone": 1}}
)
assert resp.status_code == 200
assert resp.get_json() == {"status": "reordered", "updated_count": 1}
reorder.assert_called_once_with({"mine": 0})
class TestQueueViews:
def test_admin_sees_the_whole_queue_order(self, main_module, client):
_login(client, user_id="root", db_user_id=None, is_admin=True)
order = [{"id": "a", "priority": 0}, {"id": "b", "priority": 1}]
with _builtin(main_module):
with patch.object(main_module.backend, "get_queue_order", return_value=order):
resp = client.get("/api/queue/order")
assert resp.status_code == 200
assert resp.get_json() == {"queue": order}
def test_admin_sees_every_active_download(self, main_module, client):
_login(client, user_id="root", db_user_id=None, is_admin=True)
with _builtin(main_module):
with patch.object(main_module.backend, "get_active_downloads", return_value=["a", "b"]):
resp = client.get("/api/downloads/active")
assert resp.status_code == 200
assert resp.get_json() == {"active_downloads": ["a", "b"]}
def test_both_views_refuse_a_user_without_a_database_identity(self, main_module, client):
_login(client, user_id="ghost", db_user_id=None)
with _builtin(main_module):
order = client.get("/api/queue/order")
active = client.get("/api/downloads/active")
assert order.status_code == active.status_code == 403
assert order.get_json()["code"] == active.get_json()["code"] == "user_identity_unavailable"
def test_a_failure_is_a_500_with_its_message(self, main_module, client):
_login(client, is_admin=True)
with _builtin(main_module):
with patch.object(
main_module.backend, "get_queue_order", side_effect=RuntimeError("order broke")
):
resp = client.get("/api/queue/order")
assert resp.status_code == 500
assert resp.get_json() == {"error": "order broke"}
class TestStatus:
def test_a_user_without_a_database_identity_gets_an_empty_object(self, main_module, client):
_login(client, user_id="ghost", db_user_id=None)
with _builtin(main_module):
resp = client.get("/api/status")
assert resp.status_code == 200
assert resp.get_json() == {}
def test_delivery_states_are_synced_and_announced_for_the_requests_that_changed(
self, main_module, client
):
_login(client, user_id="root", db_user_id=None, is_admin=True)
changed = [
{
"id": 4,
"status": "fulfilled",
"delivery_state": "complete",
"user_id": 9,
"book_data": {"title": "Dune"},
}
]
with _builtin(main_module):
with patch.object(main_module.backend, "queue_status", return_value={}):
with patch(
"shelfmark.core.queue_routes.sync_delivery_states_from_queue_status",
return_value=changed,
):
with patch.object(main_module, "emit_ws_event") as emit:
resp = client.get("/api/status")
assert resp.status_code == 200
rooms = {call.kwargs["room"] for call in emit.call_args_list}
assert rooms == {"user_9", "admins"}
payload = emit.call_args_list[0].kwargs["payload"]
assert payload == {
"request_id": 4,
"status": "fulfilled",
"delivery_state": "complete",
"title": "Dune",
}