Files
shelfmark/tests/download/test_orchestrator_retry.py
T
CaliBrain 10710abd4e fix(download): send every file of a multi-file download to the browser (#1489)
Download to Browser only ever sent the first file of a multi-file
release
(audiobook chapters, packs): the folder output returned final_paths[0]
and
/api/localdownload served that one path.

The folder output now keeps every final path on the in-memory task
(download_paths), and /api/localdownload streams them as one
uncompressed
ZIP named after the book when there is more than one. The archive is
never
staged on disk or held in memory, and ZIP64 covers large audiobooks.
History still stores only the first path, so a re-download from history
after a dismiss or restart serves a single file, as before.

Also brings every linter back to green on all files:
- pre-commit: bump the ruff hook to v0.17.1 to match uv.lock;
ruff-format
  the Python examples in docs/dev and skills
- ruff: fix the remaining issues in skills/shelfmark/download_books.py;
  format base_handler.py
- basedpyright: Optional and attrs errors in databazeknih.py
- knip: drop 31 unused exports, reuse ColumnColorHint in colorMaps
- oxfmt 0.72: styles.css
- direct download: fix the "Welib, mirrors" typo and the test that still
  expected the pre-OceanofPDF message

Fixes #1467
2026-10-20 02:33:53 -04:00

383 lines
12 KiB
Python

from __future__ import annotations
from pathlib import Path
from threading import Event
from unittest.mock import MagicMock
import pytest
from shelfmark.core.models import DownloadTask, QueueStatus
from shelfmark.core.queue import BookQueue
class _AvailableSource:
display_name = "Test Source"
def is_available(self):
return True
@pytest.fixture(autouse=True)
def source_available_by_default(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource())
def test_retry_download_requeues_error_task(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
task = DownloadTask(
task_id="task-1",
source="direct_download",
title="Retryable",
last_error_message="Timeout",
last_error_type="TimeoutError",
)
mock_queue = MagicMock()
mock_queue.get_task.return_value = task
mock_queue.get_task_status.return_value = QueueStatus.ERROR
mock_queue.enqueue_existing.return_value = True
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
monkeypatch.setattr(orchestrator, "ws_manager", None)
ok, error = orchestrator.retry_download("task-1")
assert ok is True
assert error is None
assert task.last_error_message is None
assert task.last_error_type is None
assert task.priority == -10
mock_queue.enqueue_existing.assert_called_once_with("task-1", priority=-10)
mock_queue.update_status_message.assert_called_once_with("task-1", "Retrying now")
def test_retry_download_rejects_request_linked_tasks(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
task = DownloadTask(
task_id="task-2",
source="prowlarr",
title="Request linked",
request_id=123,
)
mock_queue = MagicMock()
mock_queue.get_task.return_value = task
mock_queue.get_task_status.return_value = QueueStatus.ERROR
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
monkeypatch.setattr(orchestrator, "ws_manager", None)
ok, error = orchestrator.retry_download("task-2")
assert ok is False
assert error == "Request-linked downloads must be retried from requests"
mock_queue.enqueue_existing.assert_not_called()
def test_can_retry_download_task_allows_request_postprocess_retry_when_staged_file_exists(tmp_path):
import shelfmark.download.orchestrator as orchestrator
staged_file = tmp_path / "requested-staged.epub"
staged_file.write_text("staged")
task = DownloadTask(
task_id="task-request-staged-1",
source="prowlarr",
title="Requested Retryable",
request_id=123,
staged_path=str(staged_file),
)
assert orchestrator.can_retry_download_task(task, QueueStatus.ERROR) is True
def test_can_retry_download_task_blocks_request_error_retry_without_staged_file():
import shelfmark.download.orchestrator as orchestrator
task = DownloadTask(
task_id="task-request-staged-2",
source="prowlarr",
title="Requested Not Retryable",
request_id=123,
staged_path="/tmp/does-not-exist.epub",
)
assert orchestrator.can_retry_download_task(task, QueueStatus.ERROR) is False
def test_finalize_download_failure_sets_terminal_error(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
task = DownloadTask(
task_id="task-3",
source="direct_download",
title="Terminal failure",
last_error_message="Download timed out",
last_error_type="TimeoutError",
)
mock_queue = MagicMock()
mock_queue.get_task.return_value = task
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
orchestrator._finalize_download_failure("task-3")
mock_queue.update_status_message.assert_called_once_with("task-3", "Download timed out")
mock_queue.update_status.assert_called_once_with("task-3", QueueStatus.ERROR)
def test_finalize_download_failure_uses_fallback_message(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
task = DownloadTask(
task_id="task-4",
source="direct_download",
title="No message",
last_error_type="TimeoutError",
)
mock_queue = MagicMock()
mock_queue.get_task.return_value = task
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
orchestrator._finalize_download_failure("task-4")
mock_queue.update_status_message.assert_called_once_with(
"task-4", "Download failed: TimeoutError"
)
mock_queue.update_status.assert_called_once_with("task-4", QueueStatus.ERROR)
def test_callback_error_results_in_terminal_error(monkeypatch):
"""When a handler signals error via status_callback, it should result in terminal ERROR."""
import shelfmark.download.orchestrator as orchestrator
task = DownloadTask(
task_id="task-callback-1",
source="direct_download",
title="Callback Error",
)
queue = BookQueue()
queue.add(task)
terminal_calls: list[tuple[str, QueueStatus]] = []
queue.set_terminal_status_hook(
lambda task_id, status, _task: terminal_calls.append((task_id, status))
)
handler = MagicMock()
def _download(_task, _cancel_flag, _progress_callback, status_callback):
status_callback("error", "Download timed out")
return None
handler.download.side_effect = _download
handler.post_process_cleanup = MagicMock()
monkeypatch.setattr(orchestrator, "book_queue", queue)
monkeypatch.setattr(orchestrator, "get_handler", lambda _source: handler)
monkeypatch.setattr(orchestrator, "ws_manager", None)
orchestrator._process_single_download(task.task_id, Event())
assert task.last_error_message == "Download timed out"
assert task.last_error_type == "StatusCallbackError"
assert queue.get_task_status(task.task_id) == QueueStatus.ERROR
assert len(terminal_calls) == 1
assert terminal_calls[0] == (task.task_id, QueueStatus.ERROR)
def test_output_stage_retry_skips_redownload_when_staged_file_exists(monkeypatch, tmp_path):
import shelfmark.download.orchestrator as orchestrator
staged_file = tmp_path / "staged.epub"
staged_file.write_text("staged")
task = DownloadTask(
task_id="task-staged-1",
source="direct_download",
title="Reuse Staged File",
staged_path=str(staged_file),
)
handler = MagicMock()
handler.download = MagicMock(return_value=str(tmp_path / "should-not-download.epub"))
handler.post_process_cleanup = MagicMock()
mock_queue = MagicMock()
mock_queue.get_task.return_value = task
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
monkeypatch.setattr(orchestrator, "get_handler", lambda _source: handler)
seen_temp_files: list[Path] = []
def _post_process(temp_file, *_args, **_kwargs):
seen_temp_files.append(temp_file)
return "folder://done"
monkeypatch.setattr(orchestrator, "post_process_download", _post_process)
result = orchestrator._download_task(task.task_id, Event())
assert result == "folder://done"
handler.download.assert_not_called()
assert seen_temp_files == [staged_file]
assert task.staged_path is None
def test_output_stage_retry_falls_back_to_download_when_staged_file_missing(monkeypatch, tmp_path):
import shelfmark.download.orchestrator as orchestrator
missing_staged_file = tmp_path / "missing.epub"
downloaded_file = tmp_path / "downloaded.epub"
downloaded_file.write_text("downloaded")
task = DownloadTask(
task_id="task-staged-2",
source="direct_download",
title="Fallback Download",
staged_path=str(missing_staged_file),
)
handler = MagicMock()
handler.download = MagicMock(return_value=str(downloaded_file))
handler.post_process_cleanup = MagicMock()
mock_queue = MagicMock()
mock_queue.get_task.return_value = task
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
monkeypatch.setattr(orchestrator, "get_handler", lambda _source: handler)
seen_temp_files: list[Path] = []
def _post_process(temp_file, *_args, **_kwargs):
seen_temp_files.append(temp_file)
return "folder://done"
monkeypatch.setattr(orchestrator, "post_process_download", _post_process)
result = orchestrator._download_task(task.task_id, Event())
assert result == "folder://done"
handler.download.assert_called_once()
assert seen_temp_files == [downloaded_file]
assert task.staged_path is None
def test_get_book_data_clears_download_path_when_file_read_fails(monkeypatch, tmp_path):
import shelfmark.download.orchestrator as orchestrator
missing_file = tmp_path / "missing.epub"
task = DownloadTask(
task_id="task-book-data-1",
source="direct_download",
title="Missing File",
download_path=str(missing_file),
)
mock_queue = MagicMock()
mock_queue.get_task.return_value = task
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
file_data, returned_task = orchestrator.get_book_data(task.task_id)
assert file_data is None
assert returned_task is task
assert task.download_path is None
def test_get_book_path_returns_the_path_without_reading_the_file(monkeypatch, tmp_path):
import shelfmark.download.orchestrator as orchestrator
book = tmp_path / "book.epub"
book.write_bytes(b"x" * 4096)
task = DownloadTask(
task_id="task-book-path-1",
source="direct_download",
title="Streamed Book",
download_path=str(book),
)
mock_queue = MagicMock()
mock_queue.get_task.return_value = task
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
def _no_reads(*_args, **_kwargs):
raise AssertionError("get_book_path must not open the file")
monkeypatch.setattr(Path, "open", _no_reads)
path, returned_task = orchestrator.get_book_path(task.task_id)
assert path == str(book)
assert returned_task is task
def test_get_book_path_reports_a_missing_file_rather_than_a_dead_path(monkeypatch, tmp_path):
import shelfmark.download.orchestrator as orchestrator
task = DownloadTask(
task_id="task-book-path-2",
source="direct_download",
title="Gone",
download_path=str(tmp_path / "gone.epub"),
)
mock_queue = MagicMock()
mock_queue.get_task.return_value = task
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
path, returned_task = orchestrator.get_book_path(task.task_id)
assert path is None
assert returned_task is task
def test_get_book_paths_returns_every_file_still_on_disk(monkeypatch, tmp_path):
import shelfmark.download.orchestrator as orchestrator
parts = [tmp_path / f"Part {n}.mp3" for n in (1, 2, 3)]
for part in parts[:2]:
part.write_bytes(b"audio")
task = DownloadTask(
task_id="task-book-paths-1",
source="direct_download",
title="Chapters",
download_path=str(parts[0]),
download_paths=[str(part) for part in parts],
)
mock_queue = MagicMock()
mock_queue.get_task.return_value = task
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
paths, returned_task = orchestrator.get_book_paths(task.task_id)
assert paths == [str(parts[0]), str(parts[1])]
assert returned_task is task
def test_get_book_paths_ignores_a_list_that_no_longer_holds_download_path(monkeypatch, tmp_path):
import shelfmark.download.orchestrator as orchestrator
stale = tmp_path / "old-attempt.mp3"
current = tmp_path / "current.epub"
stale.write_bytes(b"old")
current.write_bytes(b"new")
task = DownloadTask(
task_id="task-book-paths-2",
source="direct_download",
title="Retried",
download_path=str(current),
download_paths=[str(stale)],
)
mock_queue = MagicMock()
mock_queue.get_task.return_value = task
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
paths, _ = orchestrator.get_book_paths(task.task_id)
assert paths == [str(current)]