diff --git a/shelfmark/core/queue_routes.py b/shelfmark/core/queue_routes.py new file mode 100644 index 00000000..ee11dae2 --- /dev/null +++ b/shelfmark/core/queue_routes.py @@ -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/", 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//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//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//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 diff --git a/shelfmark/main.py b/shelfmark/main.py index 5f758931..f0bd5a64 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -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/", 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//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//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//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) diff --git a/tests/core/test_activity_routes_api.py b/tests/core/test_activity_routes_api.py index ac63ba57..8447c4eb 100644 --- a/tests/core/test_activity_routes_api.py +++ b/tests/core/test_activity_routes_api.py @@ -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: diff --git a/tests/core/test_queue_routes.py b/tests/core/test_queue_routes.py new file mode 100644 index 00000000..7cdda99c --- /dev/null +++ b/tests/core/test_queue_routes.py @@ -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", + }